feat(openclaw): add live ops space scene state
Some checks failed
CD Pipeline / workflow-shape (push) Successful in 0s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / tests (push) Failing after 1m7s
CD Pipeline / build-and-deploy (push) Has been skipped
CD Pipeline / post-deploy-checks (push) Has been skipped

This commit is contained in:
Your Name
2026-06-29 21:17:13 +08:00
parent 1b184f9150
commit 165afd24bc
8 changed files with 1056 additions and 3 deletions

View File

@@ -0,0 +1,466 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useLocale, useTranslations } from "next-intl";
import {
Activity,
AlertTriangle,
Bot,
BrainCircuit,
CheckCircle2,
MessageSquareText,
RefreshCw,
Rocket,
SearchCheck,
ShieldCheck,
Wrench,
} from "lucide-react";
import { AppLayout } from "@/components/layout";
import { getRuntimeApiBaseUrl } from "@/lib/runtime-api-base";
import { cn } from "@/lib/utils";
type SceneStatus = "loading" | "ready" | "degraded";
type SceneZone = {
zone_id: string;
label: string;
kind: string;
position: { x: number; y: number };
};
type SceneAgent = {
agent_id: string;
label: string;
zone_id: string;
state: string;
animation: string;
position: { x: number; y: number };
current_task?: {
stage_id?: string | null;
recorded?: boolean | null;
total?: number | null;
recent?: number | null;
feeds_learning?: boolean | null;
required_for_closed_loop?: boolean | null;
};
};
type SceneWorkItem = {
work_item_id: string;
title: string;
priority: string;
status: string;
zone_id: string;
animation: string;
position: { x: number; y: number };
next_controlled_action?: string | null;
exit_criteria?: string | null;
};
type SceneState = {
schema_version: "openclaw_live_ops_scene_state_v1";
generated_at: string;
status: string;
source: {
deploy_readback_marker?: string | null;
live_source_connected?: boolean | null;
};
room: {
zones: SceneZone[];
animation_loop: {
enabled: boolean;
tick_ms: number;
motion_model: string;
};
};
agents: SceneAgent[];
work_items: SceneWorkItem[];
rollups: {
zone_count: number;
agent_count: number;
work_item_count: number;
animated_entity_count: number;
completed_work_item_count: number;
blocked_work_item_count: number;
source_stage_count: number;
recorded_stage_count: number;
};
boundaries: Record<string, boolean>;
};
const API_BASE = getRuntimeApiBaseUrl();
const zoneIconByKind: Record<string, typeof Activity> = {
control_plane: BrainCircuit,
tool_context: Wrench,
knowledge_retrieval: SearchCheck,
procedure_trust: ShieldCheck,
post_apply_verification: CheckCircle2,
receipt_delivery: MessageSquareText,
release_truth: Rocket,
};
function stateLabel(t: ReturnType<typeof useTranslations>, state: string): string {
if (state === "working") return t("states.working");
if (state === "verified") return t("states.verified");
if (state === "blocked") return t("states.blocked");
if (state === "waiting") return t("states.waiting");
return t("states.idle");
}
function stateClass(state: string): string {
if (state === "working") return "border-[#4a90d9] bg-[#eef5ff] text-[#1f5b9b]";
if (state === "verified") return "border-[#8fc29a] bg-[#f0faf2] text-[#17602a]";
if (state === "blocked") return "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]";
if (state === "waiting") return "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]";
return "border-[#d8d3c7] bg-white text-[#5f5b52]";
}
function stateFromWorkItemStatus(status: string): string {
if (status === "completed") return "verified";
if (status === "in_progress") return "working";
if (status === "blocked") return "blocked";
return "waiting";
}
function shortValue(value?: string | null): string {
if (!value) return "--";
return value.length > 18 ? `${value.slice(0, 14)}...` : value;
}
function formatNumber(value: number | null | undefined): string {
return new Intl.NumberFormat("zh-TW").format(Number(value ?? 0));
}
export default function OpenClawLiveOpsSpacePage({
params,
}: {
params: { locale: string };
}) {
const t = useTranslations("openclaw.liveOpsSpace");
const locale = useLocale();
const [scene, setScene] = useState<SceneState | null>(null);
const [status, setStatus] = useState<SceneStatus>("loading");
const [updatedAt, setUpdatedAt] = useState<Date | null>(null);
const fetchSceneState = useCallback(async () => {
if (!API_BASE) {
setStatus("degraded");
return;
}
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), 12_000);
try {
const response = await fetch(
`${API_BASE}/api/v1/agents/openclaw-live-ops-scene-state`,
{ cache: "no-store", signal: controller.signal },
);
if (!response.ok) {
setStatus("degraded");
return;
}
const payload = (await response.json()) as SceneState;
setScene(payload);
setUpdatedAt(new Date());
setStatus("ready");
} catch {
setStatus("degraded");
} finally {
window.clearTimeout(timeout);
}
}, []);
useEffect(() => {
void fetchSceneState();
const timer = window.setInterval(fetchSceneState, 15_000);
return () => window.clearInterval(timer);
}, [fetchSceneState]);
const zones = scene?.room.zones ?? [];
const agents = scene?.agents ?? [];
const workItems = scene?.work_items ?? [];
const boundaryOk = useMemo(() => {
if (!scene) return false;
return [
"raw_session_read_allowed",
"sqlite_read_allowed",
"secret_value_display_allowed",
"runtime_action_performed",
"host_or_k8s_write_performed",
].every((key) => scene.boundaries[key] === false);
}, [scene]);
return (
<AppLayout locale={params.locale} fullBleed>
<main className="min-h-screen overflow-x-hidden bg-[#f7f8f7] text-[#141413]">
<div className="mx-auto flex w-full max-w-[1520px] flex-col gap-4 px-3 py-4 lg:px-5">
<section className="grid min-w-0 items-start gap-4 lg:grid-cols-[minmax(0,1fr)_360px]">
<div className="min-w-0 border border-[#d8d3c7] bg-white">
<div className="flex min-w-0 flex-wrap items-center justify-between gap-3 border-b border-[#e5e1d8] px-4 py-3">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase text-[#77736a]">
OpenClaw
</p>
<h1 className="mt-1 break-words text-2xl font-semibold leading-tight text-[#141413] sm:text-3xl">
{t("title")}
</h1>
</div>
<div className="flex items-center gap-2">
<span
className={cn(
"inline-flex items-center gap-2 border px-3 py-1.5 text-xs font-semibold",
status === "ready"
? "border-[#8fc29a] bg-[#f0faf2] text-[#17602a]"
: "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]",
)}
>
{status === "ready" ? (
<CheckCircle2 className="h-4 w-4" aria-hidden="true" />
) : (
<AlertTriangle className="h-4 w-4" aria-hidden="true" />
)}
{status === "ready"
? t("status.ready")
: status === "loading"
? t("status.loading")
: t("status.degraded")}
</span>
<button
type="button"
onClick={() => void fetchSceneState()}
className="inline-flex h-9 w-9 items-center justify-center border border-[#d8d3c7] bg-white text-[#4f4b44] transition hover:border-[#4a90d9] hover:text-[#1f5b9b]"
title={t("actions.refresh")}
aria-label={t("actions.refresh")}
>
<RefreshCw className="h-4 w-4" aria-hidden="true" />
</button>
</div>
</div>
<div className="relative aspect-[16/10] min-h-[360px] overflow-hidden bg-[#eef3f8] sm:min-h-[500px]">
<div className="absolute inset-x-[5%] bottom-[7%] top-[8%] rotate-[-2deg] skew-y-[-7deg] border border-[#c8d6df] bg-[#fdfefe] shadow-[0_28px_70px_rgba(54,72,88,0.16)]" />
<div className="absolute inset-x-[7%] bottom-[11%] top-[12%] rotate-[-2deg] skew-y-[-7deg] bg-[linear-gradient(90deg,rgba(74,144,217,0.12)_1px,transparent_1px),linear-gradient(0deg,rgba(74,144,217,0.1)_1px,transparent_1px)] bg-[size:42px_42px]" />
{zones.map((zone) => {
const Icon = zoneIconByKind[zone.kind] ?? Activity;
return (
<div
key={zone.zone_id}
className="absolute flex h-14 w-28 -translate-x-1/2 -translate-y-1/2 rotate-[-2deg] items-center gap-2 border border-[#cbd4d8] bg-white/90 px-2 shadow-[0_10px_24px_rgba(54,72,88,0.12)] backdrop-blur"
style={{ left: `${zone.position.x}%`, top: `${zone.position.y}%` }}
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center border border-[#d8d3c7] bg-[#f7f8f7] text-[#4a90d9]">
<Icon className="h-4 w-4" aria-hidden="true" />
</span>
<span className="min-w-0 truncate text-[11px] font-semibold text-[#34302a]">
{zone.label}
</span>
</div>
);
})}
{workItems.map((item, index) => (
<div
key={item.work_item_id}
className={cn(
"openclaw-work-token absolute h-8 w-20 -translate-x-1/2 -translate-y-1/2 border px-2 py-1 text-[10px] font-semibold shadow-[0_8px_20px_rgba(54,72,88,0.16)]",
stateClass(stateFromWorkItemStatus(item.status)),
)}
style={{
left: `${item.position.x}%`,
top: `${item.position.y}%`,
animationDelay: `${index * 0.35}s`,
}}
>
<span className="block truncate">{item.work_item_id}</span>
</div>
))}
{agents.map((agent, index) => (
<div
key={agent.agent_id}
className="openclaw-agent absolute -translate-x-1/2 -translate-y-1/2"
style={{
left: `${agent.position.x}%`,
top: `${agent.position.y}%`,
animationDelay: `${index * 0.28}s`,
}}
>
<div
className={cn(
"flex h-14 w-14 items-center justify-center border-2 bg-white shadow-[0_16px_30px_rgba(54,72,88,0.2)]",
stateClass(agent.state),
)}
>
<Bot className="h-6 w-6" aria-hidden="true" />
</div>
<div className="mt-1 max-w-28 truncate border border-[#d8d3c7] bg-white px-1.5 py-0.5 text-center text-[10px] font-semibold text-[#34302a] shadow-sm">
{agent.label}
</div>
</div>
))}
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(145deg,rgba(255,255,255,0.18),transparent_45%,rgba(20,20,19,0.04))]" />
<div className="absolute bottom-3 left-3 flex flex-wrap gap-2">
<span className="border border-[#d8d3c7] bg-white/90 px-2 py-1 font-mono text-[11px] text-[#5f5b52]">
{t("source.marker")} {shortValue(scene?.source.deploy_readback_marker)}
</span>
<span className="border border-[#d8d3c7] bg-white/90 px-2 py-1 font-mono text-[11px] text-[#5f5b52]">
{t("source.updated")}{" "}
{updatedAt
? updatedAt.toLocaleTimeString(locale, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})
: "--"}
</span>
</div>
</div>
</div>
<aside className="grid min-w-0 gap-3">
<section className="border border-[#d8d3c7] bg-white p-4">
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-semibold text-[#141413]">
{t("panels.rollups")}
</h2>
<Activity className="h-4 w-4 text-[#4a90d9]" aria-hidden="true" />
</div>
<div className="mt-4 grid grid-cols-2 gap-2">
{[
["agents", scene?.rollups.agent_count],
["workItems", scene?.rollups.work_item_count],
["animated", scene?.rollups.animated_entity_count],
["blocked", scene?.rollups.blocked_work_item_count],
].map(([key, value]) => (
<div key={String(key)} className="border border-[#e5e1d8] bg-[#fafafa] p-3">
<p className="text-[11px] font-semibold text-[#77736a]">
{t(`metrics.${key}`)}
</p>
<p className="mt-1 font-mono text-2xl font-semibold text-[#141413]">
{formatNumber(Number(value ?? 0))}
</p>
</div>
))}
</div>
</section>
<section className="border border-[#d8d3c7] bg-white p-4">
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-semibold text-[#141413]">
{t("panels.boundaries")}
</h2>
<ShieldCheck
className={cn("h-4 w-4", boundaryOk ? "text-[#17602a]" : "text-[#8a5a08]")}
aria-hidden="true"
/>
</div>
<div className="mt-3 grid gap-2">
{[
"raw_session_read_allowed",
"sqlite_read_allowed",
"secret_value_display_allowed",
"runtime_action_performed",
"host_or_k8s_write_performed",
].map((key) => (
<div
key={key}
className="flex min-w-0 items-center justify-between gap-3 border border-[#e5e1d8] px-3 py-2"
>
<span className="truncate font-mono text-[11px] text-[#5f5b52]">
{key}
</span>
<span
className={cn(
"border px-2 py-0.5 text-[10px] font-semibold",
scene?.boundaries[key] === false
? "border-[#8fc29a] bg-[#f0faf2] text-[#17602a]"
: "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]",
)}
>
{scene?.boundaries[key] === false ? t("boundary.closed") : t("boundary.open")}
</span>
</div>
))}
</div>
</section>
<section className="min-h-[260px] border border-[#d8d3c7] bg-white p-4">
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-semibold text-[#141413]">
{t("panels.workItems")}
</h2>
<SearchCheck className="h-4 w-4 text-[#4a90d9]" aria-hidden="true" />
</div>
<div className="mt-3 grid gap-2">
{workItems.length === 0 ? (
<div className="border border-[#e5e1d8] bg-[#fafafa] p-4 text-sm text-[#77736a]">
{t("empty")}
</div>
) : (
workItems.slice(0, 6).map((item) => (
<div key={item.work_item_id} className="border border-[#e5e1d8] p-3">
<div className="flex min-w-0 items-center justify-between gap-2">
<span className="min-w-0 truncate text-sm font-semibold text-[#141413]">
{item.title || item.work_item_id}
</span>
<span
className={cn(
"shrink-0 border px-2 py-0.5 text-[10px] font-semibold",
stateClass(stateFromWorkItemStatus(item.status)),
)}
>
{stateLabel(t, stateFromWorkItemStatus(item.status))}
</span>
</div>
<p className="mt-2 truncate font-mono text-[11px] text-[#77736a]">
{item.next_controlled_action || item.exit_criteria || "--"}
</p>
</div>
))
)}
</div>
</section>
</aside>
</section>
</div>
</main>
<style jsx global>{`
@keyframes openclaw-agent-drift {
0%,
100% {
transform: translate(-50%, -50%) translate3d(0, 0, 0);
}
50% {
transform: translate(-50%, -50%) translate3d(5px, -7px, 0);
}
}
@keyframes openclaw-work-token-flow {
0%,
100% {
transform: translate(-50%, -50%) translate3d(0, 0, 0);
}
50% {
transform: translate(-50%, -50%) translate3d(9px, 4px, 0);
}
}
.openclaw-agent {
animation: openclaw-agent-drift 4.8s ease-in-out infinite;
will-change: transform;
}
.openclaw-work-token {
animation: openclaw-work-token-flow 6.2s ease-in-out infinite;
will-change: transform;
}
@media (prefers-reduced-motion: reduce) {
.openclaw-agent,
.openclaw-work-token {
animation: none;
}
}
`}</style>
</AppLayout>
);
}