重构:将 "lobster" 重命名为 "claw" 并添加国际化支持 (i18n)

This commit is contained in:
richarjiang
2026-03-13 12:07:28 +08:00
parent fa4c458eda
commit 9e30771180
38 changed files with 1003 additions and 344 deletions

View File

@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
OpenClaw Market is a real-time global heatmap dashboard that visualizes AI agent ("lobster") activity worldwide. Agents install the `openclaw-reporter` skill, which sends anonymous heartbeats and task summaries to this server. The frontend renders a 3D globe and dashboard panels showing live activity via SSE.
OpenClaw Market is a real-time global heatmap dashboard that visualizes AI agent ("claw") activity worldwide. Agents install the `openclaw-reporter` skill, which sends anonymous heartbeats and task summaries to this server. The frontend renders a 3D globe and dashboard panels showing live activity via SSE.
## Commands
@@ -34,16 +34,19 @@ bash scripts/deploy.sh # Build locally, rsync to server, restart PM2
- **Geo**: IP geolocation via `ip-api.com`, results cached in `geo_cache` MySQL table. Country-to-continent mapping in `lib/geo/ip-location.ts`.
- **Real-time**: Redis Pub/Sub (`lib/redis/index.ts`) for event broadcasting. SSE stream route creates a per-connection Redis subscriber.
- **Validation**: Zod schemas in `lib/validators/schemas.ts`.
- **Database**: Drizzle ORM with MySQL (`mysql2` driver). Schema in `lib/db/schema.ts`. Tables: `lobsters`, `heartbeats`, `tasks`, `geo_cache`.
- **Redis**: ioredis with two singleton clients (main + subscriber). Stores online status, active lobster sorted sets, global/region stats, hourly activity, heatmap cache.
- **Database**: Drizzle ORM with MySQL (`mysql2` driver). Schema in `lib/db/schema.ts`. Tables: `claws`, `heartbeats`, `tasks`, `geo_cache`.
- **Redis**: ioredis with two singleton clients (main + subscriber). Stores online status, active claw sorted sets, global/region stats, hourly activity, heatmap cache.
- **i18n**: `next-intl` with locale-prefixed routing (`/en/...`, `/zh/...`). Config in `i18n/routing.ts`, middleware in `middleware.ts`, translations in `messages/en.json` and `messages/zh.json`.
### Frontend Structure
- `app/[locale]/page.tsx` — Homepage (globe + dashboard)
- `app/[locale]/continent/[slug]/page.tsx` — Continent drill-down page
- `components/globe/` — 3D globe view using `react-globe.gl` (dynamically imported, no SSR)
- `components/map/` — 2D continent maps using `react-simple-maps`
- `components/dashboard/` — Stats panel, region ranking, activity timeline, lobster feed
- `components/layout/` — Navbar, particle background, view switcher, install banner
- `app/continent/[slug]/page.tsx` — Continent drill-down page
- `components/dashboard/` — Stats panel, region ranking, activity timeline, claw feed
- `components/layout/` — Navbar, particle background, view switcher, install banner, language switcher
- `messages/` — i18n translation files (en, zh)
## Environment Variables

View File

@@ -1,20 +1,15 @@
"use client";
import { use } from "react";
import { useTranslations } from "next-intl";
import { Navbar } from "@/components/layout/navbar";
import { ParticleBg } from "@/components/layout/particle-bg";
import { ViewSwitcher } from "@/components/layout/view-switcher";
import { ContinentMap } from "@/components/map/continent-map";
import { StatsPanel } from "@/components/dashboard/stats-panel";
import { LobsterFeed } from "@/components/dashboard/lobster-feed";
import { ClawFeed } from "@/components/dashboard/claw-feed";
const continentNames: Record<string, string> = {
asia: "Asia",
europe: "Europe",
americas: "Americas",
africa: "Africa",
oceania: "Oceania",
};
const continentSlugs = ["asia", "europe", "americas", "africa", "oceania"] as const;
interface PageProps {
params: Promise<{ slug: string }>;
@@ -22,7 +17,12 @@ interface PageProps {
export default function ContinentPage({ params }: PageProps) {
const { slug } = use(params);
const name = continentNames[slug] ?? "Unknown";
const t = useTranslations("continents");
const tPage = useTranslations("continentPage");
const name = continentSlugs.includes(slug as (typeof continentSlugs)[number])
? t(slug as (typeof continentSlugs)[number])
: "Unknown";
return (
<div className="relative min-h-screen">
@@ -38,7 +38,7 @@ export default function ContinentPage({ params }: PageProps) {
textShadow: "var(--glow-cyan)",
}}
>
{name} Region
{tPage("regionTitle", { name })}
</h1>
<ViewSwitcher activeContinent={slug} />
</div>
@@ -52,7 +52,7 @@ export default function ContinentPage({ params }: PageProps) {
{/* Side Panel */}
<div className="flex flex-col gap-4">
<StatsPanel />
<LobsterFeed />
<ClawFeed />
</div>
</div>
</main>

73
app/[locale]/layout.tsx Normal file
View File

@@ -0,0 +1,73 @@
import type { ReactNode } from "react";
import type { Metadata } from "next";
import { Inter, JetBrains_Mono } from "next/font/google";
import { notFound } from "next/navigation";
import { NextIntlClientProvider } from "next-intl";
import { getMessages, getTranslations } from "next-intl/server";
import { routing } from "@/i18n/routing";
import "../globals.css";
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
});
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-mono",
});
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "metadata" });
return {
title: t("title"),
description: t("description"),
alternates: {
languages: Object.fromEntries(
routing.locales.map((l) => [l, `/${l}`])
),
},
};
}
export default async function LocaleLayout({
children,
params,
}: {
children: ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
if (!routing.locales.includes(locale as "en" | "zh")) {
notFound();
}
const messages = await getMessages();
return (
<html lang={locale} className="dark">
<body
className={`${inter.variable} ${jetbrainsMono.variable} font-sans antialiased`}
style={{
backgroundColor: "var(--bg-primary)",
color: "var(--text-primary)",
}}
>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}

View File

@@ -6,7 +6,7 @@ import { ParticleBg } from "@/components/layout/particle-bg";
import { GlobeView } from "@/components/globe/globe-view";
import { StatsPanel } from "@/components/dashboard/stats-panel";
import { ActivityTimeline } from "@/components/dashboard/activity-timeline";
import { LobsterFeed } from "@/components/dashboard/lobster-feed";
import { ClawFeed } from "@/components/dashboard/claw-feed";
import { RegionRanking } from "@/components/dashboard/region-ranking";
export default function HomePage() {
@@ -36,7 +36,7 @@ export default function HomePage() {
{/* Right Panel */}
<div className="flex flex-col gap-4">
<LobsterFeed />
<ClawFeed />
</div>
</div>
</main>

View File

@@ -1,8 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import { eq, desc, sql, and } from "drizzle-orm";
import { db } from "@/lib/db";
import { lobsters, tasks } from "@/lib/db/schema";
import { getActiveLobsterIds } from "@/lib/redis";
import { claws, tasks } from "@/lib/db/schema";
import { getActiveClawIds } from "@/lib/redis";
export async function GET(req: NextRequest) {
try {
@@ -13,29 +13,29 @@ export async function GET(req: NextRequest) {
const conditions = [];
if (region) {
conditions.push(eq(lobsters.region, region));
conditions.push(eq(claws.region, region));
}
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
const lobsterRows = await db
const clawRows = await db
.select()
.from(lobsters)
.from(claws)
.where(whereClause)
.orderBy(desc(lobsters.lastHeartbeat))
.orderBy(desc(claws.lastHeartbeat))
.limit(limit);
const totalResult = await db
.select({ count: sql<number>`count(*)` })
.from(lobsters)
.from(claws)
.where(whereClause);
const total = totalResult[0]?.count ?? 0;
const activeLobsterIds = await getActiveLobsterIds();
const activeSet = new Set(activeLobsterIds);
const activeClawIds = await getActiveClawIds();
const activeSet = new Set(activeClawIds);
const lobsterList = await Promise.all(
lobsterRows.map(async (lobster) => {
const clawList = await Promise.all(
clawRows.map(async (claw) => {
const latestTaskRows = await db
.select({
summary: tasks.summary,
@@ -43,28 +43,28 @@ export async function GET(req: NextRequest) {
durationMs: tasks.durationMs,
})
.from(tasks)
.where(eq(tasks.lobsterId, lobster.id))
.where(eq(tasks.clawId, claw.id))
.orderBy(desc(tasks.timestamp))
.limit(1);
const lastTask = latestTaskRows[0] ?? null;
return {
id: lobster.id,
name: lobster.name,
model: lobster.model,
platform: lobster.platform,
city: lobster.city,
country: lobster.country,
isOnline: activeSet.has(lobster.id),
id: claw.id,
name: claw.name,
model: claw.model,
platform: claw.platform,
city: claw.city,
country: claw.country,
isOnline: activeSet.has(claw.id),
lastTask,
};
})
);
return NextResponse.json({ lobsters: lobsterList, total });
return NextResponse.json({ claws: clawList, total });
} catch (error) {
console.error("Lobsters error:", error);
console.error("Claws error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }

View File

@@ -1,10 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { lobsters, heartbeats } from "@/lib/db/schema";
import { claws, heartbeats } from "@/lib/db/schema";
import {
setLobsterOnline,
updateActiveLobsters,
setClawOnline,
updateActiveClaws,
incrementHourlyActivity,
publishEvent,
} from "@/lib/redis";
@@ -31,8 +31,8 @@ export async function POST(req: NextRequest) {
}
const apiKey = authHeader.slice(7);
const lobster = await validateApiKey(apiKey);
if (!lobster) {
const claw = await validateApiKey(apiKey);
if (!claw) {
return NextResponse.json({ error: "Invalid API key" }, { status: 401 });
}
@@ -61,7 +61,7 @@ export async function POST(req: NextRequest) {
updatedAt: now,
};
if (clientIp !== lobster.ip) {
if (clientIp !== claw.ip) {
const geo = await getGeoLocation(clientIp);
if (geo) {
updateFields.latitude = String(geo.latitude);
@@ -77,27 +77,27 @@ export async function POST(req: NextRequest) {
if (parsed.data.model) updateFields.model = parsed.data.model;
if (parsed.data.platform) updateFields.platform = parsed.data.platform;
await setLobsterOnline(lobster.id, clientIp);
await updateActiveLobsters(lobster.id);
await setClawOnline(claw.id, clientIp);
await updateActiveClaws(claw.id);
await incrementHourlyActivity();
await db
.update(lobsters)
.update(claws)
.set(updateFields)
.where(eq(lobsters.id, lobster.id));
.where(eq(claws.id, claw.id));
// Insert heartbeat record asynchronously
db.insert(heartbeats)
.values({ lobsterId: lobster.id, ip: clientIp, timestamp: now })
.values({ clawId: claw.id, ip: clientIp, timestamp: now })
.then(() => {})
.catch((err: unknown) => console.error("Failed to insert heartbeat:", err));
await publishEvent({
type: "heartbeat",
lobsterId: lobster.id,
lobsterName: (updateFields.name as string) ?? lobster.name,
city: (updateFields.city as string) ?? lobster.city,
country: (updateFields.country as string) ?? lobster.country,
clawId: claw.id,
clawName: (updateFields.name as string) ?? claw.name,
city: (updateFields.city as string) ?? claw.city,
country: (updateFields.country as string) ?? claw.country,
});
return NextResponse.json({ ok: true, nextIn: 180 });

View File

@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { gte, and, isNotNull, sql } from "drizzle-orm";
import { db } from "@/lib/db";
import { lobsters } from "@/lib/db/schema";
import { claws } from "@/lib/db/schema";
import { getCacheHeatmap, setCacheHeatmap } from "@/lib/redis";
export async function GET() {
@@ -13,34 +13,34 @@ export async function GET() {
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
const activeLobsters = await db
const activeClaws = await db
.select({
city: lobsters.city,
country: lobsters.country,
latitude: lobsters.latitude,
longitude: lobsters.longitude,
city: claws.city,
country: claws.country,
latitude: claws.latitude,
longitude: claws.longitude,
count: sql<number>`count(*)`,
})
.from(lobsters)
.from(claws)
.where(
and(
gte(lobsters.lastHeartbeat, fiveMinutesAgo),
isNotNull(lobsters.latitude),
isNotNull(lobsters.longitude)
gte(claws.lastHeartbeat, fiveMinutesAgo),
isNotNull(claws.latitude),
isNotNull(claws.longitude)
)
)
.groupBy(
lobsters.city,
lobsters.country,
lobsters.latitude,
lobsters.longitude
claws.city,
claws.country,
claws.latitude,
claws.longitude
);
const points = activeLobsters.map((row) => ({
const points = activeClaws.map((row) => ({
lat: Number(row.latitude),
lng: Number(row.longitude),
weight: row.count,
lobsterCount: row.count,
clawCount: row.count,
city: row.city,
country: row.country,
}));

View File

@@ -1,10 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
import { nanoid } from "nanoid";
import { db } from "@/lib/db";
import { lobsters } from "@/lib/db/schema";
import { claws } from "@/lib/db/schema";
import {
setLobsterOnline,
updateActiveLobsters,
setClawOnline,
updateActiveClaws,
incrementGlobalStat,
incrementRegionCount,
publishEvent,
@@ -33,14 +33,14 @@ export async function POST(req: NextRequest) {
}
const { name, model, platform } = parsed.data;
const lobsterId = nanoid(21);
const clawId = nanoid(21);
const apiKey = generateApiKey();
const clientIp = getClientIp(req);
const geo = await getGeoLocation(clientIp);
const now = new Date();
await db.insert(lobsters).values({
id: lobsterId,
await db.insert(claws).values({
id: clawId,
apiKey,
name,
model: model ?? null,
@@ -58,9 +58,9 @@ export async function POST(req: NextRequest) {
updatedAt: now,
});
await setLobsterOnline(lobsterId, clientIp);
await updateActiveLobsters(lobsterId);
await incrementGlobalStat("total_lobsters");
await setClawOnline(clawId, clientIp);
await updateActiveClaws(clawId);
await incrementGlobalStat("total_claws");
if (geo?.region) {
await incrementRegionCount(geo.region);
@@ -68,14 +68,14 @@ export async function POST(req: NextRequest) {
await publishEvent({
type: "online",
lobsterId,
lobsterName: name,
clawId,
clawName: name,
city: geo?.city ?? null,
country: geo?.country ?? null,
});
return NextResponse.json({
lobsterId,
clawId,
apiKey,
endpoint: `${process.env.NEXT_PUBLIC_APP_URL}/api/v1`,
});

View File

@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { gte, sql } from "drizzle-orm";
import { db } from "@/lib/db";
import { lobsters, tasks } from "@/lib/db/schema";
import { claws, tasks } from "@/lib/db/schema";
import {
redis,
getGlobalStats,
@@ -23,16 +23,16 @@ export async function GET() {
const now = Date.now();
const fiveMinutesAgo = now - 300_000;
const activeLobsters = await redis.zcount(
"active:lobsters",
const activeClaws = await redis.zcount(
"active:claws",
fiveMinutesAgo,
"+inf"
);
const totalLobstersResult = await db
const totalClawsResult = await db
.select({ count: sql<number>`count(*)` })
.from(lobsters);
const totalLobsters = totalLobstersResult[0]?.count ?? 0;
.from(claws);
const totalClaws = totalClawsResult[0]?.count ?? 0;
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
@@ -58,8 +58,8 @@ export async function GET() {
}
return NextResponse.json({
totalLobsters,
activeLobsters,
totalClaws,
activeClaws,
tasksToday,
tasksTotal: parseInt(globalStats.total_tasks ?? "0", 10),
avgTaskDuration,

View File

@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { eq, sql } from "drizzle-orm";
import { db } from "@/lib/db";
import { lobsters, tasks } from "@/lib/db/schema";
import { claws, tasks } from "@/lib/db/schema";
import {
incrementGlobalStat,
incrementHourlyActivity,
@@ -21,8 +21,8 @@ export async function POST(req: NextRequest) {
}
const apiKey = authHeader.slice(7);
const lobster = await validateApiKey(apiKey);
if (!lobster) {
const claw = await validateApiKey(apiKey);
if (!claw) {
return NextResponse.json({ error: "Invalid API key" }, { status: 401 });
}
@@ -39,7 +39,7 @@ export async function POST(req: NextRequest) {
const now = new Date();
const insertResult = await db.insert(tasks).values({
lobsterId: lobster.id,
clawId: claw.id,
summary,
durationMs,
model: model ?? null,
@@ -48,9 +48,9 @@ export async function POST(req: NextRequest) {
});
await db
.update(lobsters)
.set({ totalTasks: sql`${lobsters.totalTasks} + 1`, updatedAt: now })
.where(eq(lobsters.id, lobster.id));
.update(claws)
.set({ totalTasks: sql`${claws.totalTasks} + 1`, updatedAt: now })
.where(eq(claws.id, claw.id));
await incrementGlobalStat("total_tasks");
await incrementGlobalStat("tasks_today");
@@ -58,10 +58,10 @@ export async function POST(req: NextRequest) {
await publishEvent({
type: "task",
lobsterId: lobster.id,
lobsterName: lobster.name,
city: lobster.city,
country: lobster.country,
clawId: claw.id,
clawName: claw.name,
city: claw.city,
country: claw.country,
summary,
durationMs,
});

View File

@@ -1,35 +1,7 @@
import type { Metadata } from "next";
import { Inter, JetBrains_Mono } from "next/font/google";
import "./globals.css";
import type { ReactNode } from "react";
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
});
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-mono",
});
export const metadata: Metadata = {
title: "OpenClaw Market - Global Lobster Activity",
description: "Real-time visualization of AI agent activity worldwide",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className="dark">
<body
className={`${inter.variable} ${jetbrainsMono.variable} font-sans antialiased`}
style={{ backgroundColor: "var(--bg-primary)", color: "var(--text-primary)" }}
>
{children}
</body>
</html>
);
// Root layout delegates rendering of <html>/<body> to app/[locale]/layout.tsx.
// This file exists only to satisfy Next.js's requirement for a root layout.
export default function RootLayout({ children }: { children: ReactNode }) {
return children;
}

View File

@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import {
AreaChart,
Area,
@@ -17,6 +18,7 @@ interface HourlyData {
}
export function ActivityTimeline() {
const t = useTranslations("activityTimeline");
const [data, setData] = useState<HourlyData[]>([]);
useEffect(() => {
@@ -40,7 +42,7 @@ export function ActivityTimeline() {
return (
<Card className="border-white/5">
<CardHeader className="pb-2">
<CardTitle>24h Activity</CardTitle>
<CardTitle>{t("title")}</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[160px] w-full">

View File

@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useTranslations, useLocale } from "next-intl";
import { motion, AnimatePresence } from "framer-motion";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
@@ -9,7 +10,7 @@ import { useSSE } from "@/hooks/use-sse";
interface FeedItem {
id: string;
type: "task" | "online" | "offline";
lobsterName: string;
clawName: string;
city?: string;
country?: string;
summary?: string;
@@ -17,7 +18,9 @@ interface FeedItem {
timestamp: number;
}
export function LobsterFeed() {
export function ClawFeed() {
const t = useTranslations("clawFeed");
const locale = useLocale();
const [items, setItems] = useState<FeedItem[]>([]);
const handleEvent = useCallback((event: { type: string; data: Record<string, unknown> }) => {
@@ -25,7 +28,7 @@ export function LobsterFeed() {
const newItem: FeedItem = {
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
type: event.type as FeedItem["type"],
lobsterName: (event.data.lobsterName as string) ?? "Unknown",
clawName: (event.data.clawName as string) ?? "Unknown",
city: event.data.city as string | undefined,
country: event.data.country as string | undefined,
summary: event.data.summary as string | undefined,
@@ -46,17 +49,17 @@ export function LobsterFeed() {
useEffect(() => {
const fetchRecent = async () => {
try {
const res = await fetch("/api/v1/lobsters?limit=10");
const res = await fetch("/api/v1/claws?limit=10");
if (res.ok) {
const data = await res.json();
const feedItems: FeedItem[] = (data.lobsters ?? [])
const feedItems: FeedItem[] = (data.claws ?? [])
.filter((l: Record<string, unknown>) => l.lastTask)
.map((l: Record<string, unknown>) => {
const task = l.lastTask as Record<string, unknown>;
return {
id: `init-${l.id}`,
type: "task" as const,
lobsterName: l.name as string,
clawName: l.name as string,
city: l.city as string,
country: l.country as string,
summary: task.summary as string,
@@ -95,13 +98,13 @@ export function LobsterFeed() {
return (
<Card className="border-white/5">
<CardHeader className="pb-2">
<CardTitle>Live Feed</CardTitle>
<CardTitle>{t("title")}</CardTitle>
</CardHeader>
<CardContent className="max-h-[400px] overflow-y-auto p-4 pt-0">
<AnimatePresence initial={false}>
{items.length === 0 ? (
<p className="py-8 text-center text-xs text-[var(--text-muted)]">
Waiting for lobster activity...
{t("waiting")}
</p>
) : (
items.map((item) => (
@@ -118,7 +121,7 @@ export function LobsterFeed() {
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-mono text-xs font-medium text-[var(--accent-cyan)]">
{item.lobsterName}
{item.clawName}
</span>
{item.city && (
<span className="text-xs text-[var(--text-muted)]">
@@ -136,7 +139,7 @@ export function LobsterFeed() {
<Badge variant="secondary">{formatDuration(item.durationMs)}</Badge>
)}
<span className="text-[10px] text-[var(--text-muted)]">
{new Date(item.timestamp).toLocaleTimeString()}
{new Date(item.timestamp).toLocaleTimeString(locale)}
</span>
</div>
</div>

View File

@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { motion } from "framer-motion";
@@ -18,7 +19,17 @@ const regionColors: Record<string, string> = {
Oceania: "var(--accent-green)",
};
const regionNameToKey: Record<string, string> = {
Asia: "asia",
Europe: "europe",
Americas: "americas",
Africa: "africa",
Oceania: "oceania",
};
export function RegionRanking() {
const t = useTranslations("regionRanking");
const tContinents = useTranslations("continents");
const [regions, setRegions] = useState<RegionData[]>([]);
useEffect(() => {
@@ -52,11 +63,11 @@ export function RegionRanking() {
return (
<Card className="border-white/5">
<CardHeader className="pb-2">
<CardTitle>Region Ranking</CardTitle>
<CardTitle>{t("title")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3 p-4 pt-0">
{regions.length === 0 ? (
<p className="py-4 text-center text-xs text-[var(--text-muted)]">No data yet</p>
<p className="py-4 text-center text-xs text-[var(--text-muted)]">{t("noData")}</p>
) : (
regions.map((region, i) => (
<div key={region.name} className="space-y-1">
@@ -66,7 +77,9 @@ export function RegionRanking() {
#{i + 1}
</span>
<span className="text-sm font-medium" style={{ color: region.color }}>
{region.name}
{regionNameToKey[region.name]
? tContinents(regionNameToKey[region.name] as "asia" | "europe" | "americas" | "africa" | "oceania")
: region.name}
</span>
</div>
<span className="font-mono text-xs text-[var(--text-secondary)]">

View File

@@ -1,12 +1,13 @@
"use client";
import { useEffect, useState, useRef } from "react";
import { useTranslations, useLocale } from "next-intl";
import { Card, CardContent } from "@/components/ui/card";
import { Users, Zap, Clock, Activity } from "lucide-react";
interface Stats {
totalLobsters: number;
activeLobsters: number;
totalClaws: number;
activeClaws: number;
tasksToday: number;
tasksTotal: number;
avgTaskDuration: number;
@@ -42,8 +43,9 @@ function AnimatedNumber({ value, suffix = "" }: { value: number; suffix?: string
requestAnimationFrame(animate);
}, [value]);
const locale = useLocale();
const formatted = Number.isInteger(value)
? Math.round(display).toLocaleString()
? Math.round(display).toLocaleString(locale)
: display.toFixed(1);
return (
@@ -56,29 +58,29 @@ function AnimatedNumber({ value, suffix = "" }: { value: number; suffix?: string
const statCards = [
{
key: "activeLobsters" as const,
label: "Online Now",
key: "activeClaws" as const,
labelKey: "onlineNow" as const,
icon: Activity,
color: "var(--accent-green)",
glow: "0 0 20px rgba(16, 185, 129, 0.3)",
},
{
key: "totalLobsters" as const,
label: "Total Lobsters",
key: "totalClaws" as const,
labelKey: "totalClaws" as const,
icon: Users,
color: "var(--accent-cyan)",
glow: "var(--glow-cyan)",
},
{
key: "tasksToday" as const,
label: "Tasks Today",
labelKey: "tasksToday" as const,
icon: Zap,
color: "var(--accent-purple)",
glow: "var(--glow-purple)",
},
{
key: "avgTaskDuration" as const,
label: "Avg Duration",
labelKey: "avgDuration" as const,
icon: Clock,
color: "var(--accent-orange)",
glow: "0 0 20px rgba(245, 158, 11, 0.3)",
@@ -88,9 +90,10 @@ const statCards = [
];
export function StatsPanel() {
const t = useTranslations("stats");
const [stats, setStats] = useState<Stats>({
totalLobsters: 0,
activeLobsters: 0,
totalClaws: 0,
activeClaws: 0,
tasksToday: 0,
tasksTotal: 0,
avgTaskDuration: 0,
@@ -116,7 +119,7 @@ export function StatsPanel() {
return (
<div className="flex flex-col gap-3">
{statCards.map(({ key, label, icon: Icon, color, glow, suffix, transform }) => {
{statCards.map(({ key, labelKey, icon: Icon, color, glow, suffix, transform }) => {
const raw = stats[key];
const value = transform ? transform(raw) : raw;
return (
@@ -133,7 +136,7 @@ export function StatsPanel() {
<Icon className="h-5 w-5" style={{ color }} />
</div>
<div className="min-w-0">
<p className="text-xs text-[var(--text-muted)]">{label}</p>
<p className="text-xs text-[var(--text-muted)]">{t(labelKey)}</p>
<p
className="font-mono text-xl font-bold"
style={{ color, textShadow: glow }}

View File

@@ -1,15 +1,17 @@
"use client";
import { useTranslations } from "next-intl";
import { Badge } from "@/components/ui/badge";
interface LobsterTooltipProps {
interface ClawTooltipProps {
city: string;
country: string;
lobsterCount: number;
clawCount: number;
weight: number;
}
export function LobsterTooltip({ city, country, lobsterCount, weight }: LobsterTooltipProps) {
export function ClawTooltip({ city, country, clawCount, weight }: ClawTooltipProps) {
const t = useTranslations("continentMap");
return (
<div className="rounded-xl border border-white/10 bg-[var(--bg-card)]/95 p-3 shadow-xl backdrop-blur-sm">
<div className="flex items-center gap-2">
@@ -20,8 +22,8 @@ export function LobsterTooltip({ city, country, lobsterCount, weight }: LobsterT
</div>
</div>
<div className="mt-2 flex items-center gap-2">
<Badge variant="online">{lobsterCount} active</Badge>
<Badge variant="secondary">weight: {weight.toFixed(1)}</Badge>
<Badge variant="online">{t("active", { count: clawCount })}</Badge>
<Badge variant="secondary">{t("weight", { value: weight.toFixed(1) })}</Badge>
</div>
</div>
);

View File

@@ -1,6 +1,7 @@
"use client";
import { RotateCw, ZoomIn, ZoomOut } from "lucide-react";
import { useTranslations } from "next-intl";
interface GlobeControlsProps {
onResetView: () => void;
@@ -9,26 +10,27 @@ interface GlobeControlsProps {
}
export function GlobeControls({ onResetView, onZoomIn, onZoomOut }: GlobeControlsProps) {
const t = useTranslations("globeControls");
return (
<div className="absolute bottom-4 right-4 z-10 flex flex-col gap-2">
<button
onClick={onZoomIn}
className="flex h-8 w-8 items-center justify-center rounded-lg border border-white/10 bg-[var(--bg-card)]/80 text-[var(--text-secondary)] backdrop-blur-sm transition-all hover:border-[var(--accent-cyan)]/30 hover:text-[var(--accent-cyan)]"
aria-label="Zoom in"
aria-label={t("zoomIn")}
>
<ZoomIn className="h-4 w-4" />
</button>
<button
onClick={onZoomOut}
className="flex h-8 w-8 items-center justify-center rounded-lg border border-white/10 bg-[var(--bg-card)]/80 text-[var(--text-secondary)] backdrop-blur-sm transition-all hover:border-[var(--accent-cyan)]/30 hover:text-[var(--accent-cyan)]"
aria-label="Zoom out"
aria-label={t("zoomOut")}
>
<ZoomOut className="h-4 w-4" />
</button>
<button
onClick={onResetView}
className="flex h-8 w-8 items-center justify-center rounded-lg border border-white/10 bg-[var(--bg-card)]/80 text-[var(--text-secondary)] backdrop-blur-sm transition-all hover:border-[var(--accent-cyan)]/30 hover:text-[var(--accent-cyan)]"
aria-label="Reset view"
aria-label={t("resetView")}
>
<RotateCw className="h-4 w-4" />
</button>

View File

@@ -2,19 +2,25 @@
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
import dynamic from "next/dynamic";
import { useTranslations } from "next-intl";
import { useHeatmapData, type HeatmapPoint } from "@/hooks/use-heatmap-data";
import { GlobeControls } from "./globe-controls";
const Globe = dynamic(() => import("react-globe.gl"), {
ssr: false,
loading: () => (
function GlobeLoading() {
const t = useTranslations("globe");
return (
<div className="flex h-full items-center justify-center">
<div className="flex flex-col items-center gap-3">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-[var(--accent-cyan)] border-t-transparent" />
<span className="font-mono text-xs text-[var(--text-muted)]">Loading globe...</span>
<span className="font-mono text-xs text-[var(--text-muted)]">{t("loading")}</span>
</div>
</div>
),
);
}
const Globe = dynamic(() => import("react-globe.gl"), {
ssr: false,
loading: () => <GlobeLoading />,
});
interface ArcData {
@@ -26,6 +32,7 @@ interface ArcData {
}
export function GlobeView() {
const t = useTranslations("globe");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const globeRef = useRef<any>(undefined);
const containerRef = useRef<HTMLDivElement>(null);
@@ -143,7 +150,7 @@ export function GlobeView() {
</div>
</div>
<p className="mt-1 text-xs text-[var(--text-secondary)]">
{hoveredPoint.lobsterCount} active lobster{hoveredPoint.lobsterCount !== 1 ? "s" : ""}
{t("activeClaws", { count: hoveredPoint.clawCount })}
</p>
</div>
</div>

View File

@@ -2,10 +2,12 @@
import { useState } from "react";
import { Check, Copy, Terminal } from "lucide-react";
import { useTranslations } from "next-intl";
const INSTALL_COMMAND = "clawhub install openclaw-reporter";
export function InstallBanner() {
const t = useTranslations("installBanner");
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
@@ -31,10 +33,10 @@ export function InstallBanner() {
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-[var(--text-primary)]">
Join the Heatmap
{t("title")}
</p>
<p className="text-xs text-[var(--text-muted)] truncate">
Install the skill and let your lobster light up the globe
{t("subtitle")}
</p>
</div>
</div>
@@ -43,7 +45,7 @@ export function InstallBanner() {
<button
onClick={handleCopy}
className="group flex items-center gap-2 rounded-lg border border-white/10 bg-[var(--bg-primary)] px-4 py-2.5 transition-all hover:border-[var(--accent-cyan)]/40 hover:bg-[var(--bg-primary)]/80 active:scale-[0.98] cursor-pointer shrink-0"
title="Click to copy"
title={t("copyTooltip")}
>
<Terminal className="h-3.5 w-3.5 text-[var(--text-muted)]" />
<code className="font-mono text-sm text-[var(--accent-cyan)] select-all">

View File

@@ -0,0 +1,38 @@
"use client";
import { useLocale, useTranslations } from "next-intl";
import { useRouter, usePathname } from "@/i18n/navigation";
import { Globe } from "lucide-react";
import { cn } from "@/lib/utils";
import { routing } from "@/i18n/routing";
export function LanguageSwitcher() {
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
const t = useTranslations("languageSwitcher");
const switchLocale = (newLocale: string) => {
router.replace(pathname, { locale: newLocale });
};
return (
<div className="flex items-center gap-1 rounded-lg border border-white/5 bg-white/5 p-0.5">
<Globe className="mx-1 h-3 w-3 text-[var(--text-muted)]" />
{routing.locales.map((l) => (
<button
key={l}
onClick={() => switchLocale(l)}
className={cn(
"rounded-md px-2 py-1 text-xs font-medium transition-all cursor-pointer",
locale === l
? "bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)]"
: "text-[var(--text-muted)] hover:text-[var(--text-secondary)]"
)}
>
{t(l)}
</button>
))}
</div>
);
}

View File

@@ -1,14 +1,18 @@
"use client";
import Link from "next/link";
import { Activity, Globe2, Map } from "lucide-react";
import { useTranslations } from "next-intl";
import { Link } from "@/i18n/navigation";
import { cn } from "@/lib/utils";
import { LanguageSwitcher } from "./language-switcher";
interface NavbarProps {
activeView?: "globe" | "map";
}
export function Navbar({ activeView = "globe" }: NavbarProps) {
const t = useTranslations("navbar");
return (
<nav className="fixed top-0 left-0 right-0 z-50 border-b border-white/5 bg-[var(--bg-primary)]/80 backdrop-blur-xl">
<div className="mx-auto flex h-14 max-w-[1800px] items-center justify-between px-4">
@@ -18,7 +22,7 @@ export function Navbar({ activeView = "globe" }: NavbarProps) {
className="font-mono text-lg font-bold tracking-tight"
style={{ color: "var(--accent-cyan)", textShadow: "var(--glow-cyan)" }}
>
OpenClaw Market
{t("brand")}
</span>
</Link>
@@ -33,7 +37,7 @@ export function Navbar({ activeView = "globe" }: NavbarProps) {
)}
>
<Globe2 className="h-3.5 w-3.5" />
3D Globe
{t("globe")}
</Link>
<Link
href="/continent/asia"
@@ -45,15 +49,16 @@ export function Navbar({ activeView = "globe" }: NavbarProps) {
)}
>
<Map className="h-3.5 w-3.5" />
2D Map
{t("map")}
</Link>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5">
<Activity className="h-3.5 w-3.5 text-[var(--accent-green)]" />
<span className="text-xs text-[var(--text-secondary)]">Live</span>
<span className="text-xs text-[var(--text-secondary)]">{t("live")}</span>
</div>
<LanguageSwitcher />
</div>
</div>
</nav>

View File

@@ -1,22 +1,20 @@
"use client";
import Link from "next/link";
import { Globe2, Map } from "lucide-react";
import { useTranslations } from "next-intl";
import { Link } from "@/i18n/navigation";
import { cn } from "@/lib/utils";
const continents = [
{ slug: "asia", label: "Asia" },
{ slug: "europe", label: "Europe" },
{ slug: "americas", label: "Americas" },
{ slug: "africa", label: "Africa" },
{ slug: "oceania", label: "Oceania" },
];
const continentSlugs = ["asia", "europe", "americas", "africa", "oceania"] as const;
interface ViewSwitcherProps {
activeContinent?: string;
}
export function ViewSwitcher({ activeContinent }: ViewSwitcherProps) {
const tSwitcher = useTranslations("viewSwitcher");
const tContinents = useTranslations("continents");
return (
<div className="flex flex-wrap items-center gap-2">
<Link
@@ -29,21 +27,21 @@ export function ViewSwitcher({ activeContinent }: ViewSwitcherProps) {
)}
>
<Globe2 className="h-3 w-3" />
Global
{tSwitcher("global")}
</Link>
{continents.map((c) => (
{continentSlugs.map((slug) => (
<Link
key={c.slug}
href={`/continent/${c.slug}`}
key={slug}
href={`/continent/${slug}`}
className={cn(
"flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-all",
activeContinent === c.slug
activeContinent === slug
? "border-[var(--accent-purple)]/30 bg-[var(--accent-purple)]/10 text-[var(--accent-purple)]"
: "border-white/5 text-[var(--text-muted)] hover:border-white/10 hover:text-[var(--text-secondary)]"
)}
>
<Map className="h-3 w-3" />
{c.label}
{tContinents(slug)}
</Link>
))}
</div>

View File

@@ -1,6 +1,7 @@
"use client";
import { useState, useMemo } from "react";
import { useTranslations } from "next-intl";
import {
ComposableMap,
Geographies,
@@ -18,15 +19,14 @@ const GEO_URL = "https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json"
interface ContinentConfig {
center: [number, number];
zoom: number;
label: string;
}
const continentConfigs: Record<string, ContinentConfig> = {
asia: { center: [100, 35], zoom: 2.5, label: "Asia" },
europe: { center: [15, 52], zoom: 4, label: "Europe" },
americas: { center: [-80, 15], zoom: 1.8, label: "Americas" },
africa: { center: [20, 5], zoom: 2.2, label: "Africa" },
oceania: { center: [145, -25], zoom: 3, label: "Oceania" },
asia: { center: [100, 35], zoom: 2.5 },
europe: { center: [15, 52], zoom: 4 },
americas: { center: [-80, 15], zoom: 1.8 },
africa: { center: [20, 5], zoom: 2.2 },
oceania: { center: [145, -25], zoom: 3 },
};
const continentRegionMap: Record<string, string> = {
@@ -42,6 +42,7 @@ interface ContinentMapProps {
}
export function ContinentMap({ slug }: ContinentMapProps) {
const t = useTranslations("continentMap");
const config = continentConfigs[slug] ?? continentConfigs.asia;
const regionFilter = continentRegionMap[slug];
const { points } = useHeatmapData(30000);
@@ -125,8 +126,8 @@ export function ContinentMap({ slug }: ContinentMapProps) {
</button>
</div>
<div className="mt-2 flex gap-2">
<Badge variant="online">{selectedPoint.lobsterCount} lobsters</Badge>
<Badge variant="secondary">weight: {selectedPoint.weight.toFixed(1)}</Badge>
<Badge variant="online">{t("claws", { count: selectedPoint.clawCount })}</Badge>
<Badge variant="secondary">{t("weight", { value: selectedPoint.weight.toFixed(1) })}</Badge>
</div>
</CardContent>
</Card>

View File

@@ -46,7 +46,7 @@ export function HeatmapLayer({ points, projection, onPointClick }: HeatmapLayerP
onClick={() => onPointClick?.(point)}
/>
{/* Count label */}
{point.lobsterCount > 1 && (
{point.clawCount > 1 && (
<text
x={x}
y={y - radius - 4}
@@ -55,7 +55,7 @@ export function HeatmapLayer({ points, projection, onPointClick }: HeatmapLayerP
fontSize={9}
fontFamily="var(--font-mono)"
>
{point.lobsterCount}
{point.clawCount}
</text>
)}
</g>

View File

@@ -6,7 +6,7 @@ export interface HeatmapPoint {
lat: number;
lng: number;
weight: number;
lobsterCount: number;
clawCount: number;
city: string;
country: string;
}

5
i18n/navigation.ts Normal file
View File

@@ -0,0 +1,5 @@
import { createNavigation } from "next-intl/navigation";
import { routing } from "./routing";
export const { Link, redirect, usePathname, useRouter, getPathname } =
createNavigation(routing);

15
i18n/request.ts Normal file
View File

@@ -0,0 +1,15 @@
import { getRequestConfig } from "next-intl/server";
import { routing } from "./routing";
export default getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale;
if (!locale || !routing.locales.includes(locale as "en" | "zh")) {
locale = routing.defaultLocale;
}
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
};
});

7
i18n/routing.ts Normal file
View File

@@ -0,0 +1,7 @@
import { defineRouting } from "next-intl/routing";
export const routing = defineRouting({
locales: ["en", "zh"],
defaultLocale: "en",
localePrefix: "always",
});

View File

@@ -1,6 +1,6 @@
import crypto from "crypto";
import { db } from "@/lib/db";
import { lobsters } from "@/lib/db/schema";
import { claws } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
import { redis } from "@/lib/redis";
@@ -12,34 +12,34 @@ export function generateApiKey(): string {
export async function validateApiKey(apiKey: string) {
try {
const cacheKey = `lobster:key:${apiKey}`;
const cachedLobsterId = await redis.get(cacheKey);
const cacheKey = `claw:key:${apiKey}`;
const cachedClawId = await redis.get(cacheKey);
if (cachedLobsterId) {
const lobster = await db
if (cachedClawId) {
const claw = await db
.select()
.from(lobsters)
.where(eq(lobsters.id, cachedLobsterId))
.from(claws)
.where(eq(claws.id, cachedClawId))
.limit(1);
if (lobster.length > 0) {
return lobster[0];
if (claw.length > 0) {
return claw[0];
}
}
const lobster = await db
const claw = await db
.select()
.from(lobsters)
.where(eq(lobsters.apiKey, apiKey))
.from(claws)
.where(eq(claws.apiKey, apiKey))
.limit(1);
if (lobster.length === 0) {
if (claw.length === 0) {
return null;
}
await redis.set(cacheKey, lobster[0].id, "EX", API_KEY_CACHE_TTL);
await redis.set(cacheKey, claw[0].id, "EX", API_KEY_CACHE_TTL);
return lobster[0];
return claw[0];
} catch (error) {
console.error("Failed to validate API key:", error);
return null;

View File

@@ -10,7 +10,7 @@ import {
} from "drizzle-orm/mysql-core";
import { sql } from "drizzle-orm";
export const lobsters = mysqlTable("lobsters", {
export const claws = mysqlTable("claws", {
id: varchar("id", { length: 21 }).primaryKey(),
apiKey: varchar("api_key", { length: 64 }).notNull().unique(),
name: varchar("name", { length: 100 }).notNull(),
@@ -33,12 +33,12 @@ export const heartbeats = mysqlTable(
"heartbeats",
{
id: bigint("id", { mode: "number" }).primaryKey().autoincrement(),
lobsterId: varchar("lobster_id", { length: 21 }).notNull(),
clawId: varchar("claw_id", { length: 21 }).notNull(),
ip: varchar("ip", { length: 45 }),
timestamp: datetime("timestamp").default(sql`NOW()`),
},
(table) => [
index("heartbeats_lobster_id_idx").on(table.lobsterId),
index("heartbeats_claw_id_idx").on(table.clawId),
index("heartbeats_timestamp_idx").on(table.timestamp),
]
);
@@ -47,7 +47,7 @@ export const tasks = mysqlTable(
"tasks",
{
id: bigint("id", { mode: "number" }).primaryKey().autoincrement(),
lobsterId: varchar("lobster_id", { length: 21 }).notNull(),
clawId: varchar("claw_id", { length: 21 }).notNull(),
summary: varchar("summary", { length: 500 }),
durationMs: int("duration_ms"),
model: varchar("model", { length: 50 }),
@@ -55,7 +55,7 @@ export const tasks = mysqlTable(
timestamp: datetime("timestamp").default(sql`NOW()`),
},
(table) => [
index("tasks_lobster_id_idx").on(table.lobsterId),
index("tasks_claw_id_idx").on(table.clawId),
index("tasks_timestamp_idx").on(table.timestamp),
]
);

View File

@@ -24,33 +24,33 @@ if (process.env.NODE_ENV !== "production") {
}
const CHANNEL_REALTIME = "channel:realtime";
const ACTIVE_LOBSTERS_KEY = "active:lobsters";
const ACTIVE_CLAWS_KEY = "active:claws";
const STATS_GLOBAL_KEY = "stats:global";
const STATS_REGION_KEY = "stats:region";
const HEATMAP_CACHE_KEY = "cache:heatmap";
const HOURLY_ACTIVITY_KEY = "stats:hourly";
export async function setLobsterOnline(
lobsterId: string,
export async function setClawOnline(
clawId: string,
ip: string
): Promise<void> {
await redis.set(`lobster:online:${lobsterId}`, ip, "EX", 300);
await redis.set(`claw:online:${clawId}`, ip, "EX", 300);
}
export async function isLobsterOnline(lobsterId: string): Promise<boolean> {
const result = await redis.exists(`lobster:online:${lobsterId}`);
export async function isClawOnline(clawId: string): Promise<boolean> {
const result = await redis.exists(`claw:online:${clawId}`);
return result === 1;
}
export async function updateActiveLobsters(lobsterId: string): Promise<void> {
export async function updateActiveClaws(clawId: string): Promise<void> {
const now = Date.now();
await redis.zadd(ACTIVE_LOBSTERS_KEY, now, lobsterId);
await redis.zadd(ACTIVE_CLAWS_KEY, now, clawId);
}
export async function getActiveLobsterIds(
export async function getActiveClawIds(
limit: number = 100
): Promise<string[]> {
return redis.zrevrange(ACTIVE_LOBSTERS_KEY, 0, limit - 1);
return redis.zrevrange(ACTIVE_CLAWS_KEY, 0, limit - 1);
}
export async function incrementRegionCount(region: string): Promise<void> {

65
messages/en.json Normal file
View File

@@ -0,0 +1,65 @@
{
"metadata": {
"title": "OpenClaw Market - Global Claw Activity",
"description": "Real-time visualization of AI agent activity worldwide"
},
"navbar": {
"brand": "OpenClaw Market",
"globe": "3D Globe",
"map": "2D Map",
"live": "Live"
},
"installBanner": {
"title": "Join the Heatmap",
"subtitle": "Install the skill and let your claw light up the globe",
"copyTooltip": "Click to copy"
},
"stats": {
"onlineNow": "Online Now",
"totalClaws": "Total Claws",
"tasksToday": "Tasks Today",
"avgDuration": "Avg Duration"
},
"regionRanking": {
"title": "Region Ranking",
"noData": "No data yet"
},
"clawFeed": {
"title": "Live Feed",
"waiting": "Waiting for claw activity..."
},
"activityTimeline": {
"title": "24h Activity"
},
"globe": {
"loading": "Loading globe...",
"activeClaws": "{count, plural, one {# active claw} other {# active claws}}"
},
"globeControls": {
"zoomIn": "Zoom in",
"zoomOut": "Zoom out",
"resetView": "Reset view"
},
"viewSwitcher": {
"global": "Global"
},
"continents": {
"asia": "Asia",
"europe": "Europe",
"americas": "Americas",
"africa": "Africa",
"oceania": "Oceania"
},
"continentPage": {
"regionTitle": "{name} Region"
},
"continentMap": {
"claws": "{count} claws",
"active": "{count} active",
"weight": "weight: {value}"
},
"languageSwitcher": {
"en": "EN",
"zh": "中文"
}
}

65
messages/zh.json Normal file
View File

@@ -0,0 +1,65 @@
{
"metadata": {
"title": "OpenClaw Market - 全球龙虾活动",
"description": "全球 AI 代理活动实时可视化"
},
"navbar": {
"brand": "OpenClaw Market",
"globe": "3D 地球",
"map": "2D 地图",
"live": "实时"
},
"installBanner": {
"title": "加入热力图",
"subtitle": "安装技能,让你的龙虾点亮全球",
"copyTooltip": "点击复制"
},
"stats": {
"onlineNow": "当前在线",
"totalClaws": "总龙虾数",
"tasksToday": "今日任务",
"avgDuration": "平均时长"
},
"regionRanking": {
"title": "区域排名",
"noData": "暂无数据"
},
"clawFeed": {
"title": "实时动态",
"waiting": "等待龙虾活动中..."
},
"activityTimeline": {
"title": "24小时活动"
},
"globe": {
"loading": "正在加载地球...",
"activeClaws": "{count, plural, other {# 只活跃龙虾}}"
},
"globeControls": {
"zoomIn": "放大",
"zoomOut": "缩小",
"resetView": "重置视角"
},
"viewSwitcher": {
"global": "全球"
},
"continents": {
"asia": "亚洲",
"europe": "欧洲",
"americas": "美洲",
"africa": "非洲",
"oceania": "大洋洲"
},
"continentPage": {
"regionTitle": "{name}区域"
},
"continentMap": {
"claws": "{count} 只龙虾",
"active": "{count} 活跃",
"weight": "权重:{value}"
},
"languageSwitcher": {
"en": "EN",
"zh": "中文"
}
}

8
middleware.ts Normal file
View File

@@ -0,0 +1,8 @@
import createMiddleware from "next-intl/middleware";
import { routing } from "./i18n/routing";
export default createMiddleware(routing);
export const config = {
matcher: ["/((?!api|_next|_vercel|.*\\..*).*)"],
};

View File

@@ -1,7 +1,10 @@
import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin();
const nextConfig: NextConfig = {
transpilePackages: ["react-globe.gl", "globe.gl", "three"],
};
export default nextConfig;
export default withNextIntl(nextConfig);

View File

@@ -13,41 +13,42 @@
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"next": "^15.3.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"tailwind-merge": "^3.0.2",
"lucide-react": "^0.474.0",
"react-globe.gl": "^2.27.3",
"three": "^0.173.0",
"react-simple-maps": "^3.0.0",
"d3-geo": "^3.1.1",
"topojson-client": "^3.1.0",
"framer-motion": "^12.6.0",
"recharts": "^2.15.3",
"drizzle-orm": "^0.41.0",
"mysql2": "^3.14.0",
"framer-motion": "^12.6.0",
"ioredis": "^5.6.1",
"zod": "^3.24.3",
"nanoid": "^5.1.5"
"lucide-react": "^0.474.0",
"mysql2": "^3.14.0",
"nanoid": "^5.1.5",
"next": "^15.3.0",
"next-intl": "^4.8.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-globe.gl": "^2.27.3",
"react-simple-maps": "^3.0.0",
"recharts": "^2.15.3",
"tailwind-merge": "^3.0.2",
"three": "^0.173.0",
"topojson-client": "^3.1.0",
"zod": "^3.24.3"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.1.0",
"@types/d3-geo": "^3.1.0",
"@types/node": "^22.13.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/three": "^0.173.0",
"@types/topojson-client": "^3.1.5",
"@types/d3-geo": "^3.1.0",
"typescript": "^5.8.0",
"@tailwindcss/postcss": "^4.1.0",
"tailwindcss": "^4.1.0",
"postcss": "^8.5.3",
"drizzle-kit": "^0.30.6",
"eslint": "^9.21.0",
"eslint-config-next": "^15.3.0",
"drizzle-kit": "^0.30.6",
"@eslint/eslintrc": "^3.3.1"
"postcss": "^8.5.3",
"tailwindcss": "^4.1.0",
"typescript": "^5.8.0"
},
"pnpm": {
"onlyBuiltDependencies": [

402
pnpm-lock.yaml generated
View File

@@ -38,6 +38,9 @@ importers:
next:
specifier: ^15.3.0
version: 15.5.12(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-intl:
specifier: ^4.8.3
version: 4.8.3(next@15.5.12(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
react:
specifier: ^19.1.0
version: 19.2.4
@@ -447,6 +450,21 @@ packages:
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@formatjs/ecma402-abstract@3.1.1':
resolution: {integrity: sha512-jhZbTwda+2tcNrs4kKvxrPLPjx8QsBCLCUgrrJ/S+G9YrGHWLhAyFMMBHJBnBoOwuLHd7L14FgYudviKaxkO2Q==}
'@formatjs/fast-memoize@3.1.0':
resolution: {integrity: sha512-b5mvSWCI+XVKiz5WhnBCY3RJ4ZwfjAidU0yVlKa3d3MSgKmH1hC3tBGEAtYyN5mqL7N0G5x0BOUYyO8CEupWgg==}
'@formatjs/icu-messageformat-parser@3.5.1':
resolution: {integrity: sha512-sSDmSvmmoVQ92XqWb499KrIhv/vLisJU8ITFrx7T7NZHUmMY7EL9xgRowAosaljhqnj/5iufG24QrdzB6X3ItA==}
'@formatjs/icu-skeleton-parser@2.1.1':
resolution: {integrity: sha512-PSFABlcNefjI6yyk8f7nyX1DC7NHmq6WaCHZLySEXBrXuLOB2f935YsnzuPjlz+ibhb9yWTdPeVX1OVcj24w2Q==}
'@formatjs/intl-localematcher@0.8.1':
resolution: {integrity: sha512-xwEuwQFdtSq1UKtQnyTZWC+eHdv7Uygoa+H2k/9uzBVQjDyp9r20LNDNKedWXll7FssT3GRHvqsdJGYSUWqYFA==}
'@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
engines: {node: '>=18.18.0'}
@@ -692,6 +710,88 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'}
'@parcel/watcher-android-arm64@2.5.6':
resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [android]
'@parcel/watcher-darwin-arm64@2.5.6':
resolution: {integrity: sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [darwin]
'@parcel/watcher-darwin-x64@2.5.6':
resolution: {integrity: sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [darwin]
'@parcel/watcher-freebsd-x64@2.5.6':
resolution: {integrity: sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [freebsd]
'@parcel/watcher-linux-arm-glibc@2.5.6':
resolution: {integrity: sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
'@parcel/watcher-linux-arm-musl@2.5.6':
resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
'@parcel/watcher-linux-arm64-glibc@2.5.6':
resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
'@parcel/watcher-linux-arm64-musl@2.5.6':
resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
'@parcel/watcher-linux-x64-glibc@2.5.6':
resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
'@parcel/watcher-linux-x64-musl@2.5.6':
resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
'@parcel/watcher-win32-arm64@2.5.6':
resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [win32]
'@parcel/watcher-win32-ia32@2.5.6':
resolution: {integrity: sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==}
engines: {node: '>= 10.0.0'}
cpu: [ia32]
os: [win32]
'@parcel/watcher-win32-x64@2.5.6':
resolution: {integrity: sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [win32]
'@parcel/watcher@2.5.6':
resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==}
engines: {node: '>= 10.0.0'}
'@petamoriken/float16@3.9.3':
resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==}
@@ -701,9 +801,87 @@ packages:
'@rushstack/eslint-patch@1.16.1':
resolution: {integrity: sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==}
'@schummar/icu-type-parser@1.21.5':
resolution: {integrity: sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==}
'@swc/core-darwin-arm64@1.15.18':
resolution: {integrity: sha512-+mIv7uBuSaywN3C9LNuWaX1jJJ3SKfiJuE6Lr3bd+/1Iv8oMU7oLBjYMluX1UrEPzwN2qCdY6Io0yVicABoCwQ==}
engines: {node: '>=10'}
cpu: [arm64]
os: [darwin]
'@swc/core-darwin-x64@1.15.18':
resolution: {integrity: sha512-wZle0eaQhnzxWX5V/2kEOI6Z9vl/lTFEC6V4EWcn+5pDjhemCpQv9e/TDJ0GIoiClX8EDWRvuZwh+Z3dhL1NAg==}
engines: {node: '>=10'}
cpu: [x64]
os: [darwin]
'@swc/core-linux-arm-gnueabihf@1.15.18':
resolution: {integrity: sha512-ao61HGXVqrJFHAcPtF4/DegmwEkVCo4HApnotLU8ognfmU8x589z7+tcf3hU+qBiU1WOXV5fQX6W9Nzs6hjxDw==}
engines: {node: '>=10'}
cpu: [arm]
os: [linux]
'@swc/core-linux-arm64-gnu@1.15.18':
resolution: {integrity: sha512-3xnctOBLIq3kj8PxOCgPrGjBLP/kNOddr6f5gukYt/1IZxsITQaU9TDyjeX6jG+FiCIHjCuWuffsyQDL5Ew1bg==}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
'@swc/core-linux-arm64-musl@1.15.18':
resolution: {integrity: sha512-0a+Lix+FSSHBSBOA0XznCcHo5/1nA6oLLjcnocvzXeqtdjnPb+SvchItHI+lfeiuj1sClYPDvPMLSLyXFaiIKw==}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
'@swc/core-linux-x64-gnu@1.15.18':
resolution: {integrity: sha512-wG9J8vReUlpaHz4KOD/5UE1AUgirimU4UFT9oZmupUDEofxJKYb1mTA/DrMj0s78bkBiNI+7Fo2EgPuvOJfuAA==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
'@swc/core-linux-x64-musl@1.15.18':
resolution: {integrity: sha512-4nwbVvCphKzicwNWRmvD5iBaZj8JYsRGa4xOxJmOyHlMDpsvvJ2OR2cODlvWyGFH6BYL1MfIAK3qph3hp0Az6g==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
'@swc/core-win32-arm64-msvc@1.15.18':
resolution: {integrity: sha512-zk0RYO+LjiBCat2RTMHzAWaMky0cra9loH4oRrLKLLNuL+jarxKLFDA8xTZWEkCPLjUTwlRN7d28eDLLMgtUcQ==}
engines: {node: '>=10'}
cpu: [arm64]
os: [win32]
'@swc/core-win32-ia32-msvc@1.15.18':
resolution: {integrity: sha512-yVuTrZ0RccD5+PEkpcLOBAuPbYBXS6rslENvIXfvJGXSdX5QGi1ehC4BjAMl5FkKLiam4kJECUI0l7Hq7T1vwg==}
engines: {node: '>=10'}
cpu: [ia32]
os: [win32]
'@swc/core-win32-x64-msvc@1.15.18':
resolution: {integrity: sha512-7NRmE4hmUQNCbYU3Hn9Tz57mK9Qq4c97ZS+YlamlK6qG9Fb5g/BB3gPDe0iLlJkns/sYv2VWSkm8c3NmbEGjbg==}
engines: {node: '>=10'}
cpu: [x64]
os: [win32]
'@swc/core@1.15.18':
resolution: {integrity: sha512-z87aF9GphWp//fnkRsqvtY+inMVPgYW3zSlXH1kJFvRT5H/wiAn+G32qW5l3oEk63KSF1x3Ov0BfHCObAmT8RA==}
engines: {node: '>=10'}
peerDependencies:
'@swc/helpers': '>=0.5.17'
peerDependenciesMeta:
'@swc/helpers':
optional: true
'@swc/counter@0.1.3':
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
'@swc/types@0.1.25':
resolution: {integrity: sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==}
'@tailwindcss/node@4.2.1':
resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==}
@@ -1341,6 +1519,9 @@ packages:
decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
@@ -1830,6 +2011,9 @@ packages:
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
engines: {node: '>=0.10.0'}
icu-minify@4.8.3:
resolution: {integrity: sha512-65Av7FLosNk7bPbmQx5z5XG2Y3T2GFppcjiXh4z1idHeVgQxlDpAmkGoYI0eFzAvrOnjpWTL5FmPDhsdfRMPEA==}
ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
@@ -1861,6 +2045,9 @@ packages:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
intl-messageformat@11.1.2:
resolution: {integrity: sha512-ucSrQmZGAxfiBHfBRXW/k7UC8MaGFlEj4Ry1tKiDcmgwQm1y3EDl40u+4VNHYomxJQMJi9NEI3riDRlth96jKg==}
ioredis@5.10.0:
resolution: {integrity: sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA==}
engines: {node: '>=12.22.0'}
@@ -2205,6 +2392,23 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
negotiator@1.0.0:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'}
next-intl-swc-plugin-extractor@4.8.3:
resolution: {integrity: sha512-YcaT+R9z69XkGhpDarVFWUprrCMbxgIQYPUaXoE6LGVnLjGdo8hu3gL6bramDVjNKViYY8a/pXPy7Bna0mXORg==}
next-intl@4.8.3:
resolution: {integrity: sha512-PvdBDWg+Leh7BR7GJUQbCDVVaBRn37GwDBWc9sv0rVQOJDQ5JU1rVzx9EEGuOGYo0DHAl70++9LQ7HxTawdL7w==}
peerDependencies:
next: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0
typescript: ^5.0.0
peerDependenciesMeta:
typescript:
optional: true
next@15.5.12:
resolution: {integrity: sha512-Fi/wQ4Etlrn60rz78bebG1i1SR20QxvV8tVp6iJspjLUSHcZoeUXCt+vmWoEcza85ElZzExK/jJ/F6SvtGktjA==}
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
@@ -2226,6 +2430,9 @@ packages:
sass:
optional: true
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
node-exports-info@1.6.0:
resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==}
engines: {node: '>= 0.4'}
@@ -2304,6 +2511,9 @@ packages:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
po-parser@2.1.1:
resolution: {integrity: sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ==}
point-in-polygon-hao@1.2.4:
resolution: {integrity: sha512-x2pcvXeqhRHlNRdhLs/tgFapAbSSe86wa/eqmj1G6pWftbEs5aVRJhRGM6FYSUERKu0PjekJzMq0gsI2XyiclQ==}
@@ -2701,6 +2911,11 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
use-intl@4.8.3:
resolution: {integrity: sha512-nLxlC/RH+le6g3amA508Itnn/00mE+J22ui21QhOWo5V9hCEC43+WtnRAITbJW0ztVZphev5X9gvOf2/Dk9PLA==}
peerDependencies:
react: ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0
victory-vendor@36.9.2:
resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==}
@@ -2956,6 +3171,33 @@ snapshots:
'@eslint/core': 0.17.0
levn: 0.4.1
'@formatjs/ecma402-abstract@3.1.1':
dependencies:
'@formatjs/fast-memoize': 3.1.0
'@formatjs/intl-localematcher': 0.8.1
decimal.js: 10.6.0
tslib: 2.8.1
'@formatjs/fast-memoize@3.1.0':
dependencies:
tslib: 2.8.1
'@formatjs/icu-messageformat-parser@3.5.1':
dependencies:
'@formatjs/ecma402-abstract': 3.1.1
'@formatjs/icu-skeleton-parser': 2.1.1
tslib: 2.8.1
'@formatjs/icu-skeleton-parser@2.1.1':
dependencies:
'@formatjs/ecma402-abstract': 3.1.1
tslib: 2.8.1
'@formatjs/intl-localematcher@0.8.1':
dependencies:
'@formatjs/fast-memoize': 3.1.0
tslib: 2.8.1
'@humanfs/core@0.19.1': {}
'@humanfs/node@0.16.7':
@@ -3136,16 +3378,130 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {}
'@parcel/watcher-android-arm64@2.5.6':
optional: true
'@parcel/watcher-darwin-arm64@2.5.6':
optional: true
'@parcel/watcher-darwin-x64@2.5.6':
optional: true
'@parcel/watcher-freebsd-x64@2.5.6':
optional: true
'@parcel/watcher-linux-arm-glibc@2.5.6':
optional: true
'@parcel/watcher-linux-arm-musl@2.5.6':
optional: true
'@parcel/watcher-linux-arm64-glibc@2.5.6':
optional: true
'@parcel/watcher-linux-arm64-musl@2.5.6':
optional: true
'@parcel/watcher-linux-x64-glibc@2.5.6':
optional: true
'@parcel/watcher-linux-x64-musl@2.5.6':
optional: true
'@parcel/watcher-win32-arm64@2.5.6':
optional: true
'@parcel/watcher-win32-ia32@2.5.6':
optional: true
'@parcel/watcher-win32-x64@2.5.6':
optional: true
'@parcel/watcher@2.5.6':
dependencies:
detect-libc: 2.1.2
is-glob: 4.0.3
node-addon-api: 7.1.1
picomatch: 4.0.3
optionalDependencies:
'@parcel/watcher-android-arm64': 2.5.6
'@parcel/watcher-darwin-arm64': 2.5.6
'@parcel/watcher-darwin-x64': 2.5.6
'@parcel/watcher-freebsd-x64': 2.5.6
'@parcel/watcher-linux-arm-glibc': 2.5.6
'@parcel/watcher-linux-arm-musl': 2.5.6
'@parcel/watcher-linux-arm64-glibc': 2.5.6
'@parcel/watcher-linux-arm64-musl': 2.5.6
'@parcel/watcher-linux-x64-glibc': 2.5.6
'@parcel/watcher-linux-x64-musl': 2.5.6
'@parcel/watcher-win32-arm64': 2.5.6
'@parcel/watcher-win32-ia32': 2.5.6
'@parcel/watcher-win32-x64': 2.5.6
'@petamoriken/float16@3.9.3': {}
'@rtsao/scc@1.1.0': {}
'@rushstack/eslint-patch@1.16.1': {}
'@schummar/icu-type-parser@1.21.5': {}
'@swc/core-darwin-arm64@1.15.18':
optional: true
'@swc/core-darwin-x64@1.15.18':
optional: true
'@swc/core-linux-arm-gnueabihf@1.15.18':
optional: true
'@swc/core-linux-arm64-gnu@1.15.18':
optional: true
'@swc/core-linux-arm64-musl@1.15.18':
optional: true
'@swc/core-linux-x64-gnu@1.15.18':
optional: true
'@swc/core-linux-x64-musl@1.15.18':
optional: true
'@swc/core-win32-arm64-msvc@1.15.18':
optional: true
'@swc/core-win32-ia32-msvc@1.15.18':
optional: true
'@swc/core-win32-x64-msvc@1.15.18':
optional: true
'@swc/core@1.15.18':
dependencies:
'@swc/counter': 0.1.3
'@swc/types': 0.1.25
optionalDependencies:
'@swc/core-darwin-arm64': 1.15.18
'@swc/core-darwin-x64': 1.15.18
'@swc/core-linux-arm-gnueabihf': 1.15.18
'@swc/core-linux-arm64-gnu': 1.15.18
'@swc/core-linux-arm64-musl': 1.15.18
'@swc/core-linux-x64-gnu': 1.15.18
'@swc/core-linux-x64-musl': 1.15.18
'@swc/core-win32-arm64-msvc': 1.15.18
'@swc/core-win32-ia32-msvc': 1.15.18
'@swc/core-win32-x64-msvc': 1.15.18
'@swc/counter@0.1.3': {}
'@swc/helpers@0.5.15':
dependencies:
tslib: 2.8.1
'@swc/types@0.1.25':
dependencies:
'@swc/counter': 0.1.3
'@tailwindcss/node@4.2.1':
dependencies:
'@jridgewell/remapping': 2.3.5
@@ -3787,6 +4143,8 @@ snapshots:
decimal.js-light@2.5.1: {}
decimal.js@10.6.0: {}
deep-is@0.1.4: {}
define-data-property@1.1.4:
@@ -4395,6 +4753,10 @@ snapshots:
dependencies:
safer-buffer: 2.1.2
icu-minify@4.8.3:
dependencies:
'@formatjs/icu-messageformat-parser': 3.5.1
ignore@5.3.2: {}
ignore@7.0.5: {}
@@ -4418,6 +4780,13 @@ snapshots:
internmap@2.0.3: {}
intl-messageformat@11.1.2:
dependencies:
'@formatjs/ecma402-abstract': 3.1.1
'@formatjs/fast-memoize': 3.1.0
'@formatjs/icu-messageformat-parser': 3.5.1
tslib: 2.8.1
ioredis@5.10.0:
dependencies:
'@ioredis/commands': 1.5.1
@@ -4739,6 +5108,27 @@ snapshots:
natural-compare@1.4.0: {}
negotiator@1.0.0: {}
next-intl-swc-plugin-extractor@4.8.3: {}
next-intl@4.8.3(next@15.5.12(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3):
dependencies:
'@formatjs/intl-localematcher': 0.8.1
'@parcel/watcher': 2.5.6
'@swc/core': 1.15.18
icu-minify: 4.8.3
negotiator: 1.0.0
next: 15.5.12(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-intl-swc-plugin-extractor: 4.8.3
po-parser: 2.1.1
react: 19.2.4
use-intl: 4.8.3(react@19.2.4)
optionalDependencies:
typescript: 5.9.3
transitivePeerDependencies:
- '@swc/helpers'
next@15.5.12(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@next/env': 15.5.12
@@ -4762,6 +5152,8 @@ snapshots:
- '@babel/core'
- babel-plugin-macros
node-addon-api@7.1.1: {}
node-exports-info@1.6.0:
dependencies:
array.prototype.flatmap: 1.3.3
@@ -4850,6 +5242,8 @@ snapshots:
picomatch@4.0.3: {}
po-parser@2.1.1: {}
point-in-polygon-hao@1.2.4:
dependencies:
robust-predicates: 3.0.2
@@ -5379,6 +5773,14 @@ snapshots:
dependencies:
punycode: 2.3.1
use-intl@4.8.3(react@19.2.4):
dependencies:
'@formatjs/fast-memoize': 3.1.0
'@schummar/icu-type-parser': 1.21.5
icu-minify: 4.8.3
intl-messageformat: 11.1.2
react: 19.2.4
victory-vendor@36.9.2:
dependencies:
'@types/d3-array': 3.2.2

View File

@@ -1,11 +1,12 @@
---
name: openclaw-reporter
description: |
Opt-in reporter for the OpenClaw global lobster heatmap.
Opt-in reporter for the OpenClaw global claw heatmap.
On first use, ASKS the user for consent before registering.
Sends heartbeats (platform + model only) and generic task summaries to https://kymr.top/.
All network calls run synchronously in the foreground — output is visible to the user.
Data collected: user-chosen lobster name, OS platform, model name, generic task category.
Heartbeats are sent automatically via hooks at session start — no LLM action needed.
Task reports are sent automatically via hooks when the agent stops responding.
Data collected: user-chosen claw name, OS platform, model name, generic task category.
Credentials: the server returns an apiKey on registration, which is stored locally
in ~/.openclaw/config.json and sent as a Bearer token in subsequent requests.
No file paths, code, or project-specific data is ever sent.
@@ -21,11 +22,29 @@ requirements:
- name: CLAUDE_MODEL
required: false
description: "Current Claude model identifier. Used to report which model is active. Falls back to 'unknown' if not set."
hooks:
SessionStart:
- hooks:
- type: command
command: "if [ -f ~/.openclaw/config.json ]; then KEY=$(python3 -c \"import json; print(json.load(open('$HOME/.openclaw/config.json'))['apiKey'])\") && curl -s -o /dev/null --max-time 5 -X POST 'https://kymr.top/api/v1/heartbeat' -H 'Content-Type: application/json' -H \"Authorization: Bearer $KEY\" -d \"{\\\"platform\\\":\\\"$(uname -s | tr '[:upper:]' '[:lower:]')\\\",\\\"model\\\":\\\"${CLAUDE_MODEL:-unknown}\\\"}\"; fi"
timeout: 10
once: true
Stop:
- hooks:
- type: command
command: "if [ -f ~/.openclaw/config.json ]; then KEY=$(python3 -c \"import json; print(json.load(open('$HOME/.openclaw/config.json'))['apiKey'])\") && curl -s -o /dev/null --max-time 5 -X POST 'https://kymr.top/api/v1/task' -H 'Content-Type: application/json' -H \"Authorization: Bearer $KEY\" -d \"{\\\"summary\\\":\\\"Completed a task\\\",\\\"model\\\":\\\"${CLAUDE_MODEL:-unknown}\\\"}\"; fi"
timeout: 10
---
# OpenClaw Reporter
This skill lets you participate in the OpenClaw global lobster activity heatmap. It reports **anonymous, non-identifying** activity data to visualize worldwide AI agent usage.
This skill lets you participate in the OpenClaw global claw activity heatmap. It reports **anonymous, non-identifying** activity data to visualize worldwide AI agent usage.
## How It Works
- **Heartbeat**: Sent automatically at session start via a `SessionStart` hook — no manual action needed.
- **Task report**: Sent automatically when the agent finishes responding via a `Stop` hook.
- **Registration**: One-time setup requiring explicit user consent (see below).
## Data Disclosure
@@ -33,132 +52,78 @@ This skill sends the following data to `https://kymr.top/`:
| Data Field | Example | Purpose |
|---|---|---|
| Lobster Name | `CoolLobster42` (user-chosen) | Your display name on the heatmap |
| Claw Name | `CoolClaw42` (user-chosen) | Your display name on the heatmap |
| Platform | `darwin`, `linux` | OS type for heatmap stats |
| Model | `claude-sonnet-4-6` | Model usage stats |
| Task summary | `"Fixed a bug"` | Generic activity category |
| Task summary | `"Completed a task"` | Generic activity indicator |
**Stored locally:** Registration returns an `apiKey` from the server, saved in `~/.openclaw/config.json`. This token is sent as `Authorization: Bearer` header in heartbeat and task report requests. The `CLAUDE_MODEL` environment variable is read (if available) to identify the active model — it falls back to `"unknown"` if not set.
**Stored locally:** Registration returns an `apiKey` from the server, saved in `~/.openclaw/config.json` with `chmod 600` (owner-only access). This token is sent as `Authorization: Bearer` header in heartbeat and task report requests. The endpoint URL (`https://kymr.top/`) is hardcoded in hooks — not read from the config file — so a modified config cannot redirect telemetry. The `CLAUDE_MODEL` environment variable is read (if available) to identify the active model — it falls back to `"unknown"` if not set.
**Never sent:** system usernames, file paths, code snippets, project names, secrets, IP-derived geolocation beyond what the server naturally receives.
## Configuration
Config is stored at `~/.openclaw/config.json`. The skill checks for this file at the start of each conversation.
Config is stored at `~/.openclaw/config.json` (permissions `600`). The hooks check for this file before sending any requests — if it doesn't exist, nothing is sent. The endpoint is hardcoded to `https://kymr.top/` in the hook commands and is **not** configurable via the config file.
## First-Time Setup (Requires User Consent)
If `~/.openclaw/config.json` does not exist, follow these two steps:
If `~/.openclaw/config.json` does not exist, follow these steps:
### Step 1: Ask for consent
Use the AskUserQuestion tool:
- Question: "OpenClaw Reporter wants to register a lobster name and send activity heartbeats to https://kymr.top/ for the global heatmap. The server will issue an API key stored locally at ~/.openclaw/config.json. No file paths, code, or project data is sent. Allow?"
- Question: "OpenClaw Reporter wants to register a claw name and send activity heartbeats to https://kymr.top/ for the global heatmap. The server will issue an API key stored locally at ~/.openclaw/config.json. No file paths, code, or project data is sent. Allow?"
- Options: "Yes, register" / "No, skip"
**If the user selects "No, skip":** do NOT register and do NOT ask again in this session. Stop here.
### Step 2: Ask for a lobster name
### Step 2: Ask for a claw name
If the user consented, use AskUserQuestion again:
- Question: "Pick a name for your lobster on the heatmap! (e.g. CoolLobster, NightCrawler, CodeClaw)"
- Question: "Pick a name for your claw on the heatmap! (e.g. CoolClaw, NightCrawler, CodeClaw)"
- Let the user type a custom name via the "Other" option (no preset options needed, but provide a few fun suggestions as options)
- Options: "CoolLobster" / "CodeClaw" / "NightCrawler"
- Options: "CoolClaw" / "CodeClaw" / "NightCrawler"
Use whatever the user provides as the lobster name.
Use whatever the user provides as the claw name.
### Step 3: Register
After receiving the name (stored as `LOBSTER_NAME`):
After receiving the name (stored as `CLAW_NAME`):
```bash
# Create config directory
mkdir -p ~/.openclaw
OPENCLAW_ENDPOINT="https://kymr.top"
LOBSTER_NAME="USER_CHOSEN_NAME_HERE"
CLAW_NAME="USER_CHOSEN_NAME_HERE"
RESPONSE=$(curl -s -X POST "$OPENCLAW_ENDPOINT/api/v1/register" \
-H "Content-Type: application/json" \
-d "{
\"name\": \"$LOBSTER_NAME\",
\"name\": \"$CLAW_NAME\",
\"platform\": \"$(uname -s | tr '[:upper:]' '[:lower:]')\",
\"model\": \"$(echo $CLAUDE_MODEL 2>/dev/null || echo 'unknown')\"
}")
# Save config
# Save config (no endpoint stored — hooks use a hardcoded URL)
echo "$RESPONSE" | python3 -c "
import json, sys
import json, sys, os
data = json.load(sys.stdin)
config = {
'lobsterId': data.get('lobsterId', ''),
'clawId': data.get('clawId', ''),
'apiKey': data.get('apiKey', ''),
'name': '$LOBSTER_NAME',
'endpoint': '$OPENCLAW_ENDPOINT'
'name': '$CLAW_NAME'
}
with open('$HOME/.openclaw/config.json', 'w') as f:
path = os.path.expanduser('~/.openclaw/config.json')
with open(path, 'w') as f:
json.dump(config, f, indent=2)
print('Registered as:', '$LOBSTER_NAME')
os.chmod(path, 0o600)
print('Registered as:', '$CLAW_NAME')
"
```
## Heartbeat (On Every Conversation Start)
If the config file exists, send a heartbeat. The request runs **synchronously in the foreground** with a 5-second timeout, so the user sees it happen:
```bash
if [ -f ~/.openclaw/config.json ]; then
OPENCLAW_CONFIG=$(cat ~/.openclaw/config.json)
OPENCLAW_KEY=$(echo "$OPENCLAW_CONFIG" | python3 -c "import json,sys; print(json.load(sys.stdin)['apiKey'])")
OPENCLAW_ENDPOINT=$(echo "$OPENCLAW_CONFIG" | python3 -c "import json,sys; print(json.load(sys.stdin)['endpoint'])")
curl -s -o /dev/null -w "OpenClaw heartbeat: HTTP %{http_code}\n" \
--max-time 5 \
-X POST "$OPENCLAW_ENDPOINT/api/v1/heartbeat" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $OPENCLAW_KEY" \
-d "{
\"platform\": \"$(uname -s | tr '[:upper:]' '[:lower:]')\",
\"model\": \"$(echo $CLAUDE_MODEL 2>/dev/null || echo 'unknown')\"
}"
fi
```
## Task Completion Report
When a significant task is completed, report a **generic summary only**:
```bash
if [ -f ~/.openclaw/config.json ]; then
OPENCLAW_CONFIG=$(cat ~/.openclaw/config.json)
OPENCLAW_KEY=$(echo "$OPENCLAW_CONFIG" | python3 -c "import json,sys; print(json.load(sys.stdin)['apiKey'])")
OPENCLAW_ENDPOINT=$(echo "$OPENCLAW_CONFIG" | python3 -c "import json,sys; print(json.load(sys.stdin)['endpoint'])")
curl -s -o /dev/null -w "OpenClaw task report: HTTP %{http_code}\n" \
--max-time 5 \
-X POST "$OPENCLAW_ENDPOINT/api/v1/task" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $OPENCLAW_KEY" \
-d "{
\"summary\": \"TASK_SUMMARY_HERE\",
\"durationMs\": DURATION_MS_HERE,
\"model\": \"$(echo $CLAUDE_MODEL 2>/dev/null || echo 'unknown')\",
\"toolsUsed\": [\"LIST_OF_TOOLS\"]
}"
fi
```
### Task Summary Rules (STRICT)
- NEVER include file paths, variable names, or code snippets
- NEVER include API keys, secrets, or credentials
- NEVER include project names, repository names, or organization names
- NEVER include personal information (names, emails, usernames)
- ONLY use generic categories: "Implemented a feature", "Fixed a bug", "Refactored code", "Updated configuration", "Added tests"
- Keep summaries under 100 characters
## Opt-Out / Unregister
To stop all OpenClaw reporting, delete the config file:
@@ -168,13 +133,12 @@ rm -f ~/.openclaw/config.json
rmdir ~/.openclaw 2>/dev/null
```
After deletion, no heartbeats or task reports will be sent. The skill will ask for consent again if re-enabled.
After deletion, no heartbeats or task reports will be sent. The hooks silently skip when the config file is absent. The skill will ask for consent again if re-enabled.
## Behavior Rules
1. **User consent is REQUIRED** before first registration — never register silently
2. If the user declines registration, respect the decision and do not ask again in the same session
3. All network calls run **synchronously in the foreground** (no `&`) with `--max-time 5` — the user sees every request
3. Hooks run automatically — do NOT manually execute heartbeat or task report commands
4. If any OpenClaw request fails, continue normally — never interrupt the user's workflow
5. Only report genuinely completed tasks, not intermediate steps
6. Never include identifying information in task summaries
5. Never include identifying information in task summaries