mirror of
https://github.com/crabwise-ai/crabwalk.git
synced 2026-08-14 00:57:52 +00:00
init
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
# Add your environment variables here
|
||||
# Copy this file to .env.local and fill in your values
|
||||
|
||||
# Clawdbot gateway auth token
|
||||
CLAWDBOT_API_TOKEN=
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
.output/
|
||||
.vinxi/
|
||||
.vercel/
|
||||
.netlify/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Generated
|
||||
src/routeTree.gen.ts
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"version": 1,
|
||||
"projectName": "crabwalk",
|
||||
"framework": "react",
|
||||
"mode": "file-router",
|
||||
"typescript": true,
|
||||
"tailwind": true,
|
||||
"packageManager": "npm",
|
||||
"chosenIntegrations": [
|
||||
"tanstack-query",
|
||||
"tanstack-db",
|
||||
"trpc"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm run dev # start dev server on port 3000
|
||||
npm run build # production build
|
||||
npm start # run production server (.output/server/index.mjs)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
Full-stack React app using TanStack Start (file-based routing, SSR).
|
||||
|
||||
**Stack:**
|
||||
- TanStack Start + Router (file-based routing)
|
||||
- tRPC (API layer w/ superjson transformer)
|
||||
- TanStack Query (data fetching)
|
||||
- TanStack DB (client-side reactive collections)
|
||||
- Tailwind CSS v4
|
||||
- React 19
|
||||
|
||||
**Key paths:**
|
||||
- `src/routes/` - file-based routes, auto-generates `routeTree.gen`
|
||||
- `src/routes/api/trpc.$.ts` - tRPC catch-all API handler
|
||||
- `src/integrations/trpc/router.ts` - tRPC router definition (`appRouter`)
|
||||
- `src/integrations/trpc/client.ts` - tRPC client
|
||||
- `src/integrations/query/provider.tsx` - React Query provider
|
||||
- `src/router.tsx` - TanStack Router config
|
||||
|
||||
**Path alias:** `~/` maps to `src/`
|
||||
|
||||
**tRPC pattern:** Add procedures to `appRouter` in `router.ts`, import `trpc` from `client.ts` to call.
|
||||
|
||||
**TanStack DB pattern:** Create collections, use `useLiveQuery()` for reactive reads, `createTransaction()` for writes.
|
||||
|
||||
## Clawdbot Monitor
|
||||
|
||||
Real-time agent activity monitor at `/monitor`.
|
||||
|
||||
**Key paths:**
|
||||
- `src/integrations/clawdbot/` - gateway client, protocol types, parser, collections
|
||||
- `src/components/monitor/` - ReactFlow graph, session list, custom nodes
|
||||
- `src/routes/monitor/index.tsx` - main monitor page
|
||||
|
||||
**Data flow:** clawdbot gateway (ws://127.0.0.1:18789) -> TanStack Start server (WS client) -> tRPC -> browser (TanStack DB collections -> ReactFlow)
|
||||
|
||||
**Config:** Set `CLAWDBOT_API_TOKEN` env var for gateway auth.
|
||||
@@ -0,0 +1,24 @@
|
||||
# crabwalk
|
||||
|
||||
A full-stack React application built with [TanStack Start](https://tanstack.com/start).
|
||||
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start development server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Scripts
|
||||
|
||||
- `dev` - Start development server
|
||||
- `build` - Build for production
|
||||
- `start` - Start production server
|
||||
|
||||
## Learn More
|
||||
|
||||
- [TanStack Start Documentation](https://tanstack.com/start)
|
||||
- [TanStack Router Documentation](https://tanstack.com/router)
|
||||
@@ -0,0 +1,112 @@
|
||||
# Overview
|
||||
Repo: clawdbot/clawdbot
|
||||
Ref: 50b4126c79536a9645cddcfe6801916b5f6d9343
|
||||
Base URL: https://codebase.md/clawdbot/clawdbot
|
||||
Note: This repo is currently being built in the background.
|
||||
Status: /status/clawdbot/clawdbot/50b4126c79536a9645cddcfe6801916b5f6d9343
|
||||
Estimated build time: ~96 seconds.
|
||||
Try again in 60 seconds.
|
||||
|
||||
## README Summary
|
||||
Source: README.md
|
||||
Title: 🦞 Clawdbot — Personal AI Assistant
|
||||
EXFOLIATE! EXFOLIATE!
|
||||
|
||||
### Table of contents
|
||||
- Models (selection + auth)
|
||||
- Install (recommended)
|
||||
- Quick start (TL;DR)
|
||||
- Development channels
|
||||
- From source (development)
|
||||
- Security defaults (DM access)
|
||||
- Highlights
|
||||
- Star History
|
||||
|
||||
### Top sections
|
||||
#### Models (selection + auth)
|
||||
- Models config + CLI: Models - Auth profile rotation (OAuth vs API keys) + fallbacks: Model failover
|
||||
|
||||
#### Install (recommended)
|
||||
Runtime: **Node ≥22**.
|
||||
|
||||
#### Quick start (TL;DR)
|
||||
Runtime: **Node ≥22**.
|
||||
|
||||
#### Development channels
|
||||
- **stable**: tagged releases (vYYYY.M.D or vYYYY.M.D-), npm dist-tag latest. - **beta**: prerelease tags (vYYYY.M.D-beta.N), npm dist-tag beta (macOS app may be missing). - **dev**: moving head of main, npm dist-tag dev (when published).
|
||||
|
||||
## Repository tree (depth=2)
|
||||
File tree: /file_tree (supports depth, base_path, include, exclude)
|
||||
```
|
||||
├── AGENTS.md
|
||||
├── appcast.xml
|
||||
├── apps
|
||||
├── assets
|
||||
├── CHANGELOG.md
|
||||
├── CLAUDE.md
|
||||
├── CONTRIBUTING.md
|
||||
├── docker-compose.yml
|
||||
├── docker-setup.sh
|
||||
├── Dockerfile
|
||||
├── docs
|
||||
├── docs.acp.md
|
||||
├── extensions
|
||||
├── fly.toml
|
||||
├── git-hooks
|
||||
├── package.json
|
||||
├── patches
|
||||
├── pnpm-workspace.yaml
|
||||
├── README.md
|
||||
├── scripts
|
||||
├── SECURITY.md
|
||||
├── skills
|
||||
├── src
|
||||
├── Swabble
|
||||
├── test
|
||||
├── tsconfig.json
|
||||
├── ui
|
||||
├── vitest.config.ts
|
||||
├── vitest.e2e.config.ts
|
||||
├── vitest.extensions.config.ts
|
||||
├── vitest.gateway.config.ts
|
||||
├── vitest.live.config.ts
|
||||
├── vitest.unit.config.ts
|
||||
└── zizmor.yml
|
||||
```
|
||||
|
||||
## Key files
|
||||
- README: README.md
|
||||
- docs/: docs/
|
||||
- src/: src/
|
||||
- config: Dockerfile, docker-compose.yml, fly.toml, pnpm-workspace.yaml, tsconfig.json, vitest.config.ts, vitest.e2e.config.ts, vitest.extensions.config.ts, vitest.gateway.config.ts, vitest.live.config.ts, vitest.unit.config.ts, zizmor.yml
|
||||
- package.*: package.json
|
||||
|
||||
## How to navigate this repo
|
||||
Paths below are relative to Base URL.
|
||||
### Paths
|
||||
- /readme
|
||||
- /slice?path=src/index.ts&lines=1:40
|
||||
- /manifest
|
||||
- /search?query=router
|
||||
- /file_tree
|
||||
- /blob/{ref}/{path}
|
||||
|
||||
### Query params
|
||||
- ext: csv of extensions (example: ts,md)
|
||||
- base_path: path prefix for tree
|
||||
- include/exclude: glob patterns
|
||||
- depth: integer or all
|
||||
- lines: true to add line numbers
|
||||
- format: md or html
|
||||
|
||||
### Tips
|
||||
- Use ?depth=all for full tree depth.
|
||||
- /search combines lexical and semantic results when available.
|
||||
|
||||
### Examples
|
||||
- /readme
|
||||
- /slice?path=src/index.ts&lines=1:40
|
||||
- /manifest
|
||||
- /search?query=router
|
||||
- /file_tree?base_path=src&depth=2
|
||||
- /blob/main/src/index.ts
|
||||
Generated
+4355
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "crabwalk",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev --port 3000",
|
||||
"build": "vite build",
|
||||
"start": "node .output/server/index.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/db": "^0.5.0",
|
||||
"@tanstack/react-db": "^0.1.0",
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"@tanstack/react-router": "^1.132.0",
|
||||
"@tanstack/react-router-devtools": "^1.132.0",
|
||||
"@tanstack/react-start": "^1.132.0",
|
||||
"@trpc/client": "^11.0.0",
|
||||
"@trpc/server": "^11.0.0",
|
||||
"@types/dagre": "^0.7.53",
|
||||
"@xyflow/react": "^12.10.0",
|
||||
"dagre": "^0.8.5",
|
||||
"framer-motion": "^12.29.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"superjson": "^2.2.0",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"ws": "^8.19.0",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@tanstack/react-query-devtools": "^5.0.0",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^7.0.0"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 10 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,104 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Database, Home, Menu, X, Zap } from 'lucide-react'
|
||||
|
||||
export function Header() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="p-4 flex items-center bg-gray-800 text-white shadow-lg">
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="p-2 hover:bg-gray-700 rounded-lg transition-colors"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<Menu size={24} />
|
||||
</button>
|
||||
<h1 className="ml-4 text-xl font-semibold">
|
||||
<Link to="/">
|
||||
<img
|
||||
src="/tanstack-logo-light.svg"
|
||||
alt="TanStack Logo"
|
||||
className="h-10"
|
||||
/>
|
||||
</Link>
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<aside
|
||||
className={`fixed top-0 left-0 h-full w-80 bg-gray-900 text-white shadow-2xl z-50 transform transition-transform duration-300 ease-in-out flex flex-col ${
|
||||
isOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-700">
|
||||
<h2 className="text-xl font-bold">Navigation</h2>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="p-2 hover:bg-gray-800 rounded-lg transition-colors"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 p-4 overflow-y-auto">
|
||||
<Link
|
||||
to="/"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2"
|
||||
activeProps={{
|
||||
className: 'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2',
|
||||
}}
|
||||
>
|
||||
<Home size={20} />
|
||||
<span className="font-medium">Home</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/demo/query"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2"
|
||||
activeProps={{
|
||||
className: 'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2',
|
||||
}}
|
||||
>
|
||||
<Database size={20} />
|
||||
<span className="font-medium">TanStack Query</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/demo/db"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2"
|
||||
activeProps={{
|
||||
className: 'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2',
|
||||
}}
|
||||
>
|
||||
<Database size={20} />
|
||||
<span className="font-medium">TanStack DB</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/demo/trpc"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2"
|
||||
activeProps={{
|
||||
className: 'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2',
|
||||
}}
|
||||
>
|
||||
<Zap size={20} />
|
||||
<span className="font-medium">tRPC</span>
|
||||
</Link>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
type Node,
|
||||
type Edge,
|
||||
type NodeTypes,
|
||||
MarkerType,
|
||||
} from '@xyflow/react'
|
||||
import '@xyflow/react/dist/style.css'
|
||||
import { SessionNode } from './SessionNode'
|
||||
import { ActionNode } from './ActionNode'
|
||||
import { layoutGraph } from '~/lib/graph-layout'
|
||||
import type { MonitorSession, MonitorAction } from '~/integrations/clawdbot'
|
||||
|
||||
interface ActionGraphProps {
|
||||
sessions: MonitorSession[]
|
||||
actions: MonitorAction[]
|
||||
selectedSession: string | null
|
||||
onSessionSelect: (key: string | null) => void
|
||||
}
|
||||
|
||||
const nodeTypes: NodeTypes = {
|
||||
session: SessionNode,
|
||||
action: ActionNode,
|
||||
}
|
||||
|
||||
export function ActionGraph({
|
||||
sessions,
|
||||
actions,
|
||||
selectedSession,
|
||||
onSessionSelect,
|
||||
}: ActionGraphProps) {
|
||||
// Filter actions for selected session, or show all if none selected
|
||||
const visibleActions = useMemo(() => {
|
||||
if (!selectedSession) return actions.slice(-50) // Last 50 actions
|
||||
return actions.filter((a) => a.sessionKey === selectedSession)
|
||||
}, [actions, selectedSession])
|
||||
|
||||
// Build nodes
|
||||
const rawNodes = useMemo(() => {
|
||||
const nodes: Node[] = []
|
||||
|
||||
// Add session nodes
|
||||
const visibleSessions = selectedSession
|
||||
? sessions.filter((s) => s.key === selectedSession)
|
||||
: sessions
|
||||
|
||||
for (const session of visibleSessions) {
|
||||
nodes.push({
|
||||
id: `session-${session.key}`,
|
||||
type: 'session',
|
||||
position: { x: 0, y: 0 },
|
||||
data: session,
|
||||
})
|
||||
}
|
||||
|
||||
// Add action nodes
|
||||
for (const action of visibleActions) {
|
||||
nodes.push({
|
||||
id: `action-${action.id}`,
|
||||
type: 'action',
|
||||
position: { x: 0, y: 0 },
|
||||
data: action,
|
||||
})
|
||||
}
|
||||
|
||||
return nodes
|
||||
}, [sessions, visibleActions, selectedSession])
|
||||
|
||||
// Build edges
|
||||
const rawEdges = useMemo(() => {
|
||||
const edges: Edge[] = []
|
||||
|
||||
// Group actions by runId
|
||||
const runActions = new Map<string, MonitorAction[]>()
|
||||
for (const action of visibleActions) {
|
||||
const run = runActions.get(action.runId) ?? []
|
||||
run.push(action)
|
||||
runActions.set(action.runId, run)
|
||||
}
|
||||
|
||||
// Connect session to first action of each run
|
||||
for (const [runId, runActs] of runActions) {
|
||||
const sorted = [...runActs].sort((a, b) => a.seq - b.seq)
|
||||
if (sorted.length > 0) {
|
||||
const first = sorted[0]
|
||||
const sessionId = `session-${first.sessionKey}`
|
||||
edges.push({
|
||||
id: `e-session-${runId}`,
|
||||
source: sessionId,
|
||||
target: `action-${first.id}`,
|
||||
animated: first.type === 'delta',
|
||||
markerEnd: { type: MarkerType.ArrowClosed },
|
||||
style: { stroke: '#6b7280' },
|
||||
})
|
||||
}
|
||||
|
||||
// Connect actions in sequence
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const prev = sorted[i - 1]
|
||||
const curr = sorted[i]
|
||||
edges.push({
|
||||
id: `e-${prev.id}-${curr.id}`,
|
||||
source: `action-${prev.id}`,
|
||||
target: `action-${curr.id}`,
|
||||
animated: curr.type === 'delta',
|
||||
markerEnd: { type: MarkerType.ArrowClosed },
|
||||
style: { stroke: '#6b7280' },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return edges
|
||||
}, [visibleActions])
|
||||
|
||||
// Apply layout
|
||||
const { nodes: layoutedNodes, edges: layoutedEdges } = useMemo(() => {
|
||||
if (rawNodes.length === 0) return { nodes: [], edges: [] }
|
||||
return layoutGraph(rawNodes, rawEdges, {
|
||||
direction: 'TB',
|
||||
nodeWidth: 200,
|
||||
nodeHeight: 80,
|
||||
rankSep: 60,
|
||||
nodeSep: 30,
|
||||
})
|
||||
}, [rawNodes, rawEdges])
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(layoutedNodes)
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(layoutedEdges)
|
||||
|
||||
// Update nodes when layout changes
|
||||
useEffect(() => {
|
||||
setNodes(layoutedNodes)
|
||||
setEdges(layoutedEdges)
|
||||
}, [layoutedNodes, layoutedEdges, setNodes, setEdges])
|
||||
|
||||
const onNodeClick = useCallback(
|
||||
(_: React.MouseEvent, node: Node) => {
|
||||
if (node.type === 'session') {
|
||||
const sessionKey = (node.data as MonitorSession).key
|
||||
onSessionSelect(selectedSession === sessionKey ? null : sessionKey)
|
||||
}
|
||||
},
|
||||
[onSessionSelect, selectedSession]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="w-full h-full bg-gray-950">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeClick={onNodeClick}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2 }}
|
||||
minZoom={0.1}
|
||||
maxZoom={2}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background color="#374151" gap={20} />
|
||||
<Controls className="!bg-gray-800 !border-gray-700 !rounded-lg" />
|
||||
<MiniMap
|
||||
className="!bg-gray-800 !border-gray-700 !rounded-lg"
|
||||
nodeColor={(node) => {
|
||||
if (node.type === 'session') return '#06b6d4'
|
||||
return '#6b7280'
|
||||
}}
|
||||
/>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { memo, useState } from 'react'
|
||||
import { Handle, Position, type NodeProps } from '@xyflow/react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Loader2, CheckCircle, XCircle, Wrench, MessageSquare } from 'lucide-react'
|
||||
import type { MonitorAction } from '~/integrations/clawdbot'
|
||||
|
||||
type ActionNodeData = MonitorAction
|
||||
|
||||
const typeConfig = {
|
||||
delta: {
|
||||
icon: Loader2,
|
||||
color: 'border-blue-500 bg-blue-500/10',
|
||||
iconColor: 'text-blue-400',
|
||||
animate: true,
|
||||
},
|
||||
final: {
|
||||
icon: CheckCircle,
|
||||
color: 'border-green-500 bg-green-500/10',
|
||||
iconColor: 'text-green-400',
|
||||
animate: false,
|
||||
},
|
||||
aborted: {
|
||||
icon: XCircle,
|
||||
color: 'border-orange-500 bg-orange-500/10',
|
||||
iconColor: 'text-orange-400',
|
||||
animate: false,
|
||||
},
|
||||
error: {
|
||||
icon: XCircle,
|
||||
color: 'border-red-500 bg-red-500/10',
|
||||
iconColor: 'text-red-400',
|
||||
animate: false,
|
||||
},
|
||||
tool_call: {
|
||||
icon: Wrench,
|
||||
color: 'border-purple-500 bg-purple-500/10',
|
||||
iconColor: 'text-purple-400',
|
||||
animate: false,
|
||||
},
|
||||
tool_result: {
|
||||
icon: MessageSquare,
|
||||
color: 'border-cyan-500 bg-cyan-500/10',
|
||||
iconColor: 'text-cyan-400',
|
||||
animate: false,
|
||||
},
|
||||
}
|
||||
|
||||
export const ActionNode = memo(function ActionNode({
|
||||
data,
|
||||
selected,
|
||||
}: NodeProps<ActionNodeData>) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const config = typeConfig[data.type]
|
||||
const Icon = config.icon
|
||||
|
||||
const truncatedContent = data.content
|
||||
? data.content.length > 60
|
||||
? data.content.slice(0, 60) + '...'
|
||||
: data.content
|
||||
: null
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className={`
|
||||
px-3 py-2 rounded-md border min-w-[160px] max-w-[280px] cursor-pointer
|
||||
${config.color}
|
||||
${selected ? 'ring-2 ring-white/50' : ''}
|
||||
`}
|
||||
>
|
||||
<Handle type="target" position={Position.Top} className="!bg-gray-500 !w-2 !h-2" />
|
||||
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Icon
|
||||
size={14}
|
||||
className={`${config.iconColor} ${config.animate ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
<span className="text-xs font-medium text-gray-200 capitalize">
|
||||
{data.type.replace('_', ' ')}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-500 ml-auto">
|
||||
#{data.seq}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{data.toolName && (
|
||||
<div className="text-xs text-purple-300 mb-1 font-mono">
|
||||
{data.toolName}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{truncatedContent && (
|
||||
<div className="text-[11px] text-gray-300 leading-tight">
|
||||
{expanded ? data.content : truncatedContent}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expanded && data.toolArgs && (
|
||||
<pre className="mt-2 text-[10px] text-gray-400 bg-black/30 p-1 rounded overflow-auto max-h-32">
|
||||
{JSON.stringify(data.toolArgs, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
<Handle type="source" position={Position.Bottom} className="!bg-gray-500 !w-2 !h-2" />
|
||||
</motion.div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Search, Filter } from 'lucide-react'
|
||||
import { StatusIndicator } from './StatusIndicator'
|
||||
import type { MonitorSession } from '~/integrations/clawdbot'
|
||||
|
||||
interface SessionListProps {
|
||||
sessions: MonitorSession[]
|
||||
selectedKey: string | null
|
||||
onSelect: (key: string) => void
|
||||
}
|
||||
|
||||
const platformEmoji: Record<string, string> = {
|
||||
whatsapp: '💬',
|
||||
telegram: '✈️',
|
||||
discord: '🎮',
|
||||
slack: '💼',
|
||||
}
|
||||
|
||||
export function SessionList({ sessions, selectedKey, onSelect }: SessionListProps) {
|
||||
const [filter, setFilter] = useState('')
|
||||
const [platformFilter, setPlatformFilter] = useState<string | null>(null)
|
||||
|
||||
const platforms = [...new Set(sessions.map((s) => s.platform))]
|
||||
|
||||
const filteredSessions = sessions.filter((session) => {
|
||||
const matchesText =
|
||||
!filter ||
|
||||
session.recipient.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
session.agentId.toLowerCase().includes(filter.toLowerCase())
|
||||
const matchesPlatform = !platformFilter || session.platform === platformFilter
|
||||
return matchesText && matchesPlatform
|
||||
})
|
||||
|
||||
// Sort: active first, then by lastActivityAt
|
||||
const sortedSessions = [...filteredSessions].sort((a, b) => {
|
||||
if (a.status !== 'idle' && b.status === 'idle') return -1
|
||||
if (a.status === 'idle' && b.status !== 'idle') return 1
|
||||
return b.lastActivityAt - a.lastActivityAt
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-gray-900 border-r border-gray-700">
|
||||
<div className="p-3 border-b border-gray-700">
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-2.5 top-2.5 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter sessions..."
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="w-full pl-8 pr-3 py-2 text-sm bg-gray-800 border border-gray-700 rounded focus:outline-none focus:border-cyan-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{platforms.length > 1 && (
|
||||
<div className="flex gap-1 mt-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => setPlatformFilter(null)}
|
||||
className={`px-2 py-0.5 text-xs rounded ${
|
||||
!platformFilter ? 'bg-cyan-600' : 'bg-gray-700 hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{platforms.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPlatformFilter(p)}
|
||||
className={`px-2 py-0.5 text-xs rounded ${
|
||||
platformFilter === p ? 'bg-cyan-600' : 'bg-gray-700 hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
{platformEmoji[p] || '📱'} {p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{sortedSessions.map((session) => (
|
||||
<motion.button
|
||||
key={session.key}
|
||||
layout
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
onClick={() => onSelect(session.key)}
|
||||
className={`w-full text-left p-3 border-b border-gray-800 hover:bg-gray-800 transition-colors ${
|
||||
selectedKey === session.key ? 'bg-gray-800 border-l-2 border-l-cyan-500' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span>{platformEmoji[session.platform] || '📱'}</span>
|
||||
<span className="text-sm font-medium truncate flex-1">
|
||||
{session.recipient}
|
||||
</span>
|
||||
<StatusIndicator status={session.status} size="sm" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||
<span className="truncate">{session.agentId}</span>
|
||||
{session.isGroup && (
|
||||
<span className="px-1 py-0.5 bg-gray-700 rounded text-[10px]">
|
||||
group
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</motion.button>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{sortedSessions.length === 0 && (
|
||||
<div className="p-4 text-center text-gray-500 text-sm">
|
||||
No sessions found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-2 border-t border-gray-700 text-xs text-gray-500 text-center">
|
||||
{sessions.length} session{sessions.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { memo } from 'react'
|
||||
import { Handle, Position, type NodeProps } from '@xyflow/react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { MessageSquare, Users, User } from 'lucide-react'
|
||||
import { StatusIndicator } from './StatusIndicator'
|
||||
import type { MonitorSession } from '~/integrations/clawdbot'
|
||||
|
||||
type SessionNodeData = MonitorSession
|
||||
|
||||
const platformIcons: Record<string, string> = {
|
||||
whatsapp: '💬',
|
||||
telegram: '✈️',
|
||||
discord: '🎮',
|
||||
slack: '💼',
|
||||
}
|
||||
|
||||
export const SessionNode = memo(function SessionNode({
|
||||
data,
|
||||
selected,
|
||||
}: NodeProps<SessionNodeData>) {
|
||||
const platformIcon = platformIcons[data.platform] ?? '📱'
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className={`
|
||||
px-4 py-3 rounded-lg border-2 min-w-[180px]
|
||||
bg-gray-800 text-white shadow-lg
|
||||
${selected ? 'border-cyan-400' : 'border-gray-600'}
|
||||
${data.status === 'thinking' ? 'border-yellow-500' : ''}
|
||||
`}
|
||||
>
|
||||
<Handle type="target" position={Position.Top} className="!bg-gray-500" />
|
||||
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-lg">{platformIcon}</span>
|
||||
<span className="font-semibold text-sm capitalize">{data.platform}</span>
|
||||
<StatusIndicator status={data.status} size="sm" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-gray-300 text-xs">
|
||||
{data.isGroup ? (
|
||||
<Users size={12} className="text-gray-400" />
|
||||
) : (
|
||||
<User size={12} className="text-gray-400" />
|
||||
)}
|
||||
<span className="truncate max-w-[120px]">{data.recipient}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-[10px] text-gray-500 truncate">
|
||||
{data.agentId}
|
||||
</div>
|
||||
|
||||
<Handle type="source" position={Position.Bottom} className="!bg-gray-500" />
|
||||
</motion.div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Settings, X, History, Wifi, WifiOff, RefreshCw } from 'lucide-react'
|
||||
|
||||
interface SettingsPanelProps {
|
||||
connected: boolean
|
||||
historicalMode: boolean
|
||||
onHistoricalModeChange: (enabled: boolean) => void
|
||||
onConnect: () => void
|
||||
onDisconnect: () => void
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
export function SettingsPanel({
|
||||
connected,
|
||||
historicalMode,
|
||||
onHistoricalModeChange,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
onRefresh,
|
||||
}: SettingsPanelProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="p-2 bg-gray-800 hover:bg-gray-700 rounded-lg border border-gray-700 transition-colors"
|
||||
>
|
||||
<Settings size={18} className="text-gray-300" />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setOpen(false)}
|
||||
className="fixed inset-0 bg-black/50 z-40"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 300 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 300 }}
|
||||
className="fixed right-0 top-0 h-full w-80 bg-gray-900 border-l border-gray-700 z-50 p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-semibold">Settings</h2>
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
className="p-1 hover:bg-gray-800 rounded"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Connection status */}
|
||||
<div className="p-3 bg-gray-800 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{connected ? (
|
||||
<Wifi size={16} className="text-green-400" />
|
||||
) : (
|
||||
<WifiOff size={16} className="text-red-400" />
|
||||
)}
|
||||
<span className="text-sm font-medium">
|
||||
{connected ? 'Connected' : 'Disconnected'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{connected ? (
|
||||
<button
|
||||
onClick={onDisconnect}
|
||||
className="flex-1 px-3 py-1.5 text-sm bg-red-600 hover:bg-red-500 rounded"
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onConnect}
|
||||
className="flex-1 px-3 py-1.5 text-sm bg-green-600 hover:bg-green-500 rounded"
|
||||
>
|
||||
Connect
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={!connected}
|
||||
className="px-3 py-1.5 text-sm bg-gray-700 hover:bg-gray-600 rounded disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Historical mode toggle */}
|
||||
<div className="p-3 bg-gray-800 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<History size={16} className="text-gray-400" />
|
||||
<span className="text-sm font-medium">Historical Mode</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mb-3">
|
||||
Load past sessions on connect
|
||||
</p>
|
||||
<button
|
||||
onClick={() => onHistoricalModeChange(!historicalMode)}
|
||||
className={`w-full px-3 py-1.5 text-sm rounded transition-colors ${
|
||||
historicalMode
|
||||
? 'bg-cyan-600 hover:bg-cyan-500'
|
||||
: 'bg-gray-700 hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
{historicalMode ? 'Enabled' : 'Disabled'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="text-xs text-gray-500 p-3 bg-gray-800/50 rounded-lg">
|
||||
<p className="mb-2">
|
||||
<strong>Gateway:</strong> ws://127.0.0.1:18789
|
||||
</p>
|
||||
<p>
|
||||
<strong>Protocol:</strong> v3
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
interface StatusIndicatorProps {
|
||||
status: 'idle' | 'active' | 'thinking'
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'w-2 h-2',
|
||||
md: 'w-3 h-3',
|
||||
lg: 'w-4 h-4',
|
||||
}
|
||||
|
||||
const statusColors = {
|
||||
idle: 'bg-gray-500',
|
||||
active: 'bg-green-500',
|
||||
thinking: 'bg-yellow-500',
|
||||
}
|
||||
|
||||
export function StatusIndicator({ status, size = 'md' }: StatusIndicatorProps) {
|
||||
const isPulsing = status === 'active' || status === 'thinking'
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center justify-center">
|
||||
<div
|
||||
className={`rounded-full ${sizeClasses[size]} ${statusColors[status]}`}
|
||||
/>
|
||||
{isPulsing && (
|
||||
<motion.div
|
||||
className={`absolute rounded-full ${sizeClasses[size]} ${statusColors[status]}`}
|
||||
initial={{ opacity: 0.6, scale: 1 }}
|
||||
animate={{ opacity: 0, scale: 2.5 }}
|
||||
transition={{
|
||||
duration: 1.5,
|
||||
repeat: Infinity,
|
||||
ease: 'easeOut',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { ActionGraph } from './ActionGraph'
|
||||
export { SessionList } from './SessionList'
|
||||
export { SessionNode } from './SessionNode'
|
||||
export { ActionNode } from './ActionNode'
|
||||
export { StatusIndicator } from './StatusIndicator'
|
||||
export { SettingsPanel } from './SettingsPanel'
|
||||
@@ -0,0 +1,199 @@
|
||||
import WebSocket from 'ws'
|
||||
import {
|
||||
type GatewayFrame,
|
||||
type RequestFrame,
|
||||
type ResponseFrame,
|
||||
type EventFrame,
|
||||
type HelloOk,
|
||||
type ChatEvent,
|
||||
type AgentEvent,
|
||||
type SessionInfo,
|
||||
type SessionsListParams,
|
||||
createConnectParams,
|
||||
} from './protocol'
|
||||
|
||||
type EventCallback = (event: EventFrame) => void
|
||||
|
||||
export class ClawdbotClient {
|
||||
private ws: WebSocket | null = null
|
||||
private requestId = 0
|
||||
private pendingRequests = new Map<
|
||||
string,
|
||||
{ resolve: (v: unknown) => void; reject: (e: Error) => void }
|
||||
>()
|
||||
private eventListeners: EventCallback[] = []
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private _connected = false
|
||||
|
||||
constructor(
|
||||
private url: string = 'ws://127.0.0.1:18789',
|
||||
private token?: string
|
||||
) {}
|
||||
|
||||
get connected() {
|
||||
return this._connected
|
||||
}
|
||||
|
||||
async connect(): Promise<HelloOk> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.ws = new WebSocket(this.url)
|
||||
|
||||
this.ws.on('open', () => {
|
||||
const params = createConnectParams(this.token)
|
||||
this.ws!.send(JSON.stringify(params))
|
||||
})
|
||||
|
||||
this.ws.on('message', (data) => {
|
||||
try {
|
||||
const msg = JSON.parse(data.toString())
|
||||
this.handleMessage(msg, resolve, reject)
|
||||
} catch (e) {
|
||||
console.error('Failed to parse message:', e)
|
||||
}
|
||||
})
|
||||
|
||||
this.ws.on('error', (err) => {
|
||||
console.error('WebSocket error:', err)
|
||||
reject(err)
|
||||
})
|
||||
|
||||
this.ws.on('close', () => {
|
||||
this._connected = false
|
||||
this.scheduleReconnect()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private handleMessage(
|
||||
msg: GatewayFrame | HelloOk,
|
||||
connectResolve?: (v: HelloOk) => void,
|
||||
connectReject?: (e: Error) => void
|
||||
) {
|
||||
if ('type' in msg) {
|
||||
switch (msg.type) {
|
||||
case 'hello-ok':
|
||||
this._connected = true
|
||||
connectResolve?.(msg)
|
||||
break
|
||||
|
||||
case 'res':
|
||||
this.handleResponse(msg)
|
||||
break
|
||||
|
||||
case 'event':
|
||||
this.handleEvent(msg)
|
||||
break
|
||||
|
||||
case 'req':
|
||||
// Server shouldn't send requests to us
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleResponse(res: ResponseFrame) {
|
||||
const pending = this.pendingRequests.get(res.id)
|
||||
if (pending) {
|
||||
this.pendingRequests.delete(res.id)
|
||||
if (res.ok) {
|
||||
pending.resolve(res.payload)
|
||||
} else {
|
||||
pending.reject(new Error(res.error?.message || 'Request failed'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleEvent(event: EventFrame) {
|
||||
for (const listener of this.eventListeners) {
|
||||
try {
|
||||
listener(event)
|
||||
} catch (e) {
|
||||
console.error('Event listener error:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect() {
|
||||
if (this.reconnectTimer) return
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null
|
||||
this.connect().catch(console.error)
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
async request<T>(method: string, params?: unknown): Promise<T> {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
throw new Error('Not connected')
|
||||
}
|
||||
|
||||
const id = `req-${++this.requestId}`
|
||||
const req: RequestFrame = { type: 'req', id, method, params }
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pendingRequests.set(id, {
|
||||
resolve: resolve as (v: unknown) => void,
|
||||
reject,
|
||||
})
|
||||
this.ws!.send(JSON.stringify(req))
|
||||
|
||||
setTimeout(() => {
|
||||
if (this.pendingRequests.has(id)) {
|
||||
this.pendingRequests.delete(id)
|
||||
reject(new Error('Request timeout'))
|
||||
}
|
||||
}, 30000)
|
||||
})
|
||||
}
|
||||
|
||||
onEvent(callback: EventCallback): () => void {
|
||||
this.eventListeners.push(callback)
|
||||
return () => {
|
||||
const idx = this.eventListeners.indexOf(callback)
|
||||
if (idx >= 0) this.eventListeners.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
async listSessions(params?: SessionsListParams): Promise<SessionInfo[]> {
|
||||
const result = await this.request<{ sessions: SessionInfo[] }>(
|
||||
'sessions.list',
|
||||
params
|
||||
)
|
||||
return result.sessions
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
if (this.ws) {
|
||||
this.ws.close()
|
||||
this.ws = null
|
||||
}
|
||||
this._connected = false
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance for server use
|
||||
let clientInstance: ClawdbotClient | null = null
|
||||
|
||||
export function getClawdbotClient(): ClawdbotClient {
|
||||
if (!clientInstance) {
|
||||
const token = process.env.CLAWDBOT_API_TOKEN
|
||||
clientInstance = new ClawdbotClient('ws://127.0.0.1:18789', token)
|
||||
}
|
||||
return clientInstance
|
||||
}
|
||||
|
||||
// Parsed event helpers
|
||||
export function isChatEvent(
|
||||
event: EventFrame
|
||||
): event is EventFrame & { payload: ChatEvent } {
|
||||
return event.event === 'chat' && event.payload != null
|
||||
}
|
||||
|
||||
export function isAgentEvent(
|
||||
event: EventFrame
|
||||
): event is EventFrame & { payload: AgentEvent } {
|
||||
return event.event === 'agent' && event.payload != null
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { createCollection } from '@tanstack/db'
|
||||
import type { MonitorSession, MonitorAction } from './protocol'
|
||||
|
||||
export const sessionsCollection = createCollection<MonitorSession>({
|
||||
id: 'clawdbot-sessions',
|
||||
primaryKey: 'key',
|
||||
})
|
||||
|
||||
export const actionsCollection = createCollection<MonitorAction>({
|
||||
id: 'clawdbot-actions',
|
||||
primaryKey: 'id',
|
||||
})
|
||||
|
||||
// Helper to update or insert session
|
||||
export function upsertSession(session: MonitorSession) {
|
||||
const existing = sessionsCollection.state.get(session.key)
|
||||
if (existing) {
|
||||
sessionsCollection.update(session.key, session)
|
||||
} else {
|
||||
sessionsCollection.insert(session)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to add action
|
||||
export function addAction(action: MonitorAction) {
|
||||
const existing = actionsCollection.state.get(action.id)
|
||||
if (!existing) {
|
||||
actionsCollection.insert(action)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to update session status
|
||||
export function updateSessionStatus(
|
||||
key: string,
|
||||
status: MonitorSession['status']
|
||||
) {
|
||||
const session = sessionsCollection.state.get(key)
|
||||
if (session) {
|
||||
sessionsCollection.update(key, {
|
||||
status,
|
||||
lastActivityAt: Date.now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all data
|
||||
export function clearCollections() {
|
||||
for (const session of sessionsCollection.state.values()) {
|
||||
sessionsCollection.delete(session.key)
|
||||
}
|
||||
for (const action of actionsCollection.state.values()) {
|
||||
actionsCollection.delete(action.id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './protocol'
|
||||
export * from './parser'
|
||||
export * from './collections'
|
||||
export { ClawdbotClient, getClawdbotClient, isChatEvent, isAgentEvent } from './client'
|
||||
@@ -0,0 +1,126 @@
|
||||
import type {
|
||||
EventFrame,
|
||||
ChatEvent,
|
||||
AgentEvent,
|
||||
MonitorSession,
|
||||
MonitorAction,
|
||||
SessionInfo,
|
||||
} from './protocol'
|
||||
import { parseSessionKey } from './protocol'
|
||||
|
||||
export function sessionInfoToMonitor(info: SessionInfo): MonitorSession {
|
||||
const parsed = parseSessionKey(info.key)
|
||||
return {
|
||||
key: info.key,
|
||||
agentId: parsed.agentId,
|
||||
platform: parsed.platform,
|
||||
recipient: parsed.recipient,
|
||||
isGroup: parsed.isGroup,
|
||||
lastActivityAt: info.lastActivityAt,
|
||||
status: 'idle',
|
||||
}
|
||||
}
|
||||
|
||||
export function chatEventToAction(event: ChatEvent): MonitorAction {
|
||||
const action: MonitorAction = {
|
||||
id: `${event.runId}-${event.seq}`,
|
||||
runId: event.runId,
|
||||
sessionKey: event.sessionKey,
|
||||
seq: event.seq,
|
||||
type: event.state,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
if (event.message) {
|
||||
if (typeof event.message === 'string') {
|
||||
action.content = event.message
|
||||
} else if (
|
||||
typeof event.message === 'object' &&
|
||||
'content' in (event.message as Record<string, unknown>)
|
||||
) {
|
||||
const msg = event.message as Record<string, unknown>
|
||||
action.content = String(msg.content || '')
|
||||
|
||||
// Check for tool calls in message
|
||||
if (msg.tool_calls && Array.isArray(msg.tool_calls)) {
|
||||
const tc = msg.tool_calls[0] as Record<string, unknown>
|
||||
if (tc) {
|
||||
action.type = 'tool_call'
|
||||
action.toolName = String(tc.name || 'unknown')
|
||||
action.toolArgs = tc.arguments
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event.errorMessage) {
|
||||
action.content = event.errorMessage
|
||||
}
|
||||
|
||||
// Parent is previous seq in same run
|
||||
if (event.seq > 0) {
|
||||
action.parentId = `${event.runId}-${event.seq - 1}`
|
||||
}
|
||||
|
||||
return action
|
||||
}
|
||||
|
||||
export function agentEventToAction(event: AgentEvent): MonitorAction {
|
||||
const data = event.data
|
||||
|
||||
let type: MonitorAction['type'] = 'delta'
|
||||
let content: string | undefined
|
||||
let toolName: string | undefined
|
||||
let toolArgs: unknown | undefined
|
||||
|
||||
if (data.type === 'tool_use') {
|
||||
type = 'tool_call'
|
||||
toolName = String(data.name || 'unknown')
|
||||
toolArgs = data.input
|
||||
content = `Tool: ${toolName}`
|
||||
} else if (data.type === 'tool_result') {
|
||||
type = 'tool_result'
|
||||
content = String(data.content || '')
|
||||
} else if (data.type === 'text') {
|
||||
type = 'delta'
|
||||
content = String(data.text || '')
|
||||
}
|
||||
|
||||
return {
|
||||
id: `${event.runId}-${event.seq}`,
|
||||
runId: event.runId,
|
||||
sessionKey: event.stream,
|
||||
seq: event.seq,
|
||||
type,
|
||||
timestamp: event.ts,
|
||||
content,
|
||||
toolName,
|
||||
toolArgs,
|
||||
parentId: event.seq > 0 ? `${event.runId}-${event.seq - 1}` : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function parseEventFrame(
|
||||
frame: EventFrame
|
||||
): { session?: Partial<MonitorSession>; action?: MonitorAction } | null {
|
||||
if (frame.event === 'chat' && frame.payload) {
|
||||
const chatEvent = frame.payload as ChatEvent
|
||||
return {
|
||||
action: chatEventToAction(chatEvent),
|
||||
session: {
|
||||
key: chatEvent.sessionKey,
|
||||
status: chatEvent.state === 'delta' ? 'thinking' : 'active',
|
||||
lastActivityAt: Date.now(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (frame.event === 'agent' && frame.payload) {
|
||||
const agentEvent = frame.payload as AgentEvent
|
||||
return {
|
||||
action: agentEventToAction(agentEvent),
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// Clawdbot Gateway Protocol v3 types
|
||||
|
||||
// Frame types
|
||||
export interface RequestFrame {
|
||||
type: 'req'
|
||||
id: string
|
||||
method: string
|
||||
params?: unknown
|
||||
}
|
||||
|
||||
export interface ResponseFrame {
|
||||
type: 'res'
|
||||
id: string
|
||||
ok: boolean
|
||||
payload?: unknown
|
||||
error?: { code: string; message: string }
|
||||
}
|
||||
|
||||
export interface EventFrame {
|
||||
type: 'event'
|
||||
event: string
|
||||
payload?: unknown
|
||||
seq?: number
|
||||
stateVersion?: { presence: number; health: number }
|
||||
}
|
||||
|
||||
export type GatewayFrame = RequestFrame | ResponseFrame | EventFrame
|
||||
|
||||
// Connection
|
||||
export interface ClientInfo {
|
||||
id: string
|
||||
displayName: string
|
||||
version: string
|
||||
platform: string
|
||||
mode: 'ui' | 'cli' | 'bot'
|
||||
}
|
||||
|
||||
export interface ConnectParams {
|
||||
minProtocol: 3
|
||||
maxProtocol: 3
|
||||
client: ClientInfo
|
||||
auth?: { token?: string }
|
||||
}
|
||||
|
||||
export interface HelloOk {
|
||||
type: 'hello-ok'
|
||||
protocol: number
|
||||
snapshot: {
|
||||
presence: PresenceEntry[]
|
||||
health: unknown
|
||||
stateVersion: { presence: number; health: number }
|
||||
}
|
||||
features: { methods: string[]; events: string[] }
|
||||
}
|
||||
|
||||
export interface PresenceEntry {
|
||||
key: string
|
||||
client: ClientInfo
|
||||
connectedAt: number
|
||||
}
|
||||
|
||||
// Chat events
|
||||
export interface ChatEvent {
|
||||
runId: string
|
||||
sessionKey: string
|
||||
seq: number
|
||||
state: 'delta' | 'final' | 'aborted' | 'error'
|
||||
message?: unknown
|
||||
errorMessage?: string
|
||||
usage?: {
|
||||
inputTokens?: number
|
||||
outputTokens?: number
|
||||
}
|
||||
stopReason?: string
|
||||
}
|
||||
|
||||
// Agent events
|
||||
export interface AgentEvent {
|
||||
runId: string
|
||||
seq: number
|
||||
stream: string
|
||||
ts: number
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
|
||||
// Sessions
|
||||
export interface SessionsListParams {
|
||||
limit?: number
|
||||
activeMinutes?: number
|
||||
includeLastMessage?: boolean
|
||||
agentId?: string
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
key: string
|
||||
agentId: string
|
||||
createdAt: number
|
||||
lastActivityAt: number
|
||||
messageCount: number
|
||||
lastMessage?: unknown
|
||||
}
|
||||
|
||||
// App-level types
|
||||
export interface MonitorSession {
|
||||
key: string
|
||||
agentId: string
|
||||
platform: string
|
||||
recipient: string
|
||||
isGroup: boolean
|
||||
lastActivityAt: number
|
||||
status: 'idle' | 'active' | 'thinking'
|
||||
}
|
||||
|
||||
export interface MonitorAction {
|
||||
id: string
|
||||
runId: string
|
||||
sessionKey: string
|
||||
seq: number
|
||||
type: 'delta' | 'final' | 'aborted' | 'error' | 'tool_call' | 'tool_result'
|
||||
timestamp: number
|
||||
content?: string
|
||||
toolName?: string
|
||||
toolArgs?: unknown
|
||||
parentId?: string
|
||||
}
|
||||
|
||||
// Utility functions
|
||||
export function parseSessionKey(key: string): {
|
||||
agentId: string
|
||||
platform: string
|
||||
recipient: string
|
||||
isGroup: boolean
|
||||
} {
|
||||
// Format: "agent:claude:whatsapp:+1234567890"
|
||||
// Or: "agent:claude:telegram:group:12345"
|
||||
const parts = key.split(':')
|
||||
const agentId = parts[1] || 'unknown'
|
||||
const platform = parts[2] || 'unknown'
|
||||
const isGroup = parts[3] === 'group'
|
||||
const recipient = isGroup ? parts[4] || '' : parts.slice(3).join(':')
|
||||
|
||||
return { agentId, platform, recipient, isGroup }
|
||||
}
|
||||
|
||||
export function createConnectParams(token?: string): ConnectParams {
|
||||
return {
|
||||
minProtocol: 3,
|
||||
maxProtocol: 3,
|
||||
client: {
|
||||
id: 'clawdbot-control-ui',
|
||||
displayName: 'Crabwalk Monitor',
|
||||
version: '0.1.0',
|
||||
platform: typeof window !== 'undefined' ? 'browser' : 'node',
|
||||
mode: 'ui',
|
||||
},
|
||||
auth: token ? { token } : undefined,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useLiveQuery } from '@tanstack/react-db'
|
||||
import { todosCollection } from '~/lib/demo-db'
|
||||
|
||||
export const dbDevtoolsPlugin = {
|
||||
name: 'TanStack DB',
|
||||
render: (
|
||||
<div className="p-4 text-sm">
|
||||
<h3 className="font-semibold mb-2">Collections</h3>
|
||||
<DbInspector />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
function DbInspector() {
|
||||
const todos = useLiveQuery(todosCollection)
|
||||
return (
|
||||
<div>
|
||||
<div className="text-xs text-gray-400 mb-1">todos ({todos.length})</div>
|
||||
<pre className="bg-gray-800 p-2 rounded text-xs overflow-auto max-h-48">
|
||||
{JSON.stringify(todos, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||
|
||||
export const queryDevtoolsPlugin = {
|
||||
name: 'TanStack Query',
|
||||
render: <ReactQueryDevtools buttonPosition="bottom-right" />,
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
|
||||
export function QueryProvider({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export function getQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createTRPCClient, httpBatchLink } from '@trpc/client'
|
||||
import superjson from 'superjson'
|
||||
import type { AppRouter } from './router'
|
||||
|
||||
export const trpc = createTRPCClient<AppRouter>({
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url: '/api/trpc',
|
||||
transformer: superjson,
|
||||
}),
|
||||
],
|
||||
})
|
||||
@@ -0,0 +1,129 @@
|
||||
import { initTRPC } from '@trpc/server'
|
||||
import { observable } from '@trpc/server/observable'
|
||||
import superjson from 'superjson'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
getClawdbotClient,
|
||||
isChatEvent,
|
||||
isAgentEvent,
|
||||
parseEventFrame,
|
||||
sessionInfoToMonitor,
|
||||
type MonitorSession,
|
||||
type MonitorAction,
|
||||
} from '~/integrations/clawdbot'
|
||||
|
||||
const t = initTRPC.create({
|
||||
transformer: superjson,
|
||||
})
|
||||
|
||||
export const router = t.router
|
||||
export const publicProcedure = t.procedure
|
||||
|
||||
// Clawdbot router
|
||||
const clawdbotRouter = router({
|
||||
connect: publicProcedure.mutation(async () => {
|
||||
const client = getClawdbotClient()
|
||||
if (client.connected) {
|
||||
return { status: 'already_connected' as const }
|
||||
}
|
||||
try {
|
||||
const hello = await client.connect()
|
||||
return {
|
||||
status: 'connected' as const,
|
||||
protocol: hello.protocol,
|
||||
features: hello.features,
|
||||
presenceCount: hello.snapshot.presence.length,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'error' as const,
|
||||
message: error instanceof Error ? error.message : 'Connection failed',
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
disconnect: publicProcedure.mutation(() => {
|
||||
const client = getClawdbotClient()
|
||||
client.disconnect()
|
||||
return { status: 'disconnected' as const }
|
||||
}),
|
||||
|
||||
status: publicProcedure.query(() => {
|
||||
const client = getClawdbotClient()
|
||||
return { connected: client.connected }
|
||||
}),
|
||||
|
||||
sessions: publicProcedure
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
limit: z.number().optional(),
|
||||
activeMinutes: z.number().optional(),
|
||||
agentId: z.string().optional(),
|
||||
})
|
||||
.optional()
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const client = getClawdbotClient()
|
||||
if (!client.connected) {
|
||||
return { sessions: [], error: 'Not connected' }
|
||||
}
|
||||
try {
|
||||
const sessions = await client.listSessions(input)
|
||||
return {
|
||||
sessions: sessions.map(sessionInfoToMonitor),
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
sessions: [],
|
||||
error: error instanceof Error ? error.message : 'Failed to list sessions',
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
events: publicProcedure.subscription(() => {
|
||||
return observable<{
|
||||
type: 'session' | 'action'
|
||||
session?: Partial<MonitorSession>
|
||||
action?: MonitorAction
|
||||
}>((emit) => {
|
||||
const client = getClawdbotClient()
|
||||
|
||||
const unsubscribe = client.onEvent((event) => {
|
||||
const parsed = parseEventFrame(event)
|
||||
if (parsed) {
|
||||
if (parsed.session) {
|
||||
emit.next({ type: 'session', session: parsed.session })
|
||||
}
|
||||
if (parsed.action) {
|
||||
emit.next({ type: 'action', action: parsed.action })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsubscribe()
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
export const appRouter = router({
|
||||
hello: publicProcedure
|
||||
.input(z.object({ name: z.string().optional() }))
|
||||
.query(({ input }) => {
|
||||
return { greeting: `Hello ${input.name ?? 'World'}!` }
|
||||
}),
|
||||
|
||||
getItems: publicProcedure.query(() => {
|
||||
return [
|
||||
{ id: 1, name: 'Item 1' },
|
||||
{ id: 2, name: 'Item 2' },
|
||||
{ id: 3, name: 'Item 3' },
|
||||
]
|
||||
}),
|
||||
|
||||
clawdbot: clawdbotRouter,
|
||||
})
|
||||
|
||||
export type AppRouter = typeof appRouter
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createCollection } from '@tanstack/db'
|
||||
|
||||
export interface Todo {
|
||||
id: string
|
||||
text: string
|
||||
completed: boolean
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export const todosCollection = createCollection<Todo>({
|
||||
id: 'todos',
|
||||
primaryKey: 'id',
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import dagre from 'dagre'
|
||||
import type { Node, Edge } from '@xyflow/react'
|
||||
|
||||
export interface LayoutOptions {
|
||||
direction?: 'TB' | 'LR' | 'BT' | 'RL'
|
||||
nodeWidth?: number
|
||||
nodeHeight?: number
|
||||
rankSep?: number
|
||||
nodeSep?: number
|
||||
}
|
||||
|
||||
export function layoutGraph(
|
||||
nodes: Node[],
|
||||
edges: Edge[],
|
||||
options: LayoutOptions = {}
|
||||
): { nodes: Node[]; edges: Edge[] } {
|
||||
const {
|
||||
direction = 'TB',
|
||||
nodeWidth = 200,
|
||||
nodeHeight = 80,
|
||||
rankSep = 80,
|
||||
nodeSep = 40,
|
||||
} = options
|
||||
|
||||
const g = new dagre.graphlib.Graph()
|
||||
g.setDefaultEdgeLabel(() => ({}))
|
||||
g.setGraph({ rankdir: direction, ranksep: rankSep, nodesep: nodeSep })
|
||||
|
||||
// Add nodes
|
||||
for (const node of nodes) {
|
||||
const width = node.measured?.width ?? nodeWidth
|
||||
const height = node.measured?.height ?? nodeHeight
|
||||
g.setNode(node.id, { width, height })
|
||||
}
|
||||
|
||||
// Add edges
|
||||
for (const edge of edges) {
|
||||
g.setEdge(edge.source, edge.target)
|
||||
}
|
||||
|
||||
// Run layout
|
||||
dagre.layout(g)
|
||||
|
||||
// Apply positions
|
||||
const layoutedNodes = nodes.map((node) => {
|
||||
const nodeWithPosition = g.node(node.id)
|
||||
const width = node.measured?.width ?? nodeWidth
|
||||
const height = node.measured?.height ?? nodeHeight
|
||||
|
||||
return {
|
||||
...node,
|
||||
position: {
|
||||
x: nodeWithPosition.x - width / 2,
|
||||
y: nodeWithPosition.y - height / 2,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
return { nodes: layoutedNodes, edges }
|
||||
}
|
||||
|
||||
// Group nodes by session for better visual organization
|
||||
export function groupNodesBySession(
|
||||
nodes: Node[]
|
||||
): Map<string, Node[]> {
|
||||
const groups = new Map<string, Node[]>()
|
||||
|
||||
for (const node of nodes) {
|
||||
const sessionKey = node.data?.sessionKey as string | undefined
|
||||
if (sessionKey) {
|
||||
const group = groups.get(sessionKey) ?? []
|
||||
group.push(node)
|
||||
groups.set(sessionKey, group)
|
||||
}
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { createRouter as createTanStackRouter } from '@tanstack/react-router'
|
||||
import { routeTree } from './routeTree.gen'
|
||||
|
||||
export function getRouter() {
|
||||
const router = createTanStackRouter({
|
||||
routeTree,
|
||||
defaultPreload: 'intent',
|
||||
scrollRestoration: true,
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface Register {
|
||||
router: ReturnType<typeof getRouter>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import React from 'react'
|
||||
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
|
||||
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
|
||||
import appCss from '../styles.css?url'
|
||||
import { Header } from '../components/Header'
|
||||
import { QueryProvider } from '../integrations/query/provider'
|
||||
import { queryDevtoolsPlugin } from '../integrations/query/devtools'
|
||||
import { dbDevtoolsPlugin } from '../integrations/db/devtools'
|
||||
|
||||
const devtoolsPlugins = [
|
||||
queryDevtoolsPlugin,
|
||||
dbDevtoolsPlugin,
|
||||
]
|
||||
|
||||
export const Route = createRootRoute({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ charSet: 'utf-8' },
|
||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
||||
{ title: 'TanStack Start Starter' },
|
||||
],
|
||||
links: [{ rel: 'stylesheet', href: appCss }],
|
||||
}),
|
||||
shellComponent: RootDocument,
|
||||
})
|
||||
|
||||
function RootDocument({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<HeadContent />
|
||||
</head>
|
||||
<body>
|
||||
<QueryProvider>
|
||||
<Header />
|
||||
{children}
|
||||
<TanStackRouterDevtools />
|
||||
{devtoolsPlugins.map((plugin, i) => (
|
||||
<React.Fragment key={i}>{plugin.render}</React.Fragment>
|
||||
))}
|
||||
</QueryProvider>
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
|
||||
import { appRouter } from '~/integrations/trpc/router'
|
||||
|
||||
async function handler({ request }: { request: Request }) {
|
||||
return fetchRequestHandler({
|
||||
endpoint: '/api/trpc',
|
||||
req: request,
|
||||
router: appRouter,
|
||||
createContext: () => ({}),
|
||||
})
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/api/trpc/$')({
|
||||
// @ts-expect-error server property not in route types yet
|
||||
server: {
|
||||
handlers: {
|
||||
GET: handler,
|
||||
POST: handler,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useLiveQuery, createTransaction } from '@tanstack/react-db'
|
||||
import { todosCollection, type Todo } from '~/lib/demo-db'
|
||||
|
||||
export const Route = createFileRoute('/demo/db')({
|
||||
component: DbDemo,
|
||||
})
|
||||
|
||||
function DbDemo() {
|
||||
const [newTodo, setNewTodo] = useState('')
|
||||
const todos = useLiveQuery(todosCollection)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!newTodo.trim()) return
|
||||
|
||||
const tx = createTransaction({
|
||||
mutationFn: async () => {
|
||||
// Local-only collection auto-persists
|
||||
},
|
||||
})
|
||||
|
||||
tx.mutate(() => {
|
||||
todosCollection.insert({
|
||||
id: crypto.randomUUID(),
|
||||
text: newTodo.trim(),
|
||||
completed: false,
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
})
|
||||
|
||||
await tx.commit()
|
||||
setNewTodo('')
|
||||
}
|
||||
|
||||
const handleToggle = async (todo: Todo) => {
|
||||
const tx = createTransaction({
|
||||
mutationFn: async () => {},
|
||||
})
|
||||
|
||||
tx.mutate(() => {
|
||||
todosCollection.update(todo.id, { completed: !todo.completed })
|
||||
})
|
||||
|
||||
await tx.commit()
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
const tx = createTransaction({
|
||||
mutationFn: async () => {},
|
||||
})
|
||||
|
||||
tx.mutate(() => {
|
||||
todosCollection.delete(id)
|
||||
})
|
||||
|
||||
await tx.commit()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white p-8">
|
||||
<div className="max-w-md mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-6">TanStack DB Demo</h1>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mb-6">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newTodo}
|
||||
onChange={(e) => setNewTodo(e.target.value)}
|
||||
placeholder="Add a new todo..."
|
||||
className="flex-1 px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg focus:outline-none focus:border-orange-500"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-orange-600 hover:bg-orange-500 rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="space-y-2">
|
||||
{todos.map((todo) => (
|
||||
<div
|
||||
key={todo.id}
|
||||
className="flex items-center gap-3 p-3 bg-gray-800 rounded-lg"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={todo.completed}
|
||||
onChange={() => handleToggle(todo)}
|
||||
className="w-5 h-5 rounded accent-orange-500"
|
||||
/>
|
||||
<span
|
||||
className={`flex-1 ${todo.completed ? 'line-through text-gray-500' : ''}`}
|
||||
>
|
||||
{todo.text}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleDelete(todo.id)}
|
||||
className="px-2 py-1 text-red-400 hover:text-red-300 text-sm"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{todos.length === 0 && (
|
||||
<p className="text-gray-500 text-center py-4">No todos yet. Add one above.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 bg-gray-800 rounded-lg p-4">
|
||||
<h2 className="font-semibold mb-2">Database State</h2>
|
||||
<pre className="text-sm text-gray-400 overflow-auto max-h-48">
|
||||
{JSON.stringify(todos, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<Link to="/" className="text-cyan-400 hover:text-cyan-300">
|
||||
← Back to Home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
export const Route = createFileRoute('/demo/query')({
|
||||
component: QueryDemo,
|
||||
})
|
||||
|
||||
function QueryDemo() {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['demo-post'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch('https://jsonplaceholder.typicode.com/posts/1')
|
||||
if (!res.ok) throw new Error('Failed to fetch')
|
||||
return res.json()
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white p-8">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-6">TanStack Query Demo</h1>
|
||||
|
||||
{isLoading && (
|
||||
<div className="animate-pulse bg-gray-800 rounded-lg p-4">
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-900/50 border border-red-500 rounded-lg p-4">
|
||||
Error: {error.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<div className="bg-gray-800 rounded-lg p-4">
|
||||
<h2 className="text-xl font-semibold mb-2">{data.title}</h2>
|
||||
<p className="text-gray-400">{data.body}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8">
|
||||
<Link to="/" className="text-cyan-400 hover:text-cyan-300">
|
||||
← Back to Home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { trpc } from '~/integrations/trpc/client'
|
||||
|
||||
export const Route = createFileRoute('/demo/trpc')({
|
||||
component: TrpcDemo,
|
||||
})
|
||||
|
||||
function TrpcDemo() {
|
||||
const helloQuery = useQuery({
|
||||
queryKey: ['trpc', 'hello'],
|
||||
queryFn: () => trpc.hello.query({ name: 'TanStack' }),
|
||||
})
|
||||
|
||||
const itemsQuery = useQuery({
|
||||
queryKey: ['trpc', 'items'],
|
||||
queryFn: () => trpc.getItems.query(),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white p-8">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-6">tRPC Demo</h1>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="bg-gray-800 rounded-lg p-4">
|
||||
<h2 className="text-lg font-semibold mb-2">Hello Query</h2>
|
||||
{helloQuery.isLoading && (
|
||||
<p className="text-gray-400">Loading...</p>
|
||||
)}
|
||||
{helloQuery.data && (
|
||||
<p className="text-cyan-400">{helloQuery.data.greeting}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-800 rounded-lg p-4">
|
||||
<h2 className="text-lg font-semibold mb-2">Items Query</h2>
|
||||
{itemsQuery.isLoading && (
|
||||
<p className="text-gray-400">Loading...</p>
|
||||
)}
|
||||
{itemsQuery.data && (
|
||||
<ul className="space-y-1">
|
||||
{itemsQuery.data.map((item) => (
|
||||
<li key={item.id} className="text-gray-300">
|
||||
{item.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<Link to="/" className="text-cyan-400 hover:text-cyan-300">
|
||||
← Back to Home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { Activity } from 'lucide-react'
|
||||
|
||||
export const Route = createFileRoute('/')({
|
||||
component: Home,
|
||||
})
|
||||
|
||||
function Home() {
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-72px)] bg-gradient-to-br from-gray-900 to-gray-800 flex items-center justify-center">
|
||||
<div className="text-center px-4">
|
||||
<Activity size={64} className="mx-auto mb-6 text-cyan-400" />
|
||||
<h1 className="text-5xl font-bold text-white mb-4">Crabwalk</h1>
|
||||
<p className="text-xl text-gray-300 mb-8">
|
||||
Clawdbot Companion Monitor
|
||||
</p>
|
||||
<div className="flex gap-4 justify-center flex-wrap">
|
||||
<Link
|
||||
to="/monitor"
|
||||
className="px-6 py-3 bg-cyan-500 text-white rounded-lg font-medium hover:bg-cyan-600 transition-colors"
|
||||
>
|
||||
Open Monitor
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useLiveQuery, createTransaction } from '@tanstack/react-db'
|
||||
import { Activity, ArrowLeft, Loader2 } from 'lucide-react'
|
||||
import { trpc } from '~/integrations/trpc/client'
|
||||
import {
|
||||
sessionsCollection,
|
||||
actionsCollection,
|
||||
upsertSession,
|
||||
addAction,
|
||||
clearCollections,
|
||||
type MonitorSession,
|
||||
type MonitorAction,
|
||||
} from '~/integrations/clawdbot'
|
||||
import {
|
||||
ActionGraph,
|
||||
SessionList,
|
||||
SettingsPanel,
|
||||
StatusIndicator,
|
||||
} from '~/components/monitor'
|
||||
|
||||
export const Route = createFileRoute('/monitor/')({
|
||||
component: MonitorPage,
|
||||
})
|
||||
|
||||
function MonitorPage() {
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [connecting, setConnecting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [historicalMode, setHistoricalMode] = useState(false)
|
||||
const [selectedSession, setSelectedSession] = useState<string | null>(null)
|
||||
|
||||
// Live queries from TanStack DB collections
|
||||
const sessions = useLiveQuery(sessionsCollection)
|
||||
const actions = useLiveQuery(actionsCollection)
|
||||
|
||||
// Check connection status on mount
|
||||
useEffect(() => {
|
||||
checkStatus()
|
||||
}, [])
|
||||
|
||||
const checkStatus = async () => {
|
||||
try {
|
||||
const status = await trpc.clawdbot.status.query()
|
||||
setConnected(status.connected)
|
||||
} catch {
|
||||
setConnected(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleConnect = async () => {
|
||||
setConnecting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await trpc.clawdbot.connect.mutate()
|
||||
if (result.status === 'connected' || result.status === 'already_connected') {
|
||||
setConnected(true)
|
||||
await loadSessions()
|
||||
} else if (result.status === 'error') {
|
||||
setError(result.message || 'Failed to connect')
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Connection error')
|
||||
} finally {
|
||||
setConnecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
try {
|
||||
await trpc.clawdbot.disconnect.mutate()
|
||||
setConnected(false)
|
||||
clearCollections()
|
||||
} catch (e) {
|
||||
console.error('Disconnect error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const loadSessions = async () => {
|
||||
try {
|
||||
const result = await trpc.clawdbot.sessions.query(
|
||||
historicalMode ? { activeMinutes: 60 } : { activeMinutes: 5 }
|
||||
)
|
||||
if (result.sessions) {
|
||||
const tx = createTransaction({ mutationFn: async () => {} })
|
||||
tx.mutate(() => {
|
||||
for (const session of result.sessions) {
|
||||
upsertSession(session)
|
||||
}
|
||||
})
|
||||
await tx.commit()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load sessions:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
await loadSessions()
|
||||
}, [historicalMode])
|
||||
|
||||
const handleHistoricalModeChange = (enabled: boolean) => {
|
||||
setHistoricalMode(enabled)
|
||||
if (connected) {
|
||||
loadSessions()
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-connect on mount if not connected
|
||||
useEffect(() => {
|
||||
if (!connected && !connecting) {
|
||||
handleConnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-gray-950 text-white">
|
||||
{/* Header */}
|
||||
<header className="flex items-center justify-between px-4 py-3 bg-gray-900 border-b border-gray-800">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
to="/"
|
||||
className="p-2 hover:bg-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity size={20} className="text-cyan-400" />
|
||||
<h1 className="text-lg font-semibold">Clawdbot Monitor</h1>
|
||||
</div>
|
||||
<StatusIndicator status={connected ? 'active' : 'idle'} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{connecting && (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
Connecting...
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="text-sm text-red-400 max-w-xs truncate">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm text-gray-500">
|
||||
{sessions.length} sessions | {actions.length} actions
|
||||
</div>
|
||||
<SettingsPanel
|
||||
connected={connected}
|
||||
historicalMode={historicalMode}
|
||||
onHistoricalModeChange={handleHistoricalModeChange}
|
||||
onConnect={handleConnect}
|
||||
onDisconnect={handleDisconnect}
|
||||
onRefresh={handleRefresh}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<div className="w-64 flex-shrink-0">
|
||||
<SessionList
|
||||
sessions={sessions}
|
||||
selectedKey={selectedSession}
|
||||
onSelect={setSelectedSession}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Graph area */}
|
||||
<div className="flex-1">
|
||||
{sessions.length === 0 && !connecting ? (
|
||||
<div className="h-full flex items-center justify-center text-gray-500">
|
||||
<div className="text-center">
|
||||
<Activity size={48} className="mx-auto mb-4 opacity-50" />
|
||||
<p className="text-lg mb-2">No active sessions</p>
|
||||
<p className="text-sm">
|
||||
{connected
|
||||
? 'Waiting for agent activity...'
|
||||
: 'Connect to clawdbot gateway to start monitoring'}
|
||||
</p>
|
||||
{!connected && (
|
||||
<button
|
||||
onClick={handleConnect}
|
||||
disabled={connecting}
|
||||
className="mt-4 px-4 py-2 bg-cyan-600 hover:bg-cyan-500 rounded-lg text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
Connect
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ActionGraph
|
||||
sessions={sessions}
|
||||
actions={actions}
|
||||
selectedSession={selectedSession}
|
||||
onSessionSelect={setSelectedSession}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
body {
|
||||
@apply m-0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"paths": {
|
||||
"~/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
|
||||
import viteReact from '@vitejs/plugin-react'
|
||||
import viteTsConfigPaths from 'vite-tsconfig-paths'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
viteTsConfigPaths({
|
||||
projects: ['./tsconfig.json'],
|
||||
}),
|
||||
tailwindcss(),
|
||||
tanstackStart(),
|
||||
viteReact(),
|
||||
],
|
||||
})
|
||||
Reference in New Issue
Block a user