agmon 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +81 -2
- package/bin/cli.js +366 -0
- package/jsconfig.json +8 -0
- package/next.config.mjs +6 -0
- package/package.json +50 -4
- package/postcss.config.mjs +5 -0
- package/public/favicon.svg +48 -0
- package/public/icons/icon.svg +48 -0
- package/public/manifest.json +23 -0
- package/public/sw.js +42 -0
- package/src/app/api/traces/stream/route.js +89 -0
- package/src/app/api/version/route.js +23 -0
- package/src/app/globals.css +15 -0
- package/src/app/layout.js +42 -0
- package/src/app/page.js +174 -0
- package/src/components/PwaRegister.js +31 -0
- package/src/components/UpdateNotification.js +79 -0
- package/src/components/factory/AgentPanel.js +92 -0
- package/src/components/factory/office-canvas.js +95 -0
- package/src/components/factory/office-scene.js +519 -0
- package/src/components/factory/scene/characters.js +335 -0
- package/src/components/factory/scene/office-layout.js +188 -0
- package/src/components/factory/scene/token-streams.js +85 -0
- package/src/components/factory/scene/tool-icons.js +233 -0
- package/src/lib/mockTraces.js +171 -0
- package/src/lib/traceContract.js +46 -0
- package/src/lib/useFactoryTraces.js +142 -0
- package/src/lib/watchers/antigravityWatcher.js +180 -0
- package/src/lib/watchers/claudeWatcher.js +190 -0
- package/src/lib/watchers/watcherManager.js +41 -0
package/public/sw.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const CACHE_NAME = "agent-factory-v1";
|
|
2
|
+
const STATIC_ASSETS = ["/manifest.json", "/favicon.svg", "/icons/icon.svg"];
|
|
3
|
+
|
|
4
|
+
self.addEventListener("install", (event) => {
|
|
5
|
+
event.waitUntil(
|
|
6
|
+
caches.open(CACHE_NAME).then((cache) => {
|
|
7
|
+
return cache.addAll(STATIC_ASSETS).catch(() => {});
|
|
8
|
+
})
|
|
9
|
+
);
|
|
10
|
+
self.skipWaiting();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
self.addEventListener("activate", (event) => {
|
|
14
|
+
event.waitUntil(
|
|
15
|
+
caches.keys().then((keys) => {
|
|
16
|
+
return Promise.all(
|
|
17
|
+
keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))
|
|
18
|
+
);
|
|
19
|
+
})
|
|
20
|
+
);
|
|
21
|
+
self.clients.claim();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
self.addEventListener("fetch", (event) => {
|
|
25
|
+
const url = event.request.url;
|
|
26
|
+
|
|
27
|
+
// NEVER intercept Next.js chunks, HMR, or API SSE streams!
|
|
28
|
+
if (
|
|
29
|
+
url.includes("/_next/") ||
|
|
30
|
+
url.includes("/api/") ||
|
|
31
|
+
event.request.method !== "GET"
|
|
32
|
+
) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Network-first for HTML pages so user always sees the latest UI
|
|
37
|
+
event.respondWith(
|
|
38
|
+
fetch(event.request).catch(() => {
|
|
39
|
+
return caches.match(event.request);
|
|
40
|
+
})
|
|
41
|
+
);
|
|
42
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { getAllLiveTraces, getProvidersFromTraces } from "@/lib/watchers/watcherManager.js";
|
|
2
|
+
import { generateMockTraces, mockProviderDescriptors, MOCK_PRESETS } from "@/lib/mockTraces.js";
|
|
3
|
+
|
|
4
|
+
export const dynamic = "force-dynamic";
|
|
5
|
+
|
|
6
|
+
export async function GET(request) {
|
|
7
|
+
const { searchParams } = new URL(request.url);
|
|
8
|
+
const source = searchParams.get("source") || "live";
|
|
9
|
+
const mockPreset = searchParams.get("mockPreset") || searchParams.get("preset");
|
|
10
|
+
|
|
11
|
+
const encoder = new TextEncoder();
|
|
12
|
+
|
|
13
|
+
const stream = new ReadableStream({
|
|
14
|
+
async start(controller) {
|
|
15
|
+
let isAlive = true;
|
|
16
|
+
|
|
17
|
+
const send = (data) => {
|
|
18
|
+
if (!isAlive) return;
|
|
19
|
+
try {
|
|
20
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
|
|
21
|
+
} catch {
|
|
22
|
+
isAlive = false;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const pushUpdate = async () => {
|
|
27
|
+
if (!isAlive) return;
|
|
28
|
+
|
|
29
|
+
if (source === "mock" || mockPreset) {
|
|
30
|
+
const presetConfig = MOCK_PRESETS[mockPreset] || MOCK_PRESETS.cases;
|
|
31
|
+
const traces = generateMockTraces({
|
|
32
|
+
count: presetConfig.agents,
|
|
33
|
+
errorRatio: presetConfig.errorRatio,
|
|
34
|
+
cases: !!presetConfig.cases,
|
|
35
|
+
seed: 42,
|
|
36
|
+
});
|
|
37
|
+
const providers = mockProviderDescriptors(traces);
|
|
38
|
+
send({ traces, providers, timestamp: Date.now(), mode: "mock", preset: mockPreset || "cases" });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Live Mode
|
|
43
|
+
try {
|
|
44
|
+
const liveTraces = await getAllLiveTraces();
|
|
45
|
+
const providers = getProvidersFromTraces(liveTraces);
|
|
46
|
+
send({
|
|
47
|
+
traces: liveTraces,
|
|
48
|
+
providers,
|
|
49
|
+
timestamp: Date.now(),
|
|
50
|
+
mode: "live",
|
|
51
|
+
activeAgents: liveTraces.filter((t) => t.state === "streaming" || t.state === "pending").length,
|
|
52
|
+
});
|
|
53
|
+
} catch (err) {
|
|
54
|
+
console.error("[SSE] Failed to fetch live traces:", err);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// Initial push
|
|
59
|
+
await pushUpdate();
|
|
60
|
+
|
|
61
|
+
// Poll interval: 1.5 seconds for fresh log watcher updates
|
|
62
|
+
const interval = setInterval(async () => {
|
|
63
|
+
if (!isAlive) {
|
|
64
|
+
clearInterval(interval);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
await pushUpdate();
|
|
68
|
+
}, 1500);
|
|
69
|
+
|
|
70
|
+
request.signal.addEventListener("abort", () => {
|
|
71
|
+
isAlive = false;
|
|
72
|
+
clearInterval(interval);
|
|
73
|
+
try {
|
|
74
|
+
controller.close();
|
|
75
|
+
} catch {
|
|
76
|
+
// ignore
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return new Response(stream, {
|
|
83
|
+
headers: {
|
|
84
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
85
|
+
"Cache-Control": "no-cache, no-transform",
|
|
86
|
+
Connection: "keep-alive",
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
export const dynamic = "force-dynamic";
|
|
6
|
+
|
|
7
|
+
export async function GET() {
|
|
8
|
+
try {
|
|
9
|
+
const pkgPath = path.resolve(process.cwd(), "package.json");
|
|
10
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
11
|
+
return NextResponse.json({
|
|
12
|
+
version: pkg.version || "0.1.0",
|
|
13
|
+
uptime: Math.round(process.uptime()),
|
|
14
|
+
timestamp: Date.now(),
|
|
15
|
+
});
|
|
16
|
+
} catch {
|
|
17
|
+
return NextResponse.json({
|
|
18
|
+
version: "0.1.0",
|
|
19
|
+
uptime: Math.round(process.uptime()),
|
|
20
|
+
timestamp: Date.now(),
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
|
|
3
|
+
:root {
|
|
4
|
+
--background: #0b0f17;
|
|
5
|
+
--foreground: #f1f5f9;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
body {
|
|
9
|
+
background: var(--background);
|
|
10
|
+
color: var(--foreground);
|
|
11
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
12
|
+
overflow: hidden;
|
|
13
|
+
margin: 0;
|
|
14
|
+
padding: 0;
|
|
15
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import "./globals.css";
|
|
2
|
+
import PwaRegister from "@/components/PwaRegister";
|
|
3
|
+
|
|
4
|
+
export const metadata = {
|
|
5
|
+
title: "AGMon - AI Agent Office & Activity Visualizer",
|
|
6
|
+
description: "Realtime activity and token flow visualizer for Antigravity, Claude Code & AI CLIs",
|
|
7
|
+
manifest: "/manifest.json",
|
|
8
|
+
icons: {
|
|
9
|
+
icon: "/favicon.svg",
|
|
10
|
+
apple: "/icons/icon.svg",
|
|
11
|
+
},
|
|
12
|
+
appleWebApp: {
|
|
13
|
+
capable: true,
|
|
14
|
+
statusBarStyle: "black-translucent",
|
|
15
|
+
title: "AGMon",
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const viewport = {
|
|
20
|
+
themeColor: "#070b12",
|
|
21
|
+
width: "device-width",
|
|
22
|
+
initialScale: 1,
|
|
23
|
+
maximumScale: 1,
|
|
24
|
+
userScalable: false,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export default function RootLayout({ children }) {
|
|
28
|
+
return (
|
|
29
|
+
<html lang="en" className="dark">
|
|
30
|
+
<head>
|
|
31
|
+
<link rel="manifest" href="/manifest.json" />
|
|
32
|
+
<link rel="apple-touch-icon" href="/icons/icon.svg" />
|
|
33
|
+
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
34
|
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
|
35
|
+
</head>
|
|
36
|
+
<body className="antialiased bg-[#070b12] text-slate-100 select-none">
|
|
37
|
+
<PwaRegister />
|
|
38
|
+
{children}
|
|
39
|
+
</body>
|
|
40
|
+
</html>
|
|
41
|
+
);
|
|
42
|
+
}
|
package/src/app/page.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import dynamic from "next/dynamic";
|
|
4
|
+
import { useMemo, useState } from "react";
|
|
5
|
+
import { useFactoryTraces } from "@/lib/useFactoryTraces";
|
|
6
|
+
import { buildOffice } from "@/components/factory/scene/office-layout";
|
|
7
|
+
import AgentPanel from "@/components/factory/AgentPanel";
|
|
8
|
+
import UpdateNotification from "@/components/UpdateNotification";
|
|
9
|
+
|
|
10
|
+
// Dynamic import of PixiJS Canvas to avoid WebGL execution during SSR
|
|
11
|
+
const OfficeCanvas = dynamic(() => import("@/components/factory/office-canvas"), {
|
|
12
|
+
ssr: false,
|
|
13
|
+
loading: () => (
|
|
14
|
+
<div className="flex h-full w-full items-center justify-center bg-[#080d14] text-slate-500">
|
|
15
|
+
<div className="flex flex-col items-center gap-2">
|
|
16
|
+
<div className="h-8 w-8 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
|
|
17
|
+
<span className="text-xs font-mono">Initializing Agent Factory Scene...</span>
|
|
18
|
+
</div>
|
|
19
|
+
</div>
|
|
20
|
+
),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const PRESETS = [
|
|
24
|
+
{ id: "cases", label: "Edge Cases" },
|
|
25
|
+
{ id: "storm", label: "Storm (30 Agents)" },
|
|
26
|
+
{ id: "busy", label: "Busy (8 Agents)" },
|
|
27
|
+
{ id: "idle", label: "Idle (2 Agents)" },
|
|
28
|
+
{ id: "errors", label: "Error States" },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
export default function FactoryPage() {
|
|
32
|
+
const [mode, setMode] = useState("live");
|
|
33
|
+
const [mockPreset, setMockPreset] = useState("cases");
|
|
34
|
+
const [selectedId, setSelectedId] = useState(null);
|
|
35
|
+
|
|
36
|
+
const { traces, connected, live, isRefreshing, refresh } = useFactoryTraces({ mode, preset: mockPreset });
|
|
37
|
+
|
|
38
|
+
const office = useMemo(() => buildOffice(traces), [traces]);
|
|
39
|
+
const selected = useMemo(
|
|
40
|
+
() => office.workstations.find((w) => w.connectionId === selectedId) || null,
|
|
41
|
+
[office, selectedId]
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<main className="flex h-screen w-screen flex-col overflow-hidden bg-[#070b12] text-slate-100">
|
|
46
|
+
<UpdateNotification />
|
|
47
|
+
|
|
48
|
+
{/* Top Header / Nav */}
|
|
49
|
+
<header className="flex h-14 shrink-0 items-center justify-between border-b border-slate-800/80 bg-slate-950/70 px-5 backdrop-blur-md">
|
|
50
|
+
{/* Brand */}
|
|
51
|
+
<div className="flex items-center gap-3">
|
|
52
|
+
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-emerald-500 to-cyan-600 shadow-lg shadow-emerald-500/20">
|
|
53
|
+
<span className="text-lg">🤖</span>
|
|
54
|
+
</div>
|
|
55
|
+
<div>
|
|
56
|
+
<div className="flex items-center gap-2">
|
|
57
|
+
<span className="font-extrabold tracking-wider text-sm bg-gradient-to-r from-emerald-400 via-cyan-300 to-blue-400 bg-clip-text text-transparent">
|
|
58
|
+
AGMON
|
|
59
|
+
</span>
|
|
60
|
+
<span className="rounded-full bg-slate-800 px-2 py-0.5 text-[10px] font-semibold text-slate-400 uppercase tracking-wider">
|
|
61
|
+
v0.1.0
|
|
62
|
+
</span>
|
|
63
|
+
</div>
|
|
64
|
+
<p className="text-[11px] text-slate-400">AI Agent Office & Activity Visualizer</p>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
{/* Live Counters */}
|
|
69
|
+
<div className="flex items-center gap-2">
|
|
70
|
+
<div className="flex items-center gap-2 rounded-lg border border-slate-800 bg-slate-900/60 px-3 py-1 text-xs">
|
|
71
|
+
<span className="text-slate-400">Desks:</span>
|
|
72
|
+
<span className="font-bold text-slate-200">{office.stats.agents}</span>
|
|
73
|
+
</div>
|
|
74
|
+
<div className="flex items-center gap-2 rounded-lg border border-slate-800 bg-slate-900/60 px-3 py-1 text-xs">
|
|
75
|
+
<span className="text-slate-400">Working:</span>
|
|
76
|
+
<span className="font-bold text-cyan-400">{office.stats.busy}</span>
|
|
77
|
+
</div>
|
|
78
|
+
<div className="flex items-center gap-2 rounded-lg border border-slate-800 bg-slate-900/60 px-3 py-1 text-xs">
|
|
79
|
+
<span className="text-slate-400">Tokens:</span>
|
|
80
|
+
<span className="font-bold text-emerald-400">{office.stats.tokensLabel}</span>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
{/* Mode & Preset Controls */}
|
|
85
|
+
<div className="flex items-center gap-2">
|
|
86
|
+
{/* Force Refresh Button */}
|
|
87
|
+
<button
|
|
88
|
+
onClick={refresh}
|
|
89
|
+
title="Làm mới kết nối & dữ liệu ngay lập tức"
|
|
90
|
+
className="flex h-8 w-8 items-center justify-center rounded-lg border border-slate-800 bg-slate-900/60 text-slate-400 hover:border-slate-700 hover:text-slate-200 transition-all"
|
|
91
|
+
>
|
|
92
|
+
<svg
|
|
93
|
+
className={`h-4 w-4 ${isRefreshing ? "animate-spin text-emerald-400" : ""}`}
|
|
94
|
+
fill="none"
|
|
95
|
+
stroke="currentColor"
|
|
96
|
+
viewBox="0 0 24 24"
|
|
97
|
+
>
|
|
98
|
+
<path
|
|
99
|
+
strokeLinecap="round"
|
|
100
|
+
strokeLinejoin="round"
|
|
101
|
+
strokeWidth="2"
|
|
102
|
+
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
|
103
|
+
/>
|
|
104
|
+
</svg>
|
|
105
|
+
</button>
|
|
106
|
+
|
|
107
|
+
{/* Live Button */}
|
|
108
|
+
<button
|
|
109
|
+
onClick={() => {
|
|
110
|
+
setMode("live");
|
|
111
|
+
setSelectedId(null);
|
|
112
|
+
}}
|
|
113
|
+
className={`flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-semibold transition-all ${
|
|
114
|
+
mode === "live"
|
|
115
|
+
? "border border-emerald-500 bg-emerald-500/15 text-emerald-300 shadow-sm shadow-emerald-500/20"
|
|
116
|
+
: "border border-slate-800 bg-slate-900/40 text-slate-400 hover:border-slate-700 hover:text-slate-200"
|
|
117
|
+
}`}
|
|
118
|
+
>
|
|
119
|
+
<span
|
|
120
|
+
className={`h-2 w-2 rounded-full ${
|
|
121
|
+
connected && live ? "bg-emerald-400 animate-pulse shadow-sm shadow-emerald-400" : "bg-slate-500"
|
|
122
|
+
}`}
|
|
123
|
+
/>
|
|
124
|
+
Live Watchers
|
|
125
|
+
</button>
|
|
126
|
+
|
|
127
|
+
{/* Preset Selector */}
|
|
128
|
+
<div className="flex items-center rounded-lg border border-slate-800 bg-slate-900/60 p-0.5 text-xs">
|
|
129
|
+
<button
|
|
130
|
+
onClick={() => {
|
|
131
|
+
setMode("mock");
|
|
132
|
+
setSelectedId(null);
|
|
133
|
+
}}
|
|
134
|
+
className={`rounded-md px-2.5 py-1 transition-all ${
|
|
135
|
+
mode === "mock"
|
|
136
|
+
? "bg-slate-800 font-medium text-slate-200"
|
|
137
|
+
: "text-slate-400 hover:text-slate-300"
|
|
138
|
+
}`}
|
|
139
|
+
>
|
|
140
|
+
Demo Mock:
|
|
141
|
+
</button>
|
|
142
|
+
<select
|
|
143
|
+
value={mockPreset}
|
|
144
|
+
onChange={(e) => {
|
|
145
|
+
setMode("mock");
|
|
146
|
+
setMockPreset(e.target.value);
|
|
147
|
+
setSelectedId(null);
|
|
148
|
+
}}
|
|
149
|
+
className="bg-transparent px-2 py-1 text-xs text-slate-300 outline-none cursor-pointer"
|
|
150
|
+
>
|
|
151
|
+
{PRESETS.map((p) => (
|
|
152
|
+
<option key={p.id} value={p.id} className="bg-slate-900 text-slate-200">
|
|
153
|
+
{p.label}
|
|
154
|
+
</option>
|
|
155
|
+
))}
|
|
156
|
+
</select>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
</header>
|
|
160
|
+
|
|
161
|
+
{/* Main Canvas Area */}
|
|
162
|
+
<section className="relative flex-1 p-3">
|
|
163
|
+
<OfficeCanvas
|
|
164
|
+
traces={traces}
|
|
165
|
+
selectedId={selectedId}
|
|
166
|
+
onSelect={(id) => setSelectedId(id)}
|
|
167
|
+
/>
|
|
168
|
+
|
|
169
|
+
{/* Selected Agent Inspector */}
|
|
170
|
+
<AgentPanel ws={selected} onClose={() => setSelectedId(null)} />
|
|
171
|
+
</section>
|
|
172
|
+
</main>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect } from "react";
|
|
4
|
+
|
|
5
|
+
export default function PwaRegister() {
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
if (typeof window === "undefined" || !("serviceWorker" in navigator)) return;
|
|
8
|
+
|
|
9
|
+
// In development mode, unregister any active service worker so it never interferes with Turbopack/HMR
|
|
10
|
+
if (process.env.NODE_ENV !== "production") {
|
|
11
|
+
navigator.serviceWorker.getRegistrations().then((registrations) => {
|
|
12
|
+
for (const registration of registrations) {
|
|
13
|
+
registration.unregister();
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// In production, register the service worker for PWA
|
|
20
|
+
navigator.serviceWorker
|
|
21
|
+
.register("/sw.js")
|
|
22
|
+
.then((reg) => {
|
|
23
|
+
console.log("[PWA] Service Worker registered:", reg.scope);
|
|
24
|
+
})
|
|
25
|
+
.catch((err) => {
|
|
26
|
+
console.warn("[PWA] Service Worker registration failed:", err);
|
|
27
|
+
});
|
|
28
|
+
}, []);
|
|
29
|
+
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
|
|
5
|
+
export default function UpdateNotification() {
|
|
6
|
+
const [updateAvailable, setUpdateAvailable] = useState(false);
|
|
7
|
+
const [newVersion, setNewVersion] = useState(null);
|
|
8
|
+
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
// 1. Service Worker update listener (PWA)
|
|
11
|
+
if (typeof window !== "undefined" && "serviceWorker" in navigator) {
|
|
12
|
+
navigator.serviceWorker.ready.then((registration) => {
|
|
13
|
+
registration.addEventListener("updatefound", () => {
|
|
14
|
+
const newWorker = registration.installing;
|
|
15
|
+
if (newWorker) {
|
|
16
|
+
newWorker.addEventListener("statechange", () => {
|
|
17
|
+
if (newWorker.state === "installed" && navigator.serviceWorker.controller) {
|
|
18
|
+
setUpdateAvailable(true);
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 2. Version polling from server API (/api/version)
|
|
27
|
+
let initialVersion = null;
|
|
28
|
+
const checkVersion = async () => {
|
|
29
|
+
try {
|
|
30
|
+
const res = await fetch("/api/version");
|
|
31
|
+
if (res.ok) {
|
|
32
|
+
const data = await res.json();
|
|
33
|
+
if (!initialVersion) {
|
|
34
|
+
initialVersion = data.version;
|
|
35
|
+
} else if (data.version && data.version !== initialVersion) {
|
|
36
|
+
setNewVersion(data.version);
|
|
37
|
+
setUpdateAvailable(true);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
// network error
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
checkVersion();
|
|
46
|
+
const interval = setInterval(checkVersion, 60 * 1000); // Check every 60s
|
|
47
|
+
return () => clearInterval(interval);
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
const handleApplyUpdate = () => {
|
|
51
|
+
if (typeof window !== "undefined") {
|
|
52
|
+
window.location.reload();
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
if (!updateAvailable) return null;
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 rounded-full border border-emerald-500/40 bg-slate-900/95 px-4 py-2 shadow-2xl backdrop-blur-md animate-in slide-in-from-bottom-4 duration-300">
|
|
60
|
+
<span className="flex h-2 w-2 rounded-full bg-emerald-400 animate-ping" />
|
|
61
|
+
<span className="text-xs text-slate-200">
|
|
62
|
+
🚀 Đã có phiên bản mới {newVersion ? `(${newVersion})` : ""}
|
|
63
|
+
</span>
|
|
64
|
+
<button
|
|
65
|
+
onClick={handleApplyUpdate}
|
|
66
|
+
className="rounded-full bg-emerald-500 hover:bg-emerald-400 px-3 py-1 text-xs font-semibold text-slate-950 transition-colors shadow-sm"
|
|
67
|
+
>
|
|
68
|
+
Cập nhật ngay
|
|
69
|
+
</button>
|
|
70
|
+
<button
|
|
71
|
+
onClick={() => setUpdateAvailable(false)}
|
|
72
|
+
className="text-slate-400 hover:text-slate-200 text-xs ml-1"
|
|
73
|
+
aria-label="Dismiss"
|
|
74
|
+
>
|
|
75
|
+
✕
|
|
76
|
+
</button>
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
const MODE_LABELS = {
|
|
4
|
+
streaming: "Working",
|
|
5
|
+
pending: "Queued",
|
|
6
|
+
done: "Done",
|
|
7
|
+
happy: "Done",
|
|
8
|
+
sleeping: "Idle",
|
|
9
|
+
error: "Failed",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function fmtMs(ms) {
|
|
13
|
+
if (!ms) return "0ms";
|
|
14
|
+
return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function InspectorRow({ label, value, color, mono = false }) {
|
|
18
|
+
return (
|
|
19
|
+
<div className="flex items-center justify-between gap-3 py-1 text-[12px] border-b border-slate-800/40 last:border-b-0">
|
|
20
|
+
<span className="text-slate-400 font-normal">{label}</span>
|
|
21
|
+
<span
|
|
22
|
+
className={`truncate font-medium ${mono ? "font-mono" : ""}`}
|
|
23
|
+
style={color ? { color } : undefined}
|
|
24
|
+
>
|
|
25
|
+
{value}
|
|
26
|
+
</span>
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export default function AgentPanel({ ws, onClose }) {
|
|
32
|
+
if (!ws) return null;
|
|
33
|
+
const hex = `#${ws.color ? ws.color.toString(16).padStart(6, "0") : "38bdf8"}`;
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div className="absolute right-4 top-4 z-20 w-80 rounded-xl border border-slate-700/80 bg-slate-900/90 p-4 shadow-2xl backdrop-blur-md transition-all animate-in fade-in slide-in-from-right-2">
|
|
37
|
+
{/* Header */}
|
|
38
|
+
<div className="mb-3 flex items-start justify-between gap-2 border-b border-slate-800 pb-2">
|
|
39
|
+
<div className="min-w-0">
|
|
40
|
+
<div className="flex items-center gap-1.5">
|
|
41
|
+
<span
|
|
42
|
+
className="inline-block h-2 w-2 rounded-full"
|
|
43
|
+
style={{ backgroundColor: hex }}
|
|
44
|
+
/>
|
|
45
|
+
<div className="truncate text-sm font-bold text-slate-100">{ws.account}</div>
|
|
46
|
+
</div>
|
|
47
|
+
<div className="text-[11px] font-semibold uppercase tracking-wider mt-0.5" style={{ color: hex }}>
|
|
48
|
+
{MODE_LABELS[ws.mode] || ws.mode}
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
<button
|
|
52
|
+
onClick={onClose}
|
|
53
|
+
className="rounded-md p-1 text-slate-400 hover:bg-slate-800 hover:text-slate-100 transition-colors"
|
|
54
|
+
aria-label="Close"
|
|
55
|
+
>
|
|
56
|
+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
57
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
|
58
|
+
</svg>
|
|
59
|
+
</button>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
{/* Main Details */}
|
|
63
|
+
<div className="space-y-0.5">
|
|
64
|
+
<InspectorRow label="Agent / Desk" value={ws.connectionId} />
|
|
65
|
+
<InspectorRow label="Provider" value={ws.provider?.toUpperCase()} color="#38bdf8" />
|
|
66
|
+
<InspectorRow label="Model" value={ws.model} />
|
|
67
|
+
<InspectorRow label="Elapsed" value={fmtMs(ws.elapsedMs)} />
|
|
68
|
+
<InspectorRow label="Input Tokens" value={ws.tokens?.input?.toLocaleString() || "0"} mono />
|
|
69
|
+
<InspectorRow label="Output Tokens" value={ws.tokens?.output?.toLocaleString() || "0"} mono />
|
|
70
|
+
<InspectorRow
|
|
71
|
+
label="Cached"
|
|
72
|
+
value={`${ws.tokens?.cached?.toLocaleString() || "0"} (${ws.cachedPct || 0}%)`}
|
|
73
|
+
color="#34d399"
|
|
74
|
+
mono
|
|
75
|
+
/>
|
|
76
|
+
<InspectorRow label="Active Tools" value={ws.tools?.length ? ws.tools.join(", ") : "None"} />
|
|
77
|
+
{ws.currentCommand && (
|
|
78
|
+
<div className="mt-2 rounded-md bg-slate-950/80 p-2 border border-slate-800/80">
|
|
79
|
+
<div className="text-[10px] text-slate-400 uppercase tracking-wider mb-1 font-semibold">Active Command / File</div>
|
|
80
|
+
<div className="font-mono text-[11px] text-emerald-400 truncate">{ws.currentCommand}</div>
|
|
81
|
+
</div>
|
|
82
|
+
)}
|
|
83
|
+
{ws.fallbackFrom && <InspectorRow label="Fallback From" value={ws.fallbackFrom} color="#fbbf24" />}
|
|
84
|
+
{ws.errorReason && <InspectorRow label="Error Cause" value={ws.errorReason} color="#ef4444" />}
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<div className="mt-3 pt-2 border-t border-slate-800 text-[10px] text-slate-500 text-center">
|
|
88
|
+
Live telemetry via Local CLI Log Watcher
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { mountOfficeScene } from "./office-scene";
|
|
5
|
+
|
|
6
|
+
export default function OfficeCanvas({ traces = [], onStats, onSelect, selectedId = null }) {
|
|
7
|
+
const hostRef = useRef(null);
|
|
8
|
+
const sceneRef = useRef(null);
|
|
9
|
+
const latest = useRef({ onSelect, selectedId, traces });
|
|
10
|
+
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
latest.current = { onSelect, selectedId, traces };
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const [error, setError] = useState(null);
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
let disposed = false;
|
|
19
|
+
let handle = null;
|
|
20
|
+
const host = hostRef.current;
|
|
21
|
+
if (!host) return undefined;
|
|
22
|
+
|
|
23
|
+
const canvas = document.createElement("canvas");
|
|
24
|
+
canvas.style.position = "absolute";
|
|
25
|
+
canvas.style.inset = "0";
|
|
26
|
+
canvas.style.width = "100%";
|
|
27
|
+
canvas.style.height = "100%";
|
|
28
|
+
canvas.style.display = "block";
|
|
29
|
+
host.appendChild(canvas);
|
|
30
|
+
|
|
31
|
+
(async () => {
|
|
32
|
+
try {
|
|
33
|
+
const mounted = await mountOfficeScene(canvas, latest.current.traces, {
|
|
34
|
+
selectedId: latest.current.selectedId,
|
|
35
|
+
onSelect: (id) => latest.current.onSelect?.(id),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
if (disposed) {
|
|
39
|
+
mounted.destroy?.();
|
|
40
|
+
canvas.remove();
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
handle = mounted;
|
|
45
|
+
sceneRef.current = handle;
|
|
46
|
+
|
|
47
|
+
// Immediately reconcile in case traces arrived during async init
|
|
48
|
+
if (latest.current.traces && latest.current.traces.length > 0) {
|
|
49
|
+
handle.rebuild?.(latest.current.traces);
|
|
50
|
+
}
|
|
51
|
+
} catch (err) {
|
|
52
|
+
console.error("[OfficeCanvas] mount failed:", err);
|
|
53
|
+
setError(err?.message || "Failed to start the scene");
|
|
54
|
+
}
|
|
55
|
+
})();
|
|
56
|
+
|
|
57
|
+
return () => {
|
|
58
|
+
disposed = true;
|
|
59
|
+
handle?.destroy?.();
|
|
60
|
+
sceneRef.current = null;
|
|
61
|
+
host?.replaceChildren();
|
|
62
|
+
};
|
|
63
|
+
}, []);
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (sceneRef.current) {
|
|
67
|
+
sceneRef.current.rebuild?.(traces);
|
|
68
|
+
}
|
|
69
|
+
}, [traces]);
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (sceneRef.current) {
|
|
73
|
+
sceneRef.current.setSelected?.(selectedId);
|
|
74
|
+
}
|
|
75
|
+
}, [selectedId]);
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (!onStats) return undefined;
|
|
79
|
+
const id = setInterval(() => {
|
|
80
|
+
onStats?.({ mounted: Boolean(sceneRef.current) });
|
|
81
|
+
}, 2000);
|
|
82
|
+
return () => clearInterval(id);
|
|
83
|
+
}, [onStats]);
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<div className="relative h-full w-full overflow-hidden rounded-xl border border-slate-800 bg-[#080d14] shadow-2xl">
|
|
87
|
+
<div ref={hostRef} className="absolute inset-0" />
|
|
88
|
+
{error && (
|
|
89
|
+
<div className="absolute inset-0 flex items-center justify-center p-6 text-center text-sm text-rose-400">
|
|
90
|
+
{error}
|
|
91
|
+
</div>
|
|
92
|
+
)}
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|