feat(页面): 添加区域探索与国家边界功能
This commit is contained in:
@@ -1,69 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
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 { StatsPanel } from "@/components/dashboard/stats-panel";
|
||||
import { ClawFeed } from "@/components/dashboard/claw-feed";
|
||||
|
||||
const ContinentMap = dynamic(
|
||||
() =>
|
||||
import("@/components/map/continent-map").then((m) => ({
|
||||
default: m.ContinentMap,
|
||||
})),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
const continentSlugs = ["asia", "europe", "americas", "africa", "oceania"] as const;
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
|
||||
export default function ContinentPage({ params }: PageProps) {
|
||||
const { slug } = use(params);
|
||||
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">
|
||||
<ParticleBg />
|
||||
<Navbar activeView="map" />
|
||||
|
||||
<main className="relative z-10 mx-auto max-w-[1800px] px-4 pt-20 pb-8">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h1
|
||||
className="font-mono text-2xl font-bold"
|
||||
style={{
|
||||
color: "var(--accent-cyan)",
|
||||
textShadow: "var(--glow-cyan)",
|
||||
}}
|
||||
>
|
||||
{tPage("regionTitle", { name })}
|
||||
</h1>
|
||||
<ViewSwitcher activeContinent={slug} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[1fr_300px]">
|
||||
{/* Map */}
|
||||
<div>
|
||||
<ContinentMap slug={slug} />
|
||||
</div>
|
||||
|
||||
{/* Side Panel */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<StatsPanel />
|
||||
<ClawFeed />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Map } from "lucide-react";
|
||||
import { Navbar } from "@/components/layout/navbar";
|
||||
import { InstallBanner } from "@/components/layout/install-banner";
|
||||
import { ParticleBg } from "@/components/layout/particle-bg";
|
||||
@@ -8,14 +12,31 @@ import { StatsPanel } from "@/components/dashboard/stats-panel";
|
||||
import { ActivityTimeline } from "@/components/dashboard/activity-timeline";
|
||||
import { ClawFeed } from "@/components/dashboard/claw-feed";
|
||||
import { RegionRanking } from "@/components/dashboard/region-ranking";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ContinentMap = dynamic(
|
||||
() =>
|
||||
import("@/components/map/continent-map").then((m) => ({
|
||||
default: m.ContinentMap,
|
||||
})),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
const continentSlugs = ["asia", "europe", "americas", "africa", "oceania"] as const;
|
||||
|
||||
export default function HomePage() {
|
||||
const [activeContinent, setActiveContinent] = useState<string>("asia");
|
||||
const tContinents = useTranslations("continents");
|
||||
const tRegion = useTranslations("regionExplorer");
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen">
|
||||
<ParticleBg />
|
||||
<Navbar activeView="globe" />
|
||||
<Navbar />
|
||||
|
||||
<main className="relative z-10 mx-auto max-w-[1800px] px-4 pt-20 pb-8">
|
||||
{/* Section 1: Globe + Dashboard */}
|
||||
<section className="min-h-[calc(100vh-5rem)]">
|
||||
<div className="mb-4">
|
||||
<InstallBanner />
|
||||
</div>
|
||||
@@ -39,7 +60,48 @@ export default function HomePage() {
|
||||
<ClawFeed />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Section 2: Region Explorer */}
|
||||
<section className="mt-8">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2
|
||||
className="flex items-center gap-2 font-mono text-2xl font-bold"
|
||||
style={{
|
||||
color: "var(--accent-cyan)",
|
||||
textShadow: "var(--glow-cyan)",
|
||||
}}
|
||||
>
|
||||
<Map className="h-5 w-5" />
|
||||
{tRegion("title")}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Continent Tabs */}
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
{continentSlugs.map((slug) => (
|
||||
<button
|
||||
key={slug}
|
||||
onClick={() => setActiveContinent(slug)}
|
||||
className={cn(
|
||||
"rounded-lg border px-4 py-2 text-sm font-medium transition-all",
|
||||
activeContinent === slug
|
||||
? "border-[var(--accent-cyan)]/30 bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)]"
|
||||
: "border-white/5 text-[var(--text-muted)] hover:border-white/10 hover:text-[var(--text-secondary)]"
|
||||
)}
|
||||
>
|
||||
{tContinents(slug)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Map — explicit height so MapLibre can render */}
|
||||
<ContinentMap
|
||||
key={activeContinent}
|
||||
slug={activeContinent}
|
||||
className="h-[calc(100vh-14rem)]"
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { useHeatmapData, type HeatmapPoint } from "@/hooks/use-heatmap-data";
|
||||
import { useCountryData, type LabelData } from "./use-country-data";
|
||||
import { GlobeControls } from "./globe-controls";
|
||||
|
||||
function GlobeLoading() {
|
||||
@@ -34,6 +35,7 @@ interface ArcData {
|
||||
export function GlobeView() {
|
||||
const t = useTranslations("globe");
|
||||
const tPopup = useTranslations("clawPopup");
|
||||
const locale = useLocale();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const globeRef = useRef<any>(undefined);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -41,6 +43,7 @@ export function GlobeView() {
|
||||
const [hoveredPoint, setHoveredPoint] = useState<HeatmapPoint | null>(null);
|
||||
const [selectedPoint, setSelectedPoint] = useState<HeatmapPoint | null>(null);
|
||||
const { points } = useHeatmapData(30000);
|
||||
const { countries, labels } = useCountryData(locale);
|
||||
|
||||
// Generate arcs only between online points
|
||||
const arcs = useMemo((): ArcData[] => {
|
||||
@@ -117,6 +120,32 @@ export function GlobeView() {
|
||||
globeImageUrl="//unpkg.com/three-globe/example/img/earth-dark.jpg"
|
||||
atmosphereColor="#00f0ff"
|
||||
atmosphereAltitude={0.15}
|
||||
// Country polygons
|
||||
polygonsData={countries}
|
||||
polygonCapColor={() => "rgba(26, 31, 46, 0.6)"}
|
||||
polygonSideColor={() => "rgba(0, 240, 255, 0.05)"}
|
||||
polygonStrokeColor={() => "rgba(0, 240, 255, 0.15)"}
|
||||
polygonAltitude={0.005}
|
||||
// Country name labels (use HTML elements for CJK support)
|
||||
htmlElementsData={labels}
|
||||
htmlLat={(d: object) => (d as LabelData).lat}
|
||||
htmlLng={(d: object) => (d as LabelData).lng}
|
||||
htmlAltitude={0.01}
|
||||
htmlElement={(d: object) => {
|
||||
const label = d as LabelData;
|
||||
const el = document.createElement("div");
|
||||
el.textContent = label.name;
|
||||
el.style.color = "rgba(0, 240, 255, 0.5)";
|
||||
el.style.fontSize = "10px";
|
||||
el.style.fontFamily = "system-ui, -apple-system, sans-serif";
|
||||
el.style.pointerEvents = "none";
|
||||
el.style.userSelect = "none";
|
||||
el.style.whiteSpace = "nowrap";
|
||||
el.style.textShadow = "0 0 4px rgba(0, 240, 255, 0.3)";
|
||||
return el;
|
||||
}}
|
||||
// Graticules
|
||||
showGraticules={true}
|
||||
// Points
|
||||
pointsData={points}
|
||||
pointLat={(d: object) => (d as HeatmapPoint).lat}
|
||||
|
||||
53
components/globe/use-country-data.ts
Normal file
53
components/globe/use-country-data.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { feature } from "topojson-client";
|
||||
import type { Topology, GeometryCollection } from "topojson-specification";
|
||||
import type { FeatureCollection, Feature, Geometry } from "geojson";
|
||||
import { COUNTRY_LABELS } from "@/lib/geo/country-labels";
|
||||
|
||||
const TOPOJSON_URL =
|
||||
"https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json";
|
||||
|
||||
export interface LabelData {
|
||||
lat: number;
|
||||
lng: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function useCountryData(locale: string) {
|
||||
const [countries, setCountries] = useState<Feature<Geometry>[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
fetch(TOPOJSON_URL)
|
||||
.then((res) => res.json())
|
||||
.then((topo: Topology) => {
|
||||
if (cancelled) return;
|
||||
const geo = feature(
|
||||
topo,
|
||||
topo.objects.countries as GeometryCollection
|
||||
) as FeatureCollection;
|
||||
setCountries(geo.features);
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const labels: LabelData[] = useMemo(() => {
|
||||
const lang = locale === "zh" ? "zh" : "en";
|
||||
return COUNTRY_LABELS.map((c) => ({
|
||||
lat: c.lat,
|
||||
lng: c.lng,
|
||||
name: c[lang],
|
||||
}));
|
||||
}, [locale]);
|
||||
|
||||
return { countries, labels, isLoading };
|
||||
}
|
||||
@@ -1,16 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { Activity, Globe2, Map } from "lucide-react";
|
||||
import { Activity } 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) {
|
||||
export function Navbar() {
|
||||
const t = useTranslations("navbar");
|
||||
|
||||
return (
|
||||
@@ -26,33 +21,6 @@ export function Navbar({ activeView = "globe" }: NavbarProps) {
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-1 rounded-lg border border-white/5 bg-white/5 p-1">
|
||||
<Link
|
||||
href="/"
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-all",
|
||||
activeView === "globe"
|
||||
? "bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)]"
|
||||
: "text-[var(--text-muted)] hover:text-[var(--text-secondary)]"
|
||||
)}
|
||||
>
|
||||
<Globe2 className="h-3.5 w-3.5" />
|
||||
{t("globe")}
|
||||
</Link>
|
||||
<Link
|
||||
href="/continent/asia"
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-all",
|
||||
activeView === "map"
|
||||
? "bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)]"
|
||||
: "text-[var(--text-muted)] hover:text-[var(--text-secondary)]"
|
||||
)}
|
||||
>
|
||||
<Map className="h-3.5 w-3.5" />
|
||||
{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)]" />
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Globe2, Map } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
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
|
||||
href="/"
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-all",
|
||||
!activeContinent
|
||||
? "border-[var(--accent-cyan)]/30 bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)]"
|
||||
: "border-white/5 text-[var(--text-muted)] hover:border-white/10 hover:text-[var(--text-secondary)]"
|
||||
)}
|
||||
>
|
||||
<Globe2 className="h-3 w-3" />
|
||||
{tSwitcher("global")}
|
||||
</Link>
|
||||
{continentSlugs.map((slug) => (
|
||||
<Link
|
||||
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 === 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" />
|
||||
{tContinents(slug)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type { MapLayerMouseEvent, LngLatLike, MapRef } from "react-map-gl/maplib
|
||||
import type { LayerSpecification } from "maplibre-gl";
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
import { useHeatmapData, type HeatmapPoint } from "@/hooks/use-heatmap-data";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MapPopup } from "./map-popup";
|
||||
|
||||
const CARTO_STYLE =
|
||||
@@ -88,9 +89,10 @@ const circleLayer: LayerSpecification = {
|
||||
|
||||
interface ContinentMapProps {
|
||||
slug: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ContinentMap({ slug }: ContinentMapProps) {
|
||||
export function ContinentMap({ slug, className }: ContinentMapProps) {
|
||||
const locale = useLocale();
|
||||
const config = continentConfigs[slug] ?? continentConfigs.asia;
|
||||
const { points } = useHeatmapData(30000);
|
||||
@@ -162,10 +164,10 @@ export function ContinentMap({ slug }: ContinentMapProps) {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-white/5 bg-[var(--bg-secondary)]">
|
||||
<div className={cn("overflow-hidden rounded-xl border border-white/5 bg-[var(--bg-secondary)]", className)}>
|
||||
<Map
|
||||
initialViewState={config}
|
||||
style={{ width: "100%", height: "calc(100vh - 12rem)" }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
mapStyle={CARTO_STYLE}
|
||||
interactiveLayerIds={["claw-circles"]}
|
||||
onClick={handleClick}
|
||||
|
||||
58
lib/geo/country-labels.ts
Normal file
58
lib/geo/country-labels.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
export interface CountryLabel {
|
||||
lat: number;
|
||||
lng: number;
|
||||
en: string;
|
||||
zh: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ~40 major countries with approximate center coordinates and bilingual names.
|
||||
*/
|
||||
export const COUNTRY_LABELS: readonly CountryLabel[] = [
|
||||
{ lat: 39.9, lng: 116.4, en: "China", zh: "\u4e2d\u56fd" },
|
||||
{ lat: 36.2, lng: 138.3, en: "Japan", zh: "\u65e5\u672c" },
|
||||
{ lat: 36.5, lng: 127.8, en: "South Korea", zh: "\u97e9\u56fd" },
|
||||
{ lat: 20.6, lng: 79.0, en: "India", zh: "\u5370\u5ea6" },
|
||||
{ lat: 37.1, lng: -95.7, en: "United States", zh: "\u7f8e\u56fd" },
|
||||
{ lat: 56.1, lng: -106.3, en: "Canada", zh: "\u52a0\u62ff\u5927" },
|
||||
{ lat: 23.6, lng: -102.6, en: "Mexico", zh: "\u58a8\u897f\u54e5" },
|
||||
{ lat: -14.2, lng: -51.9, en: "Brazil", zh: "\u5df4\u897f" },
|
||||
{ lat: -38.4, lng: -63.6, en: "Argentina", zh: "\u963f\u6839\u5ef7" },
|
||||
{ lat: 51.2, lng: 10.4, en: "Germany", zh: "\u5fb7\u56fd" },
|
||||
{ lat: 46.2, lng: 2.2, en: "France", zh: "\u6cd5\u56fd" },
|
||||
{ lat: 55.4, lng: -3.4, en: "United Kingdom", zh: "\u82f1\u56fd" },
|
||||
{ lat: 41.9, lng: 12.6, en: "Italy", zh: "\u610f\u5927\u5229" },
|
||||
{ lat: 40.5, lng: -3.7, en: "Spain", zh: "\u897f\u73ed\u7259" },
|
||||
{ lat: 61.5, lng: 105.3, en: "Russia", zh: "\u4fc4\u7f57\u65af" },
|
||||
{ lat: -25.3, lng: 133.8, en: "Australia", zh: "\u6fb3\u5927\u5229\u4e9a" },
|
||||
{ lat: 1.4, lng: 103.8, en: "Singapore", zh: "\u65b0\u52a0\u5761" },
|
||||
{ lat: 15.9, lng: 100.9, en: "Thailand", zh: "\u6cf0\u56fd" },
|
||||
{ lat: -0.8, lng: 113.9, en: "Indonesia", zh: "\u5370\u5ea6\u5c3c\u897f\u4e9a" },
|
||||
{ lat: 14.1, lng: 121.8, en: "Philippines", zh: "\u83f2\u5f8b\u5bbe" },
|
||||
{ lat: 14.1, lng: 108.3, en: "Vietnam", zh: "\u8d8a\u5357" },
|
||||
{ lat: 4.2, lng: 101.9, en: "Malaysia", zh: "\u9a6c\u6765\u897f\u4e9a" },
|
||||
{ lat: 23.7, lng: 90.4, en: "Bangladesh", zh: "\u5b5f\u52a0\u62c9\u56fd" },
|
||||
{ lat: 30.4, lng: 69.3, en: "Pakistan", zh: "\u5df4\u57fa\u65af\u5766" },
|
||||
{ lat: 23.4, lng: 53.8, en: "UAE", zh: "\u963f\u8054\u914b" },
|
||||
{ lat: 25.3, lng: 51.2, en: "Qatar", zh: "\u5361\u5854\u5c14" },
|
||||
{ lat: 23.9, lng: 45.1, en: "Saudi Arabia", zh: "\u6c99\u7279\u963f\u62c9\u4f2f" },
|
||||
{ lat: 32.4, lng: 53.7, en: "Iran", zh: "\u4f0a\u6717" },
|
||||
{ lat: 39.1, lng: 35.2, en: "Turkey", zh: "\u571f\u8033\u5176" },
|
||||
{ lat: 31.8, lng: 35.2, en: "Israel", zh: "\u4ee5\u8272\u5217" },
|
||||
{ lat: 26.8, lng: 30.8, en: "Egypt", zh: "\u57c3\u53ca" },
|
||||
{ lat: 9.1, lng: 8.7, en: "Nigeria", zh: "\u5c3c\u65e5\u5229\u4e9a" },
|
||||
{ lat: -30.6, lng: 22.9, en: "South Africa", zh: "\u5357\u975e" },
|
||||
{ lat: -1.3, lng: 36.8, en: "Kenya", zh: "\u80af\u5c3c\u4e9a" },
|
||||
{ lat: 7.9, lng: -1.0, en: "Ghana", zh: "\u52a0\u7eb3" },
|
||||
{ lat: 48.4, lng: 31.2, en: "Ukraine", zh: "\u4e4c\u514b\u5170" },
|
||||
{ lat: 52.0, lng: 19.1, en: "Poland", zh: "\u6ce2\u5170" },
|
||||
{ lat: 60.1, lng: 18.6, en: "Sweden", zh: "\u745e\u5178" },
|
||||
{ lat: 46.8, lng: 8.2, en: "Switzerland", zh: "\u745e\u58eb" },
|
||||
{ lat: 47.5, lng: 14.6, en: "Austria", zh: "\u5965\u5730\u5229" },
|
||||
{ lat: 52.1, lng: 5.3, en: "Netherlands", zh: "\u8377\u5170" },
|
||||
{ lat: -4.0, lng: 22.0, en: "Congo", zh: "\u521a\u679c" },
|
||||
{ lat: -6.4, lng: 34.9, en: "Tanzania", zh: "\u5766\u6851\u5c3c\u4e9a" },
|
||||
{ lat: 4.6, lng: -74.3, en: "Colombia", zh: "\u54e5\u4f26\u6bd4\u4e9a" },
|
||||
{ lat: -9.2, lng: -75.0, en: "Peru", zh: "\u79d8\u9c81" },
|
||||
{ lat: -35.7, lng: 174.9, en: "New Zealand", zh: "\u65b0\u897f\u5170" },
|
||||
] as const;
|
||||
@@ -48,6 +48,9 @@
|
||||
"viewSwitcher": {
|
||||
"global": "Global"
|
||||
},
|
||||
"regionExplorer": {
|
||||
"title": "Region Explorer"
|
||||
},
|
||||
"continents": {
|
||||
"asia": "Asia",
|
||||
"europe": "Europe",
|
||||
|
||||
@@ -48,6 +48,9 @@
|
||||
"viewSwitcher": {
|
||||
"global": "全球"
|
||||
},
|
||||
"regionExplorer": {
|
||||
"title": "区域探索"
|
||||
},
|
||||
"continents": {
|
||||
"asia": "亚洲",
|
||||
"europe": "欧洲",
|
||||
|
||||
@@ -31,15 +31,19 @@
|
||||
"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/geojson": "^7946.0.16",
|
||||
"@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/topojson-specification": "^1.0.5",
|
||||
"drizzle-kit": "^0.30.6",
|
||||
"eslint": "^9.21.0",
|
||||
"eslint-config-next": "^15.3.0",
|
||||
|
||||
40
pnpm-lock.yaml
generated
40
pnpm-lock.yaml
generated
@@ -62,6 +62,9 @@ importers:
|
||||
three:
|
||||
specifier: ^0.173.0
|
||||
version: 0.173.0
|
||||
topojson-client:
|
||||
specifier: ^3.1.0
|
||||
version: 3.1.0
|
||||
zod:
|
||||
specifier: ^3.24.3
|
||||
version: 3.25.76
|
||||
@@ -72,6 +75,9 @@ importers:
|
||||
'@tailwindcss/postcss':
|
||||
specifier: ^4.1.0
|
||||
version: 4.2.1
|
||||
'@types/geojson':
|
||||
specifier: ^7946.0.16
|
||||
version: 7946.0.16
|
||||
'@types/node':
|
||||
specifier: ^22.13.0
|
||||
version: 22.19.15
|
||||
@@ -84,6 +90,12 @@ importers:
|
||||
'@types/three':
|
||||
specifier: ^0.173.0
|
||||
version: 0.173.0
|
||||
'@types/topojson-client':
|
||||
specifier: ^3.1.5
|
||||
version: 3.1.5
|
||||
'@types/topojson-specification':
|
||||
specifier: ^1.0.5
|
||||
version: 1.0.5
|
||||
drizzle-kit:
|
||||
specifier: ^0.30.6
|
||||
version: 0.30.6
|
||||
@@ -1078,6 +1090,12 @@ packages:
|
||||
'@types/three@0.173.0':
|
||||
resolution: {integrity: sha512-KtNjfI/CRB6JVKIVeZM1R3GYDX2wkoV2itNcQu2j4d7qkhjGOuB+s2oF6jl9mztycDLGMtrAnJQYxInC8Bb20A==}
|
||||
|
||||
'@types/topojson-client@3.1.5':
|
||||
resolution: {integrity: sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==}
|
||||
|
||||
'@types/topojson-specification@1.0.5':
|
||||
resolution: {integrity: sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==}
|
||||
|
||||
'@types/webxr@0.5.24':
|
||||
resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==}
|
||||
|
||||
@@ -1420,6 +1438,9 @@ packages:
|
||||
color-name@1.1.4:
|
||||
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
||||
|
||||
commander@2.20.3:
|
||||
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
|
||||
|
||||
concat-map@0.0.1:
|
||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
||||
|
||||
@@ -2973,6 +2994,10 @@ packages:
|
||||
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
||||
engines: {node: '>=8.0'}
|
||||
|
||||
topojson-client@3.1.0:
|
||||
resolution: {integrity: sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==}
|
||||
hasBin: true
|
||||
|
||||
ts-api-utils@2.4.0:
|
||||
resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==}
|
||||
engines: {node: '>=18.12'}
|
||||
@@ -3835,6 +3860,15 @@ snapshots:
|
||||
fflate: 0.8.2
|
||||
meshoptimizer: 0.18.1
|
||||
|
||||
'@types/topojson-client@3.1.5':
|
||||
dependencies:
|
||||
'@types/geojson': 7946.0.16
|
||||
'@types/topojson-specification': 1.0.5
|
||||
|
||||
'@types/topojson-specification@1.0.5':
|
||||
dependencies:
|
||||
'@types/geojson': 7946.0.16
|
||||
|
||||
'@types/webxr@0.5.24': {}
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.57.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
|
||||
@@ -4180,6 +4214,8 @@ snapshots:
|
||||
|
||||
color-name@1.1.4: {}
|
||||
|
||||
commander@2.20.3: {}
|
||||
|
||||
concat-map@0.0.1: {}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
@@ -5926,6 +5962,10 @@ snapshots:
|
||||
dependencies:
|
||||
is-number: 7.0.0
|
||||
|
||||
topojson-client@3.1.0:
|
||||
dependencies:
|
||||
commander: 2.20.3
|
||||
|
||||
ts-api-utils@2.4.0(typescript@5.9.3):
|
||||
dependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
@@ -3,11 +3,8 @@ name: openclaw-reporter
|
||||
description: |
|
||||
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/.
|
||||
Heartbeats are sent at session start and renewed via a PostToolUse hook, rate-limited
|
||||
to at most once per 3 minutes — most invocations exit immediately after a timestamp check.
|
||||
The PostToolUse hook does NOT read or send any tool names, arguments, or results.
|
||||
Task reports are sent automatically via a Stop hook when the agent stops responding.
|
||||
Sends a heartbeat (platform + model only) when the skill is loaded.
|
||||
Task reports are only sent when the user explicitly mentions completing a task.
|
||||
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.
|
||||
@@ -16,12 +13,8 @@ requirements:
|
||||
network: true
|
||||
writeFiles:
|
||||
- "~/.openclaw/config.json"
|
||||
- "~/.openclaw/scripts/heartbeat.sh"
|
||||
- "~/.openclaw/scripts/task-report.sh"
|
||||
- "~/.openclaw/.last_heartbeat"
|
||||
binaries:
|
||||
- curl
|
||||
- python3
|
||||
- uname
|
||||
env:
|
||||
- name: CLAUDE_MODEL
|
||||
@@ -31,17 +24,7 @@ hooks:
|
||||
SessionStart:
|
||||
- hooks:
|
||||
- type: command
|
||||
command: "bash $HOME/.openclaw/scripts/heartbeat.sh 2>/dev/null || true"
|
||||
timeout: 10
|
||||
PostToolUse:
|
||||
- hooks:
|
||||
- type: command
|
||||
command: "bash $HOME/.openclaw/scripts/heartbeat.sh 2>/dev/null || true"
|
||||
timeout: 5
|
||||
Stop:
|
||||
- hooks:
|
||||
- type: command
|
||||
command: "bash $HOME/.openclaw/scripts/task-report.sh 2>/dev/null || true"
|
||||
command: "CONFIG=\"$HOME/.openclaw/config.json\"; [ -f \"$CONFIG\" ] && KEY=$(grep -o '\"apiKey\"[[:space:]]*:[[:space:]]*\"[^\"]*\"' \"$CONFIG\" | head -1 | sed 's/.*\"\\([^\"]*\\)\"$/\\1/') && [ -n \"$KEY\" ] && 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}\\\"}\" 2>/dev/null || true"
|
||||
timeout: 10
|
||||
---
|
||||
|
||||
@@ -51,10 +34,9 @@ This skill lets you participate in the OpenClaw global claw activity heatmap. It
|
||||
|
||||
## How It Works
|
||||
|
||||
- **Heartbeat**: Sent at session start (`SessionStart` hook) and renewed periodically (`PostToolUse` hook, rate-limited to once per 3 minutes). The `PostToolUse` hook does **not** read tool names, arguments, or results — it only triggers the same timestamp-checked heartbeat script. Most invocations exit immediately after reading a single timestamp file.
|
||||
- **Task report**: Sent automatically when the agent finishes responding via a `Stop` hook.
|
||||
- **Heartbeat**: Sent once when the skill is loaded (`SessionStart` hook). No other automated hooks are used — no `PostToolUse`, no `Stop` hooks.
|
||||
- **Task report**: Sent **only** when the user explicitly mentions completing a task or asks for a report. The agent calls the task API directly — no automated scripts.
|
||||
- **Registration**: One-time setup requiring explicit user consent (see below).
|
||||
- **Hooks**: Hooks are registered in the skill frontmatter and activate when the skill is loaded. No system-level files are modified.
|
||||
|
||||
## Data Disclosure
|
||||
|
||||
@@ -67,15 +49,15 @@ This skill sends the following data to `https://kymr.top/` (the OpenClaw Market
|
||||
| Model | `claude-sonnet-4-6` | Model usage stats |
|
||||
| Task summary | `"Completed a task"` | Generic activity indicator |
|
||||
|
||||
**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 hook scripts — 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.
|
||||
**Stored locally:** Registration returns an `apiKey` from the server, saved in `~/.openclaw/config.json` with `chmod 600` (owner-only access). The endpoint URL (`https://kymr.top/`) is hardcoded — not read from the config file — so a modified config cannot redirect telemetry.
|
||||
|
||||
**Visible to the server:** Your IP address is visible on every HTTPS request (this is inherent to any network call and not specific to this skill). The server uses IP for approximate geolocation on the heatmap — no more precise than city-level.
|
||||
**Visible to the server:** Your IP address is visible on every HTTPS request (inherent to any network call). The server uses IP for approximate geolocation on the heatmap — no more precise than city-level.
|
||||
|
||||
**Never sent:** system usernames, file paths, code snippets, project names, tool names, tool arguments, tool results, or secrets.
|
||||
|
||||
## Configuration
|
||||
|
||||
Config is stored at `~/.openclaw/config.json` (permissions `600`). Hook scripts live at `~/.openclaw/scripts/`. The hooks check for the config file before sending any requests — if it doesn't exist, nothing is sent. The endpoint is hardcoded to `https://kymr.top/` in the scripts and is **not** configurable via the config file.
|
||||
Config is stored at `~/.openclaw/config.json` (permissions `600`). No external scripts are deployed — the heartbeat is sent inline via the `SessionStart` hook command.
|
||||
|
||||
## First-Time Setup (Requires User Consent)
|
||||
|
||||
@@ -85,7 +67,7 @@ If `~/.openclaw/config.json` does not exist, follow these steps:
|
||||
|
||||
Use the AskUserQuestion tool:
|
||||
|
||||
- Question: "OpenClaw Reporter wants to register a claw name and send periodic heartbeats (once per 3 min, platform + model only) 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, tool names, or project data is sent. Allow?"
|
||||
- Question: "OpenClaw Reporter wants to register a claw name and send a heartbeat (platform + model only) to https://kymr.top/ when the skill loads. Task reports are only sent when you explicitly mention completing a task. The server will issue an API key stored locally at ~/.openclaw/config.json. No file paths, code, tool names, 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.
|
||||
@@ -94,135 +76,89 @@ Use the AskUserQuestion tool:
|
||||
|
||||
If the user consented, use AskUserQuestion again:
|
||||
|
||||
- Question: "Pick a name for your claw on the heatmap! (e.g. CoolClaw, NightCrawler, CodeClaw)"
|
||||
- Question: "Pick a name for your claw on the heatmap! (1-30 chars, letters/numbers/hyphens/underscores only, 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: "CoolClaw" / "CodeClaw" / "NightCrawler"
|
||||
|
||||
Use whatever the user provides as the claw name.
|
||||
|
||||
### Step 3: Register and install hooks
|
||||
### Step 3: Register and save config
|
||||
|
||||
After receiving the name (stored as `CLAW_NAME`), run Step 3a and Step 3b in sequence.
|
||||
|
||||
#### Step 3a: Register and save config
|
||||
After receiving the name (stored as `CLAW_NAME`), run the following:
|
||||
|
||||
```bash
|
||||
# --- OpenClaw Registration ---
|
||||
set -e
|
||||
|
||||
OPENCLAW_ENDPOINT="https://kymr.top"
|
||||
export CLAW_NAME="USER_CHOSEN_NAME_HERE"
|
||||
CLAW_NAME="USER_CHOSEN_NAME_HERE"
|
||||
|
||||
mkdir -p ~/.openclaw/scripts
|
||||
# Validate claw name: only allow alphanumeric, hyphens, underscores (1-30 chars).
|
||||
# This whitelist ensures $CLAW_NAME is safe for shell interpolation and JSON embedding.
|
||||
if ! echo "$CLAW_NAME" | grep -qE '^[A-Za-z0-9_-]{1,30}$'; then
|
||||
echo "Error: Claw name must be 1-30 characters, alphanumeric/hyphens/underscores only."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p ~/.openclaw
|
||||
|
||||
# Register with the server
|
||||
# Safe: CLAW_NAME is validated above to contain only [A-Za-z0-9_-]
|
||||
RESPONSE=$(curl -s -X POST "$OPENCLAW_ENDPOINT/api/v1/register" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"name\": \"$CLAW_NAME\",
|
||||
\"platform\": \"$(uname -s | tr '[:upper:]' '[:lower:]')\",
|
||||
\"model\": \"$(echo $CLAUDE_MODEL 2>/dev/null || echo 'unknown')\"
|
||||
\"model\": \"${CLAUDE_MODEL:-unknown}\"
|
||||
}")
|
||||
|
||||
# Save config — pipe response via stdin to avoid shell injection
|
||||
# Creates ~/.openclaw/config.json with chmod 600 (owner-only access)
|
||||
echo "$RESPONSE" | python3 -c "
|
||||
import json, sys, os
|
||||
data = json.loads(sys.stdin.read())
|
||||
config = {
|
||||
'clawId': data.get('clawId', ''),
|
||||
'apiKey': data.get('apiKey', ''),
|
||||
'name': os.environ.get('CLAW_NAME', '')
|
||||
# Extract fields from JSON response using grep/sed (no python3 needed)
|
||||
CLAW_ID=$(echo "$RESPONSE" | grep -o '"clawId"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
|
||||
API_KEY=$(echo "$RESPONSE" | grep -o '"apiKey"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
|
||||
|
||||
if [ -z "$API_KEY" ]; then
|
||||
echo "Error: Registration failed. Server response: $RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Write config file
|
||||
cat > ~/.openclaw/config.json << CFGEOF
|
||||
{
|
||||
"clawId": "$CLAW_ID",
|
||||
"apiKey": "$API_KEY",
|
||||
"name": "$CLAW_NAME"
|
||||
}
|
||||
path = os.path.expanduser('~/.openclaw/config.json')
|
||||
with open(path, 'w') as f:
|
||||
json.dump(config, f, indent=2)
|
||||
os.chmod(path, 0o600)
|
||||
print('Registered as:', config['name'])
|
||||
"
|
||||
CFGEOF
|
||||
chmod 600 ~/.openclaw/config.json
|
||||
|
||||
echo "Registered as: $CLAW_NAME"
|
||||
```
|
||||
|
||||
#### Step 3b: Deploy hook scripts
|
||||
## Task Reporting (Manual Only)
|
||||
|
||||
Task reports are **never sent automatically**. Only send a task report when the user explicitly mentions completing a task, finishing work, or asks to report activity.
|
||||
|
||||
When triggered, run:
|
||||
|
||||
```bash
|
||||
# --- Deploy heartbeat script (rate-limited to once per 180s) ---
|
||||
cat > ~/.openclaw/scripts/heartbeat.sh << 'HOOKEOF'
|
||||
#!/bin/bash
|
||||
# OpenClaw heartbeat — rate-limited to once per 180 seconds.
|
||||
# No tool names, arguments, or results are read or sent.
|
||||
CONFIG="$HOME/.openclaw/config.json"
|
||||
[ -f "$CONFIG" ] || exit 0
|
||||
LAST_FILE="$HOME/.openclaw/.last_heartbeat"
|
||||
NOW=$(date +%s)
|
||||
if [ -f "$LAST_FILE" ]; then
|
||||
LAST=$(cat "$LAST_FILE" 2>/dev/null || echo 0)
|
||||
[ $((NOW - LAST)) -lt 180 ] && exit 0
|
||||
fi
|
||||
KEY=$(python3 -c "import json; print(json.load(open('$CONFIG'))['apiKey'])" 2>/dev/null) || exit 0
|
||||
[ -z "$KEY" ] && exit 0
|
||||
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}\"}" \
|
||||
2>/dev/null && echo "$NOW" > "$LAST_FILE" || true
|
||||
HOOKEOF
|
||||
chmod +x ~/.openclaw/scripts/heartbeat.sh
|
||||
|
||||
# --- Deploy task report script ---
|
||||
cat > ~/.openclaw/scripts/task-report.sh << 'HOOKEOF'
|
||||
#!/bin/bash
|
||||
CONFIG="$HOME/.openclaw/config.json"
|
||||
[ -f "$CONFIG" ] || exit 0
|
||||
KEY=$(python3 -c "import json; print(json.load(open('$CONFIG'))['apiKey'])" 2>/dev/null) || exit 0
|
||||
[ -z "$KEY" ] && exit 0
|
||||
[ -f "$CONFIG" ] || { echo "OpenClaw not registered."; exit 0; }
|
||||
KEY=$(grep -o '"apiKey"[[:space:]]*:[[:space:]]*"[^"]*"' "$CONFIG" | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
|
||||
[ -z "$KEY" ] && { echo "No API key found."; exit 0; }
|
||||
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}\"}" 2>/dev/null || true
|
||||
HOOKEOF
|
||||
chmod +x ~/.openclaw/scripts/task-report.sh
|
||||
|
||||
echo "--- OpenClaw setup complete ---"
|
||||
-d "{\"summary\":\"Completed a task\",\"model\":\"${CLAUDE_MODEL:-unknown}\"}" 2>/dev/null \
|
||||
&& echo "Task reported." || echo "Task report failed (non-blocking)."
|
||||
```
|
||||
|
||||
## Hook Verification (Auto-Repair)
|
||||
|
||||
**Every time this skill is loaded**, if `~/.openclaw/config.json` exists, verify that hook scripts are present. Run this check silently:
|
||||
|
||||
1. Check `~/.openclaw/scripts/heartbeat.sh` exists and is executable
|
||||
2. Check `~/.openclaw/scripts/task-report.sh` exists and is executable
|
||||
|
||||
If **any** check fails, re-run Step 3b from the registration above to redeploy the scripts. Do this silently without asking the user.
|
||||
|
||||
Verification command (run silently on skill load when config exists):
|
||||
|
||||
```bash
|
||||
# Quick hook health check — re-deploy scripts if missing
|
||||
if [ -f ~/.openclaw/config.json ]; then
|
||||
NEEDS_REPAIR=false
|
||||
[ -x ~/.openclaw/scripts/heartbeat.sh ] || NEEDS_REPAIR=true
|
||||
[ -x ~/.openclaw/scripts/task-report.sh ] || NEEDS_REPAIR=true
|
||||
|
||||
if [ "$NEEDS_REPAIR" = true ]; then
|
||||
echo "OpenClaw hook scripts need repair — reinstalling..."
|
||||
# (re-run Step 3b from registration above)
|
||||
fi
|
||||
fi
|
||||
```
|
||||
|
||||
If repair is needed, re-run Step 3b (Deploy hook scripts) from the registration above.
|
||||
|
||||
## Opt-Out / Unregister
|
||||
|
||||
To stop all OpenClaw reporting and remove hooks:
|
||||
To stop all OpenClaw reporting:
|
||||
|
||||
```bash
|
||||
# Remove scripts, config, and timestamp
|
||||
rm -f ~/.openclaw/scripts/heartbeat.sh ~/.openclaw/scripts/task-report.sh
|
||||
rmdir ~/.openclaw/scripts 2>/dev/null
|
||||
rm -f ~/.openclaw/config.json ~/.openclaw/.last_heartbeat
|
||||
rm -f ~/.openclaw/config.json
|
||||
rmdir ~/.openclaw 2>/dev/null
|
||||
|
||||
echo "OpenClaw fully uninstalled."
|
||||
```
|
||||
|
||||
@@ -232,9 +168,9 @@ After deletion, no heartbeats or task reports will be sent. The skill will ask f
|
||||
|
||||
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. Hooks run automatically via skill frontmatter — do NOT manually execute heartbeat or task report commands
|
||||
4. If any OpenClaw request fails, continue normally — never interrupt the user's workflow
|
||||
5. Never include identifying information in task summaries
|
||||
6. **On skill load**: if `~/.openclaw/config.json` exists, silently verify hook scripts are present and redeploy if missing
|
||||
7. **Idempotent**: running script deployment multiple times is safe
|
||||
3. Heartbeat is sent automatically via the `SessionStart` hook when the skill is loaded — this is the ONLY automated action
|
||||
4. **Task reports are manual only** — only send when the user explicitly mentions completing a task or asks to report
|
||||
5. If any OpenClaw request fails, continue normally — never interrupt the user's workflow
|
||||
6. Never include identifying information in task summaries
|
||||
7. **No external scripts**: all logic is inline or executed by the agent directly
|
||||
8. **No system-level modifications**: never write to `~/.claude/settings.json` or any file outside `~/.openclaw/`
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
#!/bin/bash
|
||||
# OpenClaw heartbeat — sends platform + model to heatmap server.
|
||||
# Rate-limited: sends at most once per 180 seconds.
|
||||
# Called by both SessionStart and PostToolUse hooks.
|
||||
# No tool names, arguments, or results are read or sent.
|
||||
|
||||
CONFIG="$HOME/.openclaw/config.json"
|
||||
[ -f "$CONFIG" ] || exit 0
|
||||
|
||||
# --- Rate limit check (fast path: exit in <1ms) ---
|
||||
LAST_FILE="$HOME/.openclaw/.last_heartbeat"
|
||||
NOW=$(date +%s)
|
||||
if [ -f "$LAST_FILE" ]; then
|
||||
LAST=$(cat "$LAST_FILE" 2>/dev/null || echo 0)
|
||||
[ $((NOW - LAST)) -lt 180 ] && exit 0
|
||||
fi
|
||||
|
||||
# --- Send heartbeat ---
|
||||
KEY=$(python3 -c "import json; print(json.load(open('$CONFIG'))['apiKey'])" 2>/dev/null) || exit 0
|
||||
[ -z "$KEY" ] && exit 0
|
||||
|
||||
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}\"}" \
|
||||
2>/dev/null && echo "$NOW" > "$LAST_FILE" || true
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/bin/bash
|
||||
# OpenClaw task report — Stop hook
|
||||
# Sends a generic task completion signal. Fails silently.
|
||||
|
||||
CONFIG="$HOME/.openclaw/config.json"
|
||||
[ -f "$CONFIG" ] || exit 0
|
||||
|
||||
KEY=$(python3 -c "import json; print(json.load(open('$CONFIG'))['apiKey'])" 2>/dev/null) || exit 0
|
||||
[ -z "$KEY" ] && exit 0
|
||||
|
||||
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}\"}" 2>/dev/null || true
|
||||
Reference in New Issue
Block a user