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:
xmanrui
2026-02-19 22:05:16 +08:00
parent b9f8b8b0e9
commit 44b95f9fe4
6 changed files with 742 additions and 28 deletions
+1 -1
View File
@@ -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,
+148
View File
@@ -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 });
}
}
+111
View File
@@ -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 });
}
}
+101
View File
@@ -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
View File
@@ -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
View File
@@ -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)]">