From ea345868f46254eefecb2bb5a986af65abb34bda Mon Sep 17 00:00:00 2001 From: xmanrui <841206367@qq.com> Date: Fri, 20 Feb 2026 17:00:27 +0800 Subject: [PATCH] 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 --- app/api/skills/route.ts | 169 ++++++++++++++++ app/api/stats/[agentId]/route.ts | 2 +- app/layout.tsx | 5 +- app/models/page.tsx | 53 +++-- app/page.tsx | 132 +++++++------ app/providers.tsx | 8 + app/sessions/page.tsx | 78 ++++---- app/skills/page.tsx | 169 ++++++++++++++++ app/stats/page.tsx | 70 +++---- lib/i18n.tsx | 324 +++++++++++++++++++++++++++++++ 10 files changed, 852 insertions(+), 158 deletions(-) create mode 100644 app/api/skills/route.ts create mode 100644 app/providers.tsx create mode 100644 app/skills/page.tsx create mode 100644 lib/i18n.tsx diff --git a/app/api/skills/route.ts b/app/api/skills/route.ts new file mode 100644 index 0000000..c821010 --- /dev/null +++ b/app/api/skills/route.ts @@ -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 { + const result: Record = {}; + 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> { + // Parse skillsSnapshot from session JSONL files + const agentsDir = path.join(OPENCLAW_HOME, "agents"); + const result: Record> = {}; + 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(); + + // 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 = {}; + 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 }); + } +} diff --git a/app/api/stats/[agentId]/route.ts b/app/api/stats/[agentId]/route.ts index 12957fe..07035a4 100644 --- a/app/api/stats/[agentId]/route.ts +++ b/app/api/stats/[agentId]/route.ts @@ -14,7 +14,7 @@ interface DayStat { responseTimes: number[]; // internal, stripped before response } -function parseSessions(agentId: string): DayStat[] { +function parseSessions(agentId: string): Omit[] { const sessionsDir = path.join(OPENCLAW_HOME, `agents/${agentId}/sessions`); const dayMap: Record = {}; diff --git a/app/layout.tsx b/app/layout.tsx index 0105e42..ff1a31c 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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 ( - {children} + + {children} + ); } diff --git a/app/models/page.tsx b/app/models/page.tsx index 87c2562..22de988 100644 --- a/app/models/page.tsx +++ b/app/models/page.tsx @@ -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(null); const [modelStats, setModelStats] = useState>({}); const [error, setError] = useState(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 (
-

加载失败: {error}

+

{t("common.loadError")}: {error}

); } @@ -135,7 +134,7 @@ export default function ModelsPage() { if (!data) { return (
-

加载中...

+

{t("common.loading")}

); } @@ -145,10 +144,10 @@ export default function ModelsPage() {

- OpenClaw接入模型列表 + {t("models.title")}

- 共 {data.providers.length} 个 Provider + {t("models.totalPrefix")} {data.providers.length} {t("models.providerCount")}

@@ -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")} - ← 返回总览 + {t("common.backOverview")} +
@@ -187,7 +187,7 @@ export default function ModelsPage() { {provider.usedBy.length > 0 && (
- 使用中: + {t("agent.inUse")} {provider.usedBy.map((a) => ( {a.emoji} {a.name || a.id} @@ -202,16 +202,16 @@ export default function ModelsPage() { - - - - - - - - - - + + + + + + + + + + @@ -228,12 +228,12 @@ export default function ModelsPage() {
模型 ID名称上下文窗口最大输出输入类型推理Input 用量Output 用量平均响应测试{t("models.colModelId")}{t("models.colName")}{t("models.colContext")}{t("models.colMaxOutput")}{t("models.colInputType")}{t("models.colReasoning")}{t("models.colInputToken")}{t("models.colOutputToken")}{t("models.colAvgResponse")}{t("models.colTest")}
{formatNum(m.maxTokens)}
- {(m.input || []).map((t) => ( + {(m.input || []).map((inputType) => ( - {t === "text" ? "📝" : "🖼️"} {t} + {inputType === "text" ? "📝" : "🖼️"} {inputType} ))}
@@ -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")} {result && ( @@ -271,10 +271,9 @@ export default function ModelsPage() { ) : (

- 无显式模型定义(通过 provider 名称推断) + {t("models.noExplicitModels")}

{(() => { - // 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")} {result && ( diff --git a/app/page.tsx b/app/page.tsx index e7579a3..e4e79ff 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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 = { 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
暂无数据
; +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
{t("common.noData")}
; 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
暂无响应时间数据
; + if (filtered.length === 0) return
{t("home.noResponseData")}
; 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 && ( ({platform.accountId}) )} @@ -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 (
window.open(sessionUrl, "_blank")} @@ -254,22 +255,22 @@ function AgentCard({ agent, gatewayPort, gatewayToken }: { agent: Agent; gateway
- 模型 + {t("agent.model")}
- 平台 + {t("agent.platform")}
{agent.platforms.map((p, i) => ( - + ))}
{agent.platforms.some((p) => p.appId) && (
- 飞书 App ID + {t("agent.feishuAppId")} {agent.platforms.find((p) => p.appId)?.appId} @@ -279,7 +280,7 @@ function AgentCard({ agent, gatewayPort, gatewayToken }: { agent: Agent; gateway {agent.session && (
- Token 用量 + {t("agent.tokenUsage")} {(agent.session.totalTokens / 1000).toFixed(1)}k
{agent.session.lastActive && (
- 最近活跃 + {t("agent.lastActive")} {formatTimeAgo(agent.session.lastActive)}
)} @@ -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(null); const [error, setError] = useState(null); const [refreshInterval, setRefreshInterval] = useState(0); @@ -334,6 +326,17 @@ export default function Home() { const [statsRange, setStatsRange] = useState("daily"); const timerRef = useRef(null); + const RANGE_LABELS: Record = { 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 (
-

加载失败: {error}

+

{t("common.loadError")}: {error}

); } @@ -373,7 +376,7 @@ export default function Home() { if (!data) { return (
-

加载中...

+

{t("common.loading")}

); } @@ -384,10 +387,10 @@ export default function Home() {

- 🐾 OpenClaw Bot Dashboard + {t("home.title")}

- 共 {data.agents.length} 个机器人 · 默认模型: {data.defaults.model} + {t("models.totalPrefix")} {data.agents.length} {t("home.agentCount")} · {t("home.defaultModel")}: {data.defaults.model}

@@ -400,7 +403,7 @@ export default function Home() { > {REFRESH_OPTIONS.map((opt) => ( ))} @@ -416,22 +419,29 @@ export default function Home() {
{lastUpdated && ( - 更新于 {lastUpdated} + {t("home.updatedAt")} {lastUpdated} )} - 查看模型列表 → + {t("home.viewModels")} + + {t("home.skillMgmt")} + +
{/* 卡片墙 */}
{data.agents.map((agent) => ( - + ))}
@@ -439,7 +449,7 @@ export default function Home() { {allStats && (
-

📊 全局统计趋势

+

{t("home.globalTrend")}

{(Object.keys(RANGE_LABELS) as TimeRange[]).map((r) => ( + ))} +
+ 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" + /> + + {t("skills.showing")} {filtered.length} {t("skills.unit")} + +
+ + {/* Skills Grid */} +
+ {filtered.map((skill) => ( +
+
+
+ {skill.emoji} + {skill.name} +
+ + {sourceLabel(skill.source)} + +
+

+ {skill.description || t("skills.noDesc")} +

+ {skill.usedBy.length > 0 && ( +
+ {skill.usedBy.map((agentId) => { + const agent = agents[agentId]; + return ( + + {agent?.emoji || "🤖"} {agent?.name || agentId} + + ); + })} +
+ )} +
+ ))} +
+ + ); +} diff --git a/app/stats/page.tsx b/app/stats/page.tsx index 0b6f5d4..95a7c47 100644 --- a/app/stats/page.tsx +++ b/app/stats/page.tsx @@ -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 = { - 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 (
- 暂无数据 + {noDataText}
); } @@ -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 (
- {/* Y-axis grid + labels */} {ticks.map((tick, i) => { const y = padding.top + chartH - (tick / maxVal) * chartH; return ( @@ -97,8 +90,6 @@ function BarChart({ ); })} - - {/* Bars */} {data.map((d, i) => { const groupX = padding.left + i * groupWidth; return ( @@ -116,7 +107,6 @@ function BarChart({ ); })} - {/* X-axis label */} ); })} - - {/* Axes */} @@ -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 (
- 暂无响应时间数据 + {noDataText}
); } @@ -212,6 +200,16 @@ export default function StatsPage() { const [error, setError] = useState(null); const [loading, setLoading] = useState(true); const [range, setRange] = useState("daily"); + const { t } = useI18n(); + + const getRangeLabel = (r: TimeRange): string => { + const labels: Record = { + 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 (
-

缺少 agent 参数

+

{t("stats.missingAgent")}

); } @@ -236,7 +234,7 @@ export default function StatsPage() { if (loading) { return (
-

加载中...

+

{t("common.loading")}

); } @@ -244,7 +242,7 @@ export default function StatsPage() { if (error) { return (
-

加载失败: {error}

+

{t("common.loadError")}: {error}

); } @@ -261,15 +259,15 @@ export default function StatsPage() { {/* Header */}
-

📊 {agentId} 消息统计

+

{`📊 ${agentId} ${t("stats.title")}`}

- Token 消耗与响应时间分析 + {t("stats.subtitle")}

{/* Time range selector */}
- {(Object.keys(RANGE_LABELS) as TimeRange[]).map((r) => ( + {(["daily", "weekly", "monthly"] as TimeRange[]).map((r) => ( ))}
@@ -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")} - ← 首页 + {t("stats.home")} +
{/* Summary cards */}
-
总 Input Token
+
{t("stats.totalInputToken")}
{formatTokens(totalInput)}
-
总 Output Token
+
{t("stats.totalOutputToken")}
{formatTokens(totalOutput)}
-
总消息数
+
{t("stats.totalMessages")}
{totalMessages}
-
数据周期
-
{currentData.length} {RANGE_LABELS[range].slice(1)}
+
{t("stats.dataPeriod")}
+
{currentData.length}
{/* Token chart */}
-

🔢 Token 消耗

+

{t("stats.tokenConsumption")}

Input Output @@ -334,14 +333,15 @@ export default function StatsPage() { { key: "inputTokens", color: "#3b82f6", label: "Input" }, { key: "outputTokens", color: "#10b981", label: "Output" }, ]} + noDataText={t("common.noData")} />
{/* Response time chart */} {range === "daily" && (
-

⏱️ 平均响应时间

- +

{t("stats.avgResponseTime")}

+
)} diff --git a/lib/i18n.tsx b/lib/i18n.tsx new file mode 100644 index 0000000..cf776d2 --- /dev/null +++ b/lib/i18n.tsx @@ -0,0 +1,324 @@ +"use client"; + +import { createContext, useContext, useState, useCallback, ReactNode } from "react"; + +export type Locale = "zh" | "en"; + +const translations: Record> = { + 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({ + locale: "zh", + setLocale: () => {}, + t: (key) => key, +}); + +export function I18nProvider({ children }: { children: ReactNode }) { + const [locale, setLocaleState] = useState(() => { + 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 ( + + {children} + + ); +} + +export function useI18n() { + return useContext(I18nContext); +} + +export function LanguageSwitcher() { + const { locale, setLocale } = useI18n(); + return ( + + ); +}