feat: 添加设备注册 API 并重构安装横幅组件
This commit is contained in:
@@ -50,23 +50,18 @@ export default function HomePage() {
|
||||
<Hero />
|
||||
|
||||
{/* Section 1: Main Map View + Dashboard */}
|
||||
<section className="min-h-[calc(100vh-5rem)]">
|
||||
<div className="mb-4">
|
||||
<InstallBanner />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[280px_1fr_280px]">
|
||||
{/* Left Panel */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<section id="get-started">
|
||||
<div className="grid gap-4 lg:grid-cols-[240px_1fr_280px]">
|
||||
{/* Left Panel — Stats */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<StatsPanel />
|
||||
<RegionRanking />
|
||||
<TokenLeaderboard />
|
||||
</div>
|
||||
|
||||
{/* Center - Map/Globe + Timeline */}
|
||||
{/* Center — Map/Globe (hero of the page) */}
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* View Switcher + Map Container */}
|
||||
<div className="relative h-[500px] lg:h-[600px]">
|
||||
<div className="relative h-[520px] lg:h-[680px]">
|
||||
{/* View Mode Toggle */}
|
||||
<div className="absolute left-4 top-4 z-20 flex rounded-lg border border-white/10 bg-[var(--bg-card)]/90 p-1 backdrop-blur-sm">
|
||||
<button
|
||||
@@ -95,12 +90,9 @@ export default function HomePage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Map View */}
|
||||
{viewMode === "2d" && (
|
||||
<WorldMap className="h-full w-full" />
|
||||
)}
|
||||
|
||||
{/* Globe View */}
|
||||
{viewMode === "3d" && (
|
||||
<GlobeView />
|
||||
)}
|
||||
@@ -109,8 +101,9 @@ export default function HomePage() {
|
||||
<ActivityTimeline />
|
||||
</div>
|
||||
|
||||
{/* Right Panel */}
|
||||
{/* Right Panel — Register + Feed */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<InstallBanner />
|
||||
<ClawFeed />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
40
app/api/v1/device/name/route.ts
Normal file
40
app/api/v1/device/name/route.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import { claws } from "@/lib/db/schema";
|
||||
import { authenticateRequest } from "@/lib/auth/request";
|
||||
import { updateNameSchema } from "@/lib/validators/schemas";
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
try {
|
||||
const auth = await authenticateRequest(req);
|
||||
if (auth instanceof NextResponse) {
|
||||
return auth;
|
||||
}
|
||||
const { claw } = auth;
|
||||
|
||||
const body = await req.json();
|
||||
const parsed = updateNameSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Validation failed", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { name } = parsed.data;
|
||||
|
||||
await db
|
||||
.update(claws)
|
||||
.set({ name, updatedAt: new Date() })
|
||||
.where(eq(claws.id, claw.id));
|
||||
|
||||
return NextResponse.json({ ok: true, name });
|
||||
} catch (error) {
|
||||
console.error("Update name error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
150
app/api/v1/device/register/route.ts
Normal file
150
app/api/v1/device/register/route.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { nanoid } from "nanoid";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import { claws } from "@/lib/db/schema";
|
||||
import { redis } from "@/lib/redis";
|
||||
import { generateApiKey } from "@/lib/auth/api-key";
|
||||
import { getGeoLocation } from "@/lib/geo/ip-location";
|
||||
import { applyDeterministicOffset } from "@/lib/geo/offset";
|
||||
import { deviceRegisterSchema } from "@/lib/validators/schemas";
|
||||
|
||||
function getClientIp(req: NextRequest): string {
|
||||
const forwarded = req.headers.get("x-forwarded-for");
|
||||
if (forwarded) return forwarded.split(",")[0].trim();
|
||||
const realIp = req.headers.get("x-real-ip");
|
||||
if (realIp) return realIp.trim();
|
||||
return "127.0.0.1";
|
||||
}
|
||||
|
||||
const RATE_LIMIT_KEY_PREFIX = "ratelimit:device:";
|
||||
const RATE_LIMIT_MAX = 10;
|
||||
const RATE_LIMIT_WINDOW = 3600; // 1 hour
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const clientIp = getClientIp(req);
|
||||
|
||||
// Rate limit check
|
||||
const rateLimitKey = `${RATE_LIMIT_KEY_PREFIX}${clientIp}`;
|
||||
const currentCount = await redis.incr(rateLimitKey);
|
||||
if (currentCount === 1) {
|
||||
await redis.expire(rateLimitKey, RATE_LIMIT_WINDOW);
|
||||
}
|
||||
if (currentCount > RATE_LIMIT_MAX) {
|
||||
return NextResponse.json(
|
||||
{ error: "Too many requests. Try again later." },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const parsed = deviceRegisterSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Validation failed", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { deviceId, name, platform } = parsed.data;
|
||||
|
||||
// Check if device already registered
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(claws)
|
||||
.where(eq(claws.deviceId, deviceId))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
return NextResponse.json({
|
||||
clawId: existing[0].id,
|
||||
apiKey: existing[0].apiKey,
|
||||
name: existing[0].name,
|
||||
isNew: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Create new claw
|
||||
const clawId = nanoid(21);
|
||||
const apiKey = generateApiKey();
|
||||
const geo = await getGeoLocation(clientIp);
|
||||
const now = new Date();
|
||||
|
||||
let finalLat: number | null = null;
|
||||
let finalLng: number | null = null;
|
||||
if (geo) {
|
||||
const offset = applyDeterministicOffset(
|
||||
geo.latitude,
|
||||
geo.longitude,
|
||||
clawId
|
||||
);
|
||||
finalLat = offset.lat;
|
||||
finalLng = offset.lng;
|
||||
}
|
||||
|
||||
// Auto-generate name if not provided
|
||||
const clawName = name || generateClawName();
|
||||
|
||||
await db.insert(claws).values({
|
||||
id: clawId,
|
||||
apiKey,
|
||||
deviceId,
|
||||
name: clawName,
|
||||
platform: platform ? platform.slice(0, 20) : null,
|
||||
ip: clientIp,
|
||||
latitude: finalLat !== null ? String(finalLat) : null,
|
||||
longitude: finalLng !== null ? String(finalLng) : null,
|
||||
city: geo?.city ?? null,
|
||||
country: geo?.country ?? null,
|
||||
countryCode: geo?.countryCode ?? null,
|
||||
region: geo?.region ?? null,
|
||||
lastHeartbeat: now,
|
||||
totalTasks: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
clawId,
|
||||
apiKey,
|
||||
name: clawName,
|
||||
isNew: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Device register error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const ADJECTIVES = [
|
||||
"Swift",
|
||||
"Brave",
|
||||
"Cool",
|
||||
"Dark",
|
||||
"Fire",
|
||||
"Ice",
|
||||
"Storm",
|
||||
"Shadow",
|
||||
"Cyber",
|
||||
"Neon",
|
||||
"Pixel",
|
||||
"Turbo",
|
||||
"Mega",
|
||||
"Ultra",
|
||||
"Nova",
|
||||
"Star",
|
||||
"Flash",
|
||||
"Thunder",
|
||||
"Iron",
|
||||
"Cosmic",
|
||||
];
|
||||
|
||||
function generateClawName(): string {
|
||||
const adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
|
||||
const num = Math.floor(Math.random() * 100);
|
||||
return `${adj}Claw-${num}`;
|
||||
}
|
||||
@@ -181,6 +181,7 @@ export function ClawFeed() {
|
||||
<Badge variant="secondary">{formatDuration(item.durationMs)}</Badge>
|
||||
)}
|
||||
<span className="text-[10px] text-[var(--text-muted)]">
|
||||
{new Date(item.timestamp).toLocaleDateString(locale, { month: "2-digit", day: "2-digit" })}{" "}
|
||||
{new Date(item.timestamp).toLocaleTimeString(locale)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -8,36 +8,36 @@ export function Hero() {
|
||||
const t = useTranslations("hero");
|
||||
|
||||
return (
|
||||
<section className="relative py-16 md:py-24">
|
||||
<section className="relative py-6 md:py-10">
|
||||
<div className="mx-auto max-w-4xl px-4 text-center">
|
||||
{/* Badge */}
|
||||
<Badge className="mb-6 px-4 py-1.5 text-sm animate-fade-in">
|
||||
<Badge className="mb-3 px-3 py-1 text-xs animate-fade-in">
|
||||
{t("badge")}
|
||||
</Badge>
|
||||
|
||||
{/* Main Title */}
|
||||
<h1 className="mb-6 text-4xl font-bold leading-tight tracking-tight md:text-5xl lg:text-6xl glow-text-pulse animate-fade-in-up">
|
||||
<h1 className="mb-3 text-2xl font-bold leading-tight tracking-tight md:text-3xl lg:text-4xl glow-text-pulse animate-fade-in-up">
|
||||
{t("title")}
|
||||
</h1>
|
||||
|
||||
{/* Subtitle */}
|
||||
<p className="mb-8 text-lg md:text-xl text-[var(--text-secondary)] max-w-2xl mx-auto animate-fade-in-up animation-delay-200">
|
||||
<p className="mb-4 text-sm md:text-base text-[var(--text-secondary)] max-w-2xl mx-auto animate-fade-in-up animation-delay-200">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
|
||||
{/* CTA Buttons */}
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 animate-fade-in-up animation-delay-400">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-3 animate-fade-in-up animation-delay-400">
|
||||
<a
|
||||
href="#get-started"
|
||||
className="group inline-flex items-center gap-2 rounded-lg bg-[var(--accent-cyan)] px-6 py-3 font-semibold text-[var(--bg-primary)] transition-all hover:shadow-[0_0_20px_rgba(0,240,255,0.4)] hover:scale-105"
|
||||
className="group inline-flex items-center gap-2 rounded-lg bg-[var(--accent-cyan)] px-5 py-2.5 text-sm font-semibold text-[var(--bg-primary)] transition-all hover:shadow-[0_0_20px_rgba(0,240,255,0.4)] hover:scale-105"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
{t("cta")}
|
||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
<ArrowRight className="h-3.5 w-3.5 transition-transform group-hover:translate-x-1" />
|
||||
</a>
|
||||
<a
|
||||
href="#learn-more"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-[var(--accent-cyan)]/30 bg-transparent px-6 py-3 font-semibold text-[var(--text-primary)] transition-all hover:border-[var(--accent-cyan)]/60 hover:bg-[var(--accent-cyan)]/5"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-[var(--accent-cyan)]/30 bg-transparent px-5 py-2.5 text-sm font-semibold text-[var(--text-primary)] transition-all hover:border-[var(--accent-cyan)]/60 hover:bg-[var(--accent-cyan)]/5"
|
||||
>
|
||||
{t("secondary")}
|
||||
</a>
|
||||
|
||||
@@ -1,124 +1,329 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Check, Copy, MessageSquare, Bot } from "lucide-react";
|
||||
import { useState, useCallback } from "react";
|
||||
import {
|
||||
Check,
|
||||
Copy,
|
||||
MessageSquare,
|
||||
Terminal,
|
||||
Pencil,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Bot,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useDeviceToken } from "@/hooks/use-device-token";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Tab = "openclaw" | "terminal";
|
||||
|
||||
export function InstallBanner() {
|
||||
const t = useTranslations("installBanner");
|
||||
const tGuide = useTranslations("skillGuide");
|
||||
const { token, name, isLoading, error, updateName } = useDeviceToken();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<Tab>("terminal");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [isEditingName, setIsEditingName] = useState(false);
|
||||
const [editNameValue, setEditNameValue] = useState("");
|
||||
const [nameStatus, setNameStatus] = useState<
|
||||
"idle" | "saving" | "success" | "error"
|
||||
>("idle");
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
|
||||
const prompt = t("prompt");
|
||||
const maskedToken = token ? `${token.slice(0, 6)}...` : "";
|
||||
|
||||
const handleCopy = async () => {
|
||||
const openclawPrompt = token ? t("openclawPrompt", { token }) : "";
|
||||
const terminalCommand = token ? t("terminalCommand", { token }) : "";
|
||||
|
||||
const handleCopy = useCallback(async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(prompt);
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// fallback: select the text
|
||||
// fallback
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleEditName = useCallback(() => {
|
||||
setEditNameValue(name || "");
|
||||
setIsEditingName(true);
|
||||
setNameStatus("idle");
|
||||
}, [name]);
|
||||
|
||||
const handleSaveName = useCallback(async () => {
|
||||
if (!editNameValue.trim()) return;
|
||||
setNameStatus("saving");
|
||||
const ok = await updateName(editNameValue.trim());
|
||||
if (ok) {
|
||||
setNameStatus("success");
|
||||
setIsEditingName(false);
|
||||
setTimeout(() => setNameStatus("idle"), 2000);
|
||||
} else {
|
||||
setNameStatus("error");
|
||||
}
|
||||
}, [editNameValue, updateName]);
|
||||
|
||||
const handleCancelEdit = useCallback(() => {
|
||||
setIsEditingName(false);
|
||||
setNameStatus("idle");
|
||||
}, []);
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<article className="glow-card rounded-xl px-4 py-3">
|
||||
<div className="flex items-center justify-center gap-2 py-3">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-[var(--accent-cyan)]" />
|
||||
<span className="text-xs text-[var(--text-muted)]">
|
||||
{t("generatingToken")}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error || !token) {
|
||||
return (
|
||||
<article className="glow-card rounded-xl px-4 py-3">
|
||||
<div className="flex items-center justify-center py-3">
|
||||
<span className="text-xs text-red-400">{t("tokenError")}</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<article
|
||||
className="glow-card rounded-xl px-5 py-4"
|
||||
data-skill="openclaw-reporter"
|
||||
data-install-method="clawhub"
|
||||
className="glow-card rounded-xl px-4 py-3"
|
||||
aria-label={tGuide("ariaLabel")}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{/* Compact header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg"
|
||||
style={{ backgroundColor: "rgba(0, 240, 255, 0.1)" }}
|
||||
>
|
||||
<span className="text-lg">🦞</span>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-base">🦞</span>
|
||||
<span className="text-xs font-medium text-[var(--text-primary)] truncate">
|
||||
{t("title")}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-muted)]">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Claw name row */}
|
||||
<div className="flex items-center justify-between gap-2 text-[11px]">
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<span className="text-[var(--text-muted)] shrink-0">
|
||||
{t("yourClaw")}:
|
||||
</span>
|
||||
{isEditingName ? (
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<input
|
||||
type="text"
|
||||
value={editNameValue}
|
||||
onChange={(e) => setEditNameValue(e.target.value)}
|
||||
className="w-20 rounded border border-[var(--accent-cyan)]/30 bg-[var(--bg-primary)] px-1.5 py-0.5 text-[11px] text-[var(--text-primary)] outline-none focus:border-[var(--accent-cyan)]"
|
||||
maxLength={30}
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSaveName();
|
||||
if (e.key === "Escape") handleCancelEdit();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="group flex items-center gap-1.5 rounded-lg border border-white/10 bg-[var(--bg-primary)] px-3 py-2 transition-all hover:border-[var(--accent-cyan)]/40 hover:bg-[var(--bg-primary)]/80 active:scale-[0.98] cursor-pointer shrink-0"
|
||||
title={t("copyTooltip")}
|
||||
onClick={handleSaveName}
|
||||
disabled={nameStatus === "saving"}
|
||||
className="text-[var(--accent-cyan)] hover:text-[var(--accent-cyan)]/80 cursor-pointer"
|
||||
>
|
||||
{nameStatus === "saving" ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Check className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancelEdit}
|
||||
className="text-[var(--text-muted)] hover:text-[var(--text-secondary)] text-[10px] cursor-pointer"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<span className="font-mono text-[var(--accent-cyan)] truncate">
|
||||
{name}
|
||||
</span>
|
||||
{nameStatus === "success" && (
|
||||
<Check className="h-2.5 w-2.5 text-[var(--accent-green)]" />
|
||||
)}
|
||||
<button
|
||||
onClick={handleEditName}
|
||||
className="text-[var(--text-muted)] hover:text-[var(--accent-cyan)] cursor-pointer shrink-0"
|
||||
>
|
||||
<Pencil className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Token mini display */}
|
||||
<button
|
||||
onClick={() => setShowToken(!showToken)}
|
||||
className="flex items-center gap-1 text-[var(--text-muted)] hover:text-[var(--text-secondary)] cursor-pointer shrink-0"
|
||||
title={showToken ? t("hideToken") : t("showToken")}
|
||||
>
|
||||
<code className="font-mono text-[10px]">
|
||||
{showToken ? token.slice(0, 12) + "..." : maskedToken}
|
||||
</code>
|
||||
{showToken ? (
|
||||
<EyeOff className="h-2.5 w-2.5" />
|
||||
) : (
|
||||
<Eye className="h-2.5 w-2.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Compact tabs */}
|
||||
<div className="flex rounded-md border border-white/10 bg-[var(--bg-primary)] p-0.5">
|
||||
<button
|
||||
onClick={() => setActiveTab("openclaw")}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-center gap-1 rounded px-2 py-1 text-[10px] font-medium transition-all cursor-pointer",
|
||||
activeTab === "openclaw"
|
||||
? "bg-[var(--accent-cyan)]/15 text-[var(--accent-cyan)]"
|
||||
: "text-[var(--text-muted)] hover:text-[var(--text-secondary)]"
|
||||
)}
|
||||
>
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
{t("tabOpenClaw")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("terminal")}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-center gap-1 rounded px-2 py-1 text-[10px] font-medium transition-all cursor-pointer",
|
||||
activeTab === "terminal"
|
||||
? "bg-[var(--accent-cyan)]/15 text-[var(--accent-cyan)]"
|
||||
: "text-[var(--text-muted)] hover:text-[var(--text-secondary)]"
|
||||
)}
|
||||
>
|
||||
<Terminal className="h-3 w-3" />
|
||||
{t("tabTerminal")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === "openclaw" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<button
|
||||
onClick={() => handleCopy(openclawPrompt)}
|
||||
className="group relative w-full cursor-pointer rounded-md border border-white/10 bg-[var(--bg-primary)] p-2.5 text-left transition-all hover:border-[var(--accent-cyan)]/30"
|
||||
>
|
||||
<p className="text-[11px] leading-relaxed text-[var(--text-secondary)] select-all break-all line-clamp-4">
|
||||
{openclawPrompt}
|
||||
</p>
|
||||
<div className="absolute right-1.5 top-1.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{copied ? (
|
||||
<Check className="h-3 w-3 text-[var(--accent-green)]" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3 text-[var(--text-muted)]" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleCopy(openclawPrompt)}
|
||||
className="group flex w-full items-center justify-center gap-1 rounded-md border border-white/10 bg-[var(--bg-primary)] py-1.5 text-[10px] transition-all hover:border-[var(--accent-cyan)]/40 cursor-pointer"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5 text-[var(--accent-green)]" />
|
||||
<Check className="h-2.5 w-2.5 text-[var(--accent-green)]" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5 text-[var(--text-muted)] transition-colors group-hover:text-[var(--accent-cyan)]" />
|
||||
<Copy className="h-2.5 w-2.5 text-[var(--text-muted)] group-hover:text-[var(--accent-cyan)]" />
|
||||
)}
|
||||
<span className="text-xs text-[var(--text-muted)] group-hover:text-[var(--accent-cyan)]">
|
||||
{copied ? t("copied") : t("copyButton")}
|
||||
<span className="text-[var(--text-muted)] group-hover:text-[var(--accent-cyan)]">
|
||||
{copied ? t("copied") : t("copyPrompt")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Human prompt card */}
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="group relative w-full cursor-pointer rounded-lg border border-white/10 bg-[var(--bg-primary)] p-3 text-left transition-all hover:border-[var(--accent-cyan)]/30"
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<MessageSquare className="mt-0.5 h-3.5 w-3.5 shrink-0 text-[var(--accent-cyan)]/60" />
|
||||
<p className="text-xs leading-relaxed text-[var(--text-secondary)] select-all">
|
||||
{prompt}
|
||||
</p>
|
||||
{activeTab === "terminal" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="rounded-md border border-white/10 bg-[var(--bg-primary)] p-2.5">
|
||||
<div className="flex items-start gap-1.5">
|
||||
<span className="font-mono text-[11px] text-[var(--accent-green)] select-none">
|
||||
$
|
||||
</span>
|
||||
<code className="font-mono text-[11px] leading-relaxed text-[var(--accent-cyan)] select-all break-all">
|
||||
{terminalCommand}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleCopy(terminalCommand)}
|
||||
className="group flex w-full items-center justify-center gap-1 rounded-md border border-white/10 bg-[var(--bg-primary)] py-1.5 text-[10px] transition-all hover:border-[var(--accent-cyan)]/40 cursor-pointer"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-2.5 w-2.5 text-[var(--accent-green)]" />
|
||||
) : (
|
||||
<Copy className="h-2.5 w-2.5 text-[var(--text-muted)] group-hover:text-[var(--accent-cyan)]" />
|
||||
)}
|
||||
<span className="text-[var(--text-muted)] group-hover:text-[var(--accent-cyan)]">
|
||||
{copied ? t("copied") : t("copyCommand")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI-crawlable installation guide (collapsed for humans, fully in DOM for crawlers) */}
|
||||
{/* AI-crawlable guide (collapsed) */}
|
||||
<details className="group/details">
|
||||
<summary className="flex cursor-pointer items-center gap-1.5 text-[10px] text-[var(--text-muted)] hover:text-[var(--text-secondary)]">
|
||||
<Bot className="h-3 w-3" />
|
||||
<summary className="flex cursor-pointer items-center gap-1 text-[10px] text-[var(--text-muted)] hover:text-[var(--text-secondary)]">
|
||||
<Bot className="h-2.5 w-2.5" />
|
||||
<span>{tGuide("summaryLabel")}</span>
|
||||
</summary>
|
||||
|
||||
<div className="mt-3 space-y-3 rounded-lg border border-white/5 bg-[var(--bg-primary)] p-4 text-xs leading-relaxed text-[var(--text-muted)]">
|
||||
<div className="mt-2 space-y-2 rounded-md border border-white/5 bg-[var(--bg-primary)] p-3 text-[10px] leading-relaxed text-[var(--text-muted)]">
|
||||
<section>
|
||||
<h3 className="mb-1 font-mono text-[var(--text-secondary)]">{tGuide("whatTitle")}</h3>
|
||||
<h3 className="mb-0.5 font-mono text-[var(--text-secondary)]">
|
||||
{tGuide("whatTitle")}
|
||||
</h3>
|
||||
<p>{tGuide("whatDescription")}</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-1 font-mono text-[var(--text-secondary)]">{tGuide("installTitle")}</h3>
|
||||
<ol className="list-inside list-decimal space-y-1 pl-1">
|
||||
<h3 className="mb-0.5 font-mono text-[var(--text-secondary)]">
|
||||
{tGuide("installTitle")}
|
||||
</h3>
|
||||
<ol className="list-inside list-decimal space-y-0.5 pl-1">
|
||||
<li>{tGuide("step1")}</li>
|
||||
<li>{tGuide("step2")}</li>
|
||||
<li>{tGuide("step3")}</li>
|
||||
<li>{tGuide("step4")}</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-1 font-mono text-[var(--text-secondary)]">{tGuide("commandTitle")}</h3>
|
||||
<code className="block rounded bg-[var(--bg-secondary)] px-3 py-2 font-mono text-[var(--accent-cyan)]">
|
||||
clawhub install openclaw-reporter
|
||||
<h3 className="mb-0.5 font-mono text-[var(--text-secondary)]">
|
||||
{tGuide("commandTitle")}
|
||||
</h3>
|
||||
<code className="block rounded bg-[var(--bg-secondary)] px-2 py-1.5 font-mono text-[var(--accent-cyan)] break-all">
|
||||
bash <(curl -fsSL https://kymr.top/install.sh) --token
|
||||
YOUR_TOKEN
|
||||
</code>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-1 font-mono text-[var(--text-secondary)]">{tGuide("dataTitle")}</h3>
|
||||
<h3 className="mb-0.5 font-mono text-[var(--text-secondary)]">
|
||||
{tGuide("dataTitle")}
|
||||
</h3>
|
||||
<ul className="list-inside list-disc space-y-0.5 pl-1">
|
||||
<li>{tGuide("data1")}</li>
|
||||
<li>{tGuide("data2")}</li>
|
||||
<li>{tGuide("data3")}</li>
|
||||
<li>{tGuide("data4")}</li>
|
||||
</ul>
|
||||
<p className="mt-1 italic">{tGuide("dataNever")}</p>
|
||||
<p className="mt-0.5 italic">{tGuide("dataNever")}</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-1 font-mono text-[var(--text-secondary)]">{tGuide("permissionsTitle")}</h3>
|
||||
<h3 className="mb-0.5 font-mono text-[var(--text-secondary)]">
|
||||
{tGuide("permissionsTitle")}
|
||||
</h3>
|
||||
<ul className="list-inside list-disc space-y-0.5 pl-1">
|
||||
<li>{tGuide("perm1")}</li>
|
||||
<li>{tGuide("perm2")}</li>
|
||||
|
||||
171
hooks/use-device-token.ts
Normal file
171
hooks/use-device-token.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
interface DeviceTokenData {
|
||||
clawId: string;
|
||||
apiKey: string;
|
||||
name: string;
|
||||
deviceId: string;
|
||||
}
|
||||
|
||||
interface UseDeviceTokenReturn {
|
||||
token: string | null;
|
||||
clawId: string | null;
|
||||
name: string | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
updateName: (newName: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "openclaw_device";
|
||||
|
||||
function generateUUID(): string {
|
||||
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
function getDeviceInfo(): {
|
||||
platform: string;
|
||||
browser: string;
|
||||
screen: string;
|
||||
language: string;
|
||||
} {
|
||||
if (typeof navigator === "undefined") {
|
||||
return { platform: "unknown", browser: "unknown", screen: "unknown", language: "en" };
|
||||
}
|
||||
|
||||
const ua = navigator.userAgent;
|
||||
let browser = "Unknown";
|
||||
if (ua.includes("Chrome") && !ua.includes("Edg")) browser = "Chrome";
|
||||
else if (ua.includes("Firefox")) browser = "Firefox";
|
||||
else if (ua.includes("Safari") && !ua.includes("Chrome")) browser = "Safari";
|
||||
else if (ua.includes("Edg")) browser = "Edge";
|
||||
|
||||
return {
|
||||
platform: navigator.platform || "unknown",
|
||||
browser,
|
||||
screen:
|
||||
typeof screen !== "undefined"
|
||||
? `${screen.width}x${screen.height}`
|
||||
: "unknown",
|
||||
language: navigator.language || "en",
|
||||
};
|
||||
}
|
||||
|
||||
function getCachedData(): DeviceTokenData | null {
|
||||
if (typeof localStorage === "undefined") return null;
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as DeviceTokenData;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function setCachedData(data: DeviceTokenData): void {
|
||||
if (typeof localStorage === "undefined") return;
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
||||
}
|
||||
|
||||
function updateCachedName(newName: string): void {
|
||||
const data = getCachedData();
|
||||
if (data) {
|
||||
setCachedData({ ...data, name: newName });
|
||||
}
|
||||
}
|
||||
|
||||
export function useDeviceToken(): UseDeviceTokenReturn {
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [clawId, setClawId] = useState<string | null>(null);
|
||||
const [name, setName] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
// Check cache first
|
||||
const cached = getCachedData();
|
||||
if (cached) {
|
||||
setToken(cached.apiKey);
|
||||
setClawId(cached.clawId);
|
||||
setName(cached.name);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Register new device
|
||||
try {
|
||||
const deviceId = generateUUID();
|
||||
const deviceInfo = getDeviceInfo();
|
||||
|
||||
const res = await fetch("/api/v1/device/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
deviceId,
|
||||
...deviceInfo,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Registration failed: ${res.status}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const tokenData: DeviceTokenData = {
|
||||
clawId: data.clawId,
|
||||
apiKey: data.apiKey,
|
||||
name: data.name,
|
||||
deviceId,
|
||||
};
|
||||
|
||||
setCachedData(tokenData);
|
||||
setToken(data.apiKey);
|
||||
setClawId(data.clawId);
|
||||
setName(data.name);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Registration failed");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
init();
|
||||
}, []);
|
||||
|
||||
const updateName = useCallback(
|
||||
async (newName: string): Promise<boolean> => {
|
||||
if (!token) return false;
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/v1/device/name", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ name: newName }),
|
||||
});
|
||||
|
||||
if (!res.ok) return false;
|
||||
|
||||
setName(newName);
|
||||
updateCachedName(newName);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[token]
|
||||
);
|
||||
|
||||
return { token, clawId, name, isLoading, error, updateName };
|
||||
}
|
||||
@@ -11,9 +11,12 @@ import {
|
||||
} from "drizzle-orm/mysql-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
export const claws = mysqlTable("claws", {
|
||||
export const claws = mysqlTable(
|
||||
"claws",
|
||||
{
|
||||
id: varchar("id", { length: 21 }).primaryKey(),
|
||||
apiKey: varchar("api_key", { length: 64 }).notNull().unique(),
|
||||
deviceId: varchar("device_id", { length: 64 }),
|
||||
name: varchar("name", { length: 100 }).notNull(),
|
||||
platform: varchar("platform", { length: 20 }),
|
||||
model: varchar("model", { length: 50 }),
|
||||
@@ -28,7 +31,9 @@ export const claws = mysqlTable("claws", {
|
||||
totalTasks: int("total_tasks").default(0),
|
||||
createdAt: datetime("created_at").default(sql`NOW()`),
|
||||
updatedAt: datetime("updated_at").default(sql`NOW()`),
|
||||
});
|
||||
},
|
||||
(table) => [uniqueIndex("claws_device_id_unq").on(table.deviceId)]
|
||||
);
|
||||
|
||||
export const heartbeats = mysqlTable(
|
||||
"heartbeats",
|
||||
|
||||
@@ -27,7 +27,26 @@ export const tokenSchema = z.object({
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
});
|
||||
|
||||
export const deviceRegisterSchema = z.object({
|
||||
deviceId: z.string().min(1).max(64),
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
platform: z.string().max(50).optional(),
|
||||
browser: z.string().max(100).optional(),
|
||||
screen: z.string().max(20).optional(),
|
||||
language: z.string().max(10).optional(),
|
||||
});
|
||||
|
||||
export const updateNameSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(30)
|
||||
.regex(/^[A-Za-z0-9_-]+$/, "Only alphanumeric, hyphens, and underscores"),
|
||||
});
|
||||
|
||||
export type RegisterInput = z.infer<typeof registerSchema>;
|
||||
export type HeartbeatInput = z.infer<typeof heartbeatSchema>;
|
||||
export type TaskInput = z.infer<typeof taskSchema>;
|
||||
export type TokenInput = z.infer<typeof tokenSchema>;
|
||||
export type DeviceRegisterInput = z.infer<typeof deviceRegisterSchema>;
|
||||
export type UpdateNameInput = z.infer<typeof updateNameSchema>;
|
||||
|
||||
@@ -17,12 +17,29 @@
|
||||
"live": "Live"
|
||||
},
|
||||
"installBanner": {
|
||||
"title": "Join the Heatmap",
|
||||
"subtitle": "Copy and paste this into Claude Code to get started",
|
||||
"copyTooltip": "Copy prompt to clipboard",
|
||||
"title": "Register Your Agent",
|
||||
"subtitle": "Get your agent on the global map in seconds",
|
||||
"tabOpenClaw": "OpenClaw",
|
||||
"tabTerminal": "Terminal",
|
||||
"openclawPrompt": "Help me join the OpenClaw global heatmap! Run this install command in the terminal: bash <(curl -fsSL https://kymr.top/install.sh) --token {token}",
|
||||
"terminalCommand": "bash <(curl -fsSL https://kymr.top/install.sh) --token {token}",
|
||||
"copyButton": "Copy",
|
||||
"copied": "Copied!",
|
||||
"prompt": "Help me join the OpenClaw global heatmap! First, search for the \"openclaw-reporter\" skill on ClawHub and install it. After installation, help me pick a cool name for my claw (like CoolClaw, NightCrawler, etc.) and guide me through the registration and permission setup."
|
||||
"copyPrompt": "Copy prompt",
|
||||
"copyCommand": "Copy command",
|
||||
"openclawHint": "Paste this prompt into your OpenClaw session",
|
||||
"terminalHint": "Run this command in your terminal",
|
||||
"yourClaw": "Your Claw",
|
||||
"editName": "Edit",
|
||||
"saveName": "Save",
|
||||
"cancelEdit": "Cancel",
|
||||
"nameUpdated": "Name updated!",
|
||||
"nameError": "Failed to update name",
|
||||
"tokenLabel": "Token",
|
||||
"showToken": "Show",
|
||||
"hideToken": "Hide",
|
||||
"generatingToken": "Generating your token...",
|
||||
"tokenError": "Failed to generate token. Please refresh."
|
||||
},
|
||||
"stats": {
|
||||
"onlineNow": "Online Now",
|
||||
@@ -90,26 +107,26 @@
|
||||
"close": "Close"
|
||||
},
|
||||
"skillGuide": {
|
||||
"ariaLabel": "OpenClaw Reporter Skill Installation Guide for AI Agents",
|
||||
"ariaLabel": "OpenClaw Installation Guide for AI Agents",
|
||||
"summaryLabel": "AI Agent Installation Guide (click to expand)",
|
||||
"whatTitle": "What is OpenClaw Reporter?",
|
||||
"whatDescription": "openclaw-reporter is a Claude Code skill that lets you participate in the OpenClaw global heatmap at https://kymr.top/. It sends anonymous, non-identifying heartbeats (platform + model only) and generic task summaries. No file paths, code, tool names, or project-specific data is ever sent.",
|
||||
"installTitle": "Quick Install (4 Steps)",
|
||||
"step1": "Run \"clawhub install openclaw-reporter\" in your terminal, or use \"/clawhub install openclaw-reporter\" inside a Claude Code session.",
|
||||
"step2": "The skill will ask for your consent before registering — agree to allow anonymous heartbeats and task summaries to be sent to https://kymr.top/.",
|
||||
"step3": "Pick a name for your claw (your display name on the global heatmap, e.g. CoolClaw, NightCrawler, CodeClaw).",
|
||||
"step4": "Done! Your claw will appear on the heatmap. Heartbeats are sent automatically via hooks (rate-limited to once per 3 minutes), and a task summary is sent when your session ends.",
|
||||
"whatTitle": "What is OpenClaw?",
|
||||
"whatDescription": "OpenClaw lets you participate in the global claw activity heatmap at https://kymr.top/. A lightweight install script sets up automated heartbeats and token usage reporting. No file paths, code, or project-specific data is ever sent.",
|
||||
"installTitle": "Quick Install",
|
||||
"step1": "Visit https://kymr.top/ to get your unique token.",
|
||||
"step2": "Run: bash <(curl -fsSL https://kymr.top/install.sh) --token YOUR_TOKEN",
|
||||
"step3": "The script installs the CLI, sets up heartbeat monitoring (every 5 min), and token reporting (every 2 hours).",
|
||||
"step4": "Done! Your claw appears on the heatmap automatically.",
|
||||
"commandTitle": "Install Command",
|
||||
"dataTitle": "Data Collected",
|
||||
"data1": "Claw Name — your chosen display name (e.g. CoolClaw42)",
|
||||
"data1": "Claw Name — your display name on the heatmap",
|
||||
"data2": "Platform — OS type (darwin, linux, win32)",
|
||||
"data3": "Model — Claude model identifier (e.g. claude-sonnet-4-6)",
|
||||
"data4": "Task summary — generic activity indicator (\"Completed a task\")",
|
||||
"data3": "Model — AI model identifier",
|
||||
"data4": "Token usage — daily input/output token counts",
|
||||
"dataNever": "Never sent: usernames, file paths, code, project names, tool names, secrets, or any identifying information.",
|
||||
"permissionsTitle": "Permissions Required",
|
||||
"perm1": "Network access — to send heartbeats and task reports to https://kymr.top/",
|
||||
"perm2": "Write ~/.openclaw/ — to store config (API key) and hook scripts locally",
|
||||
"perm3": "Binaries: curl, python3, uname — used by hook scripts"
|
||||
"permissionsTitle": "What Gets Installed",
|
||||
"perm1": "claw-market CLI — lightweight command-line tool",
|
||||
"perm2": "~/.openclaw/ — config file and helper scripts",
|
||||
"perm3": "Cron jobs — heartbeat check (5 min) and token report (2 hr)"
|
||||
},
|
||||
"languageSwitcher": {
|
||||
"en": "EN",
|
||||
|
||||
@@ -17,12 +17,29 @@
|
||||
"live": "实时"
|
||||
},
|
||||
"installBanner": {
|
||||
"title": "加入热力图",
|
||||
"subtitle": "复制下面这段话,粘贴到 Claude Code 对话中即可开始",
|
||||
"copyTooltip": "复制到剪贴板",
|
||||
"title": "注册你的 Agent",
|
||||
"subtitle": "几秒内让你的 Agent 出现在全球地图上",
|
||||
"tabOpenClaw": "OpenClaw",
|
||||
"tabTerminal": "终端安装",
|
||||
"openclawPrompt": "帮我加入 OpenClaw 全球龙虾热力图!在终端执行这个安装命令:bash <(curl -fsSL https://kymr.top/install.sh) --token {token}",
|
||||
"terminalCommand": "bash <(curl -fsSL https://kymr.top/install.sh) --token {token}",
|
||||
"copyButton": "复制",
|
||||
"copied": "已复制!",
|
||||
"prompt": "帮我加入 OpenClaw 全球龙虾热力图!首先,在 ClawHub 上搜索并安装 \"openclaw-reporter\" 技能。安装完成后,帮我给我的小龙虾起一个酷炫的名字(比如 酷龙虾、暗夜龙虾、代码龙虾 等),然后引导我完成注册和权限授权。"
|
||||
"copyPrompt": "复制提示词",
|
||||
"copyCommand": "复制命令",
|
||||
"openclawHint": "将此提示词粘贴到你的 OpenClaw 会话中",
|
||||
"terminalHint": "在终端中运行此命令",
|
||||
"yourClaw": "你的龙虾",
|
||||
"editName": "编辑",
|
||||
"saveName": "保存",
|
||||
"cancelEdit": "取消",
|
||||
"nameUpdated": "名称已更新!",
|
||||
"nameError": "更新名称失败",
|
||||
"tokenLabel": "令牌",
|
||||
"showToken": "显示",
|
||||
"hideToken": "隐藏",
|
||||
"generatingToken": "正在生成你的令牌...",
|
||||
"tokenError": "生成令牌失败,请刷新页面。"
|
||||
},
|
||||
"stats": {
|
||||
"onlineNow": "当前在线",
|
||||
@@ -90,26 +107,26 @@
|
||||
"close": "关闭"
|
||||
},
|
||||
"skillGuide": {
|
||||
"ariaLabel": "OpenClaw Reporter 技能安装指南(供 AI 代理阅读)",
|
||||
"ariaLabel": "OpenClaw 安装指南(供 AI 代理阅读)",
|
||||
"summaryLabel": "AI 代理安装指南(点击展开)",
|
||||
"whatTitle": "什么是 OpenClaw Reporter?",
|
||||
"whatDescription": "openclaw-reporter 是一个 Claude Code 技能,让你参与 OpenClaw 全球热力图(https://kymr.top/)。它只发送匿名的、非身份识别的心跳(操作系统 + 模型信息)和通用任务摘要。绝不发送文件路径、代码、工具名称或任何项目相关数据。",
|
||||
"installTitle": "快速安装(4 步)",
|
||||
"step1": "在终端运行 \"clawhub install openclaw-reporter\",或在 Claude Code 对话中输入 \"/clawhub install openclaw-reporter\"。",
|
||||
"step2": "技能会在注册前征求你的同意——同意允许向 https://kymr.top/ 发送匿名心跳和任务摘要。",
|
||||
"step3": "给你的小龙虾起一个名字(你在全球热力图上的显示名称,比如 酷龙虾、暗夜龙虾、代码龙虾)。",
|
||||
"step4": "完成!你的小龙虾会出现在热力图上。心跳通过 hooks 自动发送(限制为每 3 分钟最多一次),会话结束时会发送一条任务摘要。",
|
||||
"whatTitle": "什么是 OpenClaw?",
|
||||
"whatDescription": "OpenClaw 让你参与全球龙虾活动热力图(https://kymr.top/)。轻量安装脚本自动设置心跳监控和 token 用量上报。绝不发送文件路径、代码或任何项目相关数据。",
|
||||
"installTitle": "快速安装",
|
||||
"step1": "访问 https://kymr.top/ 获取你的专属令牌。",
|
||||
"step2": "运行:bash <(curl -fsSL https://kymr.top/install.sh) --token 你的令牌",
|
||||
"step3": "脚本会安装 CLI 工具,设置心跳监控(每 5 分钟)和 token 上报(每 2 小时)。",
|
||||
"step4": "完成!你的龙虾会自动出现在热力图上。",
|
||||
"commandTitle": "安装命令",
|
||||
"dataTitle": "收集的数据",
|
||||
"data1": "龙虾名称——你选择的显示名称(如 酷龙虾42)",
|
||||
"data1": "龙虾名称——你在热力图上的显示名称",
|
||||
"data2": "操作系统——系统类型(darwin、linux、win32)",
|
||||
"data3": "模型——Claude 模型标识符(如 claude-sonnet-4-6)",
|
||||
"data4": "任务摘要——通用活动指标(\"Completed a task\")",
|
||||
"data3": "模型——AI 模型标识符",
|
||||
"data4": "Token 用量——每日输入/输出 token 数量",
|
||||
"dataNever": "绝不发送:用户名、文件路径、代码、项目名称、工具名称、密钥或任何身份识别信息。",
|
||||
"permissionsTitle": "所需权限",
|
||||
"perm1": "网络访问——向 https://kymr.top/ 发送心跳和任务报告",
|
||||
"perm2": "写入 ~/.openclaw/——在本地存储配置(API 密钥)和 hook 脚本",
|
||||
"perm3": "系统工具:curl、python3、uname——供 hook 脚本使用"
|
||||
"permissionsTitle": "安装内容",
|
||||
"perm1": "claw-market CLI——轻量命令行工具",
|
||||
"perm2": "~/.openclaw/——配置文件和辅助脚本",
|
||||
"perm3": "定时任务——心跳检测(5 分钟)和 token 上报(2 小时)"
|
||||
},
|
||||
"languageSwitcher": {
|
||||
"en": "EN",
|
||||
|
||||
465
package-lock.json
generated
465
package-lock.json
generated
@@ -47,6 +47,7 @@
|
||||
"eslint-config-next": "^15.3.0",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^4.1.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.8.0"
|
||||
}
|
||||
},
|
||||
@@ -8316,6 +8317,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/next-intl/node_modules/@swc/helpers": {
|
||||
"version": "0.5.19",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@swc/helpers/-/helpers-0.5.19.tgz",
|
||||
"integrity": "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/next/node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
@@ -10335,6 +10347,459 @@
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://mirrors.tencent.com/npm/tsx/-/tsx-4.21.0.tgz",
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
|
||||
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
|
||||
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
|
||||
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
|
||||
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
|
||||
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
|
||||
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/esbuild": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://mirrors.tencent.com/npm/esbuild/-/esbuild-0.27.4.tgz",
|
||||
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.4",
|
||||
"@esbuild/android-arm": "0.27.4",
|
||||
"@esbuild/android-arm64": "0.27.4",
|
||||
"@esbuild/android-x64": "0.27.4",
|
||||
"@esbuild/darwin-arm64": "0.27.4",
|
||||
"@esbuild/darwin-x64": "0.27.4",
|
||||
"@esbuild/freebsd-arm64": "0.27.4",
|
||||
"@esbuild/freebsd-x64": "0.27.4",
|
||||
"@esbuild/linux-arm": "0.27.4",
|
||||
"@esbuild/linux-arm64": "0.27.4",
|
||||
"@esbuild/linux-ia32": "0.27.4",
|
||||
"@esbuild/linux-loong64": "0.27.4",
|
||||
"@esbuild/linux-mips64el": "0.27.4",
|
||||
"@esbuild/linux-ppc64": "0.27.4",
|
||||
"@esbuild/linux-riscv64": "0.27.4",
|
||||
"@esbuild/linux-s390x": "0.27.4",
|
||||
"@esbuild/linux-x64": "0.27.4",
|
||||
"@esbuild/netbsd-arm64": "0.27.4",
|
||||
"@esbuild/netbsd-x64": "0.27.4",
|
||||
"@esbuild/openbsd-arm64": "0.27.4",
|
||||
"@esbuild/openbsd-x64": "0.27.4",
|
||||
"@esbuild/openharmony-arm64": "0.27.4",
|
||||
"@esbuild/sunos-x64": "0.27.4",
|
||||
"@esbuild/win32-arm64": "0.27.4",
|
||||
"@esbuild/win32-ia32": "0.27.4",
|
||||
"@esbuild/win32-x64": "0.27.4"
|
||||
}
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
|
||||
291
public/install.sh
Normal file
291
public/install.sh
Normal file
@@ -0,0 +1,291 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ============================================================================
|
||||
# OpenClaw Market — One-click installer
|
||||
# Usage: bash <(curl -fsSL https://kymr.top/install.sh) --token <TOKEN>
|
||||
# ============================================================================
|
||||
|
||||
VERSION="1.0.0"
|
||||
API_ENDPOINT="https://kymr.top/api/v1"
|
||||
CONFIG_DIR="$HOME/.openclaw"
|
||||
CONFIG_FILE="$CONFIG_DIR/config.json"
|
||||
HEARTBEAT_SCRIPT="$CONFIG_DIR/heartbeat-check.sh"
|
||||
TRACK_TOKENS_SCRIPT="$CONFIG_DIR/track-tokens.sh"
|
||||
REPORT_TOKENS_SCRIPT="$CONFIG_DIR/report-tokens.sh"
|
||||
TOKEN_USAGE_FILE="$CONFIG_DIR/token-usage.json"
|
||||
CRON_TAG="# openclaw-market"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
info() { echo -e "${GREEN}[✓]${NC} $1"; }
|
||||
warn() { echo -e "${YELLOW}[!]${NC} $1"; }
|
||||
error() { echo -e "${RED}[✗]${NC} $1"; }
|
||||
step() { echo -e "${CYAN}[→]${NC} $1"; }
|
||||
|
||||
# ── Parse arguments ─────────────────────────────────────────────────────────
|
||||
TOKEN=""
|
||||
UNINSTALL=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--token) TOKEN="$2"; shift 2 ;;
|
||||
--token=*) TOKEN="${1#*=}"; shift ;;
|
||||
--uninstall) UNINSTALL=true; shift ;;
|
||||
--help|-h)
|
||||
echo "Usage: bash install.sh --token <TOKEN>"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --token <TOKEN> Your OpenClaw token (get it from https://kymr.top)"
|
||||
echo " --uninstall Remove OpenClaw and all cron jobs"
|
||||
echo " --help Show this help"
|
||||
exit 0
|
||||
;;
|
||||
*) error "Unknown option: $1"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Uninstall ───────────────────────────────────────────────────────────────
|
||||
if [ "$UNINSTALL" = true ]; then
|
||||
step "Uninstalling OpenClaw..."
|
||||
|
||||
# Remove cron jobs
|
||||
crontab -l 2>/dev/null | grep -v "$CRON_TAG" | crontab - 2>/dev/null || true
|
||||
info "Cron jobs removed"
|
||||
|
||||
# Remove config directory
|
||||
if [ -d "$CONFIG_DIR" ]; then
|
||||
rm -rf "$CONFIG_DIR"
|
||||
info "Config directory removed: $CONFIG_DIR"
|
||||
fi
|
||||
|
||||
# Optionally remove CLI
|
||||
if command -v claw-market &>/dev/null; then
|
||||
warn "CLI 'claw-market' is still installed. Run 'npm uninstall -g @ricardweii/claw-market' to remove it."
|
||||
fi
|
||||
|
||||
info "OpenClaw uninstalled successfully!"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Validate token ──────────────────────────────────────────────────────────
|
||||
if [ -z "$TOKEN" ]; then
|
||||
error "Token is required. Usage: bash install.sh --token <YOUR_TOKEN>"
|
||||
echo ""
|
||||
echo "Get your token at https://kymr.top"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${CYAN}🦞 OpenClaw Market Installer v${VERSION}${NC}"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
|
||||
# ── Check prerequisites ────────────────────────────────────────────────────
|
||||
step "Checking prerequisites..."
|
||||
|
||||
if ! command -v node &>/dev/null; then
|
||||
error "Node.js is required but not installed."
|
||||
echo " Install it from https://nodejs.org/ or use nvm:"
|
||||
echo " curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash"
|
||||
exit 1
|
||||
fi
|
||||
info "Node.js $(node --version) found"
|
||||
|
||||
if ! command -v npm &>/dev/null; then
|
||||
error "npm is required but not installed."
|
||||
exit 1
|
||||
fi
|
||||
info "npm $(npm --version) found"
|
||||
|
||||
# ── Install CLI ─────────────────────────────────────────────────────────────
|
||||
step "Installing claw-market CLI..."
|
||||
|
||||
if command -v claw-market &>/dev/null; then
|
||||
info "claw-market CLI already installed"
|
||||
else
|
||||
npm install -g @ricardweii/claw-market 2>/dev/null || {
|
||||
warn "Global install failed, trying with sudo..."
|
||||
sudo npm install -g @ricardweii/claw-market
|
||||
}
|
||||
info "claw-market CLI installed"
|
||||
fi
|
||||
|
||||
# ── Fetch claw info from server ─────────────────────────────────────────────
|
||||
step "Verifying token with server..."
|
||||
|
||||
CLAW_INFO=$(curl -sf -H "Authorization: Bearer $TOKEN" "${API_ENDPOINT}/heartbeat" \
|
||||
-X POST -H "Content-Type: application/json" -d '{}' 2>/dev/null) || {
|
||||
error "Invalid token or server unreachable."
|
||||
echo " Check your token and try again."
|
||||
exit 1
|
||||
}
|
||||
info "Token verified successfully"
|
||||
|
||||
# ── Write config ────────────────────────────────────────────────────────────
|
||||
step "Writing configuration..."
|
||||
|
||||
mkdir -p "$CONFIG_DIR"
|
||||
chmod 700 "$CONFIG_DIR"
|
||||
|
||||
cat > "$CONFIG_FILE" << EOF
|
||||
{
|
||||
"apiKey": "$TOKEN",
|
||||
"endpoint": "$API_ENDPOINT"
|
||||
}
|
||||
EOF
|
||||
chmod 600 "$CONFIG_FILE"
|
||||
info "Config written to $CONFIG_FILE"
|
||||
|
||||
# ── Create heartbeat check script ──────────────────────────────────────────
|
||||
step "Setting up heartbeat checker..."
|
||||
|
||||
cat > "$HEARTBEAT_SCRIPT" << 'HEARTBEAT_EOF'
|
||||
#!/usr/bin/env bash
|
||||
# OpenClaw heartbeat checker — runs every 5 minutes via cron
|
||||
# Checks if openclaw process is running, sends heartbeat if so
|
||||
|
||||
export PATH="$HOME/.nvm/versions/node/$(ls "$HOME/.nvm/versions/node/" 2>/dev/null | tail -1)/bin:$HOME/.local/bin:/usr/local/bin:$PATH"
|
||||
|
||||
if pgrep -f "openclaw" > /dev/null 2>&1; then
|
||||
claw-market heartbeat 2>/dev/null || true
|
||||
fi
|
||||
HEARTBEAT_EOF
|
||||
chmod +x "$HEARTBEAT_SCRIPT"
|
||||
info "Heartbeat checker created"
|
||||
|
||||
# ── Create token tracking script ───────────────────────────────────────────
|
||||
step "Setting up token tracker..."
|
||||
|
||||
cat > "$TRACK_TOKENS_SCRIPT" << 'TRACK_EOF'
|
||||
#!/usr/bin/env bash
|
||||
# OpenClaw token tracker — call with: track-tokens.sh <input> <output>
|
||||
# Accumulates daily token usage in ~/.openclaw/token-usage.json
|
||||
|
||||
CONFIG_DIR="$HOME/.openclaw"
|
||||
TOKEN_FILE="$CONFIG_DIR/token-usage.json"
|
||||
TODAY=$(date +%Y-%m-%d)
|
||||
|
||||
INPUT=${1:-0}
|
||||
OUTPUT=${2:-0}
|
||||
|
||||
# Validate numeric
|
||||
if ! [[ "$INPUT" =~ ^[0-9]+$ ]] || ! [[ "$OUTPUT" =~ ^[0-9]+$ ]]; then
|
||||
echo "Usage: track-tokens.sh <input_tokens> <output_tokens>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Read existing data or init
|
||||
if [ -f "$TOKEN_FILE" ]; then
|
||||
EXISTING_DATE=$(python3 -c "import json; d=json.load(open('$TOKEN_FILE')); print(d.get('date',''))" 2>/dev/null || echo "")
|
||||
if [ "$EXISTING_DATE" = "$TODAY" ]; then
|
||||
EXISTING_INPUT=$(python3 -c "import json; d=json.load(open('$TOKEN_FILE')); print(d.get('inputTokens',0))" 2>/dev/null || echo "0")
|
||||
EXISTING_OUTPUT=$(python3 -c "import json; d=json.load(open('$TOKEN_FILE')); print(d.get('outputTokens',0))" 2>/dev/null || echo "0")
|
||||
INPUT=$((INPUT + EXISTING_INPUT))
|
||||
OUTPUT=$((OUTPUT + EXISTING_OUTPUT))
|
||||
fi
|
||||
fi
|
||||
|
||||
# Write updated data
|
||||
cat > "$TOKEN_FILE" << EOF
|
||||
{
|
||||
"date": "$TODAY",
|
||||
"inputTokens": $INPUT,
|
||||
"outputTokens": $OUTPUT,
|
||||
"updatedAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
}
|
||||
EOF
|
||||
|
||||
echo "Token usage updated: input=$INPUT, output=$OUTPUT (date=$TODAY)"
|
||||
TRACK_EOF
|
||||
chmod +x "$TRACK_TOKENS_SCRIPT"
|
||||
info "Token tracker created"
|
||||
|
||||
# ── Create token reporter script ───────────────────────────────────────────
|
||||
step "Setting up token reporter..."
|
||||
|
||||
cat > "$REPORT_TOKENS_SCRIPT" << 'REPORT_EOF'
|
||||
#!/usr/bin/env bash
|
||||
# OpenClaw token reporter — runs every 2 hours via cron
|
||||
# Reads accumulated token usage and reports to server
|
||||
|
||||
export PATH="$HOME/.nvm/versions/node/$(ls "$HOME/.nvm/versions/node/" 2>/dev/null | tail -1)/bin:$HOME/.local/bin:/usr/local/bin:$PATH"
|
||||
|
||||
CONFIG_DIR="$HOME/.openclaw"
|
||||
TOKEN_FILE="$CONFIG_DIR/token-usage.json"
|
||||
TODAY=$(date +%Y-%m-%d)
|
||||
|
||||
# Check if token usage file exists
|
||||
if [ ! -f "$TOKEN_FILE" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Read data
|
||||
DATE=$(python3 -c "import json; d=json.load(open('$TOKEN_FILE')); print(d.get('date',''))" 2>/dev/null || echo "")
|
||||
INPUT=$(python3 -c "import json; d=json.load(open('$TOKEN_FILE')); print(d.get('inputTokens',0))" 2>/dev/null || echo "0")
|
||||
OUTPUT=$(python3 -c "import json; d=json.load(open('$TOKEN_FILE')); print(d.get('outputTokens',0))" 2>/dev/null || echo "0")
|
||||
|
||||
# Only report if there's data and it's for today
|
||||
if [ "$INPUT" = "0" ] && [ "$OUTPUT" = "0" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Report via CLI
|
||||
if [ -n "$DATE" ]; then
|
||||
claw-market token "$INPUT" "$OUTPUT" --date "$DATE" 2>/dev/null || true
|
||||
else
|
||||
claw-market token "$INPUT" "$OUTPUT" 2>/dev/null || true
|
||||
fi
|
||||
REPORT_EOF
|
||||
chmod +x "$REPORT_TOKENS_SCRIPT"
|
||||
info "Token reporter created"
|
||||
|
||||
# ── Set up cron jobs ────────────────────────────────────────────────────────
|
||||
step "Setting up scheduled tasks..."
|
||||
|
||||
# Remove existing openclaw cron jobs
|
||||
EXISTING_CRON=$(crontab -l 2>/dev/null | grep -v "$CRON_TAG" || true)
|
||||
|
||||
# Add new cron jobs
|
||||
NEW_CRON="$EXISTING_CRON
|
||||
*/5 * * * * $HEARTBEAT_SCRIPT $CRON_TAG
|
||||
7 */2 * * * $REPORT_TOKENS_SCRIPT $CRON_TAG"
|
||||
|
||||
echo "$NEW_CRON" | crontab -
|
||||
info "Cron jobs installed:"
|
||||
echo " - Heartbeat check: every 5 minutes"
|
||||
echo " - Token report: every 2 hours"
|
||||
|
||||
# ── Send initial heartbeat ──────────────────────────────────────────────────
|
||||
step "Sending initial heartbeat..."
|
||||
|
||||
claw-market heartbeat 2>/dev/null && {
|
||||
info "Initial heartbeat sent"
|
||||
} || {
|
||||
warn "Initial heartbeat failed (non-blocking)"
|
||||
}
|
||||
|
||||
# ── Done ────────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e "${GREEN}🦞 OpenClaw installed successfully!${NC}"
|
||||
echo ""
|
||||
echo " Your claw is now active on the heatmap."
|
||||
echo " View it at: https://kymr.top"
|
||||
echo ""
|
||||
echo " Scripts installed:"
|
||||
echo " $HEARTBEAT_SCRIPT — heartbeat (every 5 min)"
|
||||
echo " $TRACK_TOKENS_SCRIPT — track token usage"
|
||||
echo " $REPORT_TOKENS_SCRIPT — report tokens (every 2 hr)"
|
||||
echo ""
|
||||
echo " To track tokens manually:"
|
||||
echo " ~/.openclaw/track-tokens.sh <input> <output>"
|
||||
echo ""
|
||||
echo " To uninstall:"
|
||||
echo " bash <(curl -fsSL https://kymr.top/install.sh) --uninstall"
|
||||
echo ""
|
||||
Reference in New Issue
Block a user