mirror of
https://github.com/xmanrui/OpenClaw-bot-review.git
synced 2026-07-27 22:25:52 +00:00
feat: 全局统计图表、模型用量统计、模型测试按钮、使用中显示名称
- 新增 /api/stats-all: 聚合所有 agent 的 token 和响应时间统计 - 首页新增 token 趋势和响应时间趋势折线图(天/周/月切换) - 新增 /api/stats-models: 按模型聚合 input/output token 和平均响应时间 - 模型列表新增 Input 用量、Output 用量、平均响应三列 - 新增 /api/test-model: 支持 anthropic-messages 和 openai-completions 测试 - 每个模型新增测试按钮,显示成功/失败状态和耗时 - 新增「测试全部模型」按钮,并行测试所有模型 - 模型列表「使用中」改为显示 emoji + 机器人名称 - 页面标题改为「OpenClaw接入模型列表」
This commit is contained in:
@@ -223,7 +223,7 @@ export async function GET() {
|
||||
// 找出使用该 provider 的 agents
|
||||
const usedBy = agentsWithStatus
|
||||
.filter((a: any) => a.model.startsWith(providerId + "/"))
|
||||
.map((a: any) => ({ id: a.id, emoji: a.emoji }));
|
||||
.map((a: any) => ({ id: a.id, emoji: a.emoji, name: a.name }));
|
||||
|
||||
return {
|
||||
id: providerId,
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "", ".openclaw");
|
||||
|
||||
interface DayStat {
|
||||
date: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
messageCount: number;
|
||||
avgResponseMs: number;
|
||||
}
|
||||
|
||||
interface InternalDayStat extends DayStat {
|
||||
responseTimes: number[];
|
||||
}
|
||||
|
||||
function parseAgentSessions(agentId: string): InternalDayStat[] {
|
||||
const sessionsDir = path.join(OPENCLAW_HOME, `agents/${agentId}/sessions`);
|
||||
const dayMap: Record<string, InternalDayStat> = {};
|
||||
|
||||
let files: string[];
|
||||
try {
|
||||
files = fs.readdirSync(sessionsDir).filter(f => f.endsWith(".jsonl") && !f.includes(".deleted."));
|
||||
} catch { return []; }
|
||||
|
||||
for (const file of files) {
|
||||
let content: string;
|
||||
try { content = fs.readFileSync(path.join(sessionsDir, file), "utf-8"); } catch { continue; }
|
||||
|
||||
const lines = content.trim().split("\n");
|
||||
const messages: { role: string; ts: string; stopReason?: string }[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
let entry: any;
|
||||
try { entry = JSON.parse(line); } catch { continue; }
|
||||
if (entry.type !== "message") continue;
|
||||
const msg = entry.message;
|
||||
if (!msg || !entry.timestamp) continue;
|
||||
|
||||
const ts = entry.timestamp;
|
||||
const date = ts.slice(0, 10);
|
||||
messages.push({ role: msg.role, ts, stopReason: msg.stopReason });
|
||||
|
||||
if (msg.role === "assistant" && msg.usage) {
|
||||
if (!dayMap[date]) {
|
||||
dayMap[date] = { date, inputTokens: 0, outputTokens: 0, totalTokens: 0, messageCount: 0, avgResponseMs: 0, responseTimes: [] };
|
||||
}
|
||||
dayMap[date].inputTokens += msg.usage.input || 0;
|
||||
dayMap[date].outputTokens += msg.usage.output || 0;
|
||||
dayMap[date].totalTokens += msg.usage.totalTokens || 0;
|
||||
dayMap[date].messageCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
if (messages[i].role !== "user") continue;
|
||||
for (let j = i + 1; j < messages.length; j++) {
|
||||
if (messages[j].role === "assistant" && messages[j].stopReason === "stop") {
|
||||
const diffMs = new Date(messages[j].ts).getTime() - new Date(messages[i].ts).getTime();
|
||||
if (diffMs > 0 && diffMs < 600000) {
|
||||
const date = messages[i].ts.slice(0, 10);
|
||||
if (!dayMap[date]) {
|
||||
dayMap[date] = { date, inputTokens: 0, outputTokens: 0, totalTokens: 0, messageCount: 0, avgResponseMs: 0, responseTimes: [] };
|
||||
}
|
||||
dayMap[date].responseTimes.push(diffMs);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Object.values(dayMap);
|
||||
}
|
||||
|
||||
function aggregateToWeeklyMonthly(daily: DayStat[]) {
|
||||
const weekMap: Record<string, DayStat> = {};
|
||||
const monthMap: Record<string, DayStat> = {};
|
||||
|
||||
for (const d of daily) {
|
||||
const dt = new Date(d.date + "T00:00:00Z");
|
||||
const day = dt.getUTCDay();
|
||||
const mondayOffset = day === 0 ? -6 : 1 - day;
|
||||
const monday = new Date(dt.getTime() + mondayOffset * 86400000);
|
||||
const weekKey = monday.toISOString().slice(0, 10);
|
||||
|
||||
if (!weekMap[weekKey]) weekMap[weekKey] = { date: weekKey, inputTokens: 0, outputTokens: 0, totalTokens: 0, messageCount: 0, avgResponseMs: 0 };
|
||||
weekMap[weekKey].inputTokens += d.inputTokens;
|
||||
weekMap[weekKey].outputTokens += d.outputTokens;
|
||||
weekMap[weekKey].totalTokens += d.totalTokens;
|
||||
weekMap[weekKey].messageCount += d.messageCount;
|
||||
|
||||
const monthKey = d.date.slice(0, 7);
|
||||
if (!monthMap[monthKey]) monthMap[monthKey] = { date: monthKey, inputTokens: 0, outputTokens: 0, totalTokens: 0, messageCount: 0, avgResponseMs: 0 };
|
||||
monthMap[monthKey].inputTokens += d.inputTokens;
|
||||
monthMap[monthKey].outputTokens += d.outputTokens;
|
||||
monthMap[monthKey].totalTokens += d.totalTokens;
|
||||
monthMap[monthKey].messageCount += d.messageCount;
|
||||
}
|
||||
|
||||
return {
|
||||
weekly: Object.values(weekMap).sort((a, b) => a.date.localeCompare(b.date)),
|
||||
monthly: Object.values(monthMap).sort((a, b) => a.date.localeCompare(b.date)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const agentsDir = path.join(OPENCLAW_HOME, "agents");
|
||||
let agentIds: string[];
|
||||
try { agentIds = fs.readdirSync(agentsDir).filter(f => fs.statSync(path.join(agentsDir, f)).isDirectory()); } catch { agentIds = []; }
|
||||
|
||||
// Merge all agents into one dayMap
|
||||
const dayMap: Record<string, InternalDayStat> = {};
|
||||
|
||||
for (const agentId of agentIds) {
|
||||
const agentDays = parseAgentSessions(agentId);
|
||||
for (const ad of agentDays) {
|
||||
if (!dayMap[ad.date]) {
|
||||
dayMap[ad.date] = { date: ad.date, inputTokens: 0, outputTokens: 0, totalTokens: 0, messageCount: 0, avgResponseMs: 0, responseTimes: [] };
|
||||
}
|
||||
const d = dayMap[ad.date];
|
||||
d.inputTokens += ad.inputTokens;
|
||||
d.outputTokens += ad.outputTokens;
|
||||
d.totalTokens += ad.totalTokens;
|
||||
d.messageCount += ad.messageCount;
|
||||
d.responseTimes.push(...ad.responseTimes);
|
||||
}
|
||||
}
|
||||
|
||||
const daily: DayStat[] = Object.values(dayMap)
|
||||
.sort((a, b) => a.date.localeCompare(b.date))
|
||||
.map(({ responseTimes, ...rest }) => {
|
||||
if (responseTimes.length > 0) {
|
||||
rest.avgResponseMs = Math.round(responseTimes.reduce((a, b) => a + b, 0) / responseTimes.length);
|
||||
}
|
||||
return rest;
|
||||
});
|
||||
|
||||
const { weekly, monthly } = aggregateToWeeklyMonthly(daily);
|
||||
|
||||
return NextResponse.json({ daily, weekly, monthly });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "", ".openclaw");
|
||||
|
||||
interface ModelStat {
|
||||
modelId: string;
|
||||
provider: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
messageCount: number;
|
||||
avgResponseMs: number;
|
||||
}
|
||||
|
||||
interface InternalModelStat extends ModelStat {
|
||||
responseTimes: number[];
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const agentsDir = path.join(OPENCLAW_HOME, "agents");
|
||||
let agentIds: string[];
|
||||
try {
|
||||
agentIds = fs.readdirSync(agentsDir).filter(f => fs.statSync(path.join(agentsDir, f)).isDirectory());
|
||||
} catch { agentIds = []; }
|
||||
|
||||
const modelMap: Record<string, InternalModelStat> = {};
|
||||
|
||||
for (const agentId of agentIds) {
|
||||
const sessionsDir = path.join(agentsDir, agentId, "sessions");
|
||||
let files: string[];
|
||||
try {
|
||||
files = fs.readdirSync(sessionsDir).filter(f => f.endsWith(".jsonl") && !f.includes(".deleted."));
|
||||
} catch { continue; }
|
||||
|
||||
for (const file of files) {
|
||||
let content: string;
|
||||
try { content = fs.readFileSync(path.join(sessionsDir, file), "utf-8"); } catch { continue; }
|
||||
|
||||
const lines = content.trim().split("\n");
|
||||
const messages: { role: string; ts: string; stopReason?: string; model?: string; provider?: string }[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
let entry: any;
|
||||
try { entry = JSON.parse(line); } catch { continue; }
|
||||
if (entry.type !== "message") continue;
|
||||
const msg = entry.message;
|
||||
if (!msg || !entry.timestamp) continue;
|
||||
|
||||
messages.push({
|
||||
role: msg.role,
|
||||
ts: entry.timestamp,
|
||||
stopReason: msg.stopReason,
|
||||
model: msg.model,
|
||||
provider: msg.provider,
|
||||
});
|
||||
|
||||
if (msg.role === "assistant" && msg.usage && msg.model) {
|
||||
const key = `${msg.provider || "unknown"}/${msg.model}`;
|
||||
if (!modelMap[key]) {
|
||||
modelMap[key] = {
|
||||
modelId: msg.model,
|
||||
provider: msg.provider || "unknown",
|
||||
inputTokens: 0, outputTokens: 0, totalTokens: 0,
|
||||
messageCount: 0, avgResponseMs: 0, responseTimes: [],
|
||||
};
|
||||
}
|
||||
const m = modelMap[key];
|
||||
m.inputTokens += msg.usage.input || 0;
|
||||
m.outputTokens += msg.usage.output || 0;
|
||||
m.totalTokens += msg.usage.totalTokens || 0;
|
||||
m.messageCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Response times per model
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
if (messages[i].role !== "user") continue;
|
||||
for (let j = i + 1; j < messages.length; j++) {
|
||||
if (messages[j].role === "assistant" && messages[j].stopReason === "stop") {
|
||||
const diffMs = new Date(messages[j].ts).getTime() - new Date(messages[i].ts).getTime();
|
||||
if (diffMs > 0 && diffMs < 600000 && messages[j].model) {
|
||||
const key = `${messages[j].provider || "unknown"}/${messages[j].model}`;
|
||||
if (modelMap[key]) {
|
||||
modelMap[key].responseTimes.push(diffMs);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const models: ModelStat[] = Object.values(modelMap).map(({ responseTimes, ...rest }) => {
|
||||
if (responseTimes.length > 0) {
|
||||
rest.avgResponseMs = Math.round(responseTimes.reduce((a, b) => a + b, 0) / responseTimes.length);
|
||||
}
|
||||
return rest;
|
||||
});
|
||||
|
||||
// Sort by total tokens descending
|
||||
models.sort((a, b) => b.totalTokens - a.totalTokens);
|
||||
|
||||
return NextResponse.json({ models });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "", ".openclaw");
|
||||
const CONFIG_PATH = path.join(OPENCLAW_HOME, "openclaw.json");
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { provider: providerId, modelId } = await req.json();
|
||||
if (!providerId || !modelId) {
|
||||
return NextResponse.json({ error: "Missing provider or modelId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
|
||||
const config = JSON.parse(raw);
|
||||
const provider = config.models?.providers?.[providerId];
|
||||
if (!provider) {
|
||||
return NextResponse.json({ error: `Provider "${providerId}" not found` }, { status: 404 });
|
||||
}
|
||||
|
||||
const baseUrl = provider.baseUrl;
|
||||
const apiKey = provider.apiKey || "";
|
||||
const api = provider.api;
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
if (api === "anthropic-messages") {
|
||||
// Check for custom auth header
|
||||
const authHeader = provider.authHeader || "x-api-key";
|
||||
headers[authHeader] = apiKey;
|
||||
headers["anthropic-version"] = "2023-06-01";
|
||||
if (provider.headers) Object.assign(headers, provider.headers);
|
||||
|
||||
const body = {
|
||||
model: modelId,
|
||||
max_tokens: 32,
|
||||
messages: [{ role: "user", content: "Say hi in 3 words." }],
|
||||
};
|
||||
|
||||
const resp = await fetch(`${baseUrl}/v1/messages`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(100000),
|
||||
});
|
||||
|
||||
const elapsed = Date.now() - startTime;
|
||||
const data = await resp.json();
|
||||
|
||||
if (!resp.ok) {
|
||||
return NextResponse.json({
|
||||
ok: false,
|
||||
status: resp.status,
|
||||
error: data.error?.message || JSON.stringify(data),
|
||||
elapsed,
|
||||
});
|
||||
}
|
||||
|
||||
const text = data.content?.[0]?.text || "";
|
||||
return NextResponse.json({ ok: true, text, elapsed, model: data.model });
|
||||
|
||||
} else if (api === "openai-completions") {
|
||||
headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
|
||||
const body = {
|
||||
model: modelId,
|
||||
max_tokens: 32,
|
||||
messages: [{ role: "user", content: "Say hi in 3 words." }],
|
||||
};
|
||||
|
||||
const resp = await fetch(`${baseUrl}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(100000),
|
||||
});
|
||||
|
||||
const elapsed = Date.now() - startTime;
|
||||
const data = await resp.json();
|
||||
|
||||
if (!resp.ok) {
|
||||
return NextResponse.json({
|
||||
ok: false,
|
||||
status: resp.status,
|
||||
error: data.error?.message || JSON.stringify(data),
|
||||
elapsed,
|
||||
});
|
||||
}
|
||||
|
||||
const text = data.choices?.[0]?.message?.content || "";
|
||||
return NextResponse.json({ ok: true, text, elapsed, model: data.model });
|
||||
|
||||
} else {
|
||||
return NextResponse.json({ error: `Unknown API type: ${api}` }, { status: 400 });
|
||||
}
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ ok: false, error: err.message, elapsed: 0 }, { status: 500 });
|
||||
}
|
||||
}
|
||||
+192
-22
@@ -16,7 +16,17 @@ interface Provider {
|
||||
id: string;
|
||||
api: string;
|
||||
models: Model[];
|
||||
usedBy: { id: string; emoji: string }[];
|
||||
usedBy: { id: string; emoji: string; name: string }[];
|
||||
}
|
||||
|
||||
interface ModelStat {
|
||||
modelId: string;
|
||||
provider: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
messageCount: number;
|
||||
avgResponseMs: number;
|
||||
}
|
||||
|
||||
interface ConfigData {
|
||||
@@ -24,22 +34,92 @@ interface ConfigData {
|
||||
defaults: { model: string; fallbacks: string[] };
|
||||
}
|
||||
|
||||
interface TestResult {
|
||||
ok: boolean;
|
||||
text?: string;
|
||||
error?: string;
|
||||
elapsed: number;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
// 格式化数字
|
||||
function formatNum(n: number) {
|
||||
if (n >= 1000) return `${(n / 1000).toFixed(0)}K`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
function formatTokens(n: number): string {
|
||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
|
||||
if (n >= 1_000) return (n / 1_000).toFixed(1) + "k";
|
||||
return String(n);
|
||||
}
|
||||
|
||||
function formatMs(ms: number): string {
|
||||
if (!ms) return "-";
|
||||
if (ms < 1000) return ms + "ms";
|
||||
return (ms / 1000).toFixed(1) + "s";
|
||||
}
|
||||
|
||||
export default function ModelsPage() {
|
||||
const [data, setData] = useState<ConfigData | null>(null);
|
||||
const [modelStats, setModelStats] = useState<Record<string, ModelStat>>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [testing, setTesting] = useState<Record<string, boolean>>({});
|
||||
const [testResults, setTestResults] = useState<Record<string, TestResult>>({});
|
||||
|
||||
const testModel = async (providerId: string, modelId: string) => {
|
||||
const key = `${providerId}/${modelId}`;
|
||||
setTesting((prev) => ({ ...prev, [key]: true }));
|
||||
setTestResults((prev) => { const n = { ...prev }; delete n[key]; return n; });
|
||||
try {
|
||||
const resp = await fetch("/api/test-model", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ provider: providerId, modelId }),
|
||||
});
|
||||
const result = await resp.json();
|
||||
setTestResults((prev) => ({ ...prev, [key]: result }));
|
||||
} catch (err: any) {
|
||||
setTestResults((prev) => ({ ...prev, [key]: { ok: false, error: err.message, elapsed: 0 } }));
|
||||
} finally {
|
||||
setTesting((prev) => ({ ...prev, [key]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const testAllModels = async () => {
|
||||
if (!data) return;
|
||||
// Collect all model+provider pairs
|
||||
const pairs: { provider: string; modelId: string }[] = [];
|
||||
for (const p of data.providers) {
|
||||
if (p.models.length > 0) {
|
||||
for (const m of p.models) pairs.push({ provider: p.id, modelId: m.id });
|
||||
} else {
|
||||
// For providers without explicit models, check modelStats for known models
|
||||
const knownModels = Object.values(modelStats).filter(s => s.provider === p.id);
|
||||
for (const s of knownModels) pairs.push({ provider: s.provider, modelId: s.modelId });
|
||||
}
|
||||
}
|
||||
// Run all tests in parallel
|
||||
for (const pair of pairs) {
|
||||
testModel(pair.provider, pair.modelId);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/config")
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
if (d.error) setError(d.error);
|
||||
else setData(d);
|
||||
Promise.all([
|
||||
fetch("/api/config").then((r) => r.json()),
|
||||
fetch("/api/stats-models").then((r) => r.json()),
|
||||
])
|
||||
.then(([configData, statsData]) => {
|
||||
if (configData.error) setError(configData.error);
|
||||
else setData(configData);
|
||||
if (!statsData.error && statsData.models) {
|
||||
const map: Record<string, ModelStat> = {};
|
||||
for (const m of statsData.models) {
|
||||
map[`${m.provider}/${m.modelId}`] = m;
|
||||
}
|
||||
setModelStats(map);
|
||||
}
|
||||
})
|
||||
.catch((e) => setError(e.message));
|
||||
}, []);
|
||||
@@ -65,18 +145,31 @@ export default function ModelsPage() {
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
🧠 模型 Provider 列表
|
||||
OpenClaw接入模型列表
|
||||
</h1>
|
||||
<p className="text-[var(--text-muted)] text-sm mt-1">
|
||||
共 {data.providers.length} 个 Provider
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
className="px-4 py-2 rounded-lg bg-[var(--card)] border border-[var(--border)] text-sm font-medium hover:border-[var(--accent)] transition"
|
||||
>
|
||||
← 返回总览
|
||||
</Link>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={testAllModels}
|
||||
disabled={Object.values(testing).some(Boolean)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition ${
|
||||
Object.values(testing).some(Boolean)
|
||||
? "bg-gray-500/20 text-gray-400 cursor-wait"
|
||||
: "bg-[var(--accent)] text-[var(--bg)] hover:opacity-90 cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
{Object.values(testing).some(Boolean) ? "⏳ 测试中..." : "🧪 测试全部模型"}
|
||||
</button>
|
||||
<Link
|
||||
href="/"
|
||||
className="px-4 py-2 rounded-lg bg-[var(--card)] border border-[var(--border)] text-sm font-medium hover:border-[var(--accent)] transition"
|
||||
>
|
||||
← 返回总览
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
@@ -96,8 +189,8 @@ export default function ModelsPage() {
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-[var(--text-muted)] mr-1">使用中:</span>
|
||||
{provider.usedBy.map((a) => (
|
||||
<span key={a.id} title={a.id} className="text-lg">
|
||||
{a.emoji}
|
||||
<span key={a.id} title={a.id} className="px-2 py-0.5 rounded-full bg-[var(--bg)] text-xs font-medium">
|
||||
{a.emoji} {a.name || a.id}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -114,11 +207,20 @@ export default function ModelsPage() {
|
||||
<th className="text-left py-2 pr-4">上下文窗口</th>
|
||||
<th className="text-left py-2 pr-4">最大输出</th>
|
||||
<th className="text-left py-2 pr-4">输入类型</th>
|
||||
<th className="text-left py-2">推理</th>
|
||||
<th className="text-left py-2 pr-4">推理</th>
|
||||
<th className="text-right py-2 pr-4">Input 用量</th>
|
||||
<th className="text-right py-2 pr-4">Output 用量</th>
|
||||
<th className="text-right py-2 pr-4">平均响应</th>
|
||||
<th className="text-center py-2">测试</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{provider.models.map((m) => (
|
||||
{provider.models.map((m) => {
|
||||
const stat = modelStats[`${provider.id}/${m.id}`];
|
||||
const testKey = `${provider.id}/${m.id}`;
|
||||
const isTesting = testing[testKey];
|
||||
const result = testResults[testKey];
|
||||
return (
|
||||
<tr key={m.id} className="border-b border-[var(--border)]/50">
|
||||
<td className="py-2 pr-4 font-mono text-[var(--accent)]">{m.id}</td>
|
||||
<td className="py-2 pr-4">{m.name}</td>
|
||||
@@ -136,16 +238,84 @@ export default function ModelsPage() {
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2">{m.reasoning ? "✅" : "❌"}</td>
|
||||
<td className="py-2 pr-4">{m.reasoning ? "✅" : "❌"}</td>
|
||||
<td className="py-2 pr-4 text-right text-blue-400 font-mono text-xs">{stat ? formatTokens(stat.inputTokens) : "-"}</td>
|
||||
<td className="py-2 pr-4 text-right text-emerald-400 font-mono text-xs">{stat ? formatTokens(stat.outputTokens) : "-"}</td>
|
||||
<td className="py-2 pr-4 text-right text-amber-400 font-mono text-xs">{stat ? formatMs(stat.avgResponseMs) : "-"}</td>
|
||||
<td className="py-2 text-center">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<button
|
||||
onClick={() => testModel(provider.id, m.id)}
|
||||
disabled={isTesting}
|
||||
className={`px-2 py-1 rounded text-xs font-medium transition ${
|
||||
isTesting
|
||||
? "bg-gray-500/20 text-gray-400 cursor-wait"
|
||||
: "bg-[var(--accent)]/20 text-[var(--accent)] border border-[var(--accent)]/30 hover:bg-[var(--accent)]/40 cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
{isTesting ? "⏳ 测试中..." : "🧪 测试"}
|
||||
</button>
|
||||
{result && (
|
||||
<span className={`text-[10px] max-w-[140px] truncate ${result.ok ? "text-green-400" : "text-red-400"}`} title={result.ok ? result.text : result.error}>
|
||||
{result.ok ? `✅ ${formatMs(result.elapsed)}` : `❌ ${result.error?.slice(0, 30)}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[var(--text-muted)] text-sm">
|
||||
无显式模型定义(通过 provider 名称推断)
|
||||
</p>
|
||||
<div>
|
||||
<p className="text-[var(--text-muted)] text-sm">
|
||||
无显式模型定义(通过 provider 名称推断)
|
||||
</p>
|
||||
{(() => {
|
||||
// Show aggregated stats for this provider even without explicit model defs
|
||||
const providerStats = Object.values(modelStats).filter(s => s.provider === provider.id);
|
||||
if (providerStats.length === 0) return null;
|
||||
const totalInput = providerStats.reduce((s, m) => s + m.inputTokens, 0);
|
||||
const totalOutput = providerStats.reduce((s, m) => s + m.outputTokens, 0);
|
||||
const allRt = providerStats.filter(m => m.avgResponseMs > 0);
|
||||
const avgRt = allRt.length > 0 ? Math.round(allRt.reduce((s, m) => s + m.avgResponseMs, 0) / allRt.length) : 0;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3 mt-3 text-xs">
|
||||
{providerStats.map(s => {
|
||||
const testKey = `${s.provider}/${s.modelId}`;
|
||||
const isTesting = testing[testKey];
|
||||
const result = testResults[testKey];
|
||||
return (
|
||||
<div key={s.modelId} className="flex items-center gap-3 px-3 py-2 rounded-lg bg-[var(--bg)] border border-[var(--border)]">
|
||||
<span className="font-mono text-[var(--accent)]">{s.modelId}</span>
|
||||
<span className="text-blue-400">Input: {formatTokens(s.inputTokens)}</span>
|
||||
<span className="text-emerald-400">Output: {formatTokens(s.outputTokens)}</span>
|
||||
<span className="text-amber-400">{formatMs(s.avgResponseMs)}</span>
|
||||
<button
|
||||
onClick={() => testModel(s.provider, s.modelId)}
|
||||
disabled={isTesting}
|
||||
className={`px-2 py-0.5 rounded text-xs font-medium transition ${
|
||||
isTesting
|
||||
? "bg-gray-500/20 text-gray-400 cursor-wait"
|
||||
: "bg-[var(--accent)]/20 text-[var(--accent)] border border-[var(--accent)]/30 hover:bg-[var(--accent)]/40 cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
{isTesting ? "⏳" : "🧪 测试"}
|
||||
</button>
|
||||
{result && (
|
||||
<span className={`text-[10px] ${result.ok ? "text-green-400" : "text-red-400"}`} title={result.ok ? result.text : result.error}>
|
||||
{result.ok ? `✅ ${formatMs(result.elapsed)}` : `❌ ${result.error?.slice(0, 30)}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
+189
-5
@@ -38,6 +38,126 @@ interface ConfigData {
|
||||
groupChats?: GroupChat[];
|
||||
}
|
||||
|
||||
interface DayStat {
|
||||
date: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
messageCount: number;
|
||||
avgResponseMs: number;
|
||||
}
|
||||
|
||||
interface AllStats {
|
||||
daily: DayStat[];
|
||||
weekly: DayStat[];
|
||||
monthly: DayStat[];
|
||||
}
|
||||
|
||||
type TimeRange = "daily" | "weekly" | "monthly";
|
||||
const RANGE_LABELS: Record<TimeRange, string> = { daily: "按天", weekly: "按周", monthly: "按月" };
|
||||
|
||||
function formatTokens(n: number): string {
|
||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
|
||||
if (n >= 1_000) return (n / 1_000).toFixed(1) + "k";
|
||||
return String(n);
|
||||
}
|
||||
|
||||
function formatMs(ms: number): string {
|
||||
if (!ms) return "-";
|
||||
if (ms < 1000) return ms + "ms";
|
||||
return (ms / 1000).toFixed(1) + "s";
|
||||
}
|
||||
|
||||
// 趋势折线图
|
||||
function TrendChart({ data, lines, height = 180 }: { data: DayStat[]; lines: { key: keyof DayStat; color: string; label: string }[]; height?: number }) {
|
||||
if (data.length === 0) return <div className="flex items-center justify-center h-32 text-[var(--text-muted)] text-sm">暂无数据</div>;
|
||||
|
||||
const pad = { top: 16, right: 16, bottom: 50, left: 56 };
|
||||
const width = Math.max(500, data.length * 56 + pad.left + pad.right);
|
||||
const chartW = width - pad.left - pad.right;
|
||||
const chartH = height - pad.top - pad.bottom;
|
||||
|
||||
let maxVal = 0;
|
||||
for (const d of data) for (const l of lines) { const v = d[l.key] as number; if (v > maxVal) maxVal = v; }
|
||||
if (maxVal === 0) maxVal = 1;
|
||||
|
||||
const ticks = Array.from({ length: 5 }, (_, i) => Math.round((maxVal / 4) * i));
|
||||
|
||||
function toX(i: number) { return pad.left + (i / (data.length - 1 || 1)) * chartW; }
|
||||
function toY(v: number) { return pad.top + chartH - (v / maxVal) * chartH; }
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<svg width={width} height={height} className="text-[var(--text-muted)]">
|
||||
{ticks.map((tick, i) => (
|
||||
<g key={i}>
|
||||
<line x1={pad.left} y1={toY(tick)} x2={width - pad.right} y2={toY(tick)} stroke="currentColor" opacity={0.12} />
|
||||
<text x={pad.left - 8} y={toY(tick) + 4} textAnchor="end" fontSize={10} fill="currentColor">{formatTokens(tick)}</text>
|
||||
</g>
|
||||
))}
|
||||
{lines.map((l) => {
|
||||
const points = data.map((d, i) => `${toX(i)},${toY(d[l.key] as number)}`).join(" ");
|
||||
return <polyline key={l.key} points={points} fill="none" stroke={l.color} strokeWidth={2} opacity={0.85} />;
|
||||
})}
|
||||
{lines.map((l) => data.map((d, i) => (
|
||||
<circle key={`${l.key}-${i}`} cx={toX(i)} cy={toY(d[l.key] as number)} r={3} fill={l.color} opacity={0.9}>
|
||||
<title>{`${d.date} ${l.label}: ${formatTokens(d[l.key] as number)}`}</title>
|
||||
</circle>
|
||||
)))}
|
||||
{data.map((d, i) => (
|
||||
<text key={i} x={toX(i)} y={height - pad.bottom + 16} textAnchor="middle" fontSize={9} fill="currentColor"
|
||||
transform={`rotate(-30, ${toX(i)}, ${height - pad.bottom + 16})`}>{d.date.slice(5)}</text>
|
||||
))}
|
||||
<line x1={pad.left} y1={pad.top} x2={pad.left} y2={pad.top + chartH} stroke="currentColor" opacity={0.25} />
|
||||
<line x1={pad.left} y1={pad.top + chartH} x2={width - pad.right} y2={pad.top + chartH} stroke="currentColor" opacity={0.25} />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 响应时间趋势图
|
||||
function ResponseTrendChart({ data, height = 180 }: { data: DayStat[]; height?: number }) {
|
||||
const filtered = data.filter(d => d.avgResponseMs > 0);
|
||||
if (filtered.length === 0) return <div className="flex items-center justify-center h-32 text-[var(--text-muted)] text-sm">暂无响应时间数据</div>;
|
||||
|
||||
const pad = { top: 16, right: 16, bottom: 50, left: 56 };
|
||||
const width = Math.max(500, filtered.length * 56 + pad.left + pad.right);
|
||||
const chartW = width - pad.left - pad.right;
|
||||
const chartH = height - pad.top - pad.bottom;
|
||||
const maxVal = Math.max(...filtered.map(d => d.avgResponseMs));
|
||||
|
||||
const ticks = Array.from({ length: 5 }, (_, i) => Math.round((maxVal / 4) * i));
|
||||
function toX(i: number) { return pad.left + (i / (filtered.length - 1 || 1)) * chartW; }
|
||||
function toY(v: number) { return pad.top + chartH - (v / maxVal) * chartH; }
|
||||
|
||||
const points = filtered.map((d, i) => `${toX(i)},${toY(d.avgResponseMs)}`).join(" ");
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<svg width={width} height={height} className="text-[var(--text-muted)]">
|
||||
{ticks.map((tick, i) => (
|
||||
<g key={i}>
|
||||
<line x1={pad.left} y1={toY(tick)} x2={width - pad.right} y2={toY(tick)} stroke="currentColor" opacity={0.12} />
|
||||
<text x={pad.left - 8} y={toY(tick) + 4} textAnchor="end" fontSize={10} fill="currentColor">{formatMs(tick)}</text>
|
||||
</g>
|
||||
))}
|
||||
<polyline points={points} fill="none" stroke="#f59e0b" strokeWidth={2} opacity={0.85} />
|
||||
{filtered.map((d, i) => (
|
||||
<circle key={i} cx={toX(i)} cy={toY(d.avgResponseMs)} r={3} fill="#f59e0b" opacity={0.9}>
|
||||
<title>{`${d.date}: ${formatMs(d.avgResponseMs)}`}</title>
|
||||
</circle>
|
||||
))}
|
||||
{filtered.map((d, i) => (
|
||||
<text key={`l-${i}`} x={toX(i)} y={height - pad.bottom + 16} textAnchor="middle" fontSize={9} fill="currentColor"
|
||||
transform={`rotate(-30, ${toX(i)}, ${height - pad.bottom + 16})`}>{d.date.slice(5)}</text>
|
||||
))}
|
||||
<line x1={pad.left} y1={pad.top} x2={pad.left} y2={pad.top + chartH} stroke="currentColor" opacity={0.25} />
|
||||
<line x1={pad.left} y1={pad.top + chartH} x2={width - pad.right} y2={pad.top + chartH} stroke="currentColor" opacity={0.25} />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 时间格式化
|
||||
function formatTimeAgo(ts: number): string {
|
||||
const diff = Date.now() - ts;
|
||||
@@ -210,15 +330,20 @@ export default function Home() {
|
||||
const [refreshInterval, setRefreshInterval] = useState(0);
|
||||
const [lastUpdated, setLastUpdated] = useState<string>("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [allStats, setAllStats] = useState<AllStats | null>(null);
|
||||
const [statsRange, setStatsRange] = useState<TimeRange>("daily");
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const fetchData = useCallback(() => {
|
||||
setLoading(true);
|
||||
fetch("/api/config")
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
if (d.error) setError(d.error);
|
||||
else { setData(d); setError(null); }
|
||||
Promise.all([
|
||||
fetch("/api/config").then((r) => r.json()),
|
||||
fetch("/api/stats-all").then((r) => r.json()),
|
||||
])
|
||||
.then(([configData, statsData]) => {
|
||||
if (configData.error) setError(configData.error);
|
||||
else { setData(configData); setError(null); }
|
||||
if (!statsData.error) setAllStats(statsData);
|
||||
setLastUpdated(new Date().toLocaleTimeString("zh-CN"));
|
||||
})
|
||||
.catch((e) => setError(e.message))
|
||||
@@ -310,6 +435,65 @@ export default function Home() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 汇总统计趋势 */}
|
||||
{allStats && (
|
||||
<div className="mt-8 p-5 rounded-xl border border-[var(--border)] bg-[var(--card)]">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-sm font-semibold text-[var(--text)]">📊 全局统计趋势</h2>
|
||||
<div className="flex rounded-lg border border-[var(--border)] overflow-hidden">
|
||||
{(Object.keys(RANGE_LABELS) as TimeRange[]).map((r) => (
|
||||
<button key={r} onClick={() => setStatsRange(r)}
|
||||
className={`px-3 py-1 text-xs transition ${statsRange === r ? "bg-[var(--accent)] text-[var(--bg)] font-medium" : "bg-[var(--bg)] text-[var(--text-muted)] hover:text-[var(--text)]"}`}
|
||||
>{RANGE_LABELS[r]}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{(() => {
|
||||
const currentData = allStats[statsRange];
|
||||
const totalInput = currentData.reduce((s, d) => s + d.inputTokens, 0);
|
||||
const totalOutput = currentData.reduce((s, d) => s + d.outputTokens, 0);
|
||||
const totalMsgs = currentData.reduce((s, d) => s + d.messageCount, 0);
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-3 mb-4">
|
||||
<div className="p-3 rounded-lg bg-[var(--bg)] border border-[var(--border)]">
|
||||
<div className="text-[10px] text-[var(--text-muted)]">总 Input Token</div>
|
||||
<div className="text-lg font-bold text-blue-400">{formatTokens(totalInput)}</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-[var(--bg)] border border-[var(--border)]">
|
||||
<div className="text-[10px] text-[var(--text-muted)]">总 Output Token</div>
|
||||
<div className="text-lg font-bold text-emerald-400">{formatTokens(totalOutput)}</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-[var(--bg)] border border-[var(--border)]">
|
||||
<div className="text-[10px] text-[var(--text-muted)]">总消息数</div>
|
||||
<div className="text-lg font-bold text-purple-400">{totalMsgs}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-[var(--text-muted)]">🔢 Token 消耗趋势</span>
|
||||
<div className="flex items-center gap-3 text-[10px]">
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-blue-500 inline-block" /> Input</span>
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-emerald-500 inline-block" /> Output</span>
|
||||
</div>
|
||||
</div>
|
||||
<TrendChart data={currentData} lines={[
|
||||
{ key: "inputTokens", color: "#3b82f6", label: "Input" },
|
||||
{ key: "outputTokens", color: "#10b981", label: "Output" },
|
||||
]} />
|
||||
</div>
|
||||
{statsRange === "daily" && (
|
||||
<div>
|
||||
<span className="text-xs text-[var(--text-muted)]">⏱️ 平均响应时间趋势</span>
|
||||
<ResponseTrendChart data={currentData} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 群聊管理 */}
|
||||
{data.groupChats && data.groupChats.length > 0 && (
|
||||
<div className="mt-8 p-4 rounded-xl border border-[var(--border)] bg-[var(--card)]">
|
||||
|
||||
Reference in New Issue
Block a user