mirror of
https://github.com/crabwise-ai/crabwalk.git
synced 2026-08-14 00:57:52 +00:00
Enhance action handling and visualization in Clawdbot integration
- Introduced new action types: 'start', 'streaming', and 'complete' to improve event tracking and state management. - Updated ActionGraph component to apply edge styling based on action type, enhancing visual representation of action flows. - Modified ActionNode to display state labels and metadata for completed actions, improving user feedback. - Refined addAction function to handle new action types and associated metadata, ensuring accurate session tracking. - Enhanced chatEventToAction and agentEventToAction functions to map new action types and extract relevant metadata.
This commit is contained in:
@@ -138,6 +138,48 @@ function ActionGraphInner({
|
||||
sessionActions.set(key, list)
|
||||
}
|
||||
|
||||
// Edge styling based on action type
|
||||
const getEdgeStyle = (action: MonitorAction) => {
|
||||
switch (action.type) {
|
||||
case 'start':
|
||||
return {
|
||||
animated: true,
|
||||
style: { stroke: '#98ffc8', strokeDasharray: '5 5' }, // mint dashed
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: '#98ffc8' },
|
||||
}
|
||||
case 'streaming':
|
||||
return {
|
||||
animated: true,
|
||||
style: { stroke: '#00ffd5' }, // cyan
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: '#00ffd5' },
|
||||
}
|
||||
case 'complete':
|
||||
return {
|
||||
animated: false,
|
||||
style: { stroke: '#98ffc8' }, // mint solid
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: '#98ffc8' },
|
||||
}
|
||||
case 'error':
|
||||
return {
|
||||
animated: false,
|
||||
style: { stroke: '#ef4444' }, // red
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: '#ef4444' },
|
||||
}
|
||||
case 'aborted':
|
||||
return {
|
||||
animated: false,
|
||||
style: { stroke: '#ffb399' }, // peach
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: '#ffb399' },
|
||||
}
|
||||
default:
|
||||
return {
|
||||
animated: false,
|
||||
style: { stroke: '#52526e' },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: '#52526e' },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Connect actions in a chain per session
|
||||
for (const [sessionKey, actions] of sessionActions) {
|
||||
const sorted = [...actions].sort((a, b) => a.timestamp - b.timestamp)
|
||||
@@ -145,6 +187,8 @@ function ActionGraphInner({
|
||||
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const action = sorted[i]!
|
||||
const edgeStyle = getEdgeStyle(action)
|
||||
|
||||
if (i === 0) {
|
||||
// First action connects to session
|
||||
if (sessionNodeIds.has(sessionId)) {
|
||||
@@ -152,9 +196,7 @@ function ActionGraphInner({
|
||||
id: `e-session-${action.id}`,
|
||||
source: sessionId,
|
||||
target: `action-${action.id}`,
|
||||
animated: action.type === 'delta',
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: '#52526e' },
|
||||
style: { stroke: '#52526e' },
|
||||
...edgeStyle,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
@@ -164,9 +206,7 @@ function ActionGraphInner({
|
||||
id: `e-${prev.id}-${action.id}`,
|
||||
source: `action-${prev.id}`,
|
||||
target: `action-${action.id}`,
|
||||
animated: action.type === 'delta',
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: '#52526e' },
|
||||
style: { stroke: '#52526e' },
|
||||
...edgeStyle,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
MessageSquare,
|
||||
MessageCircle,
|
||||
Bot,
|
||||
Play,
|
||||
} from 'lucide-react'
|
||||
import type { MonitorAction } from '~/integrations/clawdbot'
|
||||
|
||||
@@ -33,16 +34,25 @@ const stateConfig: Record<
|
||||
bgColor: string
|
||||
iconColor: string
|
||||
animate: boolean
|
||||
label?: string
|
||||
}
|
||||
> = {
|
||||
delta: {
|
||||
start: {
|
||||
icon: Play,
|
||||
borderColor: 'border-neon-mint',
|
||||
bgColor: 'bg-neon-mint/10',
|
||||
iconColor: 'text-neon-mint',
|
||||
animate: false,
|
||||
label: 'Run Started',
|
||||
},
|
||||
streaming: {
|
||||
icon: Loader2,
|
||||
borderColor: 'border-neon-cyan',
|
||||
bgColor: 'bg-neon-cyan/10',
|
||||
iconColor: 'text-neon-cyan',
|
||||
animate: true,
|
||||
},
|
||||
final: {
|
||||
complete: {
|
||||
icon: CheckCircle,
|
||||
borderColor: 'border-neon-mint',
|
||||
bgColor: 'bg-neon-mint/10',
|
||||
@@ -55,6 +65,7 @@ const stateConfig: Record<
|
||||
bgColor: 'bg-neon-peach/10',
|
||||
iconColor: 'text-neon-peach',
|
||||
animate: false,
|
||||
label: 'Aborted',
|
||||
},
|
||||
error: {
|
||||
icon: XCircle,
|
||||
@@ -85,6 +96,15 @@ const eventTypeLabels: Record<MonitorAction['eventType'], { label: string; icon:
|
||||
system: { label: 'System', icon: MessageSquare },
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
const secs = ms / 1000
|
||||
if (secs < 60) return `${secs.toFixed(1)}s`
|
||||
const mins = Math.floor(secs / 60)
|
||||
const remainSecs = Math.floor(secs % 60)
|
||||
return `${mins}m ${remainSecs}s`
|
||||
}
|
||||
|
||||
export const ActionNode = memo(function ActionNode({
|
||||
data,
|
||||
selected,
|
||||
@@ -102,13 +122,19 @@ export const ActionNode = memo(function ActionNode({
|
||||
? JSON.stringify(data.content)
|
||||
: null
|
||||
|
||||
const truncatedContent = contentStr
|
||||
? contentStr.length > 100
|
||||
? contentStr.slice(0, 100) + '...'
|
||||
: contentStr
|
||||
// Use state label for start/aborted, otherwise content
|
||||
const displayContent = state.label || contentStr
|
||||
|
||||
const truncatedContent = displayContent
|
||||
? displayContent.length > 100
|
||||
? displayContent.slice(0, 100) + '...'
|
||||
: displayContent
|
||||
: null
|
||||
|
||||
const fullContent = contentStr
|
||||
const fullContent = displayContent
|
||||
|
||||
// Metadata for complete nodes
|
||||
const hasMetadata = data.type === 'complete' && (data.duration || data.inputTokens || data.outputTokens)
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
@@ -145,6 +171,24 @@ export const ActionNode = memo(function ActionNode({
|
||||
<span className="text-crab-600">></span> {formatTime(data.timestamp)}
|
||||
</div>
|
||||
|
||||
{/* Metadata for complete nodes */}
|
||||
{hasMetadata && (
|
||||
<div className="font-console text-[10px] text-shell-400 mb-1.5 flex gap-2 flex-wrap">
|
||||
{data.duration && (
|
||||
<span className="text-neon-cyan">{formatDuration(data.duration)}</span>
|
||||
)}
|
||||
{data.inputTokens && (
|
||||
<span><span className="text-shell-500">in:</span> {data.inputTokens}</span>
|
||||
)}
|
||||
{data.outputTokens && (
|
||||
<span><span className="text-shell-500">out:</span> {data.outputTokens}</span>
|
||||
)}
|
||||
{data.stopReason && (
|
||||
<span className="text-neon-peach">{data.stopReason}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.toolName && (
|
||||
<div className="font-console text-[10px] text-neon-lavender mb-1.5">
|
||||
<span className="text-shell-500">tool:</span> {data.toolName}
|
||||
|
||||
@@ -31,7 +31,11 @@ export function upsertSession(session: MonitorSession) {
|
||||
}
|
||||
|
||||
// Helper to add or update action
|
||||
// For deltas, we aggregate into a single "streaming" action per runId
|
||||
// Aggregation strategy per run:
|
||||
// - start: one node per runId (appears immediately)
|
||||
// - streaming: aggregate all deltas into one node (content updates)
|
||||
// - complete: updates streaming node with final state & metadata
|
||||
// - tool_call/tool_result: separate nodes
|
||||
export function addAction(action: MonitorAction) {
|
||||
// Learn runId → sessionKey mapping from actions with real session keys
|
||||
if (action.sessionKey && !action.sessionKey.includes('lifecycle')) {
|
||||
@@ -44,14 +48,30 @@ export function addAction(action: MonitorAction) {
|
||||
sessionKey = runSessionMap.get(action.runId) || sessionKey
|
||||
}
|
||||
|
||||
// For deltas, use runId as the key (aggregate all deltas)
|
||||
if (action.type === 'delta') {
|
||||
// Handle 'start' type - create dedicated start node
|
||||
if (action.type === 'start') {
|
||||
const startId = `${action.runId}-start`
|
||||
const existing = actionsCollection.state.get(startId)
|
||||
if (!existing) {
|
||||
actionsCollection.insert({
|
||||
...action,
|
||||
id: startId,
|
||||
sessionKey,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// For streaming, aggregate into single node per runId
|
||||
if (action.type === 'streaming') {
|
||||
const streamingId = `${action.runId}-stream`
|
||||
const existing = actionsCollection.state.get(streamingId)
|
||||
if (existing) {
|
||||
// Append content and update sessionKey if we learned it
|
||||
actionsCollection.update(streamingId, (draft) => {
|
||||
draft.content = (draft.content || '') + (action.content || '')
|
||||
if (action.content) {
|
||||
draft.content = (draft.content || '') + action.content
|
||||
}
|
||||
draft.seq = action.seq
|
||||
draft.timestamp = action.timestamp
|
||||
if (sessionKey && sessionKey !== 'lifecycle') {
|
||||
@@ -69,8 +89,8 @@ export function addAction(action: MonitorAction) {
|
||||
return
|
||||
}
|
||||
|
||||
// For final/error/aborted, update the streaming action's type
|
||||
if (action.type === 'final' || action.type === 'error' || action.type === 'aborted') {
|
||||
// For complete/error/aborted, update the streaming action
|
||||
if (action.type === 'complete' || action.type === 'error' || action.type === 'aborted') {
|
||||
const streamingId = `${action.runId}-stream`
|
||||
const streaming = actionsCollection.state.get(streamingId)
|
||||
if (streaming) {
|
||||
@@ -81,13 +101,24 @@ export function addAction(action: MonitorAction) {
|
||||
if (sessionKey && sessionKey !== 'lifecycle') {
|
||||
draft.sessionKey = sessionKey
|
||||
}
|
||||
// Copy metadata from complete event
|
||||
if (action.inputTokens !== undefined) draft.inputTokens = action.inputTokens
|
||||
if (action.outputTokens !== undefined) draft.outputTokens = action.outputTokens
|
||||
if (action.stopReason) draft.stopReason = action.stopReason
|
||||
if (action.endedAt) draft.endedAt = action.endedAt
|
||||
// Calculate duration if we have both timestamps
|
||||
if (draft.startedAt && action.endedAt) {
|
||||
draft.duration = action.endedAt - draft.startedAt
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
// No streaming action found, create as-is
|
||||
// No streaming action found, create as-is with complete state
|
||||
actionsCollection.insert({ ...action, sessionKey, id: `${action.runId}-complete` })
|
||||
return
|
||||
}
|
||||
|
||||
// For tool_call/tool_result or orphaned finals, add as new
|
||||
// For tool_call/tool_result, add as separate nodes
|
||||
const existing = actionsCollection.state.get(action.id)
|
||||
if (!existing) {
|
||||
actionsCollection.insert({ ...action, sessionKey })
|
||||
|
||||
@@ -22,16 +22,34 @@ export function sessionInfoToMonitor(info: SessionInfo): MonitorSession {
|
||||
}
|
||||
|
||||
export function chatEventToAction(event: ChatEvent): MonitorAction {
|
||||
// Map chat state to new action types
|
||||
let type: MonitorAction['type'] = 'streaming'
|
||||
if (event.state === 'final') type = 'complete'
|
||||
else if (event.state === 'delta') type = 'streaming'
|
||||
else if (event.state === 'aborted') type = 'aborted'
|
||||
else if (event.state === 'error') type = 'error'
|
||||
|
||||
const action: MonitorAction = {
|
||||
id: `${event.runId}-${event.seq}`,
|
||||
runId: event.runId,
|
||||
sessionKey: event.sessionKey,
|
||||
seq: event.seq,
|
||||
type: event.state,
|
||||
type,
|
||||
eventType: 'chat',
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
// Extract usage/stopReason from final events
|
||||
if (event.state === 'final') {
|
||||
if (event.usage) {
|
||||
action.inputTokens = event.usage.inputTokens
|
||||
action.outputTokens = event.usage.outputTokens
|
||||
}
|
||||
if (event.stopReason) {
|
||||
action.stopReason = event.stopReason
|
||||
}
|
||||
}
|
||||
|
||||
if (event.message) {
|
||||
if (typeof event.message === 'string') {
|
||||
action.content = event.message
|
||||
@@ -79,19 +97,23 @@ export function chatEventToAction(event: ChatEvent): MonitorAction {
|
||||
export function agentEventToAction(event: AgentEvent): MonitorAction {
|
||||
const data = event.data
|
||||
|
||||
let type: MonitorAction['type'] = 'delta'
|
||||
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
|
||||
|
||||
// Handle lifecycle events
|
||||
if (event.stream === 'lifecycle') {
|
||||
if (data.phase === 'start') {
|
||||
type = 'delta'
|
||||
content = 'Agent run started'
|
||||
type = 'start'
|
||||
content = 'Run started'
|
||||
startedAt = typeof data.startedAt === 'number' ? data.startedAt : event.ts
|
||||
} else if (data.phase === 'end') {
|
||||
type = 'final'
|
||||
content = 'Agent run completed'
|
||||
type = 'complete'
|
||||
content = 'Run completed'
|
||||
endedAt = typeof data.endedAt === 'number' ? data.endedAt : event.ts
|
||||
}
|
||||
} else if (data.type === 'tool_use') {
|
||||
type = 'tool_call'
|
||||
@@ -102,7 +124,7 @@ export function agentEventToAction(event: AgentEvent): MonitorAction {
|
||||
type = 'tool_result'
|
||||
content = String(data.content || '')
|
||||
} else if (data.type === 'text') {
|
||||
type = 'delta'
|
||||
type = 'streaming'
|
||||
content = String(data.text || '')
|
||||
}
|
||||
|
||||
@@ -118,6 +140,8 @@ export function agentEventToAction(event: AgentEvent): MonitorAction {
|
||||
content,
|
||||
toolName,
|
||||
toolArgs,
|
||||
startedAt,
|
||||
endedAt,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -117,12 +117,19 @@ export interface MonitorAction {
|
||||
runId: string
|
||||
sessionKey: string
|
||||
seq: number
|
||||
type: 'delta' | 'final' | 'aborted' | 'error' | 'tool_call' | 'tool_result'
|
||||
type: 'start' | 'streaming' | 'complete' | 'aborted' | 'error' | 'tool_call' | 'tool_result'
|
||||
eventType: 'chat' | 'agent' | 'system'
|
||||
timestamp: number
|
||||
content?: string
|
||||
toolName?: string
|
||||
toolArgs?: unknown
|
||||
// Metadata from lifecycle/chat events
|
||||
startedAt?: number
|
||||
endedAt?: number
|
||||
duration?: number
|
||||
inputTokens?: number
|
||||
outputTokens?: number
|
||||
stopReason?: string
|
||||
}
|
||||
|
||||
// Utility functions
|
||||
|
||||
Reference in New Issue
Block a user