mirror of
https://github.com/crabwise-ai/crabwalk.git
synced 2026-08-14 09:02:07 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5076a0de21 |
@@ -608,6 +608,10 @@ function ActionGraphInner({
|
||||
if (node.type === 'crab') return '#ef4444'
|
||||
if (node.type === 'chaserCrab') return '#ef4444'
|
||||
if (node.type === 'session') return '#98ffc8'
|
||||
// Action type colors
|
||||
const action = node.data as MonitorAction | undefined
|
||||
if (action?.type === 'error') return '#ef4444'
|
||||
if (action?.tools && action.tools.length > 0) return '#c4b5fd' // Has tools
|
||||
return '#52526e'
|
||||
}}
|
||||
maskColor="rgba(10, 10, 15, 0.8)"
|
||||
|
||||
@@ -11,8 +11,14 @@ import {
|
||||
MessageCircle,
|
||||
Bot,
|
||||
Play,
|
||||
FileText,
|
||||
Terminal,
|
||||
Search,
|
||||
Edit3,
|
||||
Globe,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import type { MonitorAction } from '~/integrations/clawdbot'
|
||||
import type { MonitorAction, ToolCall } from '~/integrations/clawdbot'
|
||||
|
||||
interface ActionNodeProps {
|
||||
data: MonitorAction
|
||||
@@ -75,20 +81,75 @@ const stateConfig: Record<
|
||||
iconColor: 'text-crab-400',
|
||||
animate: false,
|
||||
},
|
||||
tool_call: {
|
||||
icon: Wrench,
|
||||
borderColor: 'border-neon-lavender',
|
||||
bgColor: 'bg-neon-lavender/10',
|
||||
iconColor: 'text-neon-lavender',
|
||||
animate: false,
|
||||
},
|
||||
tool_result: {
|
||||
icon: MessageSquare,
|
||||
borderColor: 'border-pastel-sky',
|
||||
bgColor: 'bg-pastel-sky/10',
|
||||
iconColor: 'text-pastel-sky',
|
||||
animate: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Tool name to icon mapping
|
||||
const toolIcons: Record<string, typeof Wrench> = {
|
||||
Read: FileText,
|
||||
Write: Edit3,
|
||||
Edit: Edit3,
|
||||
Bash: Terminal,
|
||||
Grep: Search,
|
||||
Glob: Search,
|
||||
WebFetch: Globe,
|
||||
WebSearch: Globe,
|
||||
Task: Zap,
|
||||
Skill: Zap,
|
||||
}
|
||||
|
||||
function getToolIcon(name: string): typeof Wrench {
|
||||
// Check exact match
|
||||
if (toolIcons[name]) return toolIcons[name]
|
||||
// Check partial match
|
||||
for (const [key, icon] of Object.entries(toolIcons)) {
|
||||
if (name.toLowerCase().includes(key.toLowerCase())) return icon
|
||||
}
|
||||
return Wrench
|
||||
}
|
||||
|
||||
// Tool icon with hover tooltip
|
||||
function ToolIcon({ tool }: { tool: ToolCall }) {
|
||||
const [showTooltip, setShowTooltip] = useState(false)
|
||||
const Icon = getToolIcon(tool.name)
|
||||
|
||||
const statusColor = {
|
||||
pending: 'text-shell-400',
|
||||
running: 'text-neon-cyan animate-pulse',
|
||||
success: 'text-neon-mint',
|
||||
error: 'text-crab-400',
|
||||
}[tool.status]
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={() => setShowTooltip(true)}
|
||||
onMouseLeave={() => setShowTooltip(false)}
|
||||
>
|
||||
<Icon size={14} className={statusColor} />
|
||||
{showTooltip && (
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-50 pointer-events-none">
|
||||
<div className="bg-shell-800 border border-shell-600 rounded-lg px-2.5 py-2 shadow-xl min-w-[150px] max-w-[300px]">
|
||||
<div className="font-console text-[11px] text-neon-lavender font-medium mb-1">
|
||||
{tool.name}
|
||||
</div>
|
||||
<div className="font-console text-[10px] text-shell-400 mb-1">
|
||||
{tool.status}
|
||||
</div>
|
||||
{tool.args && (
|
||||
<pre className="font-console text-[9px] text-shell-500 bg-shell-950 p-1 rounded max-h-20 overflow-auto">
|
||||
{typeof tool.args === 'string' ? tool.args : JSON.stringify(tool.args, null, 1)}
|
||||
</pre>
|
||||
)}
|
||||
{tool.result && (
|
||||
<div className="mt-1 font-console text-[9px] text-shell-400 line-clamp-2">
|
||||
→ {tool.result.slice(0, 100)}{tool.result.length > 100 ? '...' : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const eventTypeLabels: Record<MonitorAction['eventType'], { label: string; icon: typeof MessageCircle }> = {
|
||||
@@ -173,6 +234,15 @@ export const ActionNode = memo(function ActionNode({
|
||||
<span className="text-crab-600">></span> {formatTime(data.timestamp)}
|
||||
</div>
|
||||
|
||||
{/* Tool icons */}
|
||||
{data.tools && data.tools.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 mb-1.5 flex-wrap">
|
||||
{data.tools.map((tool) => (
|
||||
<ToolIcon key={tool.id} tool={tool} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metadata for complete nodes */}
|
||||
{hasMetadata && (
|
||||
<div className="font-console text-xs text-shell-400 mb-1.5 flex gap-2 flex-wrap">
|
||||
@@ -191,12 +261,6 @@ export const ActionNode = memo(function ActionNode({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.toolName && (
|
||||
<div className="font-console text-[10px] text-neon-lavender mb-1.5">
|
||||
<span className="text-shell-500">tool:</span> {data.toolName}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content - markdown for both preview and expanded */}
|
||||
{(expanded ? fullContent : truncatedContent) && (
|
||||
<div className={`
|
||||
@@ -214,12 +278,6 @@ export const ActionNode = memo(function ActionNode({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expanded && data.toolArgs != null && (
|
||||
<pre className="mt-2 font-console text-[11px] text-shell-500 bg-shell-950 p-2 rounded border border-shell-800 overflow-auto max-h-32">
|
||||
{JSON.stringify(data.toolArgs, null, 2) as string}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
<Handle type="source" position={Position.Bottom} className="bg-shell-600! w-2! h-2! border-shell-800!" />
|
||||
</motion.div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createCollection, localOnlyCollectionOptions } from '@tanstack/db'
|
||||
import type { MonitorSession, MonitorAction } from './protocol'
|
||||
import type { MonitorSession, MonitorAction, ToolCall } from './protocol'
|
||||
|
||||
// Track runId → sessionKey mapping (learned from chat events)
|
||||
const runSessionMap = new Map<string, string>()
|
||||
@@ -117,11 +117,40 @@ export function addAction(action: MonitorAction) {
|
||||
actionsCollection.insert({ ...action, sessionKey, id: `${action.runId}-complete` })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// For tool_call/tool_result, add as separate nodes
|
||||
const existing = actionsCollection.state.get(action.id)
|
||||
if (!existing) {
|
||||
actionsCollection.insert({ ...action, sessionKey })
|
||||
// Add a tool call to the current streaming action for a run
|
||||
export function addToolCall(runId: string, tool: ToolCall) {
|
||||
const streamingId = `${runId}-stream`
|
||||
const streaming = actionsCollection.state.get(streamingId)
|
||||
if (streaming) {
|
||||
actionsCollection.update(streamingId, (draft) => {
|
||||
if (!draft.tools) draft.tools = []
|
||||
// Check if tool already exists (by id)
|
||||
const existingIdx = draft.tools.findIndex(t => t.id === tool.id)
|
||||
if (existingIdx >= 0) {
|
||||
draft.tools[existingIdx] = { ...draft.tools[existingIdx], ...tool }
|
||||
} else {
|
||||
draft.tools.push(tool)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Update a tool call result
|
||||
export function updateToolResult(runId: string, toolId: string, result: string, status: ToolCall['status'] = 'success') {
|
||||
const streamingId = `${runId}-stream`
|
||||
const streaming = actionsCollection.state.get(streamingId)
|
||||
if (streaming) {
|
||||
actionsCollection.update(streamingId, (draft) => {
|
||||
if (draft.tools) {
|
||||
const tool = draft.tools.find(t => t.id === toolId)
|
||||
if (tool) {
|
||||
tool.result = result
|
||||
tool.status = status
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,16 @@ import type {
|
||||
MonitorSession,
|
||||
MonitorAction,
|
||||
SessionInfo,
|
||||
ToolCall,
|
||||
} from './protocol'
|
||||
import { parseSessionKey } from './protocol'
|
||||
|
||||
export interface ParsedEvent {
|
||||
session?: Partial<MonitorSession>
|
||||
action?: MonitorAction
|
||||
toolCall?: { runId: string; tool: ToolCall }
|
||||
}
|
||||
|
||||
export function sessionInfoToMonitor(info: SessionInfo): MonitorSession {
|
||||
const parsed = parseSessionKey(info.key)
|
||||
return {
|
||||
@@ -21,7 +28,12 @@ export function sessionInfoToMonitor(info: SessionInfo): MonitorSession {
|
||||
}
|
||||
}
|
||||
|
||||
export function chatEventToAction(event: ChatEvent): MonitorAction {
|
||||
export interface ChatParseResult {
|
||||
action: MonitorAction
|
||||
toolCalls: ToolCall[]
|
||||
}
|
||||
|
||||
export function chatEventToAction(event: ChatEvent): ChatParseResult {
|
||||
// Map chat state to new action types
|
||||
let type: MonitorAction['type'] = 'streaming'
|
||||
if (event.state === 'final') type = 'complete'
|
||||
@@ -39,6 +51,8 @@ export function chatEventToAction(event: ChatEvent): MonitorAction {
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
const toolCalls: ToolCall[] = []
|
||||
|
||||
// Extract usage/stopReason from final events
|
||||
if (event.state === 'final') {
|
||||
if (event.usage) {
|
||||
@@ -56,7 +70,7 @@ export function chatEventToAction(event: ChatEvent): MonitorAction {
|
||||
} else if (typeof event.message === 'object') {
|
||||
const msg = event.message as Record<string, unknown>
|
||||
|
||||
// Extract text from content blocks: [{type: 'text', text: '...'}]
|
||||
// Extract text and tools from content blocks
|
||||
if (Array.isArray(msg.content)) {
|
||||
const texts: string[] = []
|
||||
for (const block of msg.content) {
|
||||
@@ -65,12 +79,21 @@ export function chatEventToAction(event: ChatEvent): MonitorAction {
|
||||
if (b.type === 'text' && typeof b.text === 'string') {
|
||||
texts.push(b.text)
|
||||
} else if (b.type === 'tool_use') {
|
||||
action.type = 'tool_call'
|
||||
action.toolName = String(b.name || 'unknown')
|
||||
action.toolArgs = b.input
|
||||
toolCalls.push({
|
||||
id: String(b.id || `tool-${Date.now()}`),
|
||||
name: String(b.name || 'unknown'),
|
||||
args: b.input,
|
||||
status: 'pending',
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
} else if (b.type === 'tool_result') {
|
||||
action.type = 'tool_result'
|
||||
if (typeof b.content === 'string') {
|
||||
// Find matching tool call and update it
|
||||
const toolId = String(b.tool_use_id || '')
|
||||
const existingTool = toolCalls.find(t => t.id === toolId)
|
||||
if (existingTool) {
|
||||
existingTool.result = typeof b.content === 'string' ? b.content : JSON.stringify(b.content)
|
||||
existingTool.status = b.is_error ? 'error' : 'success'
|
||||
} else if (typeof b.content === 'string') {
|
||||
texts.push(b.content)
|
||||
}
|
||||
}
|
||||
@@ -91,21 +114,25 @@ export function chatEventToAction(event: ChatEvent): MonitorAction {
|
||||
action.content = event.errorMessage
|
||||
}
|
||||
|
||||
return action
|
||||
return { action, toolCalls }
|
||||
}
|
||||
|
||||
export function agentEventToAction(event: AgentEvent): MonitorAction {
|
||||
const data = event.data
|
||||
export interface AgentParseResult {
|
||||
action?: MonitorAction
|
||||
toolCall?: ToolCall
|
||||
toolResult?: { toolId: string; result: string; isError: boolean }
|
||||
}
|
||||
|
||||
let type: MonitorAction['type'] = 'streaming'
|
||||
let content: string | undefined
|
||||
let toolName: string | undefined
|
||||
let toolArgs: unknown | undefined
|
||||
let startedAt: number | undefined
|
||||
let endedAt: number | undefined
|
||||
export function agentEventToAction(event: AgentEvent): AgentParseResult {
|
||||
const data = event.data
|
||||
|
||||
// Handle lifecycle events
|
||||
if (event.stream === 'lifecycle') {
|
||||
let type: MonitorAction['type'] = 'streaming'
|
||||
let content: string | undefined
|
||||
let startedAt: number | undefined
|
||||
let endedAt: number | undefined
|
||||
|
||||
if (data.phase === 'start') {
|
||||
type = 'start'
|
||||
content = 'Run started'
|
||||
@@ -115,39 +142,67 @@ export function agentEventToAction(event: AgentEvent): MonitorAction {
|
||||
content = 'Run completed'
|
||||
endedAt = typeof data.endedAt === 'number' ? data.endedAt : event.ts
|
||||
}
|
||||
} else 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 = 'streaming'
|
||||
content = String(data.text || '')
|
||||
|
||||
return {
|
||||
action: {
|
||||
id: `${event.runId}-${event.seq}`,
|
||||
runId: event.runId,
|
||||
sessionKey: event.sessionKey || event.stream,
|
||||
seq: event.seq,
|
||||
type,
|
||||
eventType: 'agent' as const,
|
||||
timestamp: event.ts,
|
||||
content,
|
||||
startedAt,
|
||||
endedAt,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: `${event.runId}-${event.seq}`,
|
||||
runId: event.runId,
|
||||
// Use sessionKey from event if available, fallback to stream
|
||||
sessionKey: event.sessionKey || event.stream,
|
||||
seq: event.seq,
|
||||
type,
|
||||
eventType: 'agent' as const,
|
||||
timestamp: event.ts,
|
||||
content,
|
||||
toolName,
|
||||
toolArgs,
|
||||
startedAt,
|
||||
endedAt,
|
||||
// Handle tool use - return as tool call to be aggregated
|
||||
if (data.type === 'tool_use') {
|
||||
return {
|
||||
toolCall: {
|
||||
id: String(data.id || `tool-${event.seq}`),
|
||||
name: String(data.name || 'unknown'),
|
||||
args: data.input,
|
||||
status: 'running',
|
||||
timestamp: event.ts,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle tool result
|
||||
if (data.type === 'tool_result') {
|
||||
return {
|
||||
toolResult: {
|
||||
toolId: String(data.tool_use_id || ''),
|
||||
result: typeof data.content === 'string' ? data.content : JSON.stringify(data.content),
|
||||
isError: Boolean(data.is_error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Text streaming (usually duplicates chat events)
|
||||
if (data.type === 'text') {
|
||||
return {
|
||||
action: {
|
||||
id: `${event.runId}-${event.seq}`,
|
||||
runId: event.runId,
|
||||
sessionKey: event.sessionKey || event.stream,
|
||||
seq: event.seq,
|
||||
type: 'streaming',
|
||||
eventType: 'agent' as const,
|
||||
timestamp: event.ts,
|
||||
content: String(data.text || ''),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
export function parseEventFrame(
|
||||
frame: EventFrame
|
||||
): { session?: Partial<MonitorSession>; action?: MonitorAction } | null {
|
||||
export function parseEventFrame(frame: EventFrame): ParsedEvent | null {
|
||||
// Skip system events
|
||||
if (frame.event === 'health' || frame.event === 'tick') {
|
||||
return null
|
||||
@@ -155,28 +210,33 @@ export function parseEventFrame(
|
||||
|
||||
if (frame.event === 'chat' && frame.payload) {
|
||||
const chatEvent = frame.payload as ChatEvent
|
||||
return {
|
||||
action: chatEventToAction(chatEvent),
|
||||
const { action, toolCalls } = chatEventToAction(chatEvent)
|
||||
|
||||
const result: ParsedEvent = {
|
||||
action,
|
||||
session: {
|
||||
key: chatEvent.sessionKey,
|
||||
status: chatEvent.state === 'delta' ? 'thinking' : 'active',
|
||||
lastActivityAt: Date.now(),
|
||||
},
|
||||
}
|
||||
|
||||
// If there are tool calls, return the first one (others will come in subsequent events)
|
||||
if (toolCalls.length > 0) {
|
||||
result.toolCall = { runId: chatEvent.runId, tool: toolCalls[0]! }
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
if (frame.event === 'agent' && frame.payload) {
|
||||
const agentEvent = frame.payload as AgentEvent
|
||||
const parsed = agentEventToAction(agentEvent)
|
||||
|
||||
// Skip assistant stream - it duplicates chat events
|
||||
if (agentEvent.stream === 'assistant') {
|
||||
return null
|
||||
}
|
||||
|
||||
// Only process lifecycle events (start/end markers)
|
||||
if (agentEvent.stream === 'lifecycle') {
|
||||
// Lifecycle events return action
|
||||
if (parsed.action) {
|
||||
return {
|
||||
action: agentEventToAction(agentEvent),
|
||||
action: parsed.action,
|
||||
session: agentEvent.sessionKey ? {
|
||||
key: agentEvent.sessionKey,
|
||||
status: agentEvent.data?.phase === 'start' ? 'thinking' : 'active',
|
||||
@@ -185,6 +245,40 @@ export function parseEventFrame(
|
||||
}
|
||||
}
|
||||
|
||||
// Tool calls
|
||||
if (parsed.toolCall) {
|
||||
return {
|
||||
toolCall: { runId: agentEvent.runId, tool: parsed.toolCall },
|
||||
session: agentEvent.sessionKey ? {
|
||||
key: agentEvent.sessionKey,
|
||||
status: 'thinking',
|
||||
lastActivityAt: Date.now(),
|
||||
} : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// Tool results - need special handling
|
||||
if (parsed.toolResult) {
|
||||
// Return as a pseudo tool call that will update existing tool
|
||||
return {
|
||||
toolCall: {
|
||||
runId: agentEvent.runId,
|
||||
tool: {
|
||||
id: parsed.toolResult.toolId,
|
||||
name: '', // Will be filled from existing
|
||||
result: parsed.toolResult.result,
|
||||
status: parsed.toolResult.isError ? 'error' : 'success',
|
||||
timestamp: agentEvent.ts,
|
||||
}
|
||||
},
|
||||
session: agentEvent.sessionKey ? {
|
||||
key: agentEvent.sessionKey,
|
||||
status: 'active',
|
||||
lastActivityAt: Date.now(),
|
||||
} : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -112,15 +112,27 @@ export interface MonitorSession {
|
||||
status: 'idle' | 'active' | 'thinking'
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
id: string
|
||||
name: string
|
||||
args?: unknown
|
||||
result?: string
|
||||
status: 'pending' | 'running' | 'success' | 'error'
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface MonitorAction {
|
||||
id: string
|
||||
runId: string
|
||||
sessionKey: string
|
||||
seq: number
|
||||
type: 'start' | 'streaming' | 'complete' | 'aborted' | 'error' | 'tool_call' | 'tool_result'
|
||||
type: 'start' | 'streaming' | 'complete' | 'aborted' | 'error'
|
||||
eventType: 'chat' | 'agent' | 'system'
|
||||
timestamp: number
|
||||
content?: string
|
||||
// Inline tool calls for this action
|
||||
tools?: ToolCall[]
|
||||
// Legacy fields (for backwards compat)
|
||||
toolName?: string
|
||||
toolArgs?: unknown
|
||||
// Metadata from lifecycle/chat events
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
sessionInfoToMonitor,
|
||||
type MonitorSession,
|
||||
type MonitorAction,
|
||||
type ToolCall,
|
||||
} from '~/integrations/clawdbot'
|
||||
|
||||
// Server-side debug mode state
|
||||
@@ -157,12 +158,18 @@ const clawdbotRouter = router({
|
||||
if (debugMode && parsed.action) {
|
||||
console.log('[DEBUG] Parsed action:', parsed.action.type, parsed.action.eventType, 'sessionKey:', parsed.action.sessionKey)
|
||||
}
|
||||
if (debugMode && parsed.toolCall) {
|
||||
console.log('[DEBUG] Tool call:', parsed.toolCall.tool.name, 'status:', parsed.toolCall.tool.status)
|
||||
}
|
||||
if (parsed.session) {
|
||||
emit.next({ type: 'session', session: parsed.session })
|
||||
}
|
||||
if (parsed.action) {
|
||||
emit.next({ type: 'action', action: parsed.action })
|
||||
}
|
||||
if (parsed.toolCall) {
|
||||
emit.next({ type: 'toolCall', runId: parsed.toolCall.runId, tool: parsed.toolCall.tool })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
actionsCollection,
|
||||
upsertSession,
|
||||
addAction,
|
||||
addToolCall,
|
||||
updateSessionStatus,
|
||||
clearCollections,
|
||||
} from '~/integrations/clawdbot'
|
||||
@@ -242,6 +243,9 @@ function MonitorPage() {
|
||||
if (data.type === 'action' && data.action) {
|
||||
addAction(data.action)
|
||||
}
|
||||
if (data.type === 'toolCall' && data.runId && data.tool) {
|
||||
addToolCall(data.runId, data.tool)
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error('[monitor] subscription error:', err)
|
||||
|
||||
Reference in New Issue
Block a user