feat: add i18n support (zh-CN/en) with language switcher

- Add lib/i18n.tsx with translation dictionaries, I18nProvider, useI18n hook, and LanguageSwitcher component
- Add app/providers.tsx client-side provider wrapper
- Internationalize all pages: home, models, stats, sessions, skills
- Update layout.tsx with I18nProvider
- Default language: Chinese, switchable to English via button
- Language preference persisted in localStorage
- Fix DayStat type error in stats API route
This commit is contained in:
xmanrui
2026-02-20 17:00:27 +08:00
parent 44b95f9fe4
commit ea345868f4
10 changed files with 852 additions and 158 deletions
+169
View File
@@ -0,0 +1,169 @@
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");
// Find OpenClaw package directory
function findOpenClawPkg(): string {
// Check common locations
const candidates = [
path.join(process.env.HOME || "", ".nvm/versions/node", process.version, "lib/node_modules/openclaw"),
"/usr/local/lib/node_modules/openclaw",
"/usr/lib/node_modules/openclaw",
];
for (const c of candidates) {
if (fs.existsSync(path.join(c, "package.json"))) return c;
}
// Fallback: try to find via which
return candidates[0];
}
const OPENCLAW_PKG = findOpenClawPkg();
interface SkillInfo {
id: string;
name: string;
description: string;
emoji: string;
source: string; // "builtin" | "extension" | "custom"
location: string;
usedBy: string[]; // agent ids
}
function parseFrontmatter(content: string): Record<string, string> {
const result: Record<string, string> = {};
if (!content.startsWith("---")) return result;
const parts = content.split("---", 3);
if (parts.length < 3) return result;
const fm = parts[1];
const nameMatch = fm.match(/^name:\s*(.+)/m);
if (nameMatch) result.name = nameMatch[1].trim().replace(/^["']|["']$/g, "");
const descMatch = fm.match(/^description:\s*["']?(.+?)["']?\s*$/m);
if (descMatch) result.description = descMatch[1].trim().replace(/^["']|["']$/g, "");
const emojiMatch = fm.match(/"emoji":\s*"([^"]+)"/);
if (emojiMatch) result.emoji = emojiMatch[1];
return result;
}
function scanSkillsDir(dir: string, source: string): SkillInfo[] {
const skills: SkillInfo[] = [];
if (!fs.existsSync(dir)) return skills;
for (const name of fs.readdirSync(dir).sort()) {
const skillMd = path.join(dir, name, "SKILL.md");
if (!fs.existsSync(skillMd)) continue;
const content = fs.readFileSync(skillMd, "utf-8");
const fm = parseFrontmatter(content);
skills.push({
id: name,
name: fm.name || name,
description: fm.description || "",
emoji: fm.emoji || "🔧",
source,
location: skillMd,
usedBy: [],
});
}
return skills;
}
function getAgentSkillsFromSessions(): Record<string, Set<string>> {
// Parse skillsSnapshot from session JSONL files
const agentsDir = path.join(OPENCLAW_HOME, "agents");
const result: Record<string, Set<string>> = {};
if (!fs.existsSync(agentsDir)) return result;
for (const agentId of fs.readdirSync(agentsDir)) {
const sessionsDir = path.join(agentsDir, agentId, "sessions");
if (!fs.existsSync(sessionsDir)) continue;
const jsonlFiles = fs.readdirSync(sessionsDir)
.filter(f => f.endsWith(".jsonl"))
.sort();
const skillNames = new Set<string>();
// Check the most recent session files for skillsSnapshot
for (const file of jsonlFiles.slice(-3)) {
const content = fs.readFileSync(path.join(sessionsDir, file), "utf-8");
const idx = content.indexOf("skillsSnapshot");
if (idx < 0) continue;
const chunk = content.slice(idx, idx + 5000);
// Match skill names in escaped JSON: \"name\":\"xxx\" or "name":"xxx"
const matches = chunk.matchAll(/\\?"name\\?":\s*\\?"([^"\\]+)\\?"/g);
for (const m of matches) {
const name = m[1];
// Filter out tool names and other non-skill entries
if (!["exec","read","edit","write","process","message","web_search","web_fetch",
"browser","tts","gateway","memory_search","memory_get","cron","nodes",
"canvas","session_status","sessions_list","sessions_history","sessions_send",
"sessions_spawn","agents_list"].includes(name) && name.length > 1) {
skillNames.add(name);
}
}
}
if (skillNames.size > 0) {
result[agentId] = skillNames;
}
}
return result;
}
export async function GET() {
try {
// 1. Scan builtin skills
const builtinDir = path.join(OPENCLAW_PKG, "skills");
const builtinSkills = scanSkillsDir(builtinDir, "builtin");
// 2. Scan extension skills
const extDir = path.join(OPENCLAW_PKG, "extensions");
const extSkills: SkillInfo[] = [];
if (fs.existsSync(extDir)) {
for (const ext of fs.readdirSync(extDir)) {
const skillsDir = path.join(extDir, ext, "skills");
if (fs.existsSync(skillsDir)) {
const skills = scanSkillsDir(skillsDir, `extension:${ext}`);
extSkills.push(...skills);
}
}
}
// 3. Scan custom skills (~/.openclaw/skills)
const customDir = path.join(OPENCLAW_HOME, "skills");
const customSkills = scanSkillsDir(customDir, "custom");
const allSkills = [...builtinSkills, ...extSkills, ...customSkills];
// 4. Map agent usage from session data
const agentSkills = getAgentSkillsFromSessions();
for (const skill of allSkills) {
for (const [agentId, skills] of Object.entries(agentSkills)) {
if (skills.has(skill.id) || skills.has(skill.name)) {
skill.usedBy.push(agentId);
}
}
}
// 5. Get agent info for display
const configPath = path.join(OPENCLAW_HOME, "openclaw.json");
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
const agentList = config.agents?.list || [];
const agentMap: Record<string, { name: string; emoji: string }> = {};
for (const a of agentList) {
const name = a.identity?.name || a.name || a.id;
const emoji = a.identity?.emoji || "🤖";
agentMap[a.id] = { name, emoji };
}
return NextResponse.json({
skills: allSkills,
agents: agentMap,
total: allSkills.length,
});
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
+1 -1
View File
@@ -14,7 +14,7 @@ interface DayStat {
responseTimes: number[]; // internal, stripped before response
}
function parseSessions(agentId: string): DayStat[] {
function parseSessions(agentId: string): Omit<DayStat, "responseTimes">[] {
const sessionsDir = path.join(OPENCLAW_HOME, `agents/${agentId}/sessions`);
const dayMap: Record<string, DayStat> = {};
+4 -1
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import "./globals.css";
import { Providers } from "./providers";
export const metadata: Metadata = {
title: "OpenClaw Bot Dashboard",
@@ -9,7 +10,9 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-CN">
<body>{children}</body>
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
+26 -27
View File
@@ -2,6 +2,7 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import { useI18n, LanguageSwitcher } from "@/lib/i18n";
interface Model {
id: string;
@@ -61,6 +62,7 @@ function formatMs(ms: number): string {
}
export default function ModelsPage() {
const { t } = useI18n();
const [data, setData] = useState<ConfigData | null>(null);
const [modelStats, setModelStats] = useState<Record<string, ModelStat>>({});
const [error, setError] = useState<string | null>(null);
@@ -88,18 +90,15 @@ export default function ModelsPage() {
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);
}
@@ -127,7 +126,7 @@ export default function ModelsPage() {
if (error) {
return (
<div className="min-h-screen flex items-center justify-center">
<p className="text-red-400">: {error}</p>
<p className="text-red-400">{t("common.loadError")}: {error}</p>
</div>
);
}
@@ -135,7 +134,7 @@ export default function ModelsPage() {
if (!data) {
return (
<div className="min-h-screen flex items-center justify-center">
<p className="text-[var(--text-muted)]">...</p>
<p className="text-[var(--text-muted)]">{t("common.loading")}</p>
</div>
);
}
@@ -145,10 +144,10 @@ 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">
OpenClaw接入模型列表
{t("models.title")}
</h1>
<p className="text-[var(--text-muted)] text-sm mt-1">
{data.providers.length} Provider
{t("models.totalPrefix")} {data.providers.length} {t("models.providerCount")}
</p>
</div>
<div className="flex items-center gap-3">
@@ -161,14 +160,15 @@ export default function ModelsPage() {
: "bg-[var(--accent)] text-[var(--bg)] hover:opacity-90 cursor-pointer"
}`}
>
{Object.values(testing).some(Boolean) ? "⏳ 测试中..." : "🧪 测试全部模型"}
{Object.values(testing).some(Boolean) ? t("models.testingAll") : t("models.testAll")}
</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"
>
{t("common.backOverview")}
</Link>
<LanguageSwitcher />
</div>
</div>
@@ -187,7 +187,7 @@ export default function ModelsPage() {
</div>
{provider.usedBy.length > 0 && (
<div className="flex items-center gap-1">
<span className="text-xs text-[var(--text-muted)] mr-1">使:</span>
<span className="text-xs text-[var(--text-muted)] mr-1">{t("agent.inUse")}</span>
{provider.usedBy.map((a) => (
<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}
@@ -202,16 +202,16 @@ export default function ModelsPage() {
<table className="w-full text-sm">
<thead>
<tr className="text-[var(--text-muted)] text-xs border-b border-[var(--border)]">
<th className="text-left py-2 pr-4"> ID</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 pr-4"></th>
<th className="text-left py-2 pr-4"></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>
<th className="text-left py-2 pr-4">{t("models.colModelId")}</th>
<th className="text-left py-2 pr-4">{t("models.colName")}</th>
<th className="text-left py-2 pr-4">{t("models.colContext")}</th>
<th className="text-left py-2 pr-4">{t("models.colMaxOutput")}</th>
<th className="text-left py-2 pr-4">{t("models.colInputType")}</th>
<th className="text-left py-2 pr-4">{t("models.colReasoning")}</th>
<th className="text-right py-2 pr-4">{t("models.colInputToken")}</th>
<th className="text-right py-2 pr-4">{t("models.colOutputToken")}</th>
<th className="text-right py-2 pr-4">{t("models.colAvgResponse")}</th>
<th className="text-center py-2">{t("models.colTest")}</th>
</tr>
</thead>
<tbody>
@@ -228,12 +228,12 @@ export default function ModelsPage() {
<td className="py-2 pr-4">{formatNum(m.maxTokens)}</td>
<td className="py-2 pr-4">
<div className="flex gap-1">
{(m.input || []).map((t) => (
{(m.input || []).map((inputType) => (
<span
key={t}
key={inputType}
className="px-1.5 py-0.5 rounded bg-[var(--bg)] text-xs"
>
{t === "text" ? "📝" : "🖼️"} {t}
{inputType === "text" ? "📝" : "🖼️"} {inputType}
</span>
))}
</div>
@@ -253,7 +253,7 @@ export default function ModelsPage() {
: "bg-[var(--accent)]/20 text-[var(--accent)] border border-[var(--accent)]/30 hover:bg-[var(--accent)]/40 cursor-pointer"
}`}
>
{isTesting ? "⏳ 测试中..." : "🧪 测试"}
{isTesting ? t("common.testing") : t("common.test")}
</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}>
@@ -271,10 +271,9 @@ export default function ModelsPage() {
) : (
<div>
<p className="text-[var(--text-muted)] text-sm">
provider
{t("models.noExplicitModels")}
</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);
@@ -302,7 +301,7 @@ export default function ModelsPage() {
: "bg-[var(--accent)]/20 text-[var(--accent)] border border-[var(--accent)]/30 hover:bg-[var(--accent)]/40 cursor-pointer"
}`}
>
{isTesting ? "⏳" : "🧪 测试"}
{isTesting ? "⏳" : t("common.test")}
</button>
{result && (
<span className={`text-[10px] ${result.ok ? "text-green-400" : "text-red-400"}`} title={result.ok ? result.text : result.error}>
+71 -61
View File
@@ -2,6 +2,7 @@
import { useEffect, useState, useCallback, useRef } from "react";
import Link from "next/link";
import { useI18n, LanguageSwitcher } from "@/lib/i18n";
interface Platform {
name: string;
@@ -54,7 +55,8 @@ interface AllStats {
}
type TimeRange = "daily" | "weekly" | "monthly";
const RANGE_LABELS: Record<TimeRange, string> = { daily: "按天", weekly: "按周", monthly: "按月" };
type TFunc = (key: string) => string;
function formatTokens(n: number): string {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
@@ -69,8 +71,8 @@ function formatMs(ms: number): string {
}
// 趋势折线图
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>;
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);
@@ -116,9 +118,9 @@ function TrendChart({ data, lines, height = 180 }: { data: DayStat[]; lines: { k
}
// 响应时间趋势图
function ResponseTrendChart({ data, height = 180 }: { data: DayStat[]; height?: number }) {
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"></div>;
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);
@@ -158,20 +160,8 @@ function ResponseTrendChart({ data, height = 180 }: { data: DayStat[]; height?:
);
}
// 时间格式化
function formatTimeAgo(ts: number): string {
const diff = Date.now() - ts;
const mins = Math.floor(diff / 60000);
if (mins < 1) return "刚刚";
if (mins < 60) return `${mins} 分钟前`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours} 小时前`;
const days = Math.floor(hours / 24);
return `${days} 天前`;
}
// 平台标签颜色(可点击跳转到对应平台的 session chat 页面)
function PlatformBadge({ platform, agentId, gatewayPort, gatewayToken }: { platform: Platform; agentId: string; gatewayPort: number; gatewayToken?: string }) {
// 平台标签颜色
function PlatformBadge({ platform, agentId, gatewayPort, gatewayToken, t }: { platform: Platform; agentId: string; gatewayPort: number; gatewayToken?: string; t: TFunc }) {
const isFeishu = platform.name === "feishu";
let sessionKey: string;
@@ -191,14 +181,14 @@ function PlatformBadge({ platform, agentId, gatewayPort, gatewayToken }: { platf
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
title="点击打开聊天页面"
title={t("agent.openChat")}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium cursor-pointer transition-all hover:scale-105 hover:shadow-md ${
isFeishu
? "bg-blue-500/20 text-blue-300 border border-blue-500/30 hover:bg-blue-500/40 hover:border-blue-400"
: "bg-purple-500/20 text-purple-300 border border-purple-500/30 hover:bg-purple-500/40 hover:border-purple-400"
}`}
>
{isFeishu ? "📱 飞书" : "🎮 Discord"}
{isFeishu ? t("platform.feishu") : t("platform.discord")}
{platform.accountId && (
<span className="opacity-60">({platform.accountId})</span>
)}
@@ -231,12 +221,23 @@ function ModelBadge({ model }: { model: string }) {
);
}
// Agent 卡片(点击跳转到 agent 的 main session chat 页面)
function AgentCard({ agent, gatewayPort, gatewayToken }: { agent: Agent; gatewayPort: number; gatewayToken?: string }) {
// Agent 卡片
function AgentCard({ agent, gatewayPort, gatewayToken, t }: { agent: Agent; gatewayPort: number; gatewayToken?: string; t: TFunc }) {
const sessionKey = `agent:${agent.id}:main`;
let sessionUrl = `http://localhost:${gatewayPort}/chat?session=${encodeURIComponent(sessionKey)}`;
if (gatewayToken) sessionUrl += `&token=${encodeURIComponent(gatewayToken)}`;
function formatTimeAgo(ts: number): string {
const diff = Date.now() - ts;
const mins = Math.floor(diff / 60000);
if (mins < 1) return t("common.justNow");
if (mins < 60) return `${mins} ${t("common.minutesAgo")}`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours} ${t("common.hoursAgo")}`;
const days = Math.floor(hours / 24);
return `${days} ${t("common.daysAgo")}`;
}
return (
<div
onClick={() => window.open(sessionUrl, "_blank")}
@@ -254,22 +255,22 @@ function AgentCard({ agent, gatewayPort, gatewayToken }: { agent: Agent; gateway
<div className="space-y-2">
<div>
<span className="text-xs text-[var(--text-muted)] block mb-1"></span>
<span className="text-xs text-[var(--text-muted)] block mb-1">{t("agent.model")}</span>
<ModelBadge model={agent.model} />
</div>
<div>
<span className="text-xs text-[var(--text-muted)] block mb-1"></span>
<span className="text-xs text-[var(--text-muted)] block mb-1">{t("agent.platform")}</span>
<div className="flex flex-wrap gap-1">
{agent.platforms.map((p, i) => (
<PlatformBadge key={i} platform={p} agentId={agent.id} gatewayPort={gatewayPort} gatewayToken={gatewayToken} />
<PlatformBadge key={i} platform={p} agentId={agent.id} gatewayPort={gatewayPort} gatewayToken={gatewayToken} t={t} />
))}
</div>
</div>
{agent.platforms.some((p) => p.appId) && (
<div>
<span className="text-xs text-[var(--text-muted)] block mb-1"> App ID</span>
<span className="text-xs text-[var(--text-muted)] block mb-1">{t("agent.feishuAppId")}</span>
<code className="text-xs text-[var(--accent)] bg-[var(--bg)] px-2 py-0.5 rounded">
{agent.platforms.find((p) => p.appId)?.appId}
</code>
@@ -279,7 +280,7 @@ function AgentCard({ agent, gatewayPort, gatewayToken }: { agent: Agent; gateway
{agent.session && (
<div className="pt-2 mt-2 border-t border-[var(--border)]">
<div className="flex items-center justify-between text-xs">
<span className="text-[var(--text-muted)]"></span>
<span className="text-[var(--text-muted)]">{t("agent.sessionCount")}</span>
<div className="flex items-center gap-2">
<a
href={`/sessions?agent=${agent.id}`}
@@ -293,17 +294,17 @@ function AgentCard({ agent, gatewayPort, gatewayToken }: { agent: Agent; gateway
onClick={(e) => e.stopPropagation()}
className="text-[var(--accent)] hover:underline cursor-pointer text-[10px]"
>
📊
📊 {t("agent.stats")}
</a>
</div>
</div>
<div className="flex items-center justify-between text-xs mt-1">
<span className="text-[var(--text-muted)]">Token </span>
<span className="text-[var(--text-muted)]">{t("agent.tokenUsage")}</span>
<span className="text-[var(--text)]">{(agent.session.totalTokens / 1000).toFixed(1)}k</span>
</div>
{agent.session.lastActive && (
<div className="flex items-center justify-between text-xs mt-1">
<span className="text-[var(--text-muted)]"></span>
<span className="text-[var(--text-muted)]">{t("agent.lastActive")}</span>
<span className="text-[var(--text)]">{formatTimeAgo(agent.session.lastActive)}</span>
</div>
)}
@@ -314,17 +315,8 @@ function AgentCard({ agent, gatewayPort, gatewayToken }: { agent: Agent; gateway
);
}
// 刷新选项
const REFRESH_OPTIONS = [
{ label: "手动刷新", value: 0 },
{ label: "10 秒", value: 10 },
{ label: "30 秒", value: 30 },
{ label: "1 分钟", value: 60 },
{ label: "5 分钟", value: 300 },
{ label: "10 分钟", value: 600 },
];
export default function Home() {
const { t } = useI18n();
const [data, setData] = useState<ConfigData | null>(null);
const [error, setError] = useState<string | null>(null);
const [refreshInterval, setRefreshInterval] = useState(0);
@@ -334,6 +326,17 @@ export default function Home() {
const [statsRange, setStatsRange] = useState<TimeRange>("daily");
const timerRef = useRef<NodeJS.Timeout | 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 fetchData = useCallback(() => {
setLoading(true);
Promise.all([
@@ -365,7 +368,7 @@ export default function Home() {
if (error && !data) {
return (
<div className="min-h-screen flex items-center justify-center">
<p className="text-red-400">: {error}</p>
<p className="text-red-400">{t("common.loadError")}: {error}</p>
</div>
);
}
@@ -373,7 +376,7 @@ export default function Home() {
if (!data) {
return (
<div className="min-h-screen flex items-center justify-center">
<p className="text-[var(--text-muted)]">...</p>
<p className="text-[var(--text-muted)]">{t("common.loading")}</p>
</div>
);
}
@@ -384,10 +387,10 @@ export default function Home() {
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
🐾 OpenClaw Bot Dashboard
{t("home.title")}
</h1>
<p className="text-[var(--text-muted)] text-sm mt-1">
{data.agents.length} · : {data.defaults.model}
{t("models.totalPrefix")} {data.agents.length} {t("home.agentCount")} · {t("home.defaultModel")}: {data.defaults.model}
</p>
</div>
<div className="flex items-center gap-3">
@@ -400,7 +403,7 @@ export default function Home() {
>
{REFRESH_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.value === 0 ? "🔄 手动刷新" : `⏱️ ${opt.label}`}
{opt.value === 0 ? `🔄 ${opt.label}` : `⏱️ ${opt.label}`}
</option>
))}
</select>
@@ -416,22 +419,29 @@ export default function Home() {
</div>
{lastUpdated && (
<span className="text-xs text-[var(--text-muted)]">
{lastUpdated}
{t("home.updatedAt")} {lastUpdated}
</span>
)}
<Link
href="/models"
className="px-4 py-2 rounded-lg bg-[var(--accent)] text-[var(--bg)] text-sm font-medium hover:opacity-90 transition"
>
{t("home.viewModels")}
</Link>
<Link
href="/skills"
className="px-4 py-2 rounded-lg bg-[var(--card)] border border-[var(--border)] text-sm font-medium hover:border-[var(--accent)] transition"
>
{t("home.skillMgmt")}
</Link>
<LanguageSwitcher />
</div>
</div>
{/* 卡片墙 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{data.agents.map((agent) => (
<AgentCard key={agent.id} agent={agent} gatewayPort={data.gateway?.port || 18789} gatewayToken={data.gateway?.token} />
<AgentCard key={agent.id} agent={agent} gatewayPort={data.gateway?.port || 18789} gatewayToken={data.gateway?.token} t={t} />
))}
</div>
@@ -439,7 +449,7 @@ export default function Home() {
{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>
<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)}
@@ -457,21 +467,21 @@ export default function Home() {
<>
<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-[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)]"> Output Token</div>
<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)]"></div>
<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)]">🔢 Token </span>
<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>
@@ -480,12 +490,12 @@ export default function Home() {
<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)]"> </span>
<ResponseTrendChart data={currentData} />
<span className="text-xs text-[var(--text-muted)]">{t("home.avgResponseTrend")}</span>
<ResponseTrendChart data={currentData} t={t} />
</div>
)}
</>
@@ -498,7 +508,7 @@ export default function Home() {
{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) => (
@@ -506,7 +516,7 @@ export default function Home() {
<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" ? "飞书群" : "Discord 频道"} · {group.groupId.split(":")[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) => (
@@ -516,7 +526,7 @@ export default function Home() {
))}
</div>
</div>
<span className="text-xs text-[var(--text-muted)]">{group.agents.length} </span>
<span className="text-xs text-[var(--text-muted)]">{group.agents.length} {t("home.bots")}</span>
</div>
))}
</div>
@@ -527,7 +537,7 @@ export default function Home() {
{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">
🔄 Fallback
{t("home.fallbackModels")}
</h2>
<div className="flex flex-wrap gap-2">
{data.defaults.fallbacks.map((f, i) => (
+8
View File
@@ -0,0 +1,8 @@
"use client";
import { I18nProvider } from "@/lib/i18n";
import { ReactNode } from "react";
export function Providers({ children }: { children: ReactNode }) {
return <I18nProvider>{children}</I18nProvider>;
}
+45 -33
View File
@@ -3,6 +3,7 @@
import { useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import { useI18n, LanguageSwitcher } from "@/lib/i18n";
interface Session {
key: string;
@@ -20,28 +21,16 @@ interface GatewayInfo {
token?: string;
}
const TYPE_LABELS: Record<string, { label: string; emoji: string; color: string }> = {
main: { label: "主会话", emoji: "🏠", color: "bg-green-500/20 text-green-300 border-green-500/30" },
"feishu-dm": { label: "飞书私聊", emoji: "📱", color: "bg-blue-500/20 text-blue-300 border-blue-500/30" },
"feishu-group": { label: "飞书群聊", emoji: "👥", color: "bg-blue-500/20 text-blue-300 border-blue-500/30" },
"discord-dm": { label: "Discord 私聊", emoji: "🎮", color: "bg-purple-500/20 text-purple-300 border-purple-500/30" },
"discord-channel": { label: "Discord 频道", emoji: "📢", color: "bg-purple-500/20 text-purple-300 border-purple-500/30" },
cron: { label: "定时任务", emoji: "⏰", color: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30" },
unknown: { label: "未知", emoji: "❓", color: "bg-gray-500/20 text-gray-300 border-gray-500/30" },
const TYPE_EMOJI_COLOR: Record<string, { emoji: string; color: string }> = {
main: { emoji: "🏠", color: "bg-green-500/20 text-green-300 border-green-500/30" },
"feishu-dm": { emoji: "📱", color: "bg-blue-500/20 text-blue-300 border-blue-500/30" },
"feishu-group": { emoji: "👥", color: "bg-blue-500/20 text-blue-300 border-blue-500/30" },
"discord-dm": { emoji: "🎮", color: "bg-purple-500/20 text-purple-300 border-purple-500/30" },
"discord-channel": { emoji: "📢", color: "bg-purple-500/20 text-purple-300 border-purple-500/30" },
cron: { emoji: "⏰", color: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30" },
unknown: { emoji: "❓", color: "bg-gray-500/20 text-gray-300 border-gray-500/30" },
};
function formatTimeAgo(ts: number): string {
if (!ts) return "-";
const diff = Date.now() - ts;
const mins = Math.floor(diff / 60000);
if (mins < 1) return "刚刚";
if (mins < 60) return `${mins} 分钟前`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours} 小时前`;
const days = Math.floor(hours / 24);
return `${days} 天前`;
}
function formatTime(ts: number): string {
if (!ts) return "-";
return new Date(ts).toLocaleString("zh-CN");
@@ -54,10 +43,29 @@ export default function SessionsPage() {
const [gateway, setGateway] = useState<GatewayInfo>({ port: 18789 });
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const { t } = useI18n();
function formatTimeAgo(ts: number): string {
if (!ts) return "-";
const diff = Date.now() - ts;
const mins = Math.floor(diff / 60000);
if (mins < 1) return t("common.justNow");
if (mins < 60) return `${mins} ${t("common.minutesAgo")}`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours} ${t("common.hoursAgo")}`;
const days = Math.floor(hours / 24);
return `${days} ${t("common.daysAgo")}`;
}
function getTypeLabel(type: string): { label: string; emoji: string; color: string } {
const info = TYPE_EMOJI_COLOR[type] || TYPE_EMOJI_COLOR.unknown;
const labelKey = `sessions.type.${type}` as const;
const label = t(TYPE_EMOJI_COLOR[type] ? labelKey : "sessions.type.unknown");
return { ...info, label };
}
useEffect(() => {
if (!agentId) return;
// 并行获取 sessions 和 gateway 配置
Promise.all([
fetch(`/api/sessions/${agentId}`).then((r) => r.json()),
fetch("/api/config").then((r) => r.json()),
@@ -74,7 +82,7 @@ export default function SessionsPage() {
if (!agentId) {
return (
<div className="min-h-screen flex items-center justify-center">
<p className="text-red-400"> agent </p>
<p className="text-red-400">{t("sessions.missingAgent")}</p>
</div>
);
}
@@ -82,7 +90,7 @@ export default function SessionsPage() {
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<p className="text-[var(--text-muted)]">...</p>
<p className="text-[var(--text-muted)]">{t("common.loading")}</p>
</div>
);
}
@@ -90,7 +98,7 @@ export default function SessionsPage() {
if (error) {
return (
<div className="min-h-screen flex items-center justify-center">
<p className="text-red-400">: {error}</p>
<p className="text-red-400">{t("common.loadError")}: {error}</p>
</div>
);
}
@@ -101,28 +109,32 @@ export default function SessionsPage() {
<main className="min-h-screen p-8 max-w-6xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold">📋 {agentId} </h1>
<h1 className="text-2xl font-bold">📋 {agentId} {t("sessions.title")}</h1>
<p className="text-[var(--text-muted)] text-sm mt-1">
{sessions.length} · Token: {(totalTokens / 1000).toFixed(1)}k
{sessions.length} {t("sessions.sessionCount")} · {t("sessions.totalToken")}: {(totalTokens / 1000).toFixed(1)}k
</p>
</div>
<Link
href="/"
className="px-4 py-2 rounded-lg bg-[var(--card)] border border-[var(--border)] text-sm hover:border-[var(--accent)] transition"
>
</Link>
<div className="flex items-center gap-3">
<LanguageSwitcher />
<Link
href="/"
className="px-4 py-2 rounded-lg bg-[var(--card)] border border-[var(--border)] text-sm hover:border-[var(--accent)] transition"
>
{t("common.backHome")}
</Link>
</div>
</div>
<div className="space-y-3">
{sessions.map((s) => {
const typeInfo = TYPE_LABELS[s.type] || TYPE_LABELS.unknown;
const typeInfo = getTypeLabel(s.type);
let chatUrl = `http://localhost:${gateway.port}/chat?session=${encodeURIComponent(s.key)}`;
if (gateway.token) chatUrl += `&token=${encodeURIComponent(gateway.token)}`;
return (
<div
key={s.key}
onClick={() => window.open(chatUrl, "_blank")}
title={t("agent.openChat")}
className="p-4 rounded-xl border border-[var(--border)] bg-[var(--card)] hover:border-[var(--accent)] transition cursor-pointer"
>
<div className="flex items-center justify-between mb-2">
+169
View File
@@ -0,0 +1,169 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useI18n, LanguageSwitcher } from "@/lib/i18n";
interface Skill {
id: string;
name: string;
description: string;
emoji: string;
source: string;
usedBy: string[];
}
interface AgentInfo {
name: string;
emoji: string;
}
export default function SkillsPage() {
const { t } = useI18n();
const [skills, setSkills] = useState<Skill[]>([]);
const [agents, setAgents] = useState<Record<string, AgentInfo>>({});
const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState<"all" | "builtin" | "extension" | "custom">("all");
const [search, setSearch] = useState("");
useEffect(() => {
fetch("/api/skills")
.then((r) => r.json())
.then((data) => {
if (data.error) setError(data.error);
else {
setSkills(data.skills);
setAgents(data.agents);
}
})
.catch((e) => setError(e.message));
}, []);
const filtered = skills.filter((s) => {
if (filter === "builtin" && s.source !== "builtin") return false;
if (filter === "extension" && !s.source.startsWith("extension:")) return false;
if (filter === "custom" && s.source !== "custom") return false;
if (search) {
const q = search.toLowerCase();
return (
s.name.toLowerCase().includes(q) ||
s.description.toLowerCase().includes(q) ||
s.id.toLowerCase().includes(q)
);
}
return true;
});
const sourceLabel = (s: string) => {
if (s === "builtin") return t("skills.source.builtin");
if (s.startsWith("extension:")) return s.replace("extension:", `${t("skills.extension")}:`);
return t("skills.source.custom");
};
const sourceBadgeClass = (s: string) => {
if (s === "builtin") return "bg-blue-500/20 text-blue-400";
if (s.startsWith("extension:")) return "bg-purple-500/20 text-purple-400";
return "bg-green-500/20 text-green-400";
};
if (error) {
return (
<div className="min-h-screen flex items-center justify-center">
<p className="text-red-400">{t("common.loadError")}: {error}</p>
</div>
);
}
const builtinCount = skills.filter((s) => s.source === "builtin").length;
const extCount = skills.filter((s) => s.source.startsWith("extension:")).length;
const customCount = skills.filter((s) => s.source === "custom").length;
return (
<main className="min-h-screen p-8 max-w-6xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold">{t("skills.title")}</h1>
<p className="text-[var(--text-muted)] text-sm mt-1">
{skills.length} {t("skills.count")}{t("skills.builtin")} {builtinCount} / {t("skills.extension")} {extCount} / {t("skills.custom")} {customCount}
</p>
</div>
<div className="flex items-center gap-3">
<LanguageSwitcher />
<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"
>
{t("common.backOverview")}
</Link>
</div>
</div>
{/* Filters */}
<div className="flex items-center gap-3 mb-6">
<div className="flex rounded-lg border border-[var(--border)] overflow-hidden">
{(["all", "builtin", "extension", "custom"] as const).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-3 py-1.5 text-xs font-medium transition cursor-pointer ${
filter === f
? "bg-[var(--accent)] text-[var(--bg)]"
: "bg-[var(--card)] text-[var(--text-muted)] hover:text-[var(--text)]"
}`}
>
{f === "all" ? t("skills.all") : f === "builtin" ? t("skills.builtin") : f === "extension" ? t("skills.extension") : t("skills.custom")}
</button>
))}
</div>
<input
type="text"
placeholder={t("skills.search")}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="px-3 py-1.5 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm outline-none focus:border-[var(--accent)] transition w-64"
/>
<span className="text-xs text-[var(--text-muted)]">
{t("skills.showing")} {filtered.length} {t("skills.unit")}
</span>
</div>
{/* Skills Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filtered.map((skill) => (
<div
key={`${skill.source}-${skill.id}`}
className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-4 hover:border-[var(--accent)]/50 transition"
>
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<span className="text-xl">{skill.emoji}</span>
<span className="font-semibold text-sm">{skill.name}</span>
</div>
<span className={`px-2 py-0.5 rounded-full text-[10px] font-medium ${sourceBadgeClass(skill.source)}`}>
{sourceLabel(skill.source)}
</span>
</div>
<p className="text-xs text-[var(--text-muted)] line-clamp-2 mb-3 min-h-[2.5em]">
{skill.description || t("skills.noDesc")}
</p>
{skill.usedBy.length > 0 && (
<div className="flex flex-wrap gap-1">
{skill.usedBy.map((agentId) => {
const agent = agents[agentId];
return (
<span
key={agentId}
className="px-1.5 py-0.5 rounded bg-[var(--bg)] text-[10px] font-medium"
>
{agent?.emoji || "🤖"} {agent?.name || agentId}
</span>
);
})}
</div>
)}
</div>
))}
</div>
</main>
);
}
+35 -35
View File
@@ -3,6 +3,7 @@
import { useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import { useI18n, LanguageSwitcher } from "@/lib/i18n";
interface DayStat {
date: string;
@@ -22,12 +23,6 @@ interface StatsData {
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";
@@ -46,16 +41,18 @@ function BarChart({
labelKey,
bars,
height = 220,
noDataText,
}: {
data: DayStat[];
labelKey: "date";
bars: { key: keyof DayStat; color: string; label: string }[];
height?: number;
noDataText: string;
}) {
if (data.length === 0) {
return (
<div className="flex items-center justify-center h-40 text-[var(--text-muted)] text-sm">
{noDataText}
</div>
);
}
@@ -65,7 +62,6 @@ function BarChart({
const chartW = width - padding.left - padding.right;
const chartH = height - padding.top - padding.bottom;
// Find max value across all bars
let maxVal = 0;
for (const d of data) {
for (const b of bars) {
@@ -75,17 +71,14 @@ function BarChart({
}
if (maxVal === 0) maxVal = 1;
// Y-axis ticks
const tickCount = 4;
const ticks = Array.from({ length: tickCount + 1 }, (_, i) => Math.round((maxVal / tickCount) * i));
const groupWidth = chartW / data.length;
const barWidth = Math.min(20, (groupWidth - 8) / bars.length);
return (
<div className="overflow-x-auto">
<svg width={width} height={height} className="text-[var(--text-muted)]">
{/* Y-axis grid + labels */}
{ticks.map((tick, i) => {
const y = padding.top + chartH - (tick / maxVal) * chartH;
return (
@@ -97,8 +90,6 @@ function BarChart({
</g>
);
})}
{/* Bars */}
{data.map((d, i) => {
const groupX = padding.left + i * groupWidth;
return (
@@ -116,7 +107,6 @@ function BarChart({
</g>
);
})}
{/* X-axis label */}
<text
x={groupX + groupWidth / 2}
y={height - padding.bottom + 16}
@@ -130,8 +120,6 @@ function BarChart({
</g>
);
})}
{/* Axes */}
<line x1={padding.left} y1={padding.top} x2={padding.left} y2={padding.top + chartH} stroke="currentColor" opacity={0.3} />
<line x1={padding.left} y1={padding.top + chartH} x2={width - padding.right} y2={padding.top + chartH} stroke="currentColor" opacity={0.3} />
</svg>
@@ -140,12 +128,12 @@ function BarChart({
}
// Response time chart (separate scale)
function ResponseTimeChart({ data, height = 220 }: { data: DayStat[]; height?: number }) {
function ResponseTimeChart({ data, height = 220, noDataText }: { data: DayStat[]; height?: number; noDataText: string }) {
const filtered = data.filter((d) => d.avgResponseMs > 0);
if (filtered.length === 0) {
return (
<div className="flex items-center justify-center h-40 text-[var(--text-muted)] text-sm">
{noDataText}
</div>
);
}
@@ -212,6 +200,16 @@ export default function StatsPage() {
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [range, setRange] = useState<TimeRange>("daily");
const { t } = useI18n();
const getRangeLabel = (r: TimeRange): string => {
const labels: Record<TimeRange, string> = {
daily: t("range.daily"),
weekly: t("range.weekly"),
monthly: t("range.monthly"),
};
return labels[r];
};
useEffect(() => {
if (!agentId) return;
@@ -228,7 +226,7 @@ export default function StatsPage() {
if (!agentId) {
return (
<div className="min-h-screen flex items-center justify-center">
<p className="text-red-400"> agent </p>
<p className="text-red-400">{t("stats.missingAgent")}</p>
</div>
);
}
@@ -236,7 +234,7 @@ export default function StatsPage() {
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<p className="text-[var(--text-muted)]">...</p>
<p className="text-[var(--text-muted)]">{t("common.loading")}</p>
</div>
);
}
@@ -244,7 +242,7 @@ export default function StatsPage() {
if (error) {
return (
<div className="min-h-screen flex items-center justify-center">
<p className="text-red-400">: {error}</p>
<p className="text-red-400">{t("common.loadError")}: {error}</p>
</div>
);
}
@@ -261,15 +259,15 @@ export default function StatsPage() {
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold">📊 {agentId} </h1>
<h1 className="text-2xl font-bold">{`📊 ${agentId} ${t("stats.title")}`}</h1>
<p className="text-[var(--text-muted)] text-sm mt-1">
Token
{t("stats.subtitle")}
</p>
</div>
<div className="flex items-center gap-3">
{/* Time range selector */}
<div className="flex rounded-lg border border-[var(--border)] overflow-hidden">
{(Object.keys(RANGE_LABELS) as TimeRange[]).map((r) => (
{(["daily", "weekly", "monthly"] as TimeRange[]).map((r) => (
<button
key={r}
onClick={() => setRange(r)}
@@ -279,7 +277,7 @@ export default function StatsPage() {
: "bg-[var(--card)] text-[var(--text-muted)] hover:text-[var(--text)]"
}`}
>
{RANGE_LABELS[r]}
{getRangeLabel(r)}
</button>
))}
</div>
@@ -287,41 +285,42 @@ export default function StatsPage() {
href={`/sessions?agent=${agentId}`}
className="px-4 py-2 rounded-lg bg-[var(--card)] border border-[var(--border)] text-sm hover:border-[var(--accent)] transition"
>
📋
{t("stats.sessionList")}
</Link>
<Link
href="/"
className="px-4 py-2 rounded-lg bg-[var(--card)] border border-[var(--border)] text-sm hover:border-[var(--accent)] transition"
>
{t("stats.home")}
</Link>
<LanguageSwitcher />
</div>
</div>
{/* Summary cards */}
<div className="grid grid-cols-4 gap-4 mb-6">
<div className="p-4 rounded-xl border border-[var(--border)] bg-[var(--card)]">
<div className="text-xs text-[var(--text-muted)] mb-1"> Input Token</div>
<div className="text-xs text-[var(--text-muted)] mb-1">{t("stats.totalInputToken")}</div>
<div className="text-xl font-bold text-blue-400">{formatTokens(totalInput)}</div>
</div>
<div className="p-4 rounded-xl border border-[var(--border)] bg-[var(--card)]">
<div className="text-xs text-[var(--text-muted)] mb-1"> Output Token</div>
<div className="text-xs text-[var(--text-muted)] mb-1">{t("stats.totalOutputToken")}</div>
<div className="text-xl font-bold text-emerald-400">{formatTokens(totalOutput)}</div>
</div>
<div className="p-4 rounded-xl border border-[var(--border)] bg-[var(--card)]">
<div className="text-xs text-[var(--text-muted)] mb-1"></div>
<div className="text-xs text-[var(--text-muted)] mb-1">{t("stats.totalMessages")}</div>
<div className="text-xl font-bold text-purple-400">{totalMessages}</div>
</div>
<div className="p-4 rounded-xl border border-[var(--border)] bg-[var(--card)]">
<div className="text-xs text-[var(--text-muted)] mb-1"></div>
<div className="text-xl font-bold text-[var(--text)]">{currentData.length} {RANGE_LABELS[range].slice(1)}</div>
<div className="text-xs text-[var(--text-muted)] mb-1">{t("stats.dataPeriod")}</div>
<div className="text-xl font-bold text-[var(--text)]">{currentData.length}</div>
</div>
</div>
{/* Token chart */}
<div className="p-5 rounded-xl border border-[var(--border)] bg-[var(--card)] mb-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-semibold text-[var(--text)]">🔢 Token </h2>
<h2 className="text-sm font-semibold text-[var(--text)]">{t("stats.tokenConsumption")}</h2>
<div className="flex items-center gap-4 text-xs">
<span className="flex items-center gap-1"><span className="w-3 h-3 rounded-sm bg-blue-500 inline-block" /> Input</span>
<span className="flex items-center gap-1"><span className="w-3 h-3 rounded-sm bg-emerald-500 inline-block" /> Output</span>
@@ -334,14 +333,15 @@ export default function StatsPage() {
{ key: "inputTokens", color: "#3b82f6", label: "Input" },
{ key: "outputTokens", color: "#10b981", label: "Output" },
]}
noDataText={t("common.noData")}
/>
</div>
{/* Response time chart */}
{range === "daily" && (
<div className="p-5 rounded-xl border border-[var(--border)] bg-[var(--card)]">
<h2 className="text-sm font-semibold text-[var(--text)] mb-4"> </h2>
<ResponseTimeChart data={currentData} />
<h2 className="text-sm font-semibold text-[var(--text)] mb-4">{t("stats.avgResponseTime")}</h2>
<ResponseTimeChart data={currentData} noDataText={t("stats.noResponseData")} />
</div>
)}
</main>
+324
View File
@@ -0,0 +1,324 @@
"use client";
import { createContext, useContext, useState, useCallback, ReactNode } from "react";
export type Locale = "zh" | "en";
const translations: Record<Locale, Record<string, string>> = {
zh: {
// layout
"site.title": "OpenClaw Bot Dashboard",
"site.desc": "查看所有 OpenClaw 机器人配置",
// common
"common.loading": "加载中...",
"common.loadError": "加载失败",
"common.backHome": "← 返回首页",
"common.backOverview": "← 返回总览",
"common.noData": "暂无数据",
"common.test": "🧪 测试",
"common.testing": "⏳ 测试中...",
"common.justNow": "刚刚",
"common.minutesAgo": "分钟前",
"common.hoursAgo": "小时前",
"common.daysAgo": "天前",
"common.manualRefresh": "手动刷新",
"common.seconds": "秒",
"common.minute": "分钟",
"common.minutes": "分钟",
// home page
"home.title": "🐾 OpenClaw Bot Dashboard",
"home.agentCount": "个机器人",
"home.defaultModel": "默认模型",
"home.viewModels": "查看模型列表 →",
"home.skillMgmt": "🧩 技能管理",
"home.updatedAt": "更新于",
"home.refreshManual": "🔄 手动刷新",
"home.globalTrend": "📊 全局统计趋势",
"home.totalInputToken": "总 Input Token",
"home.totalOutputToken": "总 Output Token",
"home.totalMessages": "总消息数",
"home.tokenTrend": "🔢 Token 消耗趋势",
"home.avgResponseTrend": "⏱️ 平均响应时间趋势",
"home.groupTopology": "💬 群聊拓扑",
"home.fallbackModels": "🔄 Fallback 模型",
"home.feishuGroup": "飞书群",
"home.discordChannel": "Discord 频道",
"home.bots": "个机器人",
"home.noResponseData": "暂无响应时间数据",
// agent card
"agent.model": "模型",
"agent.platform": "平台",
"agent.feishuAppId": "飞书 App ID",
"agent.sessionCount": "会话数",
"agent.tokenUsage": "Token 用量",
"agent.stats": "统计",
"agent.lastActive": "最近活跃",
"agent.inUse": "使用中:",
"agent.openChat": "点击打开聊天页面",
// platform
"platform.feishu": "📱 飞书",
"platform.discord": "🎮 Discord",
// time range
"range.daily": "按天",
"range.weekly": "按周",
"range.monthly": "按月",
// refresh options
"refresh.manual": "手动刷新",
"refresh.10s": "10 秒",
"refresh.30s": "30 秒",
"refresh.1m": "1 分钟",
"refresh.5m": "5 分钟",
"refresh.10m": "10 分钟",
// models page
"models.title": "OpenClaw接入模型列表",
"models.providerCount": "个 Provider",
"models.totalPrefix": "共",
"models.testAll": "🧪 测试全部模型",
"models.testingAll": "⏳ 测试中...",
"models.colModelId": "模型 ID",
"models.colName": "名称",
"models.colContext": "上下文窗口",
"models.colMaxOutput": "最大输出",
"models.colInputType": "输入类型",
"models.colReasoning": "推理",
"models.colInputToken": "Input Token",
"models.colOutputToken": "Output Token",
"models.colAvgResponse": "平均响应",
"models.colTest": "测试",
"models.noExplicitModels": "无显式模型定义(通过 provider 名称推断)",
// stats page
"stats.title": "消息统计",
"stats.subtitle": "Token 消耗与响应时间分析",
"stats.totalInputToken": "总 Input Token",
"stats.totalOutputToken": "总 Output Token",
"stats.totalMessages": "总消息数",
"stats.dataPeriod": "数据周期",
"stats.tokenConsumption": "🔢 Token 消耗",
"stats.avgResponseTime": "⏱️ 平均响应时间",
"stats.sessionList": "📋 会话列表",
"stats.home": "← 首页",
"stats.missingAgent": "缺少 agent 参数",
"stats.noResponseData": "暂无响应时间数据",
// sessions page
"sessions.title": "的会话列表",
"sessions.sessionCount": "个会话",
"sessions.totalToken": "总 Token",
"sessions.missingAgent": "缺少 agent 参数",
"sessions.type.main": "主会话",
"sessions.type.feishu-dm": "飞书私聊",
"sessions.type.feishu-group": "飞书群聊",
"sessions.type.discord-dm": "Discord 私聊",
"sessions.type.discord-channel": "Discord 频道",
"sessions.type.cron": "定时任务",
"sessions.type.unknown": "未知",
// skills page
"skills.title": "🧩 技能管理",
"skills.count": "个技能",
"skills.builtin": "内置",
"skills.extension": "扩展",
"skills.custom": "自定义",
"skills.all": "全部",
"skills.search": "搜索技能...",
"skills.showing": "显示",
"skills.unit": "个",
"skills.noDesc": "无描述",
"skills.source.builtin": "内置",
"skills.source.custom": "自定义",
},
en: {
// layout
"site.title": "OpenClaw Bot Dashboard",
"site.desc": "View all OpenClaw bot configurations",
// common
"common.loading": "Loading...",
"common.loadError": "Failed to load",
"common.backHome": "← Back to Home",
"common.backOverview": "← Back to Overview",
"common.noData": "No data",
"common.test": "🧪 Test",
"common.testing": "⏳ Testing...",
"common.justNow": "just now",
"common.minutesAgo": "min ago",
"common.hoursAgo": "hours ago",
"common.daysAgo": "days ago",
"common.manualRefresh": "Manual Refresh",
"common.seconds": "seconds",
"common.minute": "minute",
"common.minutes": "minutes",
// home page
"home.title": "🐾 OpenClaw Bot Dashboard",
"home.agentCount": "bots",
"home.defaultModel": "Default model",
"home.viewModels": "View Models →",
"home.skillMgmt": "🧩 Skills",
"home.updatedAt": "Updated at",
"home.refreshManual": "🔄 Manual Refresh",
"home.globalTrend": "📊 Global Statistics",
"home.totalInputToken": "Total Input Token",
"home.totalOutputToken": "Total Output Token",
"home.totalMessages": "Total Messages",
"home.tokenTrend": "🔢 Token Usage Trend",
"home.avgResponseTrend": "⏱️ Avg Response Time Trend",
"home.groupTopology": "💬 Group Chat Topology",
"home.fallbackModels": "🔄 Fallback Models",
"home.feishuGroup": "Feishu Group",
"home.discordChannel": "Discord Channel",
"home.bots": "bots",
"home.noResponseData": "No response time data",
// agent card
"agent.model": "Model",
"agent.platform": "Platform",
"agent.feishuAppId": "Feishu App ID",
"agent.sessionCount": "Sessions",
"agent.tokenUsage": "Token Usage",
"agent.stats": "Stats",
"agent.lastActive": "Last Active",
"agent.inUse": "Used by:",
"agent.openChat": "Click to open chat",
// platform
"platform.feishu": "📱 Feishu",
"platform.discord": "🎮 Discord",
// time range
"range.daily": "Daily",
"range.weekly": "Weekly",
"range.monthly": "Monthly",
// refresh options
"refresh.manual": "Manual Refresh",
"refresh.10s": "10s",
"refresh.30s": "30s",
"refresh.1m": "1 min",
"refresh.5m": "5 min",
"refresh.10m": "10 min",
// models page
"models.title": "OpenClaw Model List",
"models.providerCount": "Providers",
"models.totalPrefix": "",
"models.testAll": "🧪 Test All Models",
"models.testingAll": "⏳ Testing...",
"models.colModelId": "Model ID",
"models.colName": "Name",
"models.colContext": "Context Window",
"models.colMaxOutput": "Max Output",
"models.colInputType": "Input Type",
"models.colReasoning": "Reasoning",
"models.colInputToken": "Input Token",
"models.colOutputToken": "Output Token",
"models.colAvgResponse": "Avg Response",
"models.colTest": "Test",
"models.noExplicitModels": "No explicit model definitions (inferred from provider name)",
// stats page
"stats.title": "Message Statistics",
"stats.subtitle": "Token usage and response time analysis",
"stats.totalInputToken": "Total Input Token",
"stats.totalOutputToken": "Total Output Token",
"stats.totalMessages": "Total Messages",
"stats.dataPeriod": "Data Period",
"stats.tokenConsumption": "🔢 Token Usage",
"stats.avgResponseTime": "⏱️ Avg Response Time",
"stats.sessionList": "📋 Sessions",
"stats.home": "← Home",
"stats.missingAgent": "Missing agent parameter",
"stats.noResponseData": "No response time data",
// sessions page
"sessions.title": "Sessions",
"sessions.sessionCount": "sessions",
"sessions.totalToken": "Total Token",
"sessions.missingAgent": "Missing agent parameter",
"sessions.type.main": "Main",
"sessions.type.feishu-dm": "Feishu DM",
"sessions.type.feishu-group": "Feishu Group",
"sessions.type.discord-dm": "Discord DM",
"sessions.type.discord-channel": "Discord Channel",
"sessions.type.cron": "Cron Job",
"sessions.type.unknown": "Unknown",
// skills page
"skills.title": "🧩 Skill Management",
"skills.count": "skills",
"skills.builtin": "Built-in",
"skills.extension": "Extension",
"skills.custom": "Custom",
"skills.all": "All",
"skills.search": "Search skills...",
"skills.showing": "Showing",
"skills.unit": "",
"skills.noDesc": "No description",
"skills.source.builtin": "Built-in",
"skills.source.custom": "Custom",
},
};
interface I18nContextType {
locale: Locale;
setLocale: (l: Locale) => void;
t: (key: string) => string;
}
const I18nContext = createContext<I18nContextType>({
locale: "zh",
setLocale: () => {},
t: (key) => key,
});
export function I18nProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<Locale>(() => {
if (typeof window !== "undefined") {
return (localStorage.getItem("locale") as Locale) || "zh";
}
return "zh";
});
const setLocale = useCallback((l: Locale) => {
setLocaleState(l);
if (typeof window !== "undefined") {
localStorage.setItem("locale", l);
}
}, []);
const t = useCallback(
(key: string) => translations[locale]?.[key] ?? translations.zh[key] ?? key,
[locale]
);
return (
<I18nContext.Provider value={{ locale, setLocale, t }}>
{children}
</I18nContext.Provider>
);
}
export function useI18n() {
return useContext(I18nContext);
}
export function LanguageSwitcher() {
const { locale, setLocale } = useI18n();
return (
<button
onClick={() => setLocale(locale === "zh" ? "en" : "zh")}
className="px-3 py-1.5 rounded-lg bg-[var(--card)] border border-[var(--border)] text-xs font-medium hover:border-[var(--accent)] transition cursor-pointer"
title={locale === "zh" ? "Switch to English" : "切换到中文"}
>
{locale === "zh" ? "EN" : "中文"}
</button>
);
}