mirror of
https://github.com/xmanrui/OpenClaw-bot-review.git
synced 2026-07-27 22:25:52 +00:00
873 lines
36 KiB
TypeScript
873 lines
36 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState, useCallback, useRef } from "react";
|
|
import { useI18n } from "@/lib/i18n";
|
|
import { GatewayStatus } from "./gateway-status";
|
|
import {
|
|
AgentCard,
|
|
ModelBadge,
|
|
type AgentModelOptionGroup,
|
|
type PlatformTestResult,
|
|
type AgentModelTestResult,
|
|
type AgentSessionTestResult,
|
|
} from "./components/agent-card";
|
|
|
|
interface Platform {
|
|
name: string;
|
|
accountId?: string;
|
|
appId?: string;
|
|
botOpenId?: string;
|
|
botUserId?: string;
|
|
}
|
|
|
|
interface Agent {
|
|
id: string;
|
|
name: string;
|
|
emoji: string;
|
|
model: string;
|
|
platforms: Platform[];
|
|
session?: {
|
|
lastActive: number | null;
|
|
totalTokens: number;
|
|
contextTokens: number;
|
|
sessionCount: number;
|
|
todayAvgResponseMs: number;
|
|
messageCount: number;
|
|
weeklyResponseMs: number[];
|
|
weeklyTokens: number[];
|
|
};
|
|
}
|
|
|
|
interface GroupChat {
|
|
groupId: string;
|
|
channel: string;
|
|
agents: { id: string; emoji: string; name: string }[];
|
|
}
|
|
|
|
interface ConfigData {
|
|
agents: Agent[];
|
|
defaults: { model: string; fallbacks: string[] };
|
|
providers?: Array<{
|
|
id: string;
|
|
accessMode?: "auth" | "api_key";
|
|
models?: Array<{ id: string; name?: string }>;
|
|
}>;
|
|
gateway?: { port: number; token?: string; host?: string };
|
|
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";
|
|
|
|
interface SubagentActivityEvent {
|
|
key: string;
|
|
text: string;
|
|
at: number;
|
|
}
|
|
|
|
interface SubagentInfo {
|
|
toolId: string;
|
|
label: string;
|
|
activityEvents?: SubagentActivityEvent[];
|
|
}
|
|
|
|
interface AgentActivityData {
|
|
agentId: string;
|
|
name: string;
|
|
emoji: string;
|
|
state: "idle" | "working" | "waiting" | "offline";
|
|
lastActive: number;
|
|
subagents?: SubagentInfo[];
|
|
cronJobs?: Array<{
|
|
key: string;
|
|
jobId: string;
|
|
label: string;
|
|
isRunning: boolean;
|
|
lastRunAt: number;
|
|
nextRunAt?: number;
|
|
durationMs?: number;
|
|
lastStatus: "success" | "running" | "failed";
|
|
lastSummary?: string;
|
|
consecutiveFailures: number;
|
|
}>;
|
|
}
|
|
|
|
type TFunc = (key: string) => string;
|
|
|
|
let cachedHomeData: ConfigData | null = null;
|
|
let cachedHomeError: string | null = null;
|
|
let cachedHomeAllStats: AllStats | null = null;
|
|
let cachedHomeLastUpdated = "";
|
|
let cachedHomeRefreshInterval = 0;
|
|
let cachedHomeAgentStates: Record<string, string> = {};
|
|
|
|
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, t }: { data: DayStat[]; lines: { key: keyof DayStat; color: string; label: string }[]; height?: number; t: TFunc }) {
|
|
if (data.length === 0) return <div className="flex items-center justify-center h-32 text-[var(--text-muted)] text-sm">{t("common.noData")}</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, t }: { data: DayStat[]; height?: number; t: TFunc }) {
|
|
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">{t("home.noResponseData")}</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>
|
|
);
|
|
}
|
|
|
|
export default function Home() {
|
|
const { t, locale } = useI18n();
|
|
const [data, setData] = useState<ConfigData | null>(cachedHomeData);
|
|
const [error, setError] = useState<string | null>(cachedHomeError);
|
|
const [refreshInterval, setRefreshInterval] = useState(cachedHomeRefreshInterval);
|
|
const [lastUpdated, setLastUpdated] = useState<string>(cachedHomeLastUpdated);
|
|
const [loading, setLoading] = useState(false);
|
|
const [allStats, setAllStats] = useState<AllStats | null>(cachedHomeAllStats);
|
|
const [statsRange, setStatsRange] = useState<TimeRange>("daily");
|
|
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
|
const [testResults, setTestResults] = useState<Record<string, AgentModelTestResult | null> | null>(null);
|
|
const [testing, setTesting] = useState(false);
|
|
const [platformTestResults, setPlatformTestResults] = useState<Record<string, PlatformTestResult | null> | null>(null);
|
|
const [testingPlatforms, setTestingPlatforms] = useState(false);
|
|
const [sessionTestResults, setSessionTestResults] = useState<Record<string, AgentSessionTestResult | null> | null>(null);
|
|
const [testingSessions, setTestingSessions] = useState(false);
|
|
const [dmSessionResults, setDmSessionResults] = useState<Record<string, PlatformTestResult | null> | null>(null);
|
|
const [testingDmSessions, setTestingDmSessions] = useState(false);
|
|
const [agentStates, setAgentStates] = useState<Record<string, string>>(cachedHomeAgentStates);
|
|
const [agentActivity, setAgentActivity] = useState<AgentActivityData[] | null>(null);
|
|
|
|
const RANGE_LABELS: Record<TimeRange, string> = { daily: t("range.daily"), weekly: t("range.weekly"), monthly: t("range.monthly") };
|
|
|
|
const REFRESH_OPTIONS = [
|
|
{ label: t("refresh.manual"), value: 0 },
|
|
{ label: t("refresh.10s"), value: 10 },
|
|
{ label: t("refresh.30s"), value: 30 },
|
|
{ label: t("refresh.1m"), value: 60 },
|
|
{ label: t("refresh.5m"), value: 300 },
|
|
{ label: t("refresh.10m"), value: 600 },
|
|
];
|
|
|
|
const parseApiPayload = useCallback(async (resp: Response) => {
|
|
const raw = await resp.text();
|
|
let parsed: any = null;
|
|
try {
|
|
parsed = JSON.parse(raw);
|
|
} catch {}
|
|
const errorText = parsed?.error || raw || `HTTP ${resp.status}`;
|
|
return { ok: resp.ok, status: resp.status, data: parsed, errorText };
|
|
}, []);
|
|
|
|
const callTestApi = useCallback(async (url: string) => {
|
|
const requestWithMethod = async (method: "POST" | "GET") => {
|
|
const resp = await fetch(url, { method, cache: "no-store" });
|
|
return parseApiPayload(resp);
|
|
};
|
|
|
|
const first = await requestWithMethod("POST");
|
|
if (first.ok) return first.data;
|
|
|
|
const methodIssue = first.status === 405 || /method not allowed/i.test(first.errorText || "");
|
|
if (!methodIssue) throw new Error(first.errorText);
|
|
|
|
const fallback = await requestWithMethod("GET");
|
|
if (fallback.ok) return fallback.data;
|
|
throw new Error(fallback.errorText || first.errorText);
|
|
}, [parseApiPayload]);
|
|
|
|
const fetchData = useCallback((silent = false) => {
|
|
if (!silent) setLoading(true);
|
|
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);
|
|
cachedHomeError = configData.error;
|
|
} else {
|
|
setData(configData);
|
|
setError(null);
|
|
cachedHomeData = configData;
|
|
cachedHomeError = null;
|
|
}
|
|
if (!statsData.error) {
|
|
setAllStats(statsData);
|
|
cachedHomeAllStats = statsData;
|
|
}
|
|
const updated = new Date().toLocaleTimeString("zh-CN");
|
|
setLastUpdated(updated);
|
|
cachedHomeLastUpdated = updated;
|
|
})
|
|
.catch((e) => {
|
|
setError(e.message);
|
|
cachedHomeError = e.message;
|
|
})
|
|
.finally(() => {
|
|
if (!silent) setLoading(false);
|
|
});
|
|
}, []);
|
|
|
|
const changeAgentModel = useCallback(async (agentId: string, model: string) => {
|
|
const resp = await fetch("/api/config/agent-model", {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ agentId, model }),
|
|
});
|
|
const payload = await parseApiPayload(resp);
|
|
if (!payload.ok) {
|
|
throw new Error(payload.errorText || t("agent.modelApplyFailed"));
|
|
}
|
|
fetchData(true);
|
|
}, [fetchData, parseApiPayload, t]);
|
|
|
|
// 首次加载 - 从 localStorage 恢复测试状态
|
|
useEffect(() => {
|
|
fetchData(!!cachedHomeData);
|
|
const savedTestResults = localStorage.getItem('agentTestResults');
|
|
if (savedTestResults) {
|
|
try {
|
|
setTestResults(JSON.parse(savedTestResults));
|
|
} catch (e) {
|
|
console.error('Failed to parse testResults from localStorage', e);
|
|
}
|
|
}
|
|
const savedPlatformTestResults = localStorage.getItem('platformTestResults');
|
|
if (savedPlatformTestResults) {
|
|
try {
|
|
setPlatformTestResults(JSON.parse(savedPlatformTestResults));
|
|
} catch (e) {
|
|
console.error('Failed to parse platformTestResults from localStorage', e);
|
|
}
|
|
}
|
|
const savedSessionTestResults = localStorage.getItem('sessionTestResults');
|
|
if (savedSessionTestResults) {
|
|
try {
|
|
setSessionTestResults(JSON.parse(savedSessionTestResults));
|
|
} catch (e) {
|
|
console.error('Failed to parse sessionTestResults from localStorage', e);
|
|
}
|
|
}
|
|
const savedDmSessionResults = localStorage.getItem('dmSessionResults');
|
|
if (savedDmSessionResults) {
|
|
try {
|
|
setDmSessionResults(JSON.parse(savedDmSessionResults));
|
|
} catch (e) {
|
|
console.error('Failed to parse dmSessionResults from localStorage', e);
|
|
}
|
|
}
|
|
}, [fetchData]);
|
|
|
|
useEffect(() => {
|
|
cachedHomeRefreshInterval = refreshInterval;
|
|
}, [refreshInterval]);
|
|
|
|
// 保存测试结果到 localStorage
|
|
useEffect(() => {
|
|
if (testResults) {
|
|
localStorage.setItem('agentTestResults', JSON.stringify(testResults));
|
|
}
|
|
}, [testResults]);
|
|
|
|
useEffect(() => {
|
|
if (platformTestResults) {
|
|
localStorage.setItem('platformTestResults', JSON.stringify(platformTestResults));
|
|
}
|
|
}, [platformTestResults]);
|
|
|
|
useEffect(() => {
|
|
if (sessionTestResults) {
|
|
localStorage.setItem('sessionTestResults', JSON.stringify(sessionTestResults));
|
|
}
|
|
}, [sessionTestResults]);
|
|
|
|
useEffect(() => {
|
|
if (dmSessionResults) {
|
|
localStorage.setItem('dmSessionResults', JSON.stringify(dmSessionResults));
|
|
}
|
|
}, [dmSessionResults]);
|
|
|
|
const testAllAgents = useCallback(() => {
|
|
setTesting(true);
|
|
// Set all agents to null (testing indicator) so UI shows ⏳
|
|
const pending: Record<string, any> = {};
|
|
if (data) for (const a of data.agents) pending[a.id] = null;
|
|
setTestResults(pending);
|
|
callTestApi("/api/test-bound-models")
|
|
.then((resp) => {
|
|
if (resp.results) {
|
|
const map: Record<string, { ok: boolean; text?: string; error?: string; elapsed: number }> = {};
|
|
for (const r of resp.results) map[r.agentId] = r;
|
|
setTestResults(map);
|
|
}
|
|
})
|
|
.catch((e) => {
|
|
const msg = e instanceof Error ? e.message : "Request failed";
|
|
const failed: Record<string, { ok: boolean; error: string; elapsed: number }> = {};
|
|
if (data) for (const a of data.agents) failed[a.id] = { ok: false, error: msg, elapsed: 0 };
|
|
setTestResults(failed);
|
|
})
|
|
.finally(() => setTesting(false));
|
|
}, [data, callTestApi]);
|
|
|
|
const testAllPlatforms = useCallback(() => {
|
|
setTestingPlatforms(true);
|
|
// Set all agent:platform combos to null (⏳)
|
|
const pending: Record<string, any> = {};
|
|
if (data) {
|
|
for (const a of data.agents) {
|
|
for (const p of a.platforms) {
|
|
pending[`${a.id}:${p.name}`] = null;
|
|
}
|
|
}
|
|
}
|
|
setPlatformTestResults(pending);
|
|
callTestApi("/api/test-platforms")
|
|
.then((resp) => {
|
|
if (resp.results) {
|
|
const map: Record<string, PlatformTestResult> = {};
|
|
for (const r of resp.results) map[`${r.agentId}:${r.platform}`] = r;
|
|
setPlatformTestResults(map);
|
|
}
|
|
})
|
|
.catch((e) => {
|
|
const msg = e instanceof Error ? e.message : "Request failed";
|
|
const failed: Record<string, PlatformTestResult> = {};
|
|
if (data) {
|
|
for (const a of data.agents) {
|
|
for (const p of a.platforms) {
|
|
failed[`${a.id}:${p.name}`] = {
|
|
ok: false,
|
|
error: msg,
|
|
elapsed: 0,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
setPlatformTestResults(failed);
|
|
})
|
|
.finally(() => setTestingPlatforms(false));
|
|
}, [data, callTestApi]);
|
|
|
|
const testAllSessions = useCallback(() => {
|
|
setTestingSessions(true);
|
|
const pending: Record<string, any> = {};
|
|
if (data) for (const a of data.agents) pending[a.id] = null;
|
|
setSessionTestResults(pending);
|
|
callTestApi("/api/test-sessions")
|
|
.then((resp) => {
|
|
if (resp.results) {
|
|
const map: Record<string, { ok: boolean; reply?: string; error?: string; elapsed: number }> = {};
|
|
for (const r of resp.results) map[r.agentId] = r;
|
|
setSessionTestResults(map);
|
|
}
|
|
})
|
|
.catch((e) => {
|
|
const msg = e instanceof Error ? e.message : "Request failed";
|
|
const failed: Record<string, { ok: boolean; error: string; elapsed: number }> = {};
|
|
if (data) for (const a of data.agents) failed[a.id] = { ok: false, error: msg, elapsed: 0 };
|
|
setSessionTestResults(failed);
|
|
})
|
|
.finally(() => setTestingSessions(false));
|
|
}, [data, callTestApi]);
|
|
|
|
const testAllDmSessions = useCallback(() => {
|
|
setTestingDmSessions(true);
|
|
const pending: Record<string, any> = {};
|
|
if (data) {
|
|
for (const a of data.agents) {
|
|
for (const p of a.platforms) {
|
|
pending[`${a.id}:${p.name}`] = null;
|
|
}
|
|
}
|
|
}
|
|
setDmSessionResults(pending);
|
|
callTestApi("/api/test-dm-sessions")
|
|
.then((resp) => {
|
|
if (resp.results) {
|
|
const map: Record<string, PlatformTestResult> = {};
|
|
for (const r of resp.results) map[`${r.agentId}:${r.platform}`] = r;
|
|
setDmSessionResults(map);
|
|
}
|
|
})
|
|
.catch((e) => {
|
|
const msg = e instanceof Error ? e.message : "Request failed";
|
|
const failed: Record<string, PlatformTestResult> = {};
|
|
if (data) {
|
|
for (const a of data.agents) {
|
|
for (const p of a.platforms) {
|
|
failed[`${a.id}:${p.name}`] = {
|
|
ok: false,
|
|
error: msg,
|
|
elapsed: 0,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
setDmSessionResults(failed);
|
|
})
|
|
.finally(() => setTestingDmSessions(false));
|
|
}, [data, callTestApi]);
|
|
|
|
// 定时刷新
|
|
useEffect(() => {
|
|
if (timerRef.current) clearInterval(timerRef.current);
|
|
if (refreshInterval > 0) {
|
|
timerRef.current = setInterval(fetchData, refreshInterval * 1000);
|
|
}
|
|
return () => { if (timerRef.current) clearInterval(timerRef.current); };
|
|
}, [refreshInterval, fetchData]);
|
|
|
|
// Agent 状态轮询 (30秒)
|
|
useEffect(() => {
|
|
const fetchStatus = () => {
|
|
fetch("/api/agent-status")
|
|
.then(r => r.json())
|
|
.then(d => {
|
|
if (d.statuses) {
|
|
const map: Record<string, string> = {};
|
|
for (const s of d.statuses) map[s.agentId] = s.state;
|
|
setAgentStates(map);
|
|
cachedHomeAgentStates = map;
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
};
|
|
fetchStatus();
|
|
const timer = setInterval(fetchStatus, 30000);
|
|
return () => clearInterval(timer);
|
|
}, []);
|
|
|
|
// Agent 任務活動輪詢 (30秒)
|
|
useEffect(() => {
|
|
const fetchActivity = () => {
|
|
fetch("/api/agent-activity", { cache: "no-store" })
|
|
.then(r => r.json())
|
|
.then(d => {
|
|
if (d.agents) setAgentActivity(d.agents);
|
|
})
|
|
.catch(() => {});
|
|
};
|
|
fetchActivity();
|
|
const timer = setInterval(fetchActivity, 30000);
|
|
return () => clearInterval(timer);
|
|
}, []);
|
|
|
|
if (error && !data) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center">
|
|
<p className="text-red-400">{t("common.loadError")}: {error}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!data) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center">
|
|
<p className="text-[var(--text-muted)]">{t("common.loading")}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const providerAccessModeMap: Record<string, "auth" | "api_key"> = {};
|
|
for (const p of data.providers || []) {
|
|
if (!p?.id || !p.accessMode) continue;
|
|
providerAccessModeMap[p.id] = p.accessMode;
|
|
}
|
|
const modelOptions: AgentModelOptionGroup[] = (data.providers || [])
|
|
.filter((provider) => provider?.id && Array.isArray(provider.models) && provider.models.length > 0)
|
|
.map((provider) => ({
|
|
providerId: provider.id,
|
|
providerName: provider.id,
|
|
accessMode: provider.accessMode,
|
|
models: (provider.models || []).map((model) => ({
|
|
id: model.id,
|
|
name: model.name || model.id,
|
|
})),
|
|
}));
|
|
return (
|
|
<div className="p-3 md:p-4 max-w-6xl mx-auto">
|
|
{/* 头部 */}
|
|
<div className="flex flex-col gap-2 mb-3 md:flex-row md:items-center md:justify-between">
|
|
<div className="hidden md:block">
|
|
<h1 className="text-xl font-bold flex items-center gap-2">
|
|
🤖 {t("home.pageTitle")}
|
|
</h1>
|
|
<p className="text-[var(--text-muted)] text-xs mt-0.5">
|
|
{t("models.totalPrefix")} {data.agents.length} {t("home.agentCount")} · {t("home.defaultModel")}: {data.defaults.model}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2 overflow-x-auto pb-1 max-w-full">
|
|
<button
|
|
onClick={testAllAgents}
|
|
disabled={testing}
|
|
className="shrink-0 px-4 py-2 rounded-lg bg-[var(--card)] border border-[var(--border)] text-[var(--text)] text-sm font-medium hover:border-[var(--accent)] transition disabled:opacity-50 cursor-pointer"
|
|
>
|
|
{testing ? t("home.testingAll") : t("home.testAll")}
|
|
</button>
|
|
<button
|
|
onClick={testAllPlatforms}
|
|
disabled={testingPlatforms}
|
|
className="shrink-0 px-4 py-2 rounded-lg bg-[var(--card)] border border-[var(--border)] text-[var(--text)] text-sm font-medium hover:border-[var(--accent)] transition disabled:opacity-50 cursor-pointer"
|
|
>
|
|
{testingPlatforms ? t("home.testingPlatforms") : t("home.testPlatforms")}
|
|
</button>
|
|
<button
|
|
onClick={testAllSessions}
|
|
disabled={testingSessions}
|
|
className="shrink-0 px-4 py-2 rounded-lg bg-[var(--card)] border border-[var(--border)] text-[var(--text)] text-sm font-medium hover:border-[var(--accent)] transition disabled:opacity-50 cursor-pointer"
|
|
>
|
|
{testingSessions ? t("home.testingSessions") : t("home.testSessions")}
|
|
</button>
|
|
<button
|
|
onClick={testAllDmSessions}
|
|
disabled={testingDmSessions}
|
|
className="shrink-0 px-4 py-2 rounded-lg bg-[var(--card)] border border-[var(--border)] text-[var(--text)] text-sm font-medium hover:border-[var(--accent)] transition disabled:opacity-50 cursor-pointer"
|
|
>
|
|
{testingDmSessions ? t("home.testingDmSessions") : t("home.testDmSessions")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-2 mb-2">
|
|
<div className="shrink-0">
|
|
<GatewayStatus hideIconOnMobile />
|
|
</div>
|
|
<div className="flex items-center gap-2 min-w-0 max-w-full overflow-x-auto pb-1 md:overflow-visible md:pb-0">
|
|
<select
|
|
value={refreshInterval}
|
|
onChange={(e) => setRefreshInterval(Number(e.target.value))}
|
|
className="shrink-0 px-3 py-2 rounded-lg bg-[var(--card)] border border-[var(--border)] text-sm text-[var(--text)] cursor-pointer hover:border-[var(--accent)] transition"
|
|
>
|
|
{REFRESH_OPTIONS.map((opt) => (
|
|
<option key={opt.value} value={opt.value}>
|
|
{opt.value === 0 ? `🔄 ${opt.label}` : `⏱️ ${opt.label}`}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{refreshInterval === 0 && (
|
|
<button
|
|
onClick={() => fetchData(false)}
|
|
disabled={loading}
|
|
className="shrink-0 px-3 py-2 rounded-lg bg-[var(--card)] border border-[var(--border)] text-sm hover:border-[var(--accent)] transition disabled:opacity-50"
|
|
>
|
|
{loading ? "⏳" : "🔄"}
|
|
</button>
|
|
)}
|
|
{lastUpdated && (
|
|
<span className="shrink-0 text-xs text-[var(--text-muted)] whitespace-nowrap">
|
|
{t("home.updatedAt")} {lastUpdated}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 卡片墙 */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
{data.agents.map((agent) => (
|
|
<AgentCard key={agent.id} agent={agent} gatewayPort={data.gateway?.port || 18789} gatewayToken={data.gateway?.token} gatewayHost={data.gateway?.host} t={t} testResult={testResults?.[agent.id]} platformTestResults={platformTestResults || undefined} sessionTestResult={sessionTestResults?.[agent.id]} agentState={agentStates[agent.id]} dmSessionResults={dmSessionResults || undefined} providerAccessModeMap={providerAccessModeMap} modelOptions={modelOptions} onModelChange={changeAgentModel} />
|
|
))}
|
|
</div>
|
|
|
|
{/* Agent 任務追蹤 */}
|
|
{agentActivity && agentActivity.some(a => a.state !== "offline") && (
|
|
<div className="mt-4 p-4 rounded-xl border border-[var(--border)] bg-[var(--card)]">
|
|
<h2 className="text-sm font-semibold text-[var(--text-muted)] mb-3">📋 {t("home.agentTaskTracking")}</h2>
|
|
<div className="space-y-2">
|
|
{agentActivity
|
|
.filter(a => a.state !== "offline")
|
|
.map(agent => (
|
|
<div key={agent.agentId} className="flex items-start gap-3 p-3 rounded-lg bg-[var(--bg)] border border-[var(--border)]">
|
|
<span className="text-lg leading-none mt-0.5">{agent.emoji || "🤖"}</span>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<span className="text-sm font-medium text-[var(--text)]">{agent.name}</span>
|
|
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
|
|
agent.state === "working" ? "bg-emerald-500/20 text-emerald-400" :
|
|
agent.state === "waiting" ? "bg-amber-500/20 text-amber-400" :
|
|
"bg-[var(--border)] text-[var(--text-muted)]"
|
|
}`}>
|
|
{agent.state === "working"
|
|
? t("home.agentTaskState.working")
|
|
: agent.state === "waiting"
|
|
? t("home.agentTaskState.waiting")
|
|
: t("home.agentTaskState.idle")}
|
|
</span>
|
|
</div>
|
|
{agent.subagents && agent.subagents.length > 0 ? (
|
|
<div className="space-y-1">
|
|
<div className="text-[10px] uppercase tracking-wide opacity-60">{t("home.agentTaskSubtasks")}</div>
|
|
{agent.subagents.map((sub, i) => (
|
|
<div key={i} className="text-xs text-[var(--text-muted)]">
|
|
<span className="text-[var(--accent)] mr-1">↳</span>
|
|
<span className="font-medium text-[var(--text)]">{sub.label}</span>
|
|
{sub.activityEvents && sub.activityEvents.length > 0 && (
|
|
<span className="ml-2 opacity-70">— {sub.activityEvents[sub.activityEvents.length - 1].text}</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="text-xs text-[var(--text-muted)] opacity-60">{t("home.agentTaskNoSubtasks")}</div>
|
|
)}
|
|
{agent.cronJobs && agent.cronJobs.length > 0 ? (
|
|
<div className="space-y-1 mt-2 pt-2 border-t border-[var(--border)]">
|
|
<div className="text-[10px] uppercase tracking-wide opacity-60">{t("home.agentTaskCron")}</div>
|
|
{agent.cronJobs.map((cron) => (
|
|
<div key={cron.key} className="text-xs text-[var(--text-muted)]">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-yellow-400">⏰</span>
|
|
<span className="font-medium text-[var(--text)]">{cron.label}</span>
|
|
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
|
|
cron.lastStatus === "running"
|
|
? "bg-sky-500/20 text-sky-400"
|
|
: cron.lastStatus === "failed"
|
|
? "bg-red-500/20 text-red-400"
|
|
: "bg-emerald-500/20 text-emerald-400"
|
|
}`}>
|
|
{cron.lastStatus === "running"
|
|
? t("home.agentTaskCronState.running")
|
|
: cron.lastStatus === "failed"
|
|
? t("home.agentTaskCronState.failed")
|
|
: t("home.agentTaskCronState.success")}
|
|
</span>
|
|
{cron.consecutiveFailures > 0 && (
|
|
<span className="text-[10px] text-red-400">
|
|
{t("home.agentTaskCronFailures")} {cron.consecutiveFailures}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="ml-5 opacity-70">
|
|
{cron.lastSummary || t("home.agentTaskCronNoSummary")}
|
|
</div>
|
|
<div className="ml-5 mt-0.5 flex flex-wrap gap-x-3 gap-y-1 text-[10px] opacity-60">
|
|
{cron.durationMs !== undefined && (
|
|
<span>{t("home.agentTaskCronDuration")} {Math.max(1, Math.round(cron.durationMs / 1000))}s</span>
|
|
)}
|
|
{cron.nextRunAt ? (
|
|
<span>
|
|
{t("home.agentTaskCronNextRun")} {new Date(cron.nextRunAt).toLocaleTimeString(locale === "zh" ? "zh-CN" : locale, { hour: "2-digit", minute: "2-digit" })}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="text-xs text-[var(--text-muted)] opacity-60 mt-2">{t("home.agentTaskNoCron")}</div>
|
|
)}
|
|
</div>
|
|
<div className="text-[10px] text-[var(--text-muted)] whitespace-nowrap">
|
|
{agent.lastActive ? new Date(agent.lastActive).toLocaleTimeString(locale === "zh" ? "zh-CN" : locale, { hour: "2-digit", minute: "2-digit" }) : ""}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 汇总统计趋势 */}
|
|
{allStats && (
|
|
<div className="mt-8 p-5 rounded-xl border border-[var(--border)] bg-[var(--card)]">
|
|
<div className="flex flex-col gap-2 mb-4 md:flex-row md:items-center md:justify-between">
|
|
<h2 className="text-sm font-semibold text-[var(--text)]">{t("home.globalTrend")}</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-1 sm: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)]">{t("home.totalInputToken")}</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)]">{t("home.totalOutputToken")}</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)]">{t("home.totalMessages")}</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)]">{t("home.tokenTrend")}</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" },
|
|
]} t={t} />
|
|
</div>
|
|
{statsRange === "daily" && (
|
|
<div>
|
|
<span className="text-xs text-[var(--text-muted)]">{t("home.avgResponseTrend")}</span>
|
|
<ResponseTrendChart data={currentData} t={t} />
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
})()}
|
|
</div>
|
|
)}
|
|
|
|
{/* 群聊管理 */}
|
|
{data.groupChats && data.groupChats.length > 0 && (
|
|
<div className="mt-8 p-4 rounded-xl border border-[var(--border)] bg-[var(--card)]">
|
|
<h2 className="text-sm font-semibold text-[var(--text-muted)] mb-3">
|
|
{t("home.groupTopology")}
|
|
</h2>
|
|
<div className="space-y-3">
|
|
{data.groupChats.map((group, i) => (
|
|
<div key={i} className="flex items-center gap-3 p-3 rounded-lg bg-[var(--bg)] border border-[var(--border)]">
|
|
<span className="text-lg">{group.channel === "feishu" ? "📱" : "🎮"}</span>
|
|
<div className="flex-1">
|
|
<div className="text-xs text-[var(--text-muted)] mb-1">
|
|
{group.channel === "feishu" ? t("home.feishuGroup") : t("home.discordChannel")} · {group.groupId.split(":")[1]}
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{group.agents.map((a) => (
|
|
<span key={a.id} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-[var(--card)] border border-[var(--border)]">
|
|
{a.emoji} {a.name}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<span className="text-xs text-[var(--text-muted)]">{group.agents.length} {t("home.bots")}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Fallback 信息 */}
|
|
{data.defaults.fallbacks.length > 0 && (
|
|
<div className="mt-8 p-4 rounded-xl border border-[var(--border)] bg-[var(--card)]">
|
|
<h2 className="text-sm font-semibold text-[var(--text-muted)] mb-2">
|
|
{t("home.fallbackModels")}
|
|
</h2>
|
|
<div className="flex flex-wrap gap-2">
|
|
{data.defaults.fallbacks.map((f, i) => (
|
|
<ModelBadge key={i} model={f} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|