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:
coso
2025-12-14 18:27:58 +08:00
co-authored by Claude Sonnet 4.5
parent cf0bf919c2
commit 42d140dfd5
70 changed files with 9606 additions and 925 deletions
+796
View File
@@ -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>
);
}
+139
View File
@@ -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>
);
}
+278
View File
@@ -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>
);
}