mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
v0.18.0: 配置管理优化 - 外部配置变更检测与一键导入
This commit is contained in:
Generated
+2
-2
@@ -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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "proxycast",
|
||||
"private": true,
|
||||
"version": "0.17.12",
|
||||
"version": "0.18.0",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Generated
+1
-1
@@ -3377,7 +3377,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast"
|
||||
version = "0.17.12"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user