v0.18.0: 配置管理优化 - 外部配置变更检测与一键导入

This commit is contained in:
coso
2025-12-25 09:38:01 +08:00
parent 3d7d820f7a
commit 35b2636bce
30 changed files with 982 additions and 726 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "proxycast",
"version": "0.17.10",
"version": "0.18.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "proxycast",
"version": "0.17.10",
"version": "0.18.0",
"dependencies": {
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "proxycast",
"private": true,
"version": "0.17.12",
"version": "0.18.0",
"type": "module",
"repository": {
"type": "git",
+1 -1
View File
@@ -3377,7 +3377,7 @@ dependencies = [
[[package]]
name = "proxycast"
version = "0.17.12"
version = "0.18.0"
dependencies = [
"anyhow",
"async-stream",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "proxycast"
version = "0.17.12"
version = "0.18.0"
description = "AI API Proxy Desktop App"
authors = ["you"]
edition = "2021"
+1
View File
@@ -3,6 +3,7 @@ pub mod config_cmd;
pub mod flow_monitor_cmd;
pub mod injection_cmd;
pub mod mcp_cmd;
pub mod network_cmd;
pub mod oauth_cmd;
pub mod plugin_cmd;
pub mod prompt_cmd;
+40
View File
@@ -0,0 +1,40 @@
//! 网络相关命令
//!
//! 提供获取本地网络接口信息的功能
use serde::Serialize;
use std::net::UdpSocket;
/// 网络接口信息
#[derive(Debug, Clone, Serialize)]
pub struct NetworkInfo {
/// 本地回环地址
pub localhost: String,
/// 内网 IP 地址(局域网)
pub lan_ip: Option<String>,
}
/// 获取本地网络信息
///
/// 返回 localhost 和内网 IP 地址,用于客户端连接
#[tauri::command]
pub fn get_network_info() -> Result<NetworkInfo, String> {
let lan_ip = get_local_ip();
Ok(NetworkInfo {
localhost: "127.0.0.1".to_string(),
lan_ip,
})
}
/// 获取本机内网 IP 地址
///
/// 通过创建 UDP socket 连接外部地址来获取本机的内网 IP
fn get_local_ip() -> Option<String> {
// 创建一个 UDP socket 并连接到外部地址(不会真正发送数据)
// 这样可以获取到本机用于出站连接的 IP 地址
let socket = UdpSocket::bind("0.0.0.0:0").ok()?;
socket.connect("8.8.8.8:80").ok()?;
let local_addr = socket.local_addr().ok()?;
Some(local_addr.ip().to_string())
}
+2
View File
@@ -2241,6 +2241,8 @@ pub fn run() {
commands::window_cmd::is_fullscreen,
// Auto fix commands
commands::auto_fix_cmd::auto_fix_configuration,
// Network commands
commands::network_cmd::get_network_info,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+11 -5
View File
@@ -7,7 +7,7 @@ use std::path::PathBuf;
pub fn get_app_config_path(app_type: &AppType) -> Option<PathBuf> {
let home = dirs::home_dir()?;
match app_type {
AppType::Claude => Some(home.join(".claude.json")),
AppType::Claude => Some(home.join(".claude").join("settings.json")),
AppType::Codex => Some(home.join(".codex")),
AppType::Gemini => Some(home.join(".gemini")),
AppType::ProxyCast => None,
@@ -56,12 +56,18 @@ fn clean_claude_auth_conflict(settings: &mut Value) {
}
}
/// Sync Claude settings to ~/.claude.json
/// Sync Claude settings to ~/.claude/settings.json
fn sync_claude_settings(
provider: &Provider,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let home = dirs::home_dir().ok_or("Cannot find home directory")?;
let config_path = home.join(".claude.json");
let claude_dir = home.join(".claude");
let config_path = claude_dir.join("settings.json");
// Ensure .claude directory exists
if !claude_dir.exists() {
std::fs::create_dir_all(&claude_dir)?;
}
// Read existing settings to preserve other fields
let mut settings: Value = if config_path.exists() {
@@ -197,7 +203,7 @@ pub fn read_live_settings(
match app_type {
AppType::Claude => {
let path = home.join(".claude.json");
let path = home.join(".claude").join("settings.json");
if !path.exists() {
return Err("Claude settings file not found".into());
}
@@ -394,7 +400,7 @@ pub fn check_config_sync(
fn get_config_last_modified(app_type: &AppType) -> Option<String> {
let home = dirs::home_dir()?;
let path = match app_type {
AppType::Claude => home.join(".claude.json"),
AppType::Claude => home.join(".claude").join("settings.json"),
AppType::Codex => home.join(".codex").join("auth.json"),
AppType::Gemini => home.join(".gemini").join(".env"),
AppType::ProxyCast => return None,
+13 -7
View File
@@ -24,7 +24,7 @@ fn escape_toml_string(s: &str) -> String {
pub fn get_mcp_config_path(app_type: &AppType) -> Option<PathBuf> {
let home = dirs::home_dir()?;
match app_type {
AppType::Claude => Some(home.join(".claude.json")),
AppType::Claude => Some(home.join(".claude").join("settings.json")),
AppType::Codex => Some(home.join(".codex").join("config.toml")),
AppType::Gemini => Some(home.join(".gemini").join("settings.json")),
AppType::ProxyCast => None,
@@ -73,13 +73,19 @@ pub fn sync_mcp_to_app(
}
}
/// Sync MCP servers to Claude's .claude.json
/// Claude uses the mcpServers field in ~/.claude.json
/// Sync MCP servers to Claude's ~/.claude/settings.json
/// Claude uses the mcpServers field in ~/.claude/settings.json
fn sync_mcp_to_claude(
servers: &[&McpServer],
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let home = dirs::home_dir().ok_or("Cannot find home directory")?;
let config_path = home.join(".claude.json");
let claude_dir = home.join(".claude");
let config_path = claude_dir.join("settings.json");
// Ensure .claude directory exists
if !claude_dir.exists() {
std::fs::create_dir_all(&claude_dir)?;
}
// Read existing settings
let mut settings: Value = if config_path.exists() {
@@ -255,7 +261,7 @@ pub fn remove_mcp_from_app(
fn remove_mcp_from_claude(server_id: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let home = dirs::home_dir().ok_or("Cannot find home directory")?;
let config_path = home.join(".claude.json");
let config_path = home.join(".claude").join("settings.json");
if !config_path.exists() {
return Ok(());
@@ -353,11 +359,11 @@ pub fn remove_mcp_from_all_apps(
Ok(())
}
/// Import MCP servers from Claude's .claude.json
/// Import MCP servers from Claude's ~/.claude/settings.json
pub fn import_mcp_from_claude(
) -> Result<Vec<crate::models::McpServer>, Box<dyn std::error::Error + Send + Sync>> {
let home = dirs::home_dir().ok_or("Cannot find home directory")?;
let config_path = home.join(".claude.json");
let config_path = home.join(".claude").join("settings.json");
if !config_path.exists() {
return Ok(Vec::new());
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ProxyCast",
"version": "0.17.12",
"version": "0.18.0",
"identifier": "com.proxycast.app",
"build": {
"beforeDevCommand": "npm run dev",
+102 -160
View File
@@ -1,20 +1,14 @@
import { useState, useEffect } from "react";
import {
Activity,
Server,
Zap,
Clock,
Play,
Copy,
Check,
ChevronDown,
ChevronUp,
Settings,
RefreshCw,
} from "lucide-react";
import { LogsTab } from "./LogsTab";
import { RoutesTab } from "./RoutesTab";
import { HelpTip } from "@/components/HelpTip";
import { ProviderIcon } from "@/icons/providers";
import {
startServer,
@@ -29,6 +23,8 @@ import {
TestResult,
getDefaultProvider,
setDefaultProvider,
getNetworkInfo,
NetworkInfo,
} from "@/hooks/useTauri";
import { providerPoolApi, ProviderPoolOverview } from "@/lib/api/providerPool";
@@ -62,6 +58,9 @@ export function ApiServerPage() {
text: string;
} | null>(null);
// 网络信息
const [networkInfo, setNetworkInfo] = useState<NetworkInfo | null>(null);
// 自动清除消息
useEffect(() => {
if (message) {
@@ -96,11 +95,21 @@ export function ApiServerPage() {
fetchStatus();
fetchConfig();
loadDefaultProvider();
loadNetworkInfo();
const statusInterval = setInterval(fetchStatus, 3000);
return () => clearInterval(statusInterval);
}, []);
const loadNetworkInfo = async () => {
try {
const info = await getNetworkInfo();
setNetworkInfo(info);
} catch (e) {
console.error("Failed to get network info:", e);
}
};
const loadDefaultProvider = async () => {
try {
const dp = await getDefaultProvider();
@@ -228,12 +237,6 @@ export function ApiServerPage() {
}
};
const formatUptime = (secs: number) => {
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
return `${h}h ${m}m`;
};
const serverUrl = status
? `http://${status.host}:${status.port}`
: `http://localhost:${config?.server.port ?? 8999}`;
@@ -435,37 +438,43 @@ export function ApiServerPage() {
};
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold">API Server</h2>
<p className="text-muted-foreground">
本地代理服务器,将凭证池中的凭证转换为标准 API
</p>
<div className="space-y-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<h2 className="text-2xl font-bold">API Server</h2>
<p className="text-muted-foreground text-sm">
本地代理服务器,支持 OpenAI/Anthropic 格式
{networkInfo && (
<>
{" "}
<code className="px-1 py-0.5 rounded bg-muted text-xs">
{networkInfo.localhost}:{config?.server.port ?? 8999}
</code>
{networkInfo.lan_ip && (
<>
{" | "}
<code className="px-1 py-0.5 rounded bg-muted text-xs">
{networkInfo.lan_ip}:{config?.server.port ?? 8999}
</code>
<span className="text-xs"> (局域网)</span>
</>
)}
</>
)}
<span className="ml-2">
<span
className={`inline-block h-2 w-2 rounded-full ${status?.running ? "bg-green-500" : "bg-red-500"}`}
/>{" "}
{status?.running ? "运行中" : "已停止"}
{" · "}
{status?.requests || 0} 请求
{" · "}
<span className="capitalize">{defaultProvider}</span>
</span>
</p>
</div>
</div>
<HelpTip title="如何使用 API Server?" variant="green">
<ul className="list-disc list-inside space-y-1 text-sm text-green-700 dark:text-green-400">
<li>
启动服务后,API 地址为{" "}
<code className="px-1 py-0.5 rounded bg-green-100 dark:bg-green-900">
http://localhost:8999
</code>
</li>
<li>
支持 OpenAI 格式{" "}
<code className="px-1 py-0.5 rounded bg-green-100 dark:bg-green-900">
/v1/chat/completions
</code>{" "}
和 Anthropic 格式{" "}
<code className="px-1 py-0.5 rounded bg-green-100 dark:bg-green-900">
/v1/messages
</code>
</li>
<li>在下方选择"默认 Provider",请求会自动使用该类型凭证池中的凭证</li>
<li>可在 Claude Code、Cherry Studio、Cursor 等工具中配置使用</li>
</ul>
</HelpTip>
{message && (
<div
className={`flex items-center gap-2 rounded-lg border p-3 text-sm ${
@@ -483,50 +492,6 @@ export function ApiServerPage() {
</div>
)}
{/* Status Cards */}
<div className="grid grid-cols-4 gap-4">
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2">
<Activity className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">状态</span>
</div>
<div className="mt-2 flex items-center gap-2">
<div
className={`h-2 w-2 rounded-full ${status?.running ? "bg-green-500" : "bg-red-500"}`}
/>
<span className="font-medium">
{status?.running ? "运行中" : "已停止"}
</span>
</div>
</div>
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2">
<Zap className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">请求数</span>
</div>
<div className="mt-2 text-2xl font-bold">{status?.requests || 0}</div>
</div>
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">运行时间</span>
</div>
<div className="mt-2 font-medium">
{formatUptime(status?.uptime_secs || 0)}
</div>
</div>
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2">
<Server className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">默认 Provider</span>
</div>
<div className="mt-2 font-medium capitalize">{defaultProvider}</div>
</div>
</div>
{/* Tabs */}
<div className="flex gap-2 border-b overflow-x-auto">
{[
@@ -550,16 +515,12 @@ export function ApiServerPage() {
{/* Server Control Tab */}
{activeTab === "server" && (
<div className="space-y-6">
{/* Server Control */}
<div className="rounded-lg border bg-card p-6">
<h3 className="mb-4 font-semibold flex items-center gap-2">
<Settings className="h-4 w-4" />
服务控制
</h3>
<div className="flex items-center gap-4 mb-4">
<div className="space-y-4">
{/* Server Control - 紧凑版 */}
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-4">
<button
className={`rounded-lg px-6 py-2 text-sm font-medium text-white disabled:opacity-50 ${
className={`rounded-lg px-4 py-1.5 text-sm font-medium text-white disabled:opacity-50 ${
status?.running
? "bg-red-600 hover:bg-red-700"
: "bg-green-600 hover:bg-green-700"
@@ -573,79 +534,60 @@ export function ApiServerPage() {
? "停止服务"
: "启动服务"}
</button>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<label className="block text-muted-foreground mb-1">端口</label>
<input
type="number"
value={editPort}
onChange={(e) => setEditPort(e.target.value)}
className="w-full rounded-lg border bg-background px-3 py-2"
/>
<div className="flex items-center gap-3 text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">端口:</span>
<input
type="number"
value={editPort}
onChange={(e) => setEditPort(e.target.value)}
className="w-20 rounded border bg-background px-2 py-1 text-sm"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">API Key:</span>
<input
type="text"
value={editApiKey}
onChange={(e) => setEditApiKey(e.target.value)}
className="w-40 rounded border bg-background px-2 py-1 text-sm"
/>
</div>
<button
onClick={handleSaveServerConfig}
disabled={loading}
className="rounded border px-3 py-1 text-sm hover:bg-muted disabled:opacity-50"
>
保存
</button>
</div>
<div>
<label className="block text-muted-foreground mb-1">
API Key
</label>
<input
type="text"
value={editApiKey}
onChange={(e) => setEditApiKey(e.target.value)}
className="w-full rounded-lg border bg-background px-3 py-2"
/>
</div>
</div>
<div className="mt-4 flex items-center justify-between">
<div className="text-sm text-muted-foreground">
API 地址:{" "}
<code className="rounded bg-muted px-2 py-1">{serverUrl}</code>
</div>
<button
onClick={handleSaveServerConfig}
disabled={loading}
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-muted disabled:opacity-50"
>
保存配置
</button>
</div>
</div>
{/* Default Provider */}
<div className="rounded-lg border bg-card p-6">
<h3 className="mb-4 font-semibold">默认 Provider</h3>
<p className="mb-3 text-xs text-muted-foreground">
选择默认使用的凭证池类型,请求会自动从该类型的凭证池中轮询选择可用凭证
</p>
{providerSwitchMsg && (
<div className="mb-3 flex items-center gap-2 rounded-lg border border-green-500 bg-green-50 p-2 text-sm text-green-700 dark:bg-green-950/30">
<Check className="h-4 w-4" />
{providerSwitchMsg}
</div>
)}
{/* Default Provider - 紧凑版 */}
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center justify-between mb-3">
<span className="font-medium text-sm">默认 Provider</span>
{providerSwitchMsg && (
<span className="text-xs text-green-600 flex items-center gap-1">
<Check className="h-3 w-3" />
{providerSwitchMsg}
</span>
)}
</div>
<div className="flex flex-wrap gap-2">
{(
[
{ id: "kiro", label: "Kiro (AWS)", iconType: "kiro" },
{
id: "gemini",
label: "Gemini (Google)",
iconType: "gemini",
},
{ id: "qwen", label: "Qwen (阿里)", iconType: "qwen" },
{ id: "kiro", label: "Kiro", iconType: "kiro" },
{ id: "gemini", label: "Gemini", iconType: "gemini" },
{ id: "qwen", label: "Qwen", iconType: "qwen" },
{
id: "antigravity",
label: "Antigravity (Gemini 3 Pro)",
label: "Antigravity",
iconType: "gemini",
},
{ id: "openai", label: "OpenAI", iconType: "openai" },
{
id: "claude",
label: "Claude (Anthropic)",
iconType: "claude",
},
{ id: "claude", label: "Claude", iconType: "claude" },
] as const
).map((p) => {
const overview = poolOverview.find(
@@ -657,16 +599,16 @@ export function ApiServerPage() {
key={p.id}
onClick={() => handleSetDefaultProvider(p.id)}
disabled={loading}
className={`flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium ${
className={`flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm transition-colors ${
defaultProvider === p.id
? "bg-primary text-primary-foreground"
: "border hover:bg-muted"
? "border-primary bg-primary/10 text-primary"
: "border-border bg-card hover:bg-muted text-muted-foreground hover:text-foreground"
} disabled:opacity-50`}
>
<ProviderIcon providerType={p.iconType} size={16} />
<ProviderIcon providerType={p.iconType} size={14} />
{p.label}
{count > 0 && (
<span className="ml-1 text-xs opacity-70">({count})</span>
<span className="text-xs opacity-70">({count})</span>
)}
</button>
);
+22 -50
View File
@@ -1,12 +1,9 @@
import { useState, useEffect, useRef } from "react";
import { Trash2, Download, ArrowUp } from "lucide-react";
import { Trash2, Download } from "lucide-react";
import { getLogs, clearLogs, LogEntry } from "@/hooks/useTauri";
export function LogsTab() {
const [logs, setLogs] = useState<LogEntry[]>([]);
const [autoScroll, setAutoScroll] = useState(true);
const [showScrollTop, setShowScrollTop] = useState(false);
const logsEndRef = useRef<HTMLDivElement>(null);
const logsContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -15,29 +12,24 @@ export function LogsTab() {
return () => clearInterval(interval);
}, []);
useEffect(() => {
if (autoScroll && logsEndRef.current) {
logsEndRef.current.scrollIntoView({ behavior: "smooth" });
}
}, [logs, autoScroll]);
const handleScroll = () => {
if (logsContainerRef.current) {
setShowScrollTop(logsContainerRef.current.scrollTop > 200);
}
};
const scrollToTop = () => {
if (logsContainerRef.current) {
logsContainerRef.current.scrollTo({ top: 0, behavior: "smooth" });
setAutoScroll(false);
}
};
const fetchLogs = async () => {
try {
const l = await getLogs();
setLogs(l);
// 过滤掉 API 调用相关的日志,只保留系统日志
// API 调用日志已由 Flow Monitor 接管
const systemLogs = l.filter((log) => {
const msg = log.message;
// 排除 API 请求相关日志
if (msg.includes("[REQ]")) return false;
if (msg.includes("[ROUTE]")) return false;
if (msg.includes("[CLIENT]")) return false;
if (msg.includes("request_id=")) return false;
if (msg.includes("POST /v1/")) return false;
if (msg.includes("GET /v1/")) return false;
if (msg.includes("Using pool credential")) return false;
return true;
});
setLogs(systemLogs);
} catch (e) {
// 如果后端还没实现,使用空数组
console.error(e);
@@ -95,38 +87,28 @@ export function LogsTab() {
};
return (
<div className="space-y-6">
<div className="space-y-4">
<div className="flex items-center justify-end gap-2">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={autoScroll}
onChange={(e) => setAutoScroll(e.target.checked)}
className="rounded"
/>
自动滚动
</label>
<button
onClick={handleExport}
className="flex items-center gap-2 rounded-lg border px-3 py-2 text-sm hover:bg-muted"
className="flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm hover:bg-muted"
>
<Download className="h-4 w-4" />
导出
</button>
<button
onClick={handleClear}
className="flex items-center gap-2 rounded-lg border px-3 py-2 text-sm hover:bg-muted"
className="flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm hover:bg-muted"
>
<Trash2 className="h-4 w-4" />
清空
</button>
</div>
<div className="rounded-lg border bg-card relative">
<div className="rounded-lg border bg-card">
<div
ref={logsContainerRef}
onScroll={handleScroll}
className="max-h-[600px] overflow-auto p-4 font-mono text-sm"
className="max-h-[500px] overflow-auto p-4 font-mono text-xs"
>
{logs.length === 0 ? (
<p className="text-center text-muted-foreground">
@@ -136,7 +118,7 @@ export function LogsTab() {
logs.map((log, i) => (
<div
key={i}
className={`flex gap-2 py-1 px-2 rounded ${getLevelBg(log.level)}`}
className={`flex gap-2 py-0.5 px-2 rounded ${getLevelBg(log.level)}`}
>
<span className="text-muted-foreground shrink-0">
{new Date(log.timestamp).toLocaleTimeString()}
@@ -150,17 +132,7 @@ export function LogsTab() {
</div>
))
)}
<div ref={logsEndRef} />
</div>
{showScrollTop && (
<button
onClick={scrollToTop}
className="absolute bottom-4 right-4 rounded-full bg-primary p-2 text-primary-foreground shadow-lg hover:bg-primary/90"
title="回到顶部"
>
<ArrowUp className="h-4 w-4" />
</button>
)}
</div>
</div>
);
+3 -3
View File
@@ -41,10 +41,10 @@ export function AppTabs({ activeApp, onAppChange }: AppTabsProps) {
key={app.id}
onClick={() => onAppChange(app.id)}
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-t-lg text-sm font-medium transition-colors",
"flex items-center gap-2 px-4 py-2 rounded-lg border text-sm font-medium transition-colors",
activeApp === app.id
? "bg-primary text-primary-foreground"
: "hover:bg-muted text-muted-foreground",
? "border-primary bg-primary/10 text-primary"
: "border-border bg-card hover:bg-muted text-muted-foreground hover:text-foreground",
)}
title={app.description}
>
+8 -17
View File
@@ -2,7 +2,6 @@ import { useState } from "react";
import { AppType } from "@/lib/api/switch";
import { AppTabs } from "./AppTabs";
import { ProviderList } from "./ProviderList";
import { HelpTip } from "@/components/HelpTip";
interface ClientsPageProps {
hideHeader?: boolean;
@@ -12,29 +11,21 @@ export function ClientsPage({ hideHeader = false }: ClientsPageProps) {
const [activeApp, setActiveApp] = useState<AppType>("claude");
return (
<div className="space-y-6">
<div className="space-y-4">
{!hideHeader && (
<div>
<h2 className="text-2xl font-bold">配置切换</h2>
<p className="text-muted-foreground">
一键切换 Claude Code / Codex / Gemini CLI 的 API 配置,快速在不同
Provider 间切换
<p className="text-muted-foreground text-sm">
一键切换 API 配置,可独立使用。添加 "ProxyCast" 可将凭证池转为标准
API(
<code className="px-1 py-0.5 rounded bg-muted text-xs">
localhost:8999
</code>
)
</p>
</div>
)}
<HelpTip title="关于 ProxyCast 本地代理" variant="blue">
<p className="text-sm text-blue-700 dark:text-blue-400">
添加名为 "ProxyCast" 的 Provider
后,可将凭证池中的凭证(Kiro/Gemini/Claude 等)转换为标准
OpenAI/Anthropic API, 供 Claude Code、Codex、Cherry Studio
等工具使用。配置 API 地址为{" "}
<code className="px-1 py-0.5 rounded bg-blue-100 dark:bg-blue-900">
http://localhost:8999
</code>
</p>
</HelpTip>
<AppTabs activeApp={activeApp} onAppChange={setActiveApp} />
<ProviderList appType={activeApp} />
</div>
+1 -1
View File
@@ -8,7 +8,7 @@ interface LiveConfigModalProps {
}
const configPaths: Record<AppType, string> = {
claude: "~/.claude.json",
claude: "~/.claude/settings.json",
codex: "~/.codex/auth.json & config.toml",
gemini: "~/.gemini/.env & settings.json",
proxycast: "",
+48 -52
View File
@@ -1,4 +1,4 @@
import { Check, Edit2, Trash2, Zap } from "lucide-react";
import { Check, Edit2, Trash2 } from "lucide-react";
import { Provider } from "@/lib/api/switch";
import { cn } from "@/lib/utils";
import { ProviderIcon } from "@/icons/providers";
@@ -50,44 +50,70 @@ export function ProviderCard({
onEdit,
onDelete,
}: ProviderCardProps) {
const isProxyCast =
provider.category === "custom" && provider.name === "ProxyCast";
return (
<div
onClick={() => !isCurrent && onSwitch()}
className={cn(
"relative rounded-lg border p-4 transition-all",
"group relative rounded-xl border p-3 transition-all cursor-pointer",
isCurrent
? "border-primary bg-primary/5 ring-1 ring-primary"
: "hover:border-muted-foreground/50",
? "border-primary bg-gradient-to-r from-primary/10 to-transparent shadow-sm"
: "hover:border-primary/50 hover:shadow-md",
)}
>
{/* 选中标记 */}
{isCurrent && (
<div className="absolute -top-2 -right-2 rounded-full bg-primary p-1">
<div className="absolute -top-1.5 -right-1.5 rounded-full bg-primary p-1 shadow-sm">
<Check className="h-3 w-3 text-primary-foreground" />
</div>
)}
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-lg flex items-center justify-center">
<ProviderIcon
providerType={getProviderTypeFromName(
provider.name,
provider.category || "",
)}
size={24}
showFallback={true}
/>
</div>
<div>
<h3 className="font-medium">{provider.name}</h3>
<div className="flex items-center gap-3">
{/* 图标 */}
<div
className={cn(
"shrink-0 h-10 w-10 rounded-lg flex items-center justify-center",
isCurrent ? "bg-primary/20" : "bg-muted",
)}
>
<ProviderIcon
providerType={getProviderTypeFromName(
provider.name,
provider.category || "",
)}
size={22}
showFallback={true}
/>
</div>
{/* 名称和分类 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium truncate">{provider.name}</h3>
{provider.category && (
<span className="text-xs text-muted-foreground">
<span className="shrink-0 text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
{provider.category}
</span>
)}
</div>
{isProxyCast ? (
<p className="text-xs text-blue-600 dark:text-blue-400 truncate">
凭证池 → 标准 API
</p>
) : provider.notes ? (
<p className="text-xs text-muted-foreground truncate">
{provider.notes}
</p>
) : null}
</div>
<div className="flex gap-1">
{/* 操作按钮 */}
<div
className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={onEdit}
className="p-1.5 rounded hover:bg-muted"
@@ -96,12 +122,7 @@ export function ProviderCard({
<Edit2 className="h-3.5 w-3.5" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
if (!isCurrent) {
onDelete();
}
}}
onClick={() => !isCurrent && onDelete()}
disabled={isCurrent}
className={cn(
"p-1.5 rounded text-destructive",
@@ -115,31 +136,6 @@ export function ProviderCard({
</button>
</div>
</div>
{provider.category === "custom" && provider.name === "ProxyCast" && (
<p className="mt-2 text-xs text-blue-600 dark:text-blue-400">
本地代理服务,将凭证池中的凭证转换为标准 API
</p>
)}
{provider.notes && (
<p className="mt-2 text-sm text-muted-foreground line-clamp-2">
{provider.notes}
</p>
)}
<div className="mt-4">
{isCurrent ? (
<span className="text-sm text-primary font-medium">当前使用中</span>
) : (
<button
onClick={onSwitch}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
>
<Zap className="h-3.5 w-3.5" />
切换到此配置
</button>
)}
</div>
</div>
);
}
+368 -24
View File
@@ -1,6 +1,18 @@
import { useState } from "react";
import { Plus, RefreshCw, Eye, GitCompare } from "lucide-react";
import { AppType, SyncCheckResult } from "@/lib/api/switch";
import { useState, useEffect, useMemo } from "react";
import {
Plus,
RefreshCw,
Eye,
GitCompare,
Download,
AlertTriangle,
} from "lucide-react";
import {
AppType,
SyncCheckResult,
switchApi,
Provider,
} from "@/lib/api/switch";
import { useSwitch } from "@/hooks/useSwitch";
import { ProviderCard } from "./ProviderCard";
import { ProviderForm } from "./ProviderForm";
@@ -8,6 +20,168 @@ import { LiveConfigModal } from "./LiveConfigModal";
import { ConfigSyncDialog } from "./ConfigSyncDialog";
import { ConfirmDialog } from "@/components/ConfirmDialog";
// 敏感字段关键词
const SENSITIVE_KEYS = [
"key",
"token",
"secret",
"password",
"auth",
"credential",
"api_key",
"apikey",
"access_token",
"refresh_token",
];
// 脱敏函数:对敏感值进行遮盖
function maskSensitiveValue(value: string): string {
if (value.length <= 8) return "****";
return value.slice(0, 4) + "****" + value.slice(-4);
}
// 递归脱敏对象中的敏感字段
function maskSensitiveData(
data: Record<string, unknown>,
): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(data)) {
const lowerKey = key.toLowerCase();
const isSensitive = SENSITIVE_KEYS.some((k) => lowerKey.includes(k));
if (typeof value === "string" && isSensitive && value.length > 0) {
result[key] = maskSensitiveValue(value);
} else if (
typeof value === "object" &&
value !== null &&
!Array.isArray(value)
) {
result[key] = maskSensitiveData(value as Record<string, unknown>);
} else {
result[key] = value;
}
}
return result;
}
// 比较两个配置是否匹配(忽略非关键字段)
function configsMatch(
liveConfig: Record<string, unknown>,
providerConfig: Record<string, unknown>,
): boolean {
// 提取关键字段进行比较
// Claude: { env: { ANTHROPIC_AUTH_TOKEN: ..., ANTHROPIC_API_KEY: ... } }
// Gemini: { env: { GEMINI_API_KEY: ..., GOOGLE_API_KEY: ... } }
// Codex: { auth: { OPENAI_API_KEY: ... }, config: ... }
const getLiveEnv = (config: Record<string, unknown>) =>
(config.env as Record<string, unknown>) || {};
const getProviderEnv = (config: Record<string, unknown>) =>
(config.env as Record<string, unknown>) || {};
// Codex 特殊处理:从 auth 对象中提取
const getLiveAuth = (config: Record<string, unknown>) =>
(config.auth as Record<string, unknown>) || {};
const getProviderAuth = (config: Record<string, unknown>) =>
(config.auth as Record<string, unknown>) || {};
const liveEnv = getLiveEnv(liveConfig);
const providerEnv = getProviderEnv(providerConfig);
const liveAuth = getLiveAuth(liveConfig);
const providerAuth = getProviderAuth(providerConfig);
// 比较关键认证字段(Claude/Gemini)
const envKeysToCompare = [
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_API_KEY",
"ANTHROPIC_BASE_URL",
"GOOGLE_API_KEY",
"GEMINI_API_KEY",
];
// 检查是否有任何关键字段匹配
let hasAnyMatch = false;
// 比较 env 字段
for (const key of envKeysToCompare) {
const liveVal = liveEnv[key] as string | undefined;
const providerVal = providerEnv[key] as string | undefined;
if (liveVal && providerVal) {
if (liveVal !== providerVal) {
return false;
}
hasAnyMatch = true;
} else if (liveVal && !providerVal) {
return false;
} else if (!liveVal && providerVal && key !== "ANTHROPIC_BASE_URL") {
return false;
}
}
// 比较 Codex auth.OPENAI_API_KEY
const liveApiKey = liveAuth.OPENAI_API_KEY as string | undefined;
const providerApiKey = providerAuth.OPENAI_API_KEY as string | undefined;
if (liveApiKey && providerApiKey) {
if (liveApiKey !== providerApiKey) {
return false;
}
hasAnyMatch = true;
} else if (liveApiKey && !providerApiKey) {
return false;
} else if (!liveApiKey && providerApiKey) {
return false;
}
return hasAnyMatch;
}
// 从实际配置中提取简短描述
function getLiveConfigSummary(config: Record<string, unknown>): string {
const env = (config.env as Record<string, unknown>) || {};
const auth = (config.auth as Record<string, unknown>) || {};
if (env.ANTHROPIC_AUTH_TOKEN) {
const baseUrl = env.ANTHROPIC_BASE_URL as string;
if (baseUrl) {
try {
const url = new URL(baseUrl);
return `OAuth · ${url.host}`;
} catch {
return `OAuth · ${baseUrl}`;
}
}
return "Claude OAuth";
}
if (env.ANTHROPIC_API_KEY) {
const baseUrl = env.ANTHROPIC_BASE_URL as string;
if (baseUrl) {
try {
const url = new URL(baseUrl);
return `API Key · ${url.host}`;
} catch {
return `API Key · ${baseUrl}`;
}
}
return "Claude API Key";
}
// Gemini: GEMINI_API_KEY 或 GOOGLE_API_KEY
if (env.GEMINI_API_KEY || env.GOOGLE_API_KEY) {
return "Gemini API Key";
}
// Codex: auth.OPENAI_API_KEY
if (auth.OPENAI_API_KEY) {
return "Codex API Key";
}
return "未知配置";
}
interface ProviderListProps {
appType: AppType;
}
@@ -37,6 +211,88 @@ export function ProviderList({ appType }: ProviderListProps) {
const [syncResult, setSyncResult] = useState<SyncCheckResult | null>(null);
const [checkingSync, setCheckingSync] = useState(false);
// 实际生效的配置
const [liveConfig, setLiveConfig] = useState<Record<string, unknown> | null>(
null,
);
const [loadingLiveConfig, setLoadingLiveConfig] = useState(false);
const [importingConfig, setImportingConfig] = useState(false);
// 始终读取当前生效的配置(用于检测外部变更)
useEffect(() => {
const loadConfig = async () => {
if (appType === "proxycast") return;
setLoadingLiveConfig(true);
try {
const config = await switchApi.readLiveSettings(appType);
setLiveConfig(config);
} catch {
setLiveConfig(null);
} finally {
setLoadingLiveConfig(false);
}
};
if (!loading) {
loadConfig();
}
}, [loading, appType]);
// 检测实际配置是否与当前选中的 provider 匹配
const configMismatch = useMemo(() => {
if (!liveConfig || !currentProvider || loadingLiveConfig) return null;
if (Object.keys(liveConfig).length === 0) return null;
const matches = configsMatch(liveConfig, currentProvider.settings_config);
if (matches) return null;
// 检查是否有其他 provider 匹配实际配置
const matchingProvider = providers.find((p) =>
configsMatch(liveConfig, p.settings_config),
);
return {
liveConfig,
liveSummary: getLiveConfigSummary(liveConfig),
matchingProvider,
};
}, [liveConfig, currentProvider, providers, loadingLiveConfig]);
const handleImportCurrentConfig = async () => {
if (!liveConfig) return;
setImportingConfig(true);
try {
// 直接使用读取到的配置创建新的 provider
const providerName = `导入配置 ${new Date().toLocaleDateString()}`;
await addProvider({
name: providerName,
app_type: appType,
settings_config: liveConfig,
category: "custom",
});
// 重新加载配置
const config = await switchApi.readLiveSettings(appType);
setLiveConfig(config);
await refresh();
} catch (e) {
alert("导入失败: " + (e instanceof Error ? e.message : String(e)));
} finally {
setImportingConfig(false);
}
};
// 切换到匹配的 provider
const handleSwitchToMatching = async (provider: Provider) => {
try {
await switchToProvider(provider.id);
// 切换后重新读取实际配置,更新 UI 状态
const config = await switchApi.readLiveSettings(appType);
setLiveConfig(config);
} catch (e) {
alert("切换失败: " + (e instanceof Error ? e.message : String(e)));
}
};
const handleAdd = () => {
setEditingProvider(null);
setShowForm(true);
@@ -104,19 +360,19 @@ export function ProviderList({ appType }: ProviderListProps) {
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
<div className="flex items-center justify-center py-8">
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
);
}
if (error) {
return (
<div className="rounded-lg border border-destructive bg-destructive/10 p-4">
<div className="rounded-lg border border-destructive bg-destructive/10 p-3 text-sm">
<p className="text-destructive">{error}</p>
<button
onClick={refresh}
className="mt-2 text-sm text-muted-foreground hover:underline"
className="mt-1 text-xs text-muted-foreground hover:underline"
>
重试
</button>
@@ -125,25 +381,36 @@ export function ProviderList({ appType }: ProviderListProps) {
}
return (
<div className="space-y-4">
<div className="space-y-3">
{/* 工具栏 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<p className="text-sm text-muted-foreground">
当前: {currentProvider?.name || "未设置"}
</p>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
{loadingLiveConfig ? (
<span className="flex items-center gap-1">
<RefreshCw className="h-3 w-3 animate-spin" />
检测中...
</span>
) : configMismatch ? (
<span className="flex items-center gap-1 text-amber-600">
<AlertTriangle className="h-3.5 w-3.5" />
实际: {configMismatch.liveSummary}
</span>
) : (
<span>当前: {currentProvider?.name || "未设置"}</span>
)}
<button
onClick={() => setShowLiveConfig(true)}
className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
className="p-1 rounded hover:bg-muted"
title="查看当前生效的配置"
>
<Eye className="h-4 w-4" />
<Eye className="h-3.5 w-3.5" />
</button>
</div>
<div className="flex gap-2">
<div className="flex gap-1.5">
<button
onClick={handleCheckSync}
disabled={checkingSync}
className="p-2 rounded-lg hover:bg-muted"
className="p-1.5 rounded-lg hover:bg-muted"
title="检查外部配置同步状态"
>
<GitCompare
@@ -152,28 +419,105 @@ export function ProviderList({ appType }: ProviderListProps) {
</button>
<button
onClick={refresh}
className="p-2 rounded-lg hover:bg-muted"
className="p-1.5 rounded-lg hover:bg-muted"
title="刷新"
>
<RefreshCw className="h-4 w-4" />
</button>
<button
onClick={handleAdd}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-primary text-primary-foreground text-sm"
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-primary text-primary-foreground text-sm"
>
<Plus className="h-4 w-4" />
添加 Provider
<Plus className="h-3.5 w-3.5" />
添加
</button>
</div>
</div>
{/* 配置不匹配警告 */}
{configMismatch && (
<div className="border border-amber-500/50 bg-amber-500/10 rounded-lg p-3 space-y-2">
<div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-amber-600 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-amber-700 dark:text-amber-400">
检测到外部配置变更
</p>
<p className="text-xs text-muted-foreground mt-0.5">
实际生效的配置与当前选中的 "{currentProvider?.name}" 不一致
</p>
</div>
</div>
<div className="flex items-center gap-2">
{configMismatch.matchingProvider ? (
<button
onClick={() =>
handleSwitchToMatching(configMismatch.matchingProvider!)
}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-amber-600 text-white text-sm"
>
切换到 "{configMismatch.matchingProvider.name}"
</button>
) : (
<button
onClick={handleImportCurrentConfig}
disabled={importingConfig}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-amber-600 text-white text-sm disabled:opacity-50"
>
<Download className="h-3.5 w-3.5" />
{importingConfig ? "导入中..." : "导入为新配置"}
</button>
)}
<button
onClick={() => setShowLiveConfig(true)}
className="px-3 py-1.5 rounded-lg border text-sm hover:bg-muted"
>
查看详情
</button>
</div>
</div>
)}
{/* Provider 列表 */}
{providers.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<p>暂无 Provider 配置</p>
<p className="text-sm mt-1">点击上方按钮添加第一个配置</p>
<div className="border border-dashed rounded-lg p-4">
{loadingLiveConfig ? (
<div className="flex items-center justify-center py-4">
<RefreshCw className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : liveConfig && Object.keys(liveConfig).length > 0 ? (
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
检测到当前配置,可一键导入
</p>
<button
onClick={handleImportCurrentConfig}
disabled={importingConfig}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary text-primary-foreground text-sm disabled:opacity-50"
>
<Download className="h-3.5 w-3.5" />
{importingConfig ? "导入中..." : "导入配置"}
</button>
</div>
<pre className="p-3 rounded-lg bg-muted/50 font-mono text-xs overflow-auto max-h-40">
{JSON.stringify(maskSensitiveData(liveConfig), null, 2)}
</pre>
</div>
) : (
<div className="text-center py-4 text-muted-foreground">
<p>暂无配置</p>
<button
onClick={handleAdd}
className="mt-2 text-sm text-primary hover:underline"
>
添加第一个配置
</button>
</div>
)}
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<div className="grid gap-2 sm:grid-cols-2">
{providers.map((provider) => (
<ProviderCard
key={provider.id}
+46 -4
View File
@@ -7,18 +7,57 @@ import { ConfigPage } from "./ConfigPage";
type Tab = "switch" | "config";
const tabs = [
{ id: "switch" as Tab, label: "配置切换", icon: Monitor },
{ id: "config" as Tab, label: "配置文件", icon: FileCode },
{
id: "switch" as Tab,
label: "配置切换",
icon: Monitor,
experimental: false,
},
{
id: "config" as Tab,
label: "配置文件",
icon: FileCode,
experimental: true,
},
];
export function ConfigManagementPage() {
const [activeTab, setActiveTab] = useState<Tab>("switch");
// 根据当前 tab 显示不同的描述
const getDescription = () => {
if (activeTab === "switch") {
return (
<>
一键切换 API 配置,可独立使用。添加 "ProxyCast" 可将凭证池转为标准
API(
<code className="px-1 py-0.5 rounded bg-muted text-xs">
localhost:8999
</code>
)
</>
);
}
return (
<>
编辑 YAML 配置文件。实验功能,不影响核心使用,
<a
href="https://github.com/aiclientproxy/proxycast/issues"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
问题反馈
</a>
</>
);
};
return (
<div className="space-y-6">
<div className="space-y-4">
<div>
<h2 className="text-2xl font-bold">配置管理</h2>
<p className="text-muted-foreground">管理客户端配置和配置文件</p>
<p className="text-muted-foreground text-sm">{getDescription()}</p>
</div>
{/* Tab 切换 */}
@@ -36,6 +75,9 @@ export function ConfigManagementPage() {
>
<tab.icon className="h-4 w-4" />
{tab.label}
{tab.experimental && (
<span className="text-[8px] text-red-500">(实验)</span>
)}
</button>
))}
</div>
+11 -3
View File
@@ -88,7 +88,7 @@ export const ConfigPage = forwardRef<ConfigPageRef, ConfigPageProps>(
}
return (
<div className="space-y-6">
<div className="space-y-4">
{!hideHeader && (
<div className="flex items-center justify-between">
<div>
@@ -96,8 +96,16 @@ export const ConfigPage = forwardRef<ConfigPageRef, ConfigPageProps>(
<FileCode className="h-6 w-6" />
配置管理
</h2>
<p className="text-muted-foreground">
编辑 YAML 配置文件,导入导出配置
<p className="text-muted-foreground text-sm">
编辑 YAML 配置文件。实验功能,不影响核心使用,
<a
href="https://github.com/aiclientproxy/proxycast/issues"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
问题反馈
</a>
</p>
</div>
<div className="flex items-center gap-2">
@@ -14,7 +14,6 @@ import { AddCredentialModal } from "./AddCredentialModal";
import { EditCredentialModal } from "./EditCredentialModal";
import { ErrorDisplay, useErrorDisplay } from "./ErrorDisplay";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { HelpTip } from "@/components/HelpTip";
import { getConfig, saveConfig, Config } from "@/hooks/useTauri";
import { VertexAISection } from "./VertexAISection";
import { AmpConfigSection } from "./AmpConfigSection";
@@ -322,53 +321,24 @@ export const ProviderPoolPage = forwardRef<ProviderPoolPageRef>(
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold">凭证池</h2>
<p className="text-muted-foreground">
管理多个 AI 服务凭证,支持负载均衡和健康检测
<p className="text-muted-foreground text-sm">
管理多个 AI 服务凭证,自动轮询负载均衡。在 API Server 选择默认
Provider 后自动使用对应凭证
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleMigratePrivateConfig}
disabled={migrating || loading}
className="flex items-center gap-2 rounded-lg border px-3 py-2 text-sm hover:bg-muted disabled:opacity-50"
title="从高级设置导入 Private 凭证"
>
<Download
className={`h-4 w-4 ${migrating ? "animate-pulse" : ""}`}
/>
导入配置
</button>
<button
onClick={refresh}
disabled={loading}
className="flex items-center gap-2 rounded-lg border px-3 py-2 text-sm hover:bg-muted disabled:opacity-50"
>
<RefreshCw
className={`h-4 w-4 ${loading ? "animate-spin" : ""}`}
/>
刷新
</button>
</div>
<button
onClick={handleMigratePrivateConfig}
disabled={migrating || loading}
className="flex items-center gap-2 rounded-lg border px-3 py-2 text-sm hover:bg-muted disabled:opacity-50"
title="从高级设置导入 Private 凭证"
>
<Download
className={`h-4 w-4 ${migrating ? "animate-pulse" : ""}`}
/>
导入配置
</button>
</div>
<HelpTip title="什么是凭证池?" variant="amber">
<ul className="list-disc list-inside space-y-1 text-sm text-amber-700 dark:text-amber-400">
<li>
<span className="font-medium">Kiro/Gemini/Qwen</span>
:上传对应工具的凭证文件,ProxyCast 会自动管理 Token 刷新
</li>
<li>
<span className="font-medium">OpenAI/Claude</span>:直接填入 API
Key,用于转发请求
</li>
<li>多个凭证会自动轮询负载均衡,单个凭证失效不影响服务</li>
<li>
在 <span className="font-medium">API Server</span> 页面选择默认
Provider 后,请求会自动使用对应凭证池
</li>
</ul>
</HelpTip>
{error && (
<div className="rounded-lg border border-red-500 bg-red-50 p-4 text-red-700 dark:bg-red-950/30">
{error}
@@ -382,10 +352,10 @@ export const ProviderPoolPage = forwardRef<ProviderPoolPageRef>(
setActiveCategory("oauth");
setActiveTab(oauthProviderTypes[0]);
}}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
className={`px-4 py-2 text-sm font-medium rounded-lg border transition-colors ${
activeCategory === "oauth"
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:text-foreground hover:bg-muted/80"
? "border-primary bg-primary/10 text-primary"
: "border-border bg-card text-muted-foreground hover:text-foreground hover:bg-muted"
}`}
>
OAuth 凭证
@@ -395,10 +365,10 @@ export const ProviderPoolPage = forwardRef<ProviderPoolPageRef>(
setActiveCategory("apikey");
setActiveTab(apiKeyProviderTypes[0]);
}}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
className={`px-4 py-2 text-sm font-medium rounded-lg border transition-colors ${
activeCategory === "apikey"
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:text-foreground hover:bg-muted/80"
? "border-primary bg-primary/10 text-primary"
: "border-border bg-card text-muted-foreground hover:text-foreground hover:bg-muted"
}`}
>
API Key
@@ -408,10 +378,10 @@ export const ProviderPoolPage = forwardRef<ProviderPoolPageRef>(
setActiveCategory("config");
setActiveTab("vertex");
}}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
className={`px-4 py-2 text-sm font-medium rounded-lg border transition-colors ${
activeCategory === "config"
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:text-foreground hover:bg-muted/80"
? "border-primary bg-primary/10 text-primary"
: "border-border bg-card text-muted-foreground hover:text-foreground hover:bg-muted"
}`}
>
其他配置
@@ -429,7 +399,7 @@ export const ProviderPoolPage = forwardRef<ProviderPoolPageRef>(
key={providerType}
onClick={() => setActiveTab(providerType)}
title={providerLabels[providerType]}
className={`group relative flex items-center gap-2 px-3 py-2 rounded-lg border transition-all ${
className={`group relative flex items-center justify-center gap-2 min-w-[120px] px-3 py-2 rounded-lg border transition-all ${
isActive
? "border-primary bg-primary/10 text-primary shadow-sm"
: "border-border bg-card hover:border-primary/50 hover:bg-muted text-muted-foreground hover:text-foreground"
@@ -465,7 +435,7 @@ export const ProviderPoolPage = forwardRef<ProviderPoolPageRef>(
key={providerType}
onClick={() => setActiveTab(providerType)}
title={providerLabels[providerType]}
className={`group relative flex items-center gap-2 px-3 py-2 rounded-lg border transition-all ${
className={`group relative flex items-center justify-center gap-2 min-w-[120px] px-3 py-2 rounded-lg border transition-all ${
isActive
? "border-primary bg-primary/10 text-primary shadow-sm"
: "border-border bg-card hover:border-primary/50 hover:bg-muted text-muted-foreground hover:text-foreground"
@@ -499,7 +469,7 @@ export const ProviderPoolPage = forwardRef<ProviderPoolPageRef>(
<button
key={tabId}
onClick={() => setActiveTab(tabId)}
className={`px-3 py-2 rounded-lg border text-sm font-medium transition-all ${
className={`min-w-[120px] px-3 py-2 rounded-lg border text-sm font-medium transition-all ${
isActive
? "border-primary bg-primary/10 text-primary shadow-sm"
: "border-border bg-card hover:border-primary/50 hover:bg-muted text-muted-foreground hover:text-foreground"
+49 -67
View File
@@ -1,3 +1,7 @@
/**
* @file DirectorySettings.tsx
* @description 配置目录设置 - 自定义各应用配置文件目录
*/
import { useState } from "react";
import { Folder, RotateCcw } from "lucide-react";
@@ -23,98 +27,76 @@ export function DirectorySettings() {
const handleSave = async () => {
setSaving(true);
// TODO: 保存目录配置到后端
await new Promise((resolve) => setTimeout(resolve, 500));
setSaving(false);
};
const directoryItems = [
{
key: "claudeConfigDir" as const,
label: "Claude 配置目录",
description: "Claude Code 配置文件存储位置",
},
{
key: "codexConfigDir" as const,
label: "Codex 配置目录",
description: "Codex CLI 配置文件存储位置",
},
{
key: "geminiConfigDir" as const,
label: "Gemini 配置目录",
description: "Gemini CLI 配置文件存储位置",
},
{ key: "claudeConfigDir" as const, label: "Claude" },
{ key: "codexConfigDir" as const, label: "Codex" },
{ key: "geminiConfigDir" as const, label: "Gemini" },
];
return (
<div className="space-y-6 max-w-2xl">
<div>
<h3 className="text-sm font-medium">配置目录</h3>
<p className="text-xs text-muted-foreground">
自定义各应用的配置文件目录位置。修改后需要重启应用生效。
<div className="space-y-3 max-w-2xl">
{/* 配置目录 */}
<div className="rounded-lg border p-3">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium">配置目录</h3>
<button
onClick={handleSave}
disabled={saving}
className="px-3 py-1 rounded bg-primary text-primary-foreground text-xs hover:bg-primary/90 disabled:opacity-50"
>
{saving ? "..." : "保存"}
</button>
</div>
<p className="text-xs text-muted-foreground mb-3">
自定义各应用配置文件目录,修改后需重启生效
</p>
</div>
<div className="space-y-4">
{directoryItems.map((item) => (
<div key={item.key} className="p-4 rounded-lg border space-y-2">
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium">{item.label}</label>
<p className="text-xs text-muted-foreground">
{item.description}
</p>
</div>
<button
onClick={() => handleReset(item.key)}
className="p-1.5 rounded hover:bg-muted text-muted-foreground"
title="重置为默认"
>
<RotateCcw className="h-4 w-4" />
</button>
</div>
<div className="flex gap-2">
<div className="space-y-2">
{directoryItems.map((item) => (
<div key={item.key} className="flex items-center gap-2">
<span className="text-xs text-muted-foreground w-14 shrink-0">
{item.label}
</span>
<div className="relative flex-1">
<Folder className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Folder className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<input
type="text"
value={dirs[item.key]}
onChange={(e) =>
setDirs((prev) => ({ ...prev, [item.key]: e.target.value }))
}
className="w-full pl-9 pr-3 py-2 rounded-lg border bg-background text-sm font-mono focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none"
className="w-full pl-8 pr-8 py-1.5 rounded border bg-background text-sm font-mono focus:ring-1 focus:ring-primary/20 focus:border-primary outline-none"
placeholder={defaultDirs[item.key]}
/>
<button
onClick={() => handleReset(item.key)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 rounded hover:bg-muted text-muted-foreground"
title="重置"
>
<RotateCcw className="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
))}
</div>
<div className="flex justify-end">
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
>
{saving ? "保存中..." : "保存设置"}
</button>
))}
</div>
</div>
{/* 数据管理 */}
<div className="pt-6 border-t space-y-4">
<div>
<div className="rounded-lg border p-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">数据管理</h3>
<p className="text-xs text-muted-foreground">导入或导出配置数据</p>
</div>
<div className="flex gap-3">
<button className="px-4 py-2 rounded-lg border text-sm hover:bg-muted">
导出配置
</button>
<button className="px-4 py-2 rounded-lg border text-sm hover:bg-muted">
导入配置
</button>
<div className="flex gap-2">
<button className="px-3 py-1 rounded border text-xs hover:bg-muted">
导出配置
</button>
<button className="px-3 py-1 rounded border text-xs hover:bg-muted">
导入配置
</button>
</div>
</div>
</div>
</div>
+11 -3
View File
@@ -19,11 +19,19 @@ export function ExtensionsSettings() {
const [activeTab, setActiveTab] = useState<Tab>("mcp");
return (
<div className="space-y-6">
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold">扩展管理</h3>
<p className="text-muted-foreground">
管理 MCP 服务器、Prompts 和 Skills
<p className="text-muted-foreground text-sm">
管理 MCP 服务器、Prompts 和 Skills。实验功能,不影响核心使用,
<a
href="https://github.com/aiclientproxy/proxycast/issues"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
问题反馈
</a>
</p>
</div>
+101 -155
View File
@@ -1,5 +1,9 @@
/**
* @file GeneralSettings.tsx
* @description 通用设置页面 - 主题、代理、启动行为配置
*/
import { useState, useEffect } from "react";
import { Moon, Sun, Monitor, Globe, RefreshCw } from "lucide-react";
import { Moon, Sun, Monitor, RefreshCw, Info } from "lucide-react";
import { cn, validateProxyUrl } from "@/lib/utils";
import { getConfig, saveConfig, Config } from "@/hooks/useTauri";
@@ -22,13 +26,10 @@ export function GeneralSettings() {
const [configLoading, setConfigLoading] = useState(true);
useEffect(() => {
// 读取当前主题
const savedTheme = localStorage.getItem("theme") as Theme | null;
if (savedTheme) {
setTheme(savedTheme);
}
// 加载配置
loadConfig();
}, []);
@@ -38,7 +39,6 @@ export function GeneralSettings() {
const c = await getConfig();
setConfig(c);
setProxyUrl(c.proxy_url || "");
// 加载最小化到托盘设置
setMinimizeToTray(c.minimize_to_tray ?? true);
} catch (e) {
console.error("加载配置失败:", e);
@@ -50,8 +50,6 @@ export function GeneralSettings() {
const handleThemeChange = (newTheme: Theme) => {
setTheme(newTheme);
localStorage.setItem("theme", newTheme);
// 应用主题
const root = document.documentElement;
if (newTheme === "system") {
const systemDark = window.matchMedia(
@@ -66,9 +64,7 @@ export function GeneralSettings() {
const handleProxyUrlChange = (value: string) => {
setProxyUrl(value);
if (value && !validateProxyUrl(value)) {
setProxyError(
"代理 URL 格式无效,请使用 http://、https:// 或 socks5:// 开头的地址",
);
setProxyError("格式无效,请使用 http://、https:// 或 socks5:// 开头");
} else {
setProxyError(null);
}
@@ -76,27 +72,18 @@ export function GeneralSettings() {
const handleSaveProxy = async () => {
if (!config) return;
// 验证格式
if (proxyUrl && !validateProxyUrl(proxyUrl)) {
setProxyError(
"代理 URL 格式无效,请使用 http://、https:// 或 socks5:// 开头的地址",
);
setProxyError("格式无效,请使用 http://、https:// 或 socks5:// 开头");
return;
}
setProxySaving(true);
setProxyMessage(null);
try {
const newConfig = {
...config,
proxy_url: proxyUrl.trim() || null,
};
const newConfig = { ...config, proxy_url: proxyUrl.trim() || null };
await saveConfig(newConfig);
setConfig(newConfig);
setProxyMessage({ type: "success", text: "代理设置已保存" });
setTimeout(() => setProxyMessage(null), 3000);
setProxyMessage({ type: "success", text: "已保存" });
setTimeout(() => setProxyMessage(null), 2000);
} catch (e: unknown) {
const errorMessage = e instanceof Error ? e.message : String(e);
setProxyMessage({ type: "error", text: `保存失败: ${errorMessage}` });
@@ -108,166 +95,125 @@ export function GeneralSettings() {
const themeOptions = [
{ id: "light" as Theme, label: "浅色", icon: Sun },
{ id: "dark" as Theme, label: "深色", icon: Moon },
{ id: "system" as Theme, label: "跟随系统", icon: Monitor },
{ id: "system" as Theme, label: "系统", icon: Monitor },
];
return (
<div className="space-y-6 max-w-2xl">
{/* 网络代理设置 */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Globe className="h-5 w-5 text-blue-500" />
<div>
<h3 className="text-sm font-medium">网络代理</h3>
<p className="text-xs text-muted-foreground">
配置 HTTP/HTTPS/SOCKS5 代理,用于访问海外 API 服务
</p>
</div>
<div className="space-y-4 max-w-2xl">
{/* 网络代理 */}
<div className="rounded-lg border p-3">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium">全局代理</h3>
{proxyMessage && (
<span
className={cn(
"text-xs px-2 py-0.5 rounded",
proxyMessage.type === "error"
? "bg-destructive/10 text-destructive"
: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
)}
>
{proxyMessage.text}
</span>
)}
</div>
{configLoading ? (
<div className="flex items-center justify-center p-4">
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
<div className="flex items-center justify-center py-2">
<RefreshCw className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : (
<div className="space-y-4 p-4 rounded-lg border">
{/* 代理消息提示 */}
{proxyMessage && (
<div
className={`rounded-lg border p-3 text-sm ${
proxyMessage.type === "error"
? "border-destructive bg-destructive/10 text-destructive"
: "border-green-500 bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400"
}`}
>
{proxyMessage.text}
</div>
)}
<div>
<label className="block text-sm font-medium mb-1.5">
全局代理 URL
</label>
<div className="space-y-2">
<div className="flex gap-2">
<input
type="text"
value={proxyUrl}
onChange={(e) => handleProxyUrlChange(e.target.value)}
placeholder="例如: http://127.0.0.1:7890 或 socks5://127.0.0.1:1080"
placeholder="http://127.0.0.1:7890 或 socks5://127.0.0.1:1080"
className={cn(
"w-full px-3 py-2 rounded-lg border bg-background text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none",
proxyError &&
"border-destructive focus:border-destructive focus:ring-destructive/20",
"flex-1 px-3 py-1.5 rounded border bg-background text-sm focus:ring-1 focus:ring-primary/20 focus:border-primary outline-none",
proxyError && "border-destructive",
)}
/>
{proxyError ? (
<p className="text-xs text-destructive mt-1">{proxyError}</p>
) : (
<p className="text-xs text-muted-foreground mt-1">
留空表示不使用代理。支持 http://、https://、socks5:// 协议
</p>
)}
<button
onClick={handleSaveProxy}
disabled={proxySaving || !!proxyError}
className="px-3 py-1.5 rounded bg-primary text-primary-foreground text-sm hover:bg-primary/90 disabled:opacity-50"
>
{proxySaving ? "..." : "保存"}
</button>
</div>
<div className="rounded-lg bg-blue-50 dark:bg-blue-900/20 p-3 text-sm">
<p className="font-medium text-blue-700 dark:text-blue-300">
代理优先级说明:
{proxyError ? (
<p className="text-xs text-destructive">{proxyError}</p>
) : (
<p className="text-xs text-muted-foreground flex items-center gap-1">
<Info className="h-3 w-3" />
凭证级代理优先于全局代理,留空表示直连
</p>
<ul className="mt-1 list-inside list-disc text-blue-600 dark:text-blue-400 text-xs">
<li>凭证级代理优先于全局代理</li>
<li>如果凭证未设置代理,则使用此全局代理</li>
<li>全局代理为空时,直接连接 API 服务</li>
</ul>
</div>
<button
onClick={handleSaveProxy}
disabled={proxySaving || !!proxyError}
className="w-full px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
>
{proxySaving ? "保存中..." : "保存代理设置"}
</button>
)}
</div>
)}
</div>
{/* 主题设置 */}
<div className="space-y-3">
<div>
{/* 主题 */}
<div className="rounded-lg border p-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">主题</h3>
<p className="text-xs text-muted-foreground">选择界面显示主题</p>
</div>
<div className="flex gap-2">
{themeOptions.map((option) => (
<button
key={option.id}
onClick={() => handleThemeChange(option.id)}
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-lg border transition-colors",
theme === option.id
? "border-primary bg-primary/10 text-primary"
: "border-border hover:border-muted-foreground/50",
)}
>
<option.icon className="h-4 w-4" />
<span className="text-sm">{option.label}</span>
</button>
))}
<div className="flex gap-1">
{themeOptions.map((option) => (
<button
key={option.id}
onClick={() => handleThemeChange(option.id)}
className={cn(
"flex items-center gap-1.5 px-3 py-1 rounded text-sm transition-colors",
theme === option.id
? "bg-primary/10 text-primary"
: "hover:bg-muted",
)}
>
<option.icon className="h-3.5 w-3.5" />
{option.label}
</button>
))}
</div>
</div>
</div>
{/* 启动设置 */}
<div className="space-y-4">
<div>
<h3 className="text-sm font-medium">启动行为</h3>
<p className="text-xs text-muted-foreground">
配置应用启动和关闭行为
</p>
</div>
{/* 启动行为 */}
<div className="rounded-lg border p-3 space-y-2">
<h3 className="text-sm font-medium">启动行为</h3>
<div className="space-y-3">
<label className="flex items-center justify-between p-3 rounded-lg border cursor-pointer hover:bg-muted/50">
<div>
<span className="text-sm font-medium">开机自启动</span>
<p className="text-xs text-muted-foreground">
系统启动时自动运行 ProxyCast
</p>
</div>
<input
type="checkbox"
checked={launchOnStartup}
onChange={(e) => setLaunchOnStartup(e.target.checked)}
className="w-4 h-4 rounded border-gray-300"
/>
</label>
<label className="flex items-center justify-between py-1.5 cursor-pointer">
<span className="text-sm">开机自启动</span>
<input
type="checkbox"
checked={launchOnStartup}
onChange={(e) => setLaunchOnStartup(e.target.checked)}
className="w-4 h-4 rounded border-gray-300"
/>
</label>
<label className="flex items-center justify-between p-3 rounded-lg border cursor-pointer hover:bg-muted/50">
<div>
<span className="text-sm font-medium">关闭时最小化到托盘</span>
<p className="text-xs text-muted-foreground">
点击关闭按钮时最小化而不是退出
</p>
</div>
<input
type="checkbox"
checked={minimizeToTray}
onChange={async (e) => {
const newValue = e.target.checked;
setMinimizeToTray(newValue);
if (config) {
try {
await saveConfig({ ...config, minimize_to_tray: newValue });
setConfig({ ...config, minimize_to_tray: newValue });
} catch (err) {
console.error("保存最小化到托盘设置失败:", err);
// 恢复原值
setMinimizeToTray(!newValue);
}
<label className="flex items-center justify-between py-1.5 cursor-pointer border-t pt-2">
<span className="text-sm">关闭时最小化到托盘</span>
<input
type="checkbox"
checked={minimizeToTray}
onChange={async (e) => {
const newValue = e.target.checked;
setMinimizeToTray(newValue);
if (config) {
try {
await saveConfig({ ...config, minimize_to_tray: newValue });
setConfig({ ...config, minimize_to_tray: newValue });
} catch (err) {
console.error("保存最小化到托盘设置失败:", err);
setMinimizeToTray(!newValue);
}
}}
className="w-4 h-4 rounded border-gray-300"
/>
</label>
</div>
}
}}
className="w-4 h-4 rounded border-gray-300"
/>
</label>
</div>
</div>
);
+55 -74
View File
@@ -1,11 +1,16 @@
/**
* @file QuotaSettings.tsx
* @description 配额超限策略设置
*/
import { useState, useEffect } from "react";
import { RefreshCw, CheckCircle2, AlertTriangle } from "lucide-react";
import { RefreshCw } from "lucide-react";
import {
getConfig,
saveConfig,
Config,
QuotaExceededConfig,
} from "@/hooks/useTauri";
import { cn } from "@/lib/utils";
export function QuotaSettings() {
const [config, setConfig] = useState<Config | null>(null);
@@ -34,11 +39,11 @@ export function QuotaSettings() {
setMessage(null);
try {
await saveConfig(config);
setMessage({ type: "success", text: "配额设置已保存" });
setTimeout(() => setMessage(null), 3000);
setMessage({ type: "success", text: "已保存" });
setTimeout(() => setMessage(null), 2000);
} catch (e: unknown) {
const errorMessage = e instanceof Error ? e.message : String(e);
setMessage({ type: "error", text: `保存失败: ${errorMessage}` });
setMessage({ type: "error", text: `失败: ${errorMessage}` });
}
setSaving(false);
};
@@ -53,8 +58,8 @@ export function QuotaSettings() {
if (!config) {
return (
<div className="flex items-center justify-center h-32">
<div className="animate-spin h-6 w-6 border-2 border-primary border-t-transparent rounded-full" />
<div className="flex items-center justify-center h-20">
<RefreshCw className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
);
}
@@ -62,44 +67,35 @@ export function QuotaSettings() {
const quota = config.quota_exceeded;
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<RefreshCw className="h-5 w-5 text-orange-500" />
<div>
<h3 className="text-sm font-medium">配额超限策略</h3>
<p className="text-xs text-muted-foreground">
配置配额超限时的自动切换行为
</p>
<div className="rounded-lg border p-3 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">配额超限策略</h3>
<div className="flex items-center gap-2">
{message && (
<span
className={cn(
"text-xs px-2 py-0.5 rounded",
message.type === "error"
? "bg-destructive/10 text-destructive"
: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
)}
>
{message.text}
</span>
)}
<button
onClick={handleSave}
disabled={saving}
className="px-3 py-1 rounded bg-primary text-primary-foreground text-xs hover:bg-primary/90 disabled:opacity-50"
>
{saving ? "..." : "保存"}
</button>
</div>
</div>
{/* 消息提示 */}
{message && (
<div
className={`rounded-lg border p-3 text-sm flex items-center gap-2 ${
message.type === "error"
? "border-destructive bg-destructive/10 text-destructive"
: "border-green-500 bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400"
}`}
>
{message.type === "success" ? (
<CheckCircle2 className="h-4 w-4" />
) : (
<AlertTriangle className="h-4 w-4" />
)}
{message.text}
</div>
)}
<div className="p-4 rounded-lg border space-y-4">
{/* 自动切换凭证 */}
<label className="flex items-center justify-between p-3 rounded-lg border cursor-pointer hover:bg-muted/50">
<div>
<span className="text-sm font-medium">自动切换凭证</span>
<p className="text-xs text-muted-foreground">
配额超限时自动切换到下一个可用凭证
</p>
</div>
<div className="space-y-2">
<label className="flex items-center justify-between py-1.5 cursor-pointer">
<span className="text-sm">自动切换凭证</span>
<input
type="checkbox"
checked={quota.switch_project}
@@ -108,14 +104,8 @@ export function QuotaSettings() {
/>
</label>
{/* 尝试预览模型 */}
<label className="flex items-center justify-between p-3 rounded-lg border cursor-pointer hover:bg-muted/50">
<div>
<span className="text-sm font-medium">尝试预览模型</span>
<p className="text-xs text-muted-foreground">
主模型配额超限时尝试使用预览版本
</p>
</div>
<label className="flex items-center justify-between py-1.5 cursor-pointer border-t pt-2">
<span className="text-sm">尝试预览模型</span>
<input
type="checkbox"
checked={quota.switch_preview_model}
@@ -126,32 +116,23 @@ export function QuotaSettings() {
/>
</label>
{/* 冷却时间 */}
<div>
<label className="block text-sm font-medium mb-1.5">
冷却时间(秒)
</label>
<input
type="number"
min={0}
value={quota.cooldown_seconds}
onChange={(e) =>
updateQuota({ cooldown_seconds: parseInt(e.target.value) || 300 })
}
className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none"
/>
<p className="text-xs text-muted-foreground mt-1">
凭证配额超限后的恢复等待时间,默认 300 秒
</p>
<div className="flex items-center justify-between py-1.5 border-t pt-2">
<span className="text-sm">冷却时间</span>
<div className="flex items-center gap-1">
<input
type="number"
min={0}
value={quota.cooldown_seconds}
onChange={(e) =>
updateQuota({
cooldown_seconds: parseInt(e.target.value) || 300,
})
}
className="w-20 px-2 py-1 rounded border bg-background text-sm text-right focus:ring-1 focus:ring-primary/20 focus:border-primary outline-none"
/>
<span className="text-xs text-muted-foreground">秒</span>
</div>
</div>
<button
onClick={handleSave}
disabled={saving}
className="w-full px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
>
{saving ? "保存中..." : "保存配额设置"}
</button>
</div>
</div>
);
+13 -11
View File
@@ -1,5 +1,5 @@
import { useState } from "react";
import { Route, Shield, AlertTriangle } from "lucide-react";
import { Route, Shield } from "lucide-react";
import { cn } from "@/lib/utils";
import { RoutingPage } from "../routing/RoutingPage";
import { ResiliencePage } from "../resilience/ResiliencePage";
@@ -15,17 +15,19 @@ export function RoutingSettings() {
const [activeTab, setActiveTab] = useState<Tab>("routing");
return (
<div className="space-y-6">
<div className="space-y-4">
<div>
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold">路由管理</h3>
<div className="flex items-center gap-1 px-2 py-1 bg-yellow-100 text-yellow-800 text-xs rounded-md">
<AlertTriangle className="h-3 w-3" />
实验功能
</div>
</div>
<p className="text-muted-foreground">
配置智能路由规则和容错策略(实验性功能,可能不稳定)
<h3 className="text-lg font-semibold">路由管理</h3>
<p className="text-muted-foreground text-sm">
配置智能路由规则和容错策略。实验功能,不影响核心使用,
<a
href="https://github.com/aiclientproxy/proxycast/issues"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
问题反馈
</a>
</p>
</div>
+7 -8
View File
@@ -1,7 +1,6 @@
import { useState } from "react";
import { cn } from "@/lib/utils";
import { GeneralSettings } from "./GeneralSettings";
import { ProxySettings } from "./ProxySettings";
import { DirectorySettings } from "./DirectorySettings";
import { AboutSection } from "./AboutSection";
import { TlsSettings } from "./TlsSettings";
@@ -12,20 +11,18 @@ import { RoutingSettings } from "./RoutingSettings";
type SettingsTab =
| "general"
| "proxy"
| "security"
| "advanced"
| "extensions"
| "routing"
| "about";
const tabs: { id: SettingsTab; label: string }[] = [
const tabs: { id: SettingsTab; label: string; experimental?: boolean }[] = [
{ id: "general", label: "通用" },
{ id: "proxy", label: "代理服务" },
{ id: "security", label: "安全" },
{ id: "advanced", label: "高级" },
{ id: "extensions", label: "扩展" },
{ id: "routing", label: "路由管理 (实验)" },
{ id: "extensions", label: "扩展", experimental: true },
{ id: "routing", label: "路由管理", experimental: true },
{ id: "about", label: "关于" },
];
@@ -53,6 +50,9 @@ export function SettingsPage() {
)}
>
{tab.label}
{tab.experimental && (
<span className="text-[8px] text-red-500 ml-1">(实验)</span>
)}
{activeTab === tab.id && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
@@ -63,7 +63,6 @@ export function SettingsPage() {
{/* 内容区域 */}
<div className="flex-1 overflow-auto">
{activeTab === "general" && <GeneralSettings />}
{activeTab === "proxy" && <ProxySettings />}
{activeTab === "security" && (
<div className="space-y-6 max-w-2xl">
<TlsSettings />
@@ -71,7 +70,7 @@ export function SettingsPage() {
</div>
)}
{activeTab === "advanced" && (
<div className="space-y-6 max-w-2xl">
<div className="space-y-4 max-w-2xl">
<DirectorySettings />
<QuotaSettings />
</div>
+1 -1
View File
@@ -8,7 +8,7 @@ interface LiveConfigModalProps {
}
const configPaths: Record<AppType, string> = {
claude: "~/.claude.json",
claude: "~/.claude/settings.json",
codex: "~/.codex/auth.json & config.toml",
gemini: "~/.gemini/.env & settings.json",
proxycast: "",
+21
View File
@@ -0,0 +1,21 @@
import { AlertTriangle, ExternalLink } from "lucide-react";
export function ExperimentalBanner() {
return (
<div className="flex items-center gap-2 px-3 py-2 bg-yellow-50 border border-yellow-200 rounded-md text-xs text-yellow-800">
<AlertTriangle className="h-3 w-3 shrink-0" />
<span>
实验功能,不影响核心使用。问题反馈:
<a
href="https://github.com/aiclientproxy/proxycast/issues"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 text-yellow-700 hover:text-yellow-900 underline ml-1"
>
GitHub Issue
<ExternalLink className="h-2.5 w-2.5" />
</a>
</span>
</div>
);
}
+14
View File
@@ -464,3 +464,17 @@ export async function setEndpointProvider(
): Promise<string> {
return invoke("set_endpoint_provider", { endpoint: clientType, provider });
}
// Network Info
export interface NetworkInfo {
localhost: string;
lan_ip: string | null;
}
/**
* 获取本地网络信息
* @returns 本地和内网 IP 地址
*/
export async function getNetworkInfo(): Promise<NetworkInfo> {
return invoke("get_network_info");
}
+2 -19
View File
@@ -1,7 +1,6 @@
import { useState, useCallback, useEffect } from "react";
import {
Activity,
RefreshCw,
Download,
BarChart3,
List,
@@ -100,11 +99,6 @@ export function FlowMonitorPage() {
setSelectedFlow(null);
}, []);
// 刷新数据
const handleRefresh = useCallback(() => {
setRefreshKey((prev) => prev + 1);
}, []);
// 导出单个 Flow
const handleExportFlow = useCallback(
(flowId: string, _format: ExportFormat) => {
@@ -128,8 +122,8 @@ export function FlowMonitorPage() {
// 清理成功
const handleCleanupSuccess = useCallback(() => {
// 清理成功后刷新数据
handleRefresh();
}, [handleRefresh]);
setRefreshKey((prev) => prev + 1);
}, []);
// 设置窗口大小
const handleSetWindowSize = useCallback(async (optionId: string) => {
@@ -265,15 +259,6 @@ export function FlowMonitorPage() {
<Trash2 className="h-4 w-4 text-red-500" />
清理
</button>
{/* 刷新按钮 */}
<button
onClick={handleRefresh}
className="flex items-center gap-1 rounded-lg border px-3 py-2 text-sm hover:bg-muted"
>
<RefreshCw className="h-4 w-4" />
刷新
</button>
</div>
</div>
@@ -295,7 +280,6 @@ export function FlowMonitorPage() {
key={refreshKey}
filter={filter}
onFlowSelect={handleFlowSelect}
onRefresh={handleRefresh}
enableRealtime={true}
/>
) : (
@@ -303,7 +287,6 @@ export function FlowMonitorPage() {
key={refreshKey}
filter={filter}
autoRefreshInterval={30000}
onRefresh={handleRefresh}
/>
)}
</>