mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
feat: v0.5.0 - 整合模型和日志页面到 API Server
将"模型"和"日志"页面迁移到 API Server 页面中作为标签页,使功能更加集中和关联。 主要变更: - 创建 ModelsTab.tsx 和 LogsTab.tsx 组件 - 在 API Server 页面添加"可用模型"和"日志"两个新标签页 - 从导航栏移除独立的"模型"和"日志"入口 - 删除原始的 Models.tsx 和 Logs.tsx 文件 - 更新版本号到 0.5.0 优化: - 导航栏更简洁,减少了两个独立入口 - API Server 页面成为管理代理服务的中心 - 所有功能逻辑保持不变,只是组织结构更合理 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.5
parent
cf0bf919c2
commit
42d140dfd5
@@ -0,0 +1,796 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Activity,
|
||||
Server,
|
||||
Zap,
|
||||
Clock,
|
||||
Play,
|
||||
Copy,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Settings,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { ModelsTab } from "./ModelsTab";
|
||||
import { LogsTab } from "./LogsTab";
|
||||
import {
|
||||
startServer,
|
||||
stopServer,
|
||||
getServerStatus,
|
||||
getConfig,
|
||||
saveConfig,
|
||||
reloadCredentials,
|
||||
testApi,
|
||||
ServerStatus,
|
||||
Config,
|
||||
TestResult,
|
||||
getDefaultProvider,
|
||||
setDefaultProvider,
|
||||
// OpenAI/Claude Custom
|
||||
getOpenAICustomStatus,
|
||||
setOpenAICustomConfig,
|
||||
getClaudeCustomStatus,
|
||||
setClaudeCustomConfig,
|
||||
OpenAICustomStatus,
|
||||
ClaudeCustomStatus,
|
||||
} from "@/hooks/useTauri";
|
||||
|
||||
interface TestState {
|
||||
endpoint: string;
|
||||
status: "idle" | "loading" | "success" | "error";
|
||||
response?: string;
|
||||
time?: number;
|
||||
httpStatus?: number;
|
||||
}
|
||||
|
||||
type TabId = "server" | "openai" | "claude" | "models" | "logs";
|
||||
|
||||
export function ApiServerPage() {
|
||||
const [status, setStatus] = useState<ServerStatus | null>(null);
|
||||
const [config, setConfig] = useState<Config | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [_error, setError] = useState<string | null>(null);
|
||||
const [testResults, setTestResults] = useState<Record<string, TestState>>({});
|
||||
const [copiedCmd, setCopiedCmd] = useState<string | null>(null);
|
||||
const [expandedTest, setExpandedTest] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<TabId>("server");
|
||||
|
||||
// Config editing
|
||||
const [editPort, setEditPort] = useState<string>("");
|
||||
const [editApiKey, setEditApiKey] = useState<string>("");
|
||||
const [defaultProvider, setDefaultProviderState] = useState<string>("kiro");
|
||||
|
||||
// OpenAI Custom state
|
||||
const [openaiStatus, setOpenaiStatus] = useState<OpenAICustomStatus | null>(
|
||||
null,
|
||||
);
|
||||
const [openaiApiKey, setOpenaiApiKey] = useState("");
|
||||
const [openaiBaseUrl, setOpenaiBaseUrl] = useState("");
|
||||
|
||||
// Claude Custom state
|
||||
const [claudeStatus, setClaudeStatus] = useState<ClaudeCustomStatus | null>(
|
||||
null,
|
||||
);
|
||||
const [claudeApiKey, setClaudeApiKey] = useState("");
|
||||
const [claudeBaseUrl, setClaudeBaseUrl] = useState("");
|
||||
|
||||
const [message, setMessage] = useState<{
|
||||
type: "success" | "error";
|
||||
text: string;
|
||||
} | null>(null);
|
||||
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const s = await getServerStatus();
|
||||
setStatus(s);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
const c = await getConfig();
|
||||
setConfig(c);
|
||||
setEditPort(c.server.port.toString());
|
||||
setEditApiKey(c.server.api_key);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const loadOpenAICustomStatus = async () => {
|
||||
try {
|
||||
const status = await getOpenAICustomStatus();
|
||||
setOpenaiStatus(status);
|
||||
setOpenaiBaseUrl(status.base_url);
|
||||
} catch (e) {
|
||||
console.error("Failed to load OpenAI Custom status:", e);
|
||||
}
|
||||
};
|
||||
|
||||
const loadClaudeCustomStatus = async () => {
|
||||
try {
|
||||
const status = await getClaudeCustomStatus();
|
||||
setClaudeStatus(status);
|
||||
setClaudeBaseUrl(status.base_url);
|
||||
} catch (e) {
|
||||
console.error("Failed to load Claude Custom status:", e);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
fetchConfig();
|
||||
loadDefaultProvider();
|
||||
loadOpenAICustomStatus();
|
||||
loadClaudeCustomStatus();
|
||||
|
||||
const statusInterval = setInterval(fetchStatus, 3000);
|
||||
return () => clearInterval(statusInterval);
|
||||
}, []);
|
||||
|
||||
const loadDefaultProvider = async () => {
|
||||
try {
|
||||
const dp = await getDefaultProvider();
|
||||
setDefaultProviderState(dp);
|
||||
} catch (e) {
|
||||
console.error("Failed to get default provider:", e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStart = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await reloadCredentials();
|
||||
await startServer();
|
||||
await fetchStatus();
|
||||
setMessage({ type: "success", text: "服务已启动" });
|
||||
} catch (e: unknown) {
|
||||
const errMsg = e instanceof Error ? e.message : String(e);
|
||||
setError(errMsg);
|
||||
setMessage({ type: "error", text: `启动失败: ${errMsg}` });
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await stopServer();
|
||||
await fetchStatus();
|
||||
setMessage({ type: "success", text: "服务已停止" });
|
||||
} catch (e: unknown) {
|
||||
const errMsg = e instanceof Error ? e.message : String(e);
|
||||
setError(errMsg);
|
||||
setMessage({ type: "error", text: `停止失败: ${errMsg}` });
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleSaveServerConfig = async () => {
|
||||
if (!config) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const newConfig = {
|
||||
...config,
|
||||
server: {
|
||||
...config.server,
|
||||
port: parseInt(editPort) || 3001,
|
||||
api_key: editApiKey,
|
||||
},
|
||||
};
|
||||
await saveConfig(newConfig);
|
||||
await fetchConfig();
|
||||
setMessage({ type: "success", text: "服务器配置已保存" });
|
||||
} catch (e: unknown) {
|
||||
const errMsg = e instanceof Error ? e.message : String(e);
|
||||
setMessage({ type: "error", text: `保存失败: ${errMsg}` });
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleSetDefaultProvider = async (providerId: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await setDefaultProvider(providerId);
|
||||
setDefaultProviderState(providerId);
|
||||
setMessage({ type: "success", text: `默认 Provider 已切换为: ${providerId}` });
|
||||
} catch (e: unknown) {
|
||||
const errMsg = e instanceof Error ? e.message : String(e);
|
||||
setMessage({ type: "error", text: `切换失败: ${errMsg}` });
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleSaveOpenAIConfig = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await setOpenAICustomConfig(
|
||||
openaiApiKey || null,
|
||||
openaiBaseUrl || null,
|
||||
true,
|
||||
);
|
||||
await loadOpenAICustomStatus();
|
||||
setMessage({ type: "success", text: "OpenAI 配置已保存" });
|
||||
} catch (e: unknown) {
|
||||
const errMsg = e instanceof Error ? e.message : String(e);
|
||||
setMessage({ type: "error", text: `保存失败: ${errMsg}` });
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleSaveClaudeConfig = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await setClaudeCustomConfig(
|
||||
claudeApiKey || null,
|
||||
claudeBaseUrl || null,
|
||||
true,
|
||||
);
|
||||
await loadClaudeCustomStatus();
|
||||
setMessage({ type: "success", text: "Claude 配置已保存" });
|
||||
} catch (e: unknown) {
|
||||
const errMsg = e instanceof Error ? e.message : String(e);
|
||||
setMessage({ type: "error", text: `保存失败: ${errMsg}` });
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
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:3001";
|
||||
const apiKey = config?.server.api_key || "proxycast-key";
|
||||
|
||||
// Test endpoints
|
||||
const testEndpoints = [
|
||||
{
|
||||
id: "health",
|
||||
name: "健康检查",
|
||||
method: "GET",
|
||||
path: "/health",
|
||||
needsAuth: false,
|
||||
body: null,
|
||||
},
|
||||
{
|
||||
id: "models",
|
||||
name: "模型列表",
|
||||
method: "GET",
|
||||
path: "/v1/models",
|
||||
needsAuth: true,
|
||||
body: null,
|
||||
},
|
||||
{
|
||||
id: "chat",
|
||||
name: "OpenAI Chat",
|
||||
method: "POST",
|
||||
path: "/v1/chat/completions",
|
||||
needsAuth: true,
|
||||
body: JSON.stringify({
|
||||
model: "claude-sonnet-4-5",
|
||||
messages: [{ role: "user", content: "Say hi in one word" }],
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "anthropic",
|
||||
name: "Anthropic Messages",
|
||||
method: "POST",
|
||||
path: "/v1/messages",
|
||||
needsAuth: true,
|
||||
body: JSON.stringify({
|
||||
model: "claude-sonnet-4-5",
|
||||
max_tokens: 100,
|
||||
messages: [{ role: "user", content: "What is 1+1? Answer with just the number." }],
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
const runTest = async (endpoint: (typeof testEndpoints)[0]) => {
|
||||
setTestResults((prev) => ({
|
||||
...prev,
|
||||
[endpoint.id]: { endpoint: endpoint.path, status: "loading" },
|
||||
}));
|
||||
|
||||
try {
|
||||
const result: TestResult = await testApi(
|
||||
endpoint.method,
|
||||
endpoint.path,
|
||||
endpoint.body,
|
||||
endpoint.needsAuth,
|
||||
);
|
||||
|
||||
setTestResults((prev) => ({
|
||||
...prev,
|
||||
[endpoint.id]: {
|
||||
endpoint: endpoint.path,
|
||||
status: result.success ? "success" : "error",
|
||||
response: result.body || `HTTP ${result.status}: 无响应内容`,
|
||||
time: result.time_ms,
|
||||
httpStatus: result.status,
|
||||
},
|
||||
}));
|
||||
} catch (e: unknown) {
|
||||
const errMsg = e instanceof Error ? e.message : String(e);
|
||||
setTestResults((prev) => ({
|
||||
...prev,
|
||||
[endpoint.id]: {
|
||||
endpoint: endpoint.path,
|
||||
status: "error",
|
||||
response: `请求失败: ${errMsg}`,
|
||||
},
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const runAllTests = async () => {
|
||||
for (const endpoint of testEndpoints) {
|
||||
await runTest(endpoint);
|
||||
}
|
||||
};
|
||||
|
||||
const getCurlCommand = (endpoint: (typeof testEndpoints)[0]) => {
|
||||
let cmd = `curl -s ${serverUrl}${endpoint.path}`;
|
||||
if (endpoint.needsAuth) {
|
||||
cmd += ` \\\n -H "Authorization: Bearer ${apiKey}"`;
|
||||
}
|
||||
if (endpoint.body) {
|
||||
cmd += ` \\\n -H "Content-Type: application/json"`;
|
||||
cmd += ` \\\n -d '${endpoint.body}'`;
|
||||
}
|
||||
return cmd;
|
||||
};
|
||||
|
||||
const copyCommand = (id: string, cmd: string) => {
|
||||
navigator.clipboard.writeText(cmd);
|
||||
setCopiedCmd(id);
|
||||
setTimeout(() => setCopiedCmd(null), 2000);
|
||||
};
|
||||
|
||||
const getStatusBadge = (result?: TestState) => {
|
||||
if (!result || result.status === "idle") {
|
||||
return <span className="text-xs text-gray-400">未测试</span>;
|
||||
}
|
||||
if (result.status === "loading") {
|
||||
return <span className="text-xs text-blue-500">测试中...</span>;
|
||||
}
|
||||
if (result.status === "success") {
|
||||
return <span className="text-xs text-green-600">{result.time}ms</span>;
|
||||
}
|
||||
return (
|
||||
<span className="text-xs text-red-500">
|
||||
失败 {result.httpStatus ? `(${result.httpStatus})` : ""}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">API Server</h2>
|
||||
<p className="text-muted-foreground">
|
||||
管理代理 API 服务器和自定义 API 配置
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-lg border p-3 text-sm ${
|
||||
message.type === "success"
|
||||
? "border-green-500 bg-green-50 text-green-700 dark:bg-green-950/30"
|
||||
: "border-red-500 bg-red-50 text-red-700 dark:bg-red-950/30"
|
||||
}`}
|
||||
>
|
||||
{message.type === "success" ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
{message.text}
|
||||
</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">
|
||||
{[
|
||||
{ id: "server" as TabId, name: "服务器控制" },
|
||||
{ id: "openai" as TabId, name: "OpenAI 自定义" },
|
||||
{ id: "claude" as TabId, name: "Claude 自定义" },
|
||||
{ id: "models" as TabId, name: "可用模型" },
|
||||
{ id: "logs" as TabId, name: "日志" },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px whitespace-nowrap ${
|
||||
activeTab === tab.id
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{tab.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<button
|
||||
className="rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 disabled:opacity-50"
|
||||
onClick={handleStart}
|
||||
disabled={loading || status?.running}
|
||||
>
|
||||
{loading ? "处理中..." : "启动服务"}
|
||||
</button>
|
||||
<button
|
||||
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50"
|
||||
onClick={handleStop}
|
||||
disabled={loading || !status?.running}
|
||||
>
|
||||
停止服务
|
||||
</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>
|
||||
<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>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{["kiro", "gemini", "qwen", "openai", "claude"].map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => handleSetDefaultProvider(p)}
|
||||
disabled={loading}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-medium ${
|
||||
defaultProvider === p
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "border hover:bg-muted"
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{p === "kiro"
|
||||
? "Kiro Claude"
|
||||
: p === "gemini"
|
||||
? "Gemini CLI"
|
||||
: p === "qwen"
|
||||
? "通义千问"
|
||||
: p === "openai"
|
||||
? "OpenAI 自定义"
|
||||
: "Claude 自定义"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Testing */}
|
||||
<div className="rounded-lg border bg-card p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="font-semibold">API 测试</h3>
|
||||
<button
|
||||
onClick={runAllTests}
|
||||
disabled={!status?.running}
|
||||
className="flex items-center gap-2 rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
测试全部
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{testEndpoints.map((endpoint) => {
|
||||
const result = testResults[endpoint.id];
|
||||
const isExpanded = expandedTest === endpoint.id;
|
||||
const curlCmd = getCurlCommand(endpoint);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={endpoint.id}
|
||||
className="rounded-lg border bg-background"
|
||||
>
|
||||
<div className="flex items-center justify-between p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`rounded px-2 py-0.5 text-xs font-medium ${
|
||||
endpoint.method === "GET"
|
||||
? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
|
||||
: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
|
||||
}`}
|
||||
>
|
||||
{endpoint.method}
|
||||
</span>
|
||||
<span className="font-medium">{endpoint.name}</span>
|
||||
<code className="text-xs text-muted-foreground">
|
||||
{endpoint.path}
|
||||
</code>
|
||||
{getStatusBadge(result)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => copyCommand(endpoint.id, curlCmd)}
|
||||
className="rounded p-1.5 hover:bg-muted"
|
||||
title="复制 curl 命令"
|
||||
>
|
||||
{copiedCmd === endpoint.id ? (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => runTest(endpoint)}
|
||||
disabled={
|
||||
!status?.running || result?.status === "loading"
|
||||
}
|
||||
className="rounded bg-primary/10 px-2 py-1 text-xs font-medium text-primary hover:bg-primary/20 disabled:opacity-50"
|
||||
>
|
||||
测试
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setExpandedTest(isExpanded ? null : endpoint.id)
|
||||
}
|
||||
className="rounded p-1.5 hover:bg-muted"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="border-t p-3 space-y-3">
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-muted-foreground">
|
||||
curl 命令
|
||||
</p>
|
||||
<pre className="rounded bg-muted p-2 text-xs overflow-x-auto">
|
||||
{curlCmd}
|
||||
</pre>
|
||||
</div>
|
||||
{result?.response && (
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-muted-foreground">
|
||||
响应{" "}
|
||||
{result.httpStatus && `(HTTP ${result.httpStatus})`}
|
||||
</p>
|
||||
<pre
|
||||
className={`rounded p-2 text-xs overflow-x-auto max-h-40 ${
|
||||
result.status === "success"
|
||||
? "bg-green-50 dark:bg-green-950/30"
|
||||
: "bg-red-50 dark:bg-red-950/30"
|
||||
}`}
|
||||
>
|
||||
{(() => {
|
||||
try {
|
||||
return JSON.stringify(
|
||||
JSON.parse(result.response),
|
||||
null,
|
||||
2,
|
||||
);
|
||||
} catch {
|
||||
return result.response || "(空响应)";
|
||||
}
|
||||
})()}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OpenAI Custom Tab */}
|
||||
{activeTab === "openai" && (
|
||||
<div className="rounded-lg border bg-card p-6">
|
||||
<h3 className="mb-4 font-semibold">OpenAI 自定义 API 配置</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
配置自定义 OpenAI 兼容 API 端点,用于转发请求到其他 OpenAI 兼容服务
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-muted-foreground mb-1">
|
||||
API Key
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={openaiApiKey}
|
||||
onChange={(e) => setOpenaiApiKey(e.target.value)}
|
||||
placeholder="sk-..."
|
||||
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-muted-foreground mb-1">
|
||||
Base URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={openaiBaseUrl}
|
||||
onChange={(e) => setOpenaiBaseUrl(e.target.value)}
|
||||
placeholder="https://api.openai.com/v1"
|
||||
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground">状态:</span>
|
||||
<span
|
||||
className={
|
||||
openaiStatus?.has_api_key ? "text-green-600" : "text-red-500"
|
||||
}
|
||||
>
|
||||
{openaiStatus?.has_api_key ? "已配置" : "未配置"}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSaveOpenAIConfig}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "保存中..." : "保存配置"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Claude Custom Tab */}
|
||||
{activeTab === "claude" && (
|
||||
<div className="rounded-lg border bg-card p-6">
|
||||
<h3 className="mb-4 font-semibold">Claude 自定义 API 配置</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
配置自定义 Claude API 端点,用于直接调用 Anthropic API
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-muted-foreground mb-1">
|
||||
API Key
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={claudeApiKey}
|
||||
onChange={(e) => setClaudeApiKey(e.target.value)}
|
||||
placeholder="sk-ant-..."
|
||||
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-muted-foreground mb-1">
|
||||
Base URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={claudeBaseUrl}
|
||||
onChange={(e) => setClaudeBaseUrl(e.target.value)}
|
||||
placeholder="https://api.anthropic.com"
|
||||
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground">状态:</span>
|
||||
<span
|
||||
className={
|
||||
claudeStatus?.has_api_key ? "text-green-600" : "text-red-500"
|
||||
}
|
||||
>
|
||||
{claudeStatus?.has_api_key ? "已配置" : "未配置"}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSaveClaudeConfig}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "保存中..." : "保存配置"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Models Tab */}
|
||||
{activeTab === "models" && <ModelsTab />}
|
||||
|
||||
{/* Logs Tab */}
|
||||
{activeTab === "logs" && <LogsTab />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useState, useEffect, useRef } from "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 logsEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs();
|
||||
const interval = setInterval(fetchLogs, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoScroll && logsEndRef.current) {
|
||||
logsEndRef.current.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, [logs, autoScroll]);
|
||||
|
||||
const fetchLogs = async () => {
|
||||
try {
|
||||
const l = await getLogs();
|
||||
setLogs(l);
|
||||
} catch (e) {
|
||||
// 如果后端还没实现,使用空数组
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = async () => {
|
||||
try {
|
||||
await clearLogs();
|
||||
setLogs([]);
|
||||
} catch {
|
||||
setLogs([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const content = logs
|
||||
.map(
|
||||
(l) =>
|
||||
`[${new Date(l.timestamp).toLocaleString()}] [${l.level.toUpperCase()}] ${l.message}`,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
const blob = new Blob([content], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `proxycast-logs-${new Date().toISOString().slice(0, 10)}.txt`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const getLevelColor = (level: string) => {
|
||||
switch (level) {
|
||||
case "error":
|
||||
return "text-red-500";
|
||||
case "warn":
|
||||
return "text-yellow-500";
|
||||
case "debug":
|
||||
return "text-gray-400";
|
||||
default:
|
||||
return "text-blue-500";
|
||||
}
|
||||
};
|
||||
|
||||
const getLevelBg = (level: string) => {
|
||||
switch (level) {
|
||||
case "error":
|
||||
return "bg-red-500/10";
|
||||
case "warn":
|
||||
return "bg-yellow-500/10";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<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"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card">
|
||||
<div className="max-h-[600px] overflow-auto p-4 font-mono text-sm">
|
||||
{logs.length === 0 ? (
|
||||
<p className="text-center text-muted-foreground">
|
||||
暂无日志,启动服务后将显示请求日志
|
||||
</p>
|
||||
) : (
|
||||
logs.map((log, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex gap-2 py-1 px-2 rounded ${getLevelBg(log.level)}`}
|
||||
>
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{new Date(log.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
<span
|
||||
className={`font-medium shrink-0 ${getLevelColor(log.level)}`}
|
||||
>
|
||||
[{log.level.toUpperCase()}]
|
||||
</span>
|
||||
<span className="break-all">{log.message}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={logsEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Cpu, RefreshCw, Copy, Check, Search } from "lucide-react";
|
||||
import { getAvailableModels, ModelInfo } from "@/hooks/useTauri";
|
||||
|
||||
// 模型分组配置
|
||||
const MODEL_GROUPS: Record<
|
||||
string,
|
||||
{ name: string; color: string; models: string[] }
|
||||
> = {
|
||||
kiro: {
|
||||
name: "Kiro Claude",
|
||||
color: "bg-purple-100 text-purple-700",
|
||||
models: [
|
||||
"claude-sonnet-4-5",
|
||||
"claude-sonnet-4-5-20250514",
|
||||
"claude-sonnet-4-5-20250929",
|
||||
"claude-3-7-sonnet-20250219",
|
||||
"claude-3-5-sonnet-latest",
|
||||
"claude-3-5-sonnet-20241022",
|
||||
"claude-opus-4-5-20250514",
|
||||
"claude-haiku-4-5-20250514",
|
||||
],
|
||||
},
|
||||
gemini: {
|
||||
name: "Gemini CLI",
|
||||
color: "bg-blue-100 text-blue-700",
|
||||
models: [
|
||||
"gemini-2.5-flash",
|
||||
"gemini-2.5-flash-lite",
|
||||
"gemini-2.5-pro",
|
||||
"gemini-2.5-pro-preview-06-05",
|
||||
"gemini-3-pro-preview",
|
||||
"gemini-2.0-flash-exp",
|
||||
],
|
||||
},
|
||||
qwen: {
|
||||
name: "通义千问",
|
||||
color: "bg-orange-100 text-orange-700",
|
||||
models: [
|
||||
"qwen3-coder-plus",
|
||||
"qwen3-coder-flash",
|
||||
"qwen-coder-plus",
|
||||
"qwen-coder-turbo",
|
||||
],
|
||||
},
|
||||
openai: {
|
||||
name: "OpenAI",
|
||||
color: "bg-green-100 text-green-700",
|
||||
models: [
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
"gpt-4-turbo",
|
||||
"gpt-4",
|
||||
"gpt-3.5-turbo",
|
||||
"o1-preview",
|
||||
"o1-mini",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export function ModelsTab() {
|
||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchModels();
|
||||
}, []);
|
||||
|
||||
const fetchModels = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await getAvailableModels();
|
||||
setModels(data || []);
|
||||
} catch (e: any) {
|
||||
setError(e.toString());
|
||||
setModels([]);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const copyModelId = (id: string) => {
|
||||
navigator.clipboard.writeText(id);
|
||||
setCopied(id);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
};
|
||||
|
||||
const getModelGroup = (modelId: string): string | null => {
|
||||
for (const [groupId, group] of Object.entries(MODEL_GROUPS)) {
|
||||
if (
|
||||
group.models.some((m) =>
|
||||
modelId.toLowerCase().includes(m.toLowerCase().split("-")[0]),
|
||||
)
|
||||
) {
|
||||
return groupId;
|
||||
}
|
||||
}
|
||||
// 根据 owned_by 判断
|
||||
const model = models.find((m) => m.id === modelId);
|
||||
if (model?.owned_by === "anthropic") return "kiro";
|
||||
if (model?.owned_by === "google") return "gemini";
|
||||
if (model?.owned_by === "alibaba") return "qwen";
|
||||
if (model?.owned_by === "openai") return "openai";
|
||||
return null;
|
||||
};
|
||||
|
||||
const getGroupBadge = (groupId: string | null) => {
|
||||
if (!groupId || !MODEL_GROUPS[groupId]) return null;
|
||||
const group = MODEL_GROUPS[groupId];
|
||||
return (
|
||||
<span
|
||||
className={`rounded px-2 py-0.5 text-xs font-medium ${group.color}`}
|
||||
>
|
||||
{group.name}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// 过滤模型
|
||||
const filteredModels = models.filter((model) => {
|
||||
const matchesSearch = model.id.toLowerCase().includes(search.toLowerCase());
|
||||
const matchesGroup =
|
||||
!selectedGroup || getModelGroup(model.id) === selectedGroup;
|
||||
return matchesSearch && matchesGroup;
|
||||
});
|
||||
|
||||
// 按 provider 分组统计
|
||||
const groupCounts = models.reduce(
|
||||
(acc, model) => {
|
||||
const group = getModelGroup(model.id);
|
||||
if (group) {
|
||||
acc[group] = (acc[group] || 0) + 1;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-500 bg-red-50 p-4 text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 搜索和过滤 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索模型..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full rounded-lg border bg-background pl-10 pr-4 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchModels}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Provider 过滤标签 */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => setSelectedGroup(null)}
|
||||
className={`rounded-lg px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
!selectedGroup
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted hover:bg-muted/80"
|
||||
}`}
|
||||
>
|
||||
全部 ({models.length})
|
||||
</button>
|
||||
{Object.entries(MODEL_GROUPS).map(([groupId, group]) => {
|
||||
const count = groupCounts[groupId] || 0;
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<button
|
||||
key={groupId}
|
||||
onClick={() =>
|
||||
setSelectedGroup(selectedGroup === groupId ? null : groupId)
|
||||
}
|
||||
className={`rounded-lg px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
selectedGroup === groupId
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted hover:bg-muted/80"
|
||||
}`}
|
||||
>
|
||||
{group.name} ({count})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 模型列表 */}
|
||||
<div className="rounded-lg border bg-card">
|
||||
<div className="border-b px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">模型列表</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{filteredModels.length} 个模型
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : filteredModels.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<Cpu className="h-12 w-12 mb-2 opacity-50" />
|
||||
<p>暂无模型数据</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{filteredModels.map((model) => (
|
||||
<div
|
||||
key={model.id}
|
||||
className="flex items-center justify-between px-4 py-3 hover:bg-muted/50"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Cpu className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="font-medium">{model.id}</code>
|
||||
{getGroupBadge(getModelGroup(model.id))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{model.owned_by}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => copyModelId(model.id)}
|
||||
className="rounded p-2 hover:bg-muted"
|
||||
title="复制模型 ID"
|
||||
>
|
||||
{copied === model.id ? (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 使用说明 */}
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h3 className="mb-2 font-semibold">使用说明</h3>
|
||||
<div className="space-y-2 text-sm text-muted-foreground">
|
||||
<p>• 点击模型 ID 右侧的复制按钮可快速复制模型名称</p>
|
||||
<p>
|
||||
• 在 API 请求中使用{" "}
|
||||
<code className="rounded bg-muted px-1">model</code> 参数指定模型
|
||||
</p>
|
||||
<p>• 不同 Provider 支持的模型不同,请确保已配置对应的凭证</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user