@multiclawcli/dashboard 0.0.0-stage → 0.2.0
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/LICENSE +21 -0
- package/README.md +15 -2
- package/components.json +16 -0
- package/index.html +12 -0
- package/package.json +50 -3
- package/postcss.config.js +3 -0
- package/src/client/App.tsx +96 -0
- package/src/client/components/AgentCard.tsx +60 -0
- package/src/client/components/LogPanel.tsx +43 -0
- package/src/client/components/PipelineBar.tsx +234 -0
- package/src/client/components/ui/badge.tsx +28 -0
- package/src/client/components/ui/button.tsx +43 -0
- package/src/client/components/ui/card.tsx +22 -0
- package/src/client/components/ui/scroll-area.tsx +43 -0
- package/src/client/components/ui/separator.tsx +23 -0
- package/src/client/hooks/useWebSocket.ts +33 -0
- package/src/client/index.css +31 -0
- package/src/client/lib/utils.ts +6 -0
- package/src/client/main.tsx +8 -0
- package/src/client/store/useOrchestrationStore.ts +132 -0
- package/src/server/DashboardServer.ts +56 -0
- package/src/server/WebSocketHub.ts +35 -0
- package/src/server/index.ts +2 -0
- package/tailwind.config.js +50 -0
- package/tsconfig.json +12 -0
- package/tsconfig.server.json +9 -0
- package/vite.config.ts +25 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 PengQi Shi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @multiclawcli/dashboard
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@multiclawcli/dashboard)
|
|
4
|
+
[](https://github.com/shipengqi/multiclaw)
|
|
5
|
+
|
|
6
|
+
Real-time dashboard for [multiclaw](https://github.com/shipengqi/multiclaw) — the multi-agent orchestration CLI.
|
|
7
|
+
|
|
8
|
+
This package provides the web UI and WebSocket server used internally by the `multiclaw` CLI. It is not intended for direct use; install [`multiclaw`](https://www.npmjs.com/package/multiclaw) instead.
|
|
9
|
+
|
|
10
|
+
## Documentation
|
|
11
|
+
|
|
12
|
+
Full documentation is available in the [main repository](https://github.com/shipengqi/multiclaw).
|
|
13
|
+
|
|
14
|
+
## License
|
|
15
|
+
|
|
16
|
+
[MIT](https://github.com/shipengqi/multiclaw/blob/main/LICENSE)
|
package/components.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema.json",
|
|
3
|
+
"style": "default",
|
|
4
|
+
"rsc": false,
|
|
5
|
+
"tsx": true,
|
|
6
|
+
"tailwind": {
|
|
7
|
+
"config": "tailwind.config.js",
|
|
8
|
+
"css": "src/client/index.css",
|
|
9
|
+
"baseColor": "slate",
|
|
10
|
+
"cssVariables": true
|
|
11
|
+
},
|
|
12
|
+
"aliases": {
|
|
13
|
+
"components": "@/components",
|
|
14
|
+
"utils": "@/lib/utils"
|
|
15
|
+
}
|
|
16
|
+
}
|
package/index.html
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>MultiClaw Dashboard</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/src/client/main.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
package/package.json
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@multiclawcli/dashboard",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
"./server": {
|
|
7
|
+
"types": "./dist/server/index.d.ts",
|
|
8
|
+
"default": "./dist/server/index.js"
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "https://github.com/shipengqi/multiclaw.git",
|
|
14
|
+
"directory": "packages/dashboard"
|
|
15
|
+
},
|
|
16
|
+
"homepage": "https://github.com/shipengqi/multiclaw#readme",
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/shipengqi/multiclaw/issues"
|
|
19
|
+
},
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@multiclawcli/core": "0.2.0",
|
|
25
|
+
"ws": "^8.16.0",
|
|
26
|
+
"zustand": "^4.5.0",
|
|
27
|
+
"react": "^18.2.0",
|
|
28
|
+
"react-dom": "^18.2.0",
|
|
29
|
+
"class-variance-authority": "^0.7.0",
|
|
30
|
+
"clsx": "^2.1.0",
|
|
31
|
+
"tailwind-merge": "^2.2.0",
|
|
32
|
+
"lucide-react": "^0.400.0",
|
|
33
|
+
"@radix-ui/react-scroll-area": "^1.0.5",
|
|
34
|
+
"@radix-ui/react-slot": "^1.0.2",
|
|
35
|
+
"@radix-ui/react-separator": "^1.0.3"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@vitejs/plugin-react": "^4.2.0",
|
|
39
|
+
"vite": "^5.1.0",
|
|
40
|
+
"tailwindcss": "^3.4.0",
|
|
41
|
+
"tailwindcss-animate": "^1.0.7",
|
|
42
|
+
"autoprefixer": "^10.4.0",
|
|
43
|
+
"postcss": "^8.4.0",
|
|
44
|
+
"typescript": "^5.4.0",
|
|
45
|
+
"@types/react": "^18.2.0",
|
|
46
|
+
"@types/react-dom": "^18.2.0",
|
|
47
|
+
"@types/ws": "^8.5.0"
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "vite build && tsc -p tsconfig.server.json",
|
|
51
|
+
"dev:client": "vite"
|
|
52
|
+
}
|
|
6
53
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import React, { useState, useEffect, useRef } from "react"
|
|
2
|
+
import { Badge } from "@/components/ui/badge"
|
|
3
|
+
import { useWebSocket } from "./hooks/useWebSocket"
|
|
4
|
+
import { useOrchestrationStore } from "./store/useOrchestrationStore"
|
|
5
|
+
import { PipelineBar } from "./components/PipelineBar"
|
|
6
|
+
import { LogPanel } from "./components/LogPanel"
|
|
7
|
+
|
|
8
|
+
export function App() {
|
|
9
|
+
const apply = useOrchestrationStore((s) => s.apply)
|
|
10
|
+
const { name, requirement, running, success, totalDuration, stages, agents, preflightAgentIds, preflightBuffer } =
|
|
11
|
+
useOrchestrationStore()
|
|
12
|
+
const [selected, setSelected] = useState<string | null>(null)
|
|
13
|
+
const [elapsed, setElapsed] = useState(0)
|
|
14
|
+
const autoSelectedRef = useRef(false)
|
|
15
|
+
|
|
16
|
+
useWebSocket(apply)
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if (!running) { setElapsed(0); return }
|
|
20
|
+
const start = Date.now()
|
|
21
|
+
const id = setInterval(() => setElapsed(Date.now() - start), 1000)
|
|
22
|
+
return () => clearInterval(id)
|
|
23
|
+
}, [running])
|
|
24
|
+
|
|
25
|
+
// Auto-select leader the moment it appears as running
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (autoSelectedRef.current) return
|
|
28
|
+
for (const [id, agent] of Object.entries(preflightBuffer)) {
|
|
29
|
+
if (agent.status === "running") {
|
|
30
|
+
setSelected(id)
|
|
31
|
+
autoSelectedRef.current = true
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}, [preflightBuffer])
|
|
36
|
+
|
|
37
|
+
// Reset auto-select flag at the start of each new run
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (running) autoSelectedRef.current = false
|
|
40
|
+
}, [running])
|
|
41
|
+
|
|
42
|
+
const leaderIds = preflightAgentIds.length > 0 ? preflightAgentIds : Object.keys(preflightBuffer)
|
|
43
|
+
const leaderAgents = preflightAgentIds.length > 0 ? agents : preflightBuffer
|
|
44
|
+
|
|
45
|
+
const hasPipelineBar = leaderIds.length > 0 || stages.length > 0
|
|
46
|
+
|
|
47
|
+
const statusBadge = running
|
|
48
|
+
? <Badge className="bg-green-500 hover:bg-green-500/80">Running</Badge>
|
|
49
|
+
: success === undefined
|
|
50
|
+
? <Badge variant="secondary">Waiting</Badge>
|
|
51
|
+
: success
|
|
52
|
+
? <Badge className="bg-green-500 hover:bg-green-500/80">Done</Badge>
|
|
53
|
+
: <Badge variant="destructive">Failed</Badge>
|
|
54
|
+
|
|
55
|
+
const displayDuration = totalDuration != null ? totalDuration : running ? elapsed : null
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div className="h-screen flex flex-col bg-background">
|
|
59
|
+
<header className="flex flex-col items-start justify-between px-6 py-3 border-b shrink-0 gap-2">
|
|
60
|
+
<div className="flex w-full items-center justify-between">
|
|
61
|
+
<h1 className="text-base font-semibold">
|
|
62
|
+
MultiClaw{name && <span className="text-muted-foreground font-normal"> · {name}</span>}
|
|
63
|
+
</h1>
|
|
64
|
+
<div className="flex items-center gap-3 text-sm">
|
|
65
|
+
{displayDuration != null && (
|
|
66
|
+
<span className="text-muted-foreground">{(displayDuration / 1000).toFixed(1)}s</span>
|
|
67
|
+
)}
|
|
68
|
+
{statusBadge}
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
{requirement && (
|
|
72
|
+
<p className="text-sm text-muted-foreground line-clamp-2">
|
|
73
|
+
{requirement}
|
|
74
|
+
</p>
|
|
75
|
+
)}
|
|
76
|
+
</header>
|
|
77
|
+
|
|
78
|
+
{hasPipelineBar && (
|
|
79
|
+
<div className="border-b px-4 py-3 shrink-0 overflow-x-auto">
|
|
80
|
+
<PipelineBar
|
|
81
|
+
leaderIds={leaderIds}
|
|
82
|
+
leaderAgents={leaderAgents}
|
|
83
|
+
stages={stages}
|
|
84
|
+
agents={agents}
|
|
85
|
+
onSelect={setSelected}
|
|
86
|
+
selectedId={selected}
|
|
87
|
+
/>
|
|
88
|
+
</div>
|
|
89
|
+
)}
|
|
90
|
+
|
|
91
|
+
<div className="flex-1 overflow-hidden p-4">
|
|
92
|
+
<LogPanel agentId={selected} />
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import { Card, CardContent } from "@/components/ui/card"
|
|
3
|
+
import { Badge } from "@/components/ui/badge"
|
|
4
|
+
import { cn } from "@/lib/utils"
|
|
5
|
+
|
|
6
|
+
type AgentStatus = "pending" | "running" | "retrying" | "success" | "failed" | "skipped"
|
|
7
|
+
|
|
8
|
+
const STATUS_CONFIG: Record<AgentStatus, {
|
|
9
|
+
label: string
|
|
10
|
+
variant: "secondary" | "default" | "destructive" | "outline"
|
|
11
|
+
className?: string
|
|
12
|
+
}> = {
|
|
13
|
+
pending: { label: "pending", variant: "secondary" },
|
|
14
|
+
running: { label: "running", variant: "default", className: "animate-pulse" },
|
|
15
|
+
retrying: { label: "retrying", variant: "default", className: "animate-pulse bg-yellow-500 hover:bg-yellow-500/80" },
|
|
16
|
+
success: { label: "done", variant: "default", className: "bg-green-500 hover:bg-green-500/80" },
|
|
17
|
+
failed: { label: "failed", variant: "destructive" },
|
|
18
|
+
skipped: { label: "skipped", variant: "outline" },
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function AgentCard({ agent, onClick, selected }: {
|
|
22
|
+
agent: { name: string; icon?: string; status: string; duration?: number; taskTitle?: string; model?: string }
|
|
23
|
+
onClick: () => void
|
|
24
|
+
selected?: boolean
|
|
25
|
+
}) {
|
|
26
|
+
const cfg = STATUS_CONFIG[agent.status as AgentStatus] ?? STATUS_CONFIG.pending
|
|
27
|
+
return (
|
|
28
|
+
<Card
|
|
29
|
+
onClick={onClick}
|
|
30
|
+
className={cn("cursor-pointer hover:bg-accent transition-colors", selected && "bg-accent ring-1 ring-ring")}
|
|
31
|
+
>
|
|
32
|
+
<CardContent className="flex flex-col gap-2 p-3">
|
|
33
|
+
<div className="flex items-center justify-between">
|
|
34
|
+
<div className="flex items-center gap-2">
|
|
35
|
+
<span>{agent.icon ?? "-"}</span>
|
|
36
|
+
<div className="flex flex-col gap-0.5">
|
|
37
|
+
<span className="font-medium text-sm">{agent.name}</span>
|
|
38
|
+
{agent.taskTitle && (
|
|
39
|
+
<span className="text-xs text-muted-foreground">{agent.taskTitle}</span>
|
|
40
|
+
)}
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
<Badge variant={cfg.variant} className={cn(cfg.className)}>
|
|
44
|
+
{cfg.label}
|
|
45
|
+
</Badge>
|
|
46
|
+
</div>
|
|
47
|
+
<div className="flex items-center justify-between text-xs">
|
|
48
|
+
{agent.duration != null && (
|
|
49
|
+
<span className="text-muted-foreground">
|
|
50
|
+
{(agent.duration / 1000).toFixed(1)}s
|
|
51
|
+
</span>
|
|
52
|
+
)}
|
|
53
|
+
{agent.model && (
|
|
54
|
+
<span className="text-muted-foreground">{agent.model}</span>
|
|
55
|
+
)}
|
|
56
|
+
</div>
|
|
57
|
+
</CardContent>
|
|
58
|
+
</Card>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import React, { useEffect, useRef } from "react"
|
|
2
|
+
import { useOrchestrationStore } from "../store/useOrchestrationStore"
|
|
3
|
+
|
|
4
|
+
export function LogPanel({ agentId }: { agentId: string | null }) {
|
|
5
|
+
const agents = useOrchestrationStore((s) => s.agents)
|
|
6
|
+
const preflightBuffer = useOrchestrationStore((s) => s.preflightBuffer)
|
|
7
|
+
const scrollRef = useRef<HTMLDivElement>(null)
|
|
8
|
+
const userScrolled = useRef(false)
|
|
9
|
+
const agentState = agentId ? (agents[agentId] ?? preflightBuffer[agentId]) : undefined
|
|
10
|
+
const logs = agentState?.logs ?? ""
|
|
11
|
+
const agentName = agentState?.name ?? null
|
|
12
|
+
|
|
13
|
+
const handleScroll = () => {
|
|
14
|
+
const el = scrollRef.current
|
|
15
|
+
if (!el) return
|
|
16
|
+
userScrolled.current = el.scrollHeight - el.scrollTop - el.clientHeight > 50
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (userScrolled.current) return
|
|
21
|
+
const el = scrollRef.current
|
|
22
|
+
if (el) el.scrollTop = el.scrollHeight
|
|
23
|
+
}, [logs])
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div
|
|
27
|
+
ref={scrollRef}
|
|
28
|
+
onScroll={handleScroll}
|
|
29
|
+
className="h-full overflow-auto rounded-lg border bg-zinc-950 text-zinc-100"
|
|
30
|
+
>
|
|
31
|
+
<div className="p-4 font-mono text-sm">
|
|
32
|
+
{agentId ? (
|
|
33
|
+
<>
|
|
34
|
+
<p className="text-zinc-400 mb-3 text-xs">{agentName} logs</p>
|
|
35
|
+
<pre className="whitespace-pre-wrap leading-relaxed">{logs}</pre>
|
|
36
|
+
</>
|
|
37
|
+
) : (
|
|
38
|
+
<p className="text-zinc-500">Select an agent to view live logs</p>
|
|
39
|
+
)}
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import { cn } from "@/lib/utils"
|
|
3
|
+
import type { StageInfo } from "@multiclawcli/core"
|
|
4
|
+
|
|
5
|
+
type Status = "pending" | "running" | "retrying" | "success" | "failed" | "skipped"
|
|
6
|
+
|
|
7
|
+
const LEADER_BAR: Record<Status, string> = {
|
|
8
|
+
pending: "bg-muted-foreground/20",
|
|
9
|
+
running: "bg-violet-500 animate-pulse",
|
|
10
|
+
retrying: "bg-yellow-500 animate-pulse",
|
|
11
|
+
success: "bg-violet-400",
|
|
12
|
+
failed: "bg-destructive",
|
|
13
|
+
skipped: "bg-muted-foreground/20",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const STAGE_BAR: Record<Status, string> = {
|
|
17
|
+
pending: "bg-muted-foreground/20",
|
|
18
|
+
running: "bg-blue-500 animate-pulse",
|
|
19
|
+
retrying: "bg-yellow-500 animate-pulse",
|
|
20
|
+
success: "bg-green-500",
|
|
21
|
+
failed: "bg-destructive",
|
|
22
|
+
skipped: "bg-muted-foreground/20",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const NAME_COLOR: Record<Status, string> = {
|
|
26
|
+
pending: "text-muted-foreground",
|
|
27
|
+
running: "text-foreground",
|
|
28
|
+
retrying: "text-yellow-600",
|
|
29
|
+
success: "text-foreground",
|
|
30
|
+
failed: "text-destructive",
|
|
31
|
+
skipped: "text-muted-foreground/50",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const STATUS_LABEL: Record<Status, string> = {
|
|
35
|
+
pending: "pending",
|
|
36
|
+
running: "running",
|
|
37
|
+
retrying: "retrying",
|
|
38
|
+
success: "",
|
|
39
|
+
failed: "failed",
|
|
40
|
+
skipped: "skipped",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const STATUS_LABEL_COLOR: Record<Status, string> = {
|
|
44
|
+
pending: "text-muted-foreground/50",
|
|
45
|
+
running: "text-blue-500",
|
|
46
|
+
retrying: "text-yellow-500",
|
|
47
|
+
success: "text-green-500",
|
|
48
|
+
failed: "text-destructive",
|
|
49
|
+
skipped: "text-muted-foreground/40",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const LEADER_LABEL_COLOR: Record<Status, string> = {
|
|
53
|
+
...STATUS_LABEL_COLOR,
|
|
54
|
+
running: "text-violet-500",
|
|
55
|
+
success: "text-violet-400",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function stageAggregate(stage: StageInfo, agents: Record<string, { status: string }>): Status {
|
|
59
|
+
const ss = stage.agents.map((a) => (agents[a.id]?.status ?? "pending") as Status)
|
|
60
|
+
if (ss.some((s) => s === "failed")) return "failed"
|
|
61
|
+
if (ss.some((s) => s === "running" || s === "retrying")) return "running"
|
|
62
|
+
if (ss.every((s) => s === "success")) return "success"
|
|
63
|
+
if (ss.every((s) => s === "skipped")) return "skipped"
|
|
64
|
+
return "pending"
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface AgentInfo {
|
|
68
|
+
id: string
|
|
69
|
+
name: string
|
|
70
|
+
icon?: string
|
|
71
|
+
model?: string
|
|
72
|
+
taskTitle?: string
|
|
73
|
+
status: string
|
|
74
|
+
duration?: number
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function AgentRow({
|
|
78
|
+
agent,
|
|
79
|
+
isSelected,
|
|
80
|
+
onClick,
|
|
81
|
+
labelColor,
|
|
82
|
+
}: {
|
|
83
|
+
agent: AgentInfo
|
|
84
|
+
isSelected: boolean
|
|
85
|
+
onClick: () => void
|
|
86
|
+
labelColor: Record<Status, string>
|
|
87
|
+
}) {
|
|
88
|
+
const status = agent.status as Status
|
|
89
|
+
return (
|
|
90
|
+
<button
|
|
91
|
+
onClick={onClick}
|
|
92
|
+
className={cn(
|
|
93
|
+
"flex items-center gap-1.5 w-full px-2 py-1.5 text-left transition-colors rounded",
|
|
94
|
+
"hover:bg-accent",
|
|
95
|
+
isSelected && "bg-accent",
|
|
96
|
+
)}
|
|
97
|
+
>
|
|
98
|
+
<span className="text-sm leading-none shrink-0">{agent.icon ?? "-"}</span>
|
|
99
|
+
<div className="flex-1 min-w-0">
|
|
100
|
+
<div className={cn("text-[11px] font-medium truncate", NAME_COLOR[status])}>
|
|
101
|
+
{agent.name}
|
|
102
|
+
</div>
|
|
103
|
+
{agent.taskTitle && (
|
|
104
|
+
<div className="text-[8px] text-muted-foreground truncate">
|
|
105
|
+
{agent.taskTitle}
|
|
106
|
+
</div>
|
|
107
|
+
)}
|
|
108
|
+
<div className="flex items-center justify-between gap-2 text-[9px]">
|
|
109
|
+
<div className={cn(labelColor[status])}>
|
|
110
|
+
{agent.duration != null
|
|
111
|
+
? `${(agent.duration / 1000).toFixed(1)}s`
|
|
112
|
+
: STATUS_LABEL[status]}
|
|
113
|
+
</div>
|
|
114
|
+
{agent.model && (
|
|
115
|
+
<span className="text-muted-foreground text-[8px] truncate">
|
|
116
|
+
{agent.model}
|
|
117
|
+
</span>
|
|
118
|
+
)}
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
</button>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function PipelineBar({
|
|
126
|
+
leaderIds,
|
|
127
|
+
leaderAgents,
|
|
128
|
+
stages,
|
|
129
|
+
agents,
|
|
130
|
+
onSelect,
|
|
131
|
+
selectedId,
|
|
132
|
+
}: {
|
|
133
|
+
leaderIds: string[]
|
|
134
|
+
leaderAgents: Record<string, AgentInfo>
|
|
135
|
+
stages: StageInfo[]
|
|
136
|
+
agents: Record<string, AgentInfo>
|
|
137
|
+
onSelect: (id: string) => void
|
|
138
|
+
selectedId?: string | null
|
|
139
|
+
}) {
|
|
140
|
+
const hasLeader = leaderIds.length > 0
|
|
141
|
+
const hasPipeline = stages.length > 0
|
|
142
|
+
const leaderRunning = leaderIds.some((id) => leaderAgents[id]?.status === "running")
|
|
143
|
+
if (!hasLeader && !hasPipeline) return null
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<div className="flex items-stretch gap-0 overflow-x-auto">
|
|
147
|
+
{/* Leader card */}
|
|
148
|
+
{hasLeader && leaderIds.map((id) => {
|
|
149
|
+
const agent = leaderAgents[id] ?? { id, name: id, status: "pending" }
|
|
150
|
+
const status = agent.status as Status
|
|
151
|
+
return (
|
|
152
|
+
<div key={id} className="shrink-0 w-[130px] rounded-md border bg-card overflow-hidden">
|
|
153
|
+
<div className={cn("h-1 w-full", LEADER_BAR[status])} />
|
|
154
|
+
<div className="px-2 pt-1 pb-0.5">
|
|
155
|
+
<span className="text-[9px] uppercase tracking-wide text-muted-foreground/70 font-semibold">
|
|
156
|
+
Leader
|
|
157
|
+
</span>
|
|
158
|
+
</div>
|
|
159
|
+
<AgentRow
|
|
160
|
+
agent={agent}
|
|
161
|
+
isSelected={selectedId === id}
|
|
162
|
+
onClick={() => onSelect(id)}
|
|
163
|
+
labelColor={LEADER_LABEL_COLOR}
|
|
164
|
+
/>
|
|
165
|
+
</div>
|
|
166
|
+
)
|
|
167
|
+
})}
|
|
168
|
+
|
|
169
|
+
{/* Connector: "generating" dots while leader is running, "generated" arrow once pipeline is ready */}
|
|
170
|
+
{hasLeader && (
|
|
171
|
+
<div className="flex flex-col items-center justify-center shrink-0 px-2 gap-0.5 self-center">
|
|
172
|
+
{hasPipeline ? (
|
|
173
|
+
<>
|
|
174
|
+
<span className="text-[8px] uppercase tracking-wide text-muted-foreground/50">generated</span>
|
|
175
|
+
<div className="flex items-center">
|
|
176
|
+
<div className="w-5 h-px border-t border-dashed border-muted-foreground/30" />
|
|
177
|
+
<svg width="5" height="7" viewBox="0 0 5 7" className="text-muted-foreground/40 shrink-0">
|
|
178
|
+
<path d="M0 0 L5 3.5 L0 7 Z" fill="currentColor" />
|
|
179
|
+
</svg>
|
|
180
|
+
</div>
|
|
181
|
+
</>
|
|
182
|
+
) : leaderRunning ? (
|
|
183
|
+
<>
|
|
184
|
+
<span className="text-[8px] text-violet-400 animate-pulse">generating</span>
|
|
185
|
+
<div className="flex items-center gap-0.5">
|
|
186
|
+
{[0, 1, 2].map((i) => (
|
|
187
|
+
<span
|
|
188
|
+
key={i}
|
|
189
|
+
className="w-1 h-1 rounded-full bg-violet-400 animate-bounce"
|
|
190
|
+
style={{ animationDelay: `${i * 150}ms` }}
|
|
191
|
+
/>
|
|
192
|
+
))}
|
|
193
|
+
</div>
|
|
194
|
+
</>
|
|
195
|
+
) : null}
|
|
196
|
+
</div>
|
|
197
|
+
)}
|
|
198
|
+
|
|
199
|
+
{/* Pipeline stage cards */}
|
|
200
|
+
{hasPipeline && stages.map((stage, idx) => {
|
|
201
|
+
const ss = stageAggregate(stage, agents)
|
|
202
|
+
return (
|
|
203
|
+
<React.Fragment key={stage.stageIndex}>
|
|
204
|
+
{idx > 0 && (
|
|
205
|
+
<div className="flex items-center self-center shrink-0">
|
|
206
|
+
<div className="w-3 h-px bg-border" />
|
|
207
|
+
<svg width="5" height="7" viewBox="0 0 5 7" className="text-muted-foreground/40 shrink-0">
|
|
208
|
+
<path d="M0 0 L5 3.5 L0 7 Z" fill="currentColor" />
|
|
209
|
+
</svg>
|
|
210
|
+
</div>
|
|
211
|
+
)}
|
|
212
|
+
<div className="shrink-0 w-[130px] rounded-md border bg-card overflow-hidden">
|
|
213
|
+
<div className={cn("h-1 w-full", STAGE_BAR[ss])} />
|
|
214
|
+
<div className="py-1">
|
|
215
|
+
{stage.agents.map((a) => {
|
|
216
|
+
const agent = agents[a.id] ?? { id: a.id, name: a.name, icon: a.icon, status: "pending" }
|
|
217
|
+
return (
|
|
218
|
+
<AgentRow
|
|
219
|
+
key={a.id}
|
|
220
|
+
agent={agent}
|
|
221
|
+
isSelected={selectedId === a.id}
|
|
222
|
+
onClick={() => onSelect(a.id)}
|
|
223
|
+
labelColor={STATUS_LABEL_COLOR}
|
|
224
|
+
/>
|
|
225
|
+
)
|
|
226
|
+
})}
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
</React.Fragment>
|
|
230
|
+
)
|
|
231
|
+
})}
|
|
232
|
+
</div>
|
|
233
|
+
)
|
|
234
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority"
|
|
3
|
+
import { cn } from "@/lib/utils"
|
|
4
|
+
|
|
5
|
+
const badgeVariants = cva(
|
|
6
|
+
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
|
7
|
+
{
|
|
8
|
+
variants: {
|
|
9
|
+
variant: {
|
|
10
|
+
default: "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
|
11
|
+
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
|
12
|
+
destructive: "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
|
13
|
+
outline: "text-foreground",
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
defaultVariants: { variant: "default" },
|
|
17
|
+
}
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
export interface BadgeProps
|
|
21
|
+
extends React.HTMLAttributes<HTMLDivElement>,
|
|
22
|
+
VariantProps<typeof badgeVariants> {}
|
|
23
|
+
|
|
24
|
+
function Badge({ className, variant, ...props }: BadgeProps) {
|
|
25
|
+
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export { Badge, badgeVariants }
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { Slot } from "@radix-ui/react-slot"
|
|
3
|
+
import { cva, type VariantProps } from "class-variance-authority"
|
|
4
|
+
import { cn } from "@/lib/utils"
|
|
5
|
+
|
|
6
|
+
const buttonVariants = cva(
|
|
7
|
+
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
|
8
|
+
{
|
|
9
|
+
variants: {
|
|
10
|
+
variant: {
|
|
11
|
+
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
|
12
|
+
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
|
13
|
+
outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
|
14
|
+
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
|
15
|
+
ghost: "hover:bg-accent hover:text-accent-foreground",
|
|
16
|
+
link: "text-primary underline-offset-4 hover:underline",
|
|
17
|
+
},
|
|
18
|
+
size: {
|
|
19
|
+
default: "h-9 px-4 py-2",
|
|
20
|
+
sm: "h-8 rounded-md px-3 text-xs",
|
|
21
|
+
lg: "h-10 rounded-md px-8",
|
|
22
|
+
icon: "h-9 w-9",
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
defaultVariants: { variant: "default", size: "default" },
|
|
26
|
+
}
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
export interface ButtonProps
|
|
30
|
+
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
|
31
|
+
VariantProps<typeof buttonVariants> {
|
|
32
|
+
asChild?: boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
36
|
+
({ className, variant, size, asChild = false, ...props }, ref) => {
|
|
37
|
+
const Comp = asChild ? Slot : "button"
|
|
38
|
+
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
|
39
|
+
}
|
|
40
|
+
)
|
|
41
|
+
Button.displayName = "Button"
|
|
42
|
+
|
|
43
|
+
export { Button, buttonVariants }
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { cn } from "@/lib/utils"
|
|
3
|
+
|
|
4
|
+
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
5
|
+
({ className, ...props }, ref) => (
|
|
6
|
+
<div
|
|
7
|
+
ref={ref}
|
|
8
|
+
className={cn("rounded-xl border bg-card text-card-foreground shadow", className)}
|
|
9
|
+
{...props}
|
|
10
|
+
/>
|
|
11
|
+
)
|
|
12
|
+
)
|
|
13
|
+
Card.displayName = "Card"
|
|
14
|
+
|
|
15
|
+
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
16
|
+
({ className, ...props }, ref) => (
|
|
17
|
+
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
|
18
|
+
)
|
|
19
|
+
)
|
|
20
|
+
CardContent.displayName = "CardContent"
|
|
21
|
+
|
|
22
|
+
export { Card, CardContent }
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
|
3
|
+
import { cn } from "@/lib/utils"
|
|
4
|
+
|
|
5
|
+
const ScrollArea = React.forwardRef<
|
|
6
|
+
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
|
7
|
+
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
|
8
|
+
>(({ className, children, ...props }, ref) => (
|
|
9
|
+
<ScrollAreaPrimitive.Root
|
|
10
|
+
ref={ref}
|
|
11
|
+
className={cn("relative overflow-hidden", className)}
|
|
12
|
+
{...props}
|
|
13
|
+
>
|
|
14
|
+
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
|
15
|
+
{children}
|
|
16
|
+
</ScrollAreaPrimitive.Viewport>
|
|
17
|
+
<ScrollBar />
|
|
18
|
+
<ScrollAreaPrimitive.Corner />
|
|
19
|
+
</ScrollAreaPrimitive.Root>
|
|
20
|
+
))
|
|
21
|
+
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
|
22
|
+
|
|
23
|
+
const ScrollBar = React.forwardRef<
|
|
24
|
+
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
|
25
|
+
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
|
26
|
+
>(({ className, orientation = "vertical", ...props }, ref) => (
|
|
27
|
+
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
|
28
|
+
ref={ref}
|
|
29
|
+
orientation={orientation}
|
|
30
|
+
className={cn(
|
|
31
|
+
"flex touch-none select-none transition-colors",
|
|
32
|
+
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-[1px]",
|
|
33
|
+
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
|
34
|
+
className
|
|
35
|
+
)}
|
|
36
|
+
{...props}
|
|
37
|
+
>
|
|
38
|
+
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
|
39
|
+
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
|
40
|
+
))
|
|
41
|
+
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
|
42
|
+
|
|
43
|
+
export { ScrollArea, ScrollBar }
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
|
3
|
+
import { cn } from "@/lib/utils"
|
|
4
|
+
|
|
5
|
+
const Separator = React.forwardRef<
|
|
6
|
+
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
|
7
|
+
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
|
8
|
+
>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
|
|
9
|
+
<SeparatorPrimitive.Root
|
|
10
|
+
ref={ref}
|
|
11
|
+
decorative={decorative}
|
|
12
|
+
orientation={orientation}
|
|
13
|
+
className={cn(
|
|
14
|
+
"shrink-0 bg-border",
|
|
15
|
+
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
|
16
|
+
className
|
|
17
|
+
)}
|
|
18
|
+
{...props}
|
|
19
|
+
/>
|
|
20
|
+
))
|
|
21
|
+
Separator.displayName = SeparatorPrimitive.Root.displayName
|
|
22
|
+
|
|
23
|
+
export { Separator }
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react"
|
|
2
|
+
import type { MultiClawEvent } from "@multiclawcli/core"
|
|
3
|
+
|
|
4
|
+
export function useWebSocket(onEvent: (e: MultiClawEvent) => void): void {
|
|
5
|
+
const ref = useRef(onEvent)
|
|
6
|
+
ref.current = onEvent
|
|
7
|
+
// Stop reconnecting after orchestration completes to avoid errors when server has shut down
|
|
8
|
+
const doneRef = useRef(false)
|
|
9
|
+
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
const wsUrl = `ws://${location.host}`
|
|
12
|
+
let ws: WebSocket
|
|
13
|
+
let retry: ReturnType<typeof setTimeout>
|
|
14
|
+
|
|
15
|
+
const connect = () => {
|
|
16
|
+
if (doneRef.current) return
|
|
17
|
+
ws = new WebSocket(wsUrl)
|
|
18
|
+
ws.onmessage = (msg) => {
|
|
19
|
+
try {
|
|
20
|
+
const event = JSON.parse(msg.data) as MultiClawEvent
|
|
21
|
+
if (event.type === "orchestration:complete") doneRef.current = true
|
|
22
|
+
ref.current(event)
|
|
23
|
+
} catch {}
|
|
24
|
+
}
|
|
25
|
+
ws.onclose = () => {
|
|
26
|
+
if (!doneRef.current) retry = setTimeout(connect, 1500)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
connect()
|
|
30
|
+
|
|
31
|
+
return () => { clearTimeout(retry); ws?.close() }
|
|
32
|
+
}, [])
|
|
33
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
@tailwind base;
|
|
2
|
+
@tailwind components;
|
|
3
|
+
@tailwind utilities;
|
|
4
|
+
|
|
5
|
+
@layer base {
|
|
6
|
+
:root {
|
|
7
|
+
--background: 0 0% 100%;
|
|
8
|
+
--foreground: 222.2 84% 4.9%;
|
|
9
|
+
--card: 0 0% 100%;
|
|
10
|
+
--card-foreground: 222.2 84% 4.9%;
|
|
11
|
+
--primary: 221.2 83.2% 53.3%;
|
|
12
|
+
--primary-foreground: 210 40% 98%;
|
|
13
|
+
--secondary: 210 40% 96.1%;
|
|
14
|
+
--secondary-foreground: 222.2 47.4% 11.2%;
|
|
15
|
+
--muted: 210 40% 96.1%;
|
|
16
|
+
--muted-foreground: 215.4 16.3% 46.9%;
|
|
17
|
+
--accent: 210 40% 96.1%;
|
|
18
|
+
--accent-foreground: 222.2 47.4% 11.2%;
|
|
19
|
+
--destructive: 0 84.2% 60.2%;
|
|
20
|
+
--destructive-foreground: 210 40% 98%;
|
|
21
|
+
--border: 214.3 31.8% 91.4%;
|
|
22
|
+
--input: 214.3 31.8% 91.4%;
|
|
23
|
+
--ring: 221.2 83.2% 53.3%;
|
|
24
|
+
--radius: 0.5rem;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@layer base {
|
|
29
|
+
* { @apply border-border; }
|
|
30
|
+
body { @apply bg-background text-foreground; }
|
|
31
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { create } from "zustand"
|
|
2
|
+
import type { MultiClawEvent, StageInfo, AgentStatus } from "@multiclawcli/core"
|
|
3
|
+
|
|
4
|
+
const stripAnsi = (str: string) => str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "")
|
|
5
|
+
|
|
6
|
+
interface AgentState {
|
|
7
|
+
id: string
|
|
8
|
+
name: string
|
|
9
|
+
icon?: string
|
|
10
|
+
model?: string
|
|
11
|
+
taskTitle?: string
|
|
12
|
+
status: AgentStatus
|
|
13
|
+
duration?: number
|
|
14
|
+
logs: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface OrchestrationState {
|
|
18
|
+
name: string
|
|
19
|
+
requirement?: string
|
|
20
|
+
running: boolean
|
|
21
|
+
success?: boolean
|
|
22
|
+
totalDuration?: number
|
|
23
|
+
stages: StageInfo[]
|
|
24
|
+
agents: Record<string, AgentState>
|
|
25
|
+
// Agents that completed before orchestration:start (e.g. leader)
|
|
26
|
+
preflightBuffer: Record<string, AgentState>
|
|
27
|
+
preflightAgentIds: string[]
|
|
28
|
+
apply: (e: MultiClawEvent) => void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const useOrchestrationStore = create<OrchestrationState>((set) => ({
|
|
32
|
+
name: "",
|
|
33
|
+
requirement: undefined,
|
|
34
|
+
running: false,
|
|
35
|
+
stages: [],
|
|
36
|
+
agents: {},
|
|
37
|
+
preflightBuffer: {},
|
|
38
|
+
preflightAgentIds: [],
|
|
39
|
+
apply: (e) => set((state) => {
|
|
40
|
+
switch (e.type) {
|
|
41
|
+
case "orchestration:start": {
|
|
42
|
+
// Move pre-flight agents (e.g. leader) into the main agents map
|
|
43
|
+
const preflightAgentIds = Object.keys(state.preflightBuffer)
|
|
44
|
+
const map: Record<string, AgentState> = { ...state.preflightBuffer }
|
|
45
|
+
for (const stage of e.payload.stages) {
|
|
46
|
+
for (const a of stage.agents) {
|
|
47
|
+
map[a.id] = { id: a.id, name: a.name, icon: a.icon, model: a.model, taskTitle: a.taskTitle, status: "pending", logs: "" }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
name: e.payload.name, requirement: e.payload.requirement, running: true,
|
|
52
|
+
stages: e.payload.stages, agents: map,
|
|
53
|
+
preflightBuffer: {}, preflightAgentIds,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
case "agent:start": {
|
|
57
|
+
const agentId = e.payload.agentId
|
|
58
|
+
// If already a known pipeline agent, update in place
|
|
59
|
+
if (agentId in state.agents) {
|
|
60
|
+
return { agents: { ...state.agents, [agentId]: { ...state.agents[agentId], status: "running" } } }
|
|
61
|
+
}
|
|
62
|
+
// Otherwise buffer as pre-flight (will be merged at orchestration:start)
|
|
63
|
+
const existing = state.preflightBuffer[agentId]
|
|
64
|
+
return {
|
|
65
|
+
preflightBuffer: {
|
|
66
|
+
...state.preflightBuffer,
|
|
67
|
+
[agentId]: existing
|
|
68
|
+
? { ...existing, status: "running" }
|
|
69
|
+
: { id: agentId, name: e.payload.agentName, icon: e.payload.icon, model: e.payload.model, taskTitle: e.payload.taskTitle, status: "running", logs: "" },
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
case "agent:output": {
|
|
74
|
+
const { agentId, chunk } = e.payload
|
|
75
|
+
const text = stripAnsi(chunk)
|
|
76
|
+
if (agentId in state.agents) {
|
|
77
|
+
const a = state.agents[agentId]
|
|
78
|
+
return { agents: { ...state.agents, [agentId]: { ...a, logs: a.logs + text } } }
|
|
79
|
+
}
|
|
80
|
+
if (agentId in state.preflightBuffer) {
|
|
81
|
+
const a = state.preflightBuffer[agentId]
|
|
82
|
+
return { preflightBuffer: { ...state.preflightBuffer, [agentId]: { ...a, logs: a.logs + text } } }
|
|
83
|
+
}
|
|
84
|
+
return {}
|
|
85
|
+
}
|
|
86
|
+
case "agent:retrying": {
|
|
87
|
+
const { agentId, attempt, maxAttempts } = e.payload
|
|
88
|
+
const msg = `\n[retry] attempt ${attempt} of ${maxAttempts}...\n`
|
|
89
|
+
if (agentId in state.agents) {
|
|
90
|
+
const a = state.agents[agentId]
|
|
91
|
+
return { agents: { ...state.agents, [agentId]: { ...a, status: "retrying", logs: a.logs + msg } } }
|
|
92
|
+
}
|
|
93
|
+
if (agentId in state.preflightBuffer) {
|
|
94
|
+
const a = state.preflightBuffer[agentId]
|
|
95
|
+
return { preflightBuffer: { ...state.preflightBuffer, [agentId]: { ...a, status: "retrying", logs: a.logs + msg } } }
|
|
96
|
+
}
|
|
97
|
+
return {}
|
|
98
|
+
}
|
|
99
|
+
case "agent:complete": {
|
|
100
|
+
const { agentId, duration } = e.payload
|
|
101
|
+
if (agentId in state.agents) {
|
|
102
|
+
return { agents: { ...state.agents, [agentId]: { ...state.agents[agentId], status: "success", duration } } }
|
|
103
|
+
}
|
|
104
|
+
if (agentId in state.preflightBuffer) {
|
|
105
|
+
return { preflightBuffer: { ...state.preflightBuffer, [agentId]: { ...state.preflightBuffer[agentId], status: "success", duration } } }
|
|
106
|
+
}
|
|
107
|
+
return {}
|
|
108
|
+
}
|
|
109
|
+
case "agent:failed": {
|
|
110
|
+
const { agentId, duration } = e.payload
|
|
111
|
+
if (agentId in state.agents) {
|
|
112
|
+
return { agents: { ...state.agents, [agentId]: { ...state.agents[agentId], status: "failed", duration } } }
|
|
113
|
+
}
|
|
114
|
+
if (agentId in state.preflightBuffer) {
|
|
115
|
+
return { preflightBuffer: { ...state.preflightBuffer, [agentId]: { ...state.preflightBuffer[agentId], status: "failed", duration } } }
|
|
116
|
+
}
|
|
117
|
+
return {}
|
|
118
|
+
}
|
|
119
|
+
case "agent:skipped": {
|
|
120
|
+
const { agentId } = e.payload
|
|
121
|
+
if (agentId in state.agents) {
|
|
122
|
+
return { agents: { ...state.agents, [agentId]: { ...state.agents[agentId], status: "skipped" } } }
|
|
123
|
+
}
|
|
124
|
+
return {}
|
|
125
|
+
}
|
|
126
|
+
case "orchestration:complete":
|
|
127
|
+
return { running: false, success: e.payload.success, totalDuration: e.payload.totalDuration }
|
|
128
|
+
default:
|
|
129
|
+
return {}
|
|
130
|
+
}
|
|
131
|
+
}),
|
|
132
|
+
}))
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import * as http from "http"
|
|
2
|
+
import * as path from "path"
|
|
3
|
+
import * as fs from "fs"
|
|
4
|
+
import { WebSocketServer } from "ws"
|
|
5
|
+
import type { EventBus } from "@multiclawcli/core"
|
|
6
|
+
import { WebSocketHub } from "./WebSocketHub"
|
|
7
|
+
|
|
8
|
+
export interface DashboardServerOptions {
|
|
9
|
+
port: number
|
|
10
|
+
acceptClientEvents?: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class DashboardServer {
|
|
14
|
+
private server: http.Server
|
|
15
|
+
private hub: WebSocketHub
|
|
16
|
+
private port: number
|
|
17
|
+
|
|
18
|
+
constructor(options: DashboardServerOptions) {
|
|
19
|
+
this.port = options.port
|
|
20
|
+
|
|
21
|
+
const clientDist = path.resolve(new URL(import.meta.url).pathname, "../../client")
|
|
22
|
+
|
|
23
|
+
this.server = http.createServer((req, res) => {
|
|
24
|
+
let filePath = path.join(
|
|
25
|
+
clientDist,
|
|
26
|
+
req.url === "/" ? "index.html" : req.url!
|
|
27
|
+
)
|
|
28
|
+
if (!fs.existsSync(filePath)) filePath = path.join(clientDist, "index.html")
|
|
29
|
+
|
|
30
|
+
const ext = path.extname(filePath)
|
|
31
|
+
const mime: Record<string, string> = {
|
|
32
|
+
".html": "text/html", ".js": "text/javascript",
|
|
33
|
+
".css": "text/css", ".svg": "image/svg+xml",
|
|
34
|
+
}
|
|
35
|
+
const stream = fs.createReadStream(filePath)
|
|
36
|
+
stream.on("error", () => { res.writeHead(404); res.end() })
|
|
37
|
+
res.writeHead(200, { "Content-Type": mime[ext] ?? "text/plain" })
|
|
38
|
+
stream.pipe(res)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const wss = new WebSocketServer({ server: this.server })
|
|
42
|
+
this.hub = new WebSocketHub(wss, options.acceptClientEvents)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
start(): Promise<void> {
|
|
46
|
+
return new Promise((resolve) => this.server.listen(this.port, () => resolve()))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
bindEventBus(eventBus: EventBus): void {
|
|
50
|
+
eventBus.subscribe((e) => this.hub.broadcast(e))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
stop(): void {
|
|
54
|
+
this.server.close()
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { WebSocketServer, WebSocket } from "ws"
|
|
2
|
+
import type { MultiClawEvent } from "@multiclawcli/core"
|
|
3
|
+
|
|
4
|
+
export class WebSocketHub {
|
|
5
|
+
private wss: WebSocketServer
|
|
6
|
+
private clients = new Set<WebSocket>()
|
|
7
|
+
private history: MultiClawEvent[] = []
|
|
8
|
+
|
|
9
|
+
constructor(wss: WebSocketServer, acceptClientEvents = false) {
|
|
10
|
+
this.wss = wss
|
|
11
|
+
this.wss.on("connection", (ws) => {
|
|
12
|
+
this.clients.add(ws)
|
|
13
|
+
for (const e of this.history) ws.send(JSON.stringify(e))
|
|
14
|
+
|
|
15
|
+
if (acceptClientEvents) {
|
|
16
|
+
ws.on("message", (data) => {
|
|
17
|
+
try {
|
|
18
|
+
const event = JSON.parse(data.toString()) as MultiClawEvent
|
|
19
|
+
this.broadcast(event)
|
|
20
|
+
} catch { /* ignore */ }
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
ws.on("close", () => this.clients.delete(ws))
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
broadcast(event: MultiClawEvent): void {
|
|
29
|
+
this.history.push(event)
|
|
30
|
+
const msg = JSON.stringify(event)
|
|
31
|
+
for (const ws of this.clients) {
|
|
32
|
+
if (ws.readyState === ws.OPEN) ws.send(msg)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import tailwindAnimate from "tailwindcss-animate"
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
content: ["./src/client/**/*.{ts,tsx,html}", "./index.html"],
|
|
5
|
+
theme: {
|
|
6
|
+
extend: {
|
|
7
|
+
colors: {
|
|
8
|
+
border: "hsl(var(--border))",
|
|
9
|
+
input: "hsl(var(--input))",
|
|
10
|
+
ring: "hsl(var(--ring))",
|
|
11
|
+
background: "hsl(var(--background))",
|
|
12
|
+
foreground: "hsl(var(--foreground))",
|
|
13
|
+
primary: {
|
|
14
|
+
DEFAULT: "hsl(var(--primary))",
|
|
15
|
+
foreground: "hsl(var(--primary-foreground))",
|
|
16
|
+
},
|
|
17
|
+
secondary: {
|
|
18
|
+
DEFAULT: "hsl(var(--secondary))",
|
|
19
|
+
foreground: "hsl(var(--secondary-foreground))",
|
|
20
|
+
},
|
|
21
|
+
destructive: {
|
|
22
|
+
DEFAULT: "hsl(var(--destructive))",
|
|
23
|
+
foreground: "hsl(var(--destructive-foreground))",
|
|
24
|
+
},
|
|
25
|
+
muted: {
|
|
26
|
+
DEFAULT: "hsl(var(--muted))",
|
|
27
|
+
foreground: "hsl(var(--muted-foreground))",
|
|
28
|
+
},
|
|
29
|
+
accent: {
|
|
30
|
+
DEFAULT: "hsl(var(--accent))",
|
|
31
|
+
foreground: "hsl(var(--accent-foreground))",
|
|
32
|
+
},
|
|
33
|
+
card: {
|
|
34
|
+
DEFAULT: "hsl(var(--card))",
|
|
35
|
+
foreground: "hsl(var(--card-foreground))",
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
borderRadius: {
|
|
39
|
+
lg: "var(--radius)",
|
|
40
|
+
md: "calc(var(--radius) - 2px)",
|
|
41
|
+
sm: "calc(var(--radius) - 4px)",
|
|
42
|
+
},
|
|
43
|
+
keyframes: {
|
|
44
|
+
"pulse-fast": { "0%, 100%": { opacity: "1" }, "50%": { opacity: ".5" } },
|
|
45
|
+
},
|
|
46
|
+
animation: { "pulse-fast": "pulse-fast 1s ease-in-out infinite" },
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
plugins: [tailwindAnimate],
|
|
50
|
+
}
|
package/tsconfig.json
ADDED
package/vite.config.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { defineConfig } from "vite"
|
|
2
|
+
import react from "@vitejs/plugin-react"
|
|
3
|
+
import * as path from "path"
|
|
4
|
+
|
|
5
|
+
export default defineConfig({
|
|
6
|
+
plugins: [react()],
|
|
7
|
+
root: ".",
|
|
8
|
+
define: {
|
|
9
|
+
// ws 等 Node.js 包引用了 global,浏览器端需要映射到 globalThis
|
|
10
|
+
global: "globalThis",
|
|
11
|
+
},
|
|
12
|
+
build: {
|
|
13
|
+
outDir: "dist/client",
|
|
14
|
+
emptyOutDir: true,
|
|
15
|
+
},
|
|
16
|
+
resolve: {
|
|
17
|
+
alias: {
|
|
18
|
+
"@multiclawcli/core": path.resolve(__dirname, "../core/src"),
|
|
19
|
+
"@": path.resolve(__dirname, "./src/client"),
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
optimizeDeps: {
|
|
23
|
+
exclude: ["ws", "child_process", "fs", "path"],
|
|
24
|
+
},
|
|
25
|
+
})
|