feat: live workflow trace + completed execution diagram in chat
All checks were successful
Stuffle/nebula-os/pipeline/head This commit looks good
All checks were successful
Stuffle/nebula-os/pipeline/head This commit looks good
During pending:
- TypingBubble simplified to plain subtitle text (no bubble box):
Sparkles icon + animated dots + status label (context-aware)
- LiveWorkflowTrace below the text: nodes appear one by one with
timing (Input→Context→LLM→Tools→Response), each node glows purple
while active, turns green when complete, current node spins Loader2
After response (on each Nebula message):
- WorkflowTrace renders below the message with a collapsible one-line
summary: '⚡ 285ms · 4o-mini · 2 tools · 3 chunks ▶'
- Expanded view: icon nodes in a horizontal scrollable flow with
coloured circles, tool names, and detail labels per node
(e.g. 'List Agents · 3 agents', 'Create Agent · ...abc123')
- Nodes built from real response data: toolActions, chunksUsed,
latencyMs, modelUsed (now stored on ChatMessage)
- Shows RAG node only when chunks_used > 0
- Shows Synthesis node only when tools were called
- Model prefix stripped (gpt-, openai/, anthropic/) for compact display
Types:
- Added modelUsed?: string to ChatMessage
- aiMsg now stores resp.model_used
This commit is contained in:
@@ -45,6 +45,7 @@ interface ChatMessage {
|
||||
toolActions?: ToolAction[]
|
||||
chunksUsed?: number
|
||||
latencyMs?: number
|
||||
modelUsed?: string
|
||||
tryItData?: TryItData
|
||||
}
|
||||
|
||||
@@ -756,25 +757,138 @@ function InlineSteps({ taskId }: { taskId: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Typing bubble — inline in thread, not a floating footer ───────────────
|
||||
// ── Typing indicator + live workflow trace ───────────────────────────────────
|
||||
|
||||
function inferStatusLabel(lastUserMsg: string): string[] {
|
||||
const TOOL_LABEL: Record<string, string> = {
|
||||
list_agents: 'List Agents',
|
||||
create_agent: 'Create Agent',
|
||||
run_agent: 'Run Agent',
|
||||
create_plugin: 'Create Plugin',
|
||||
publish_plugin: 'Publish Plugin',
|
||||
list_integrations: 'Integrations',
|
||||
connect_integration: 'Connect',
|
||||
list_provider_tools: 'List Tools',
|
||||
try_it_out: 'Run Demo',
|
||||
}
|
||||
|
||||
const TOOL_ICON: Record<string, React.ElementType> = {
|
||||
list_agents: Bot,
|
||||
create_agent: Bot,
|
||||
run_agent: Zap,
|
||||
create_plugin: Package,
|
||||
publish_plugin: Globe,
|
||||
list_integrations: Plug,
|
||||
connect_integration: Plug,
|
||||
list_provider_tools: Database,
|
||||
try_it_out: Zap,
|
||||
}
|
||||
|
||||
function toolDetail(ta: ToolAction): string {
|
||||
const r = ta.result as Record<string, unknown>
|
||||
if (ta.tool === 'list_agents') {
|
||||
const agents = (r.agents as unknown[]) ?? []
|
||||
return `${agents.length} agent${agents.length !== 1 ? 's' : ''}`
|
||||
}
|
||||
if (ta.tool === 'create_agent') return r.agent_id ? String(r.agent_id).slice(-6) : 'created'
|
||||
if (ta.tool === 'run_agent') return r.task_id ? String(r.task_id).slice(-6) : 'dispatched'
|
||||
if (ta.tool === 'create_plugin') return r.draft ? 'draft' : (r.name as string) ?? 'saved'
|
||||
if (ta.tool === 'publish_plugin') return 'published'
|
||||
if (ta.tool === 'list_integrations') return 'catalog'
|
||||
if (ta.tool === 'try_it_out') return (r.template_name as string) ?? 'demo'
|
||||
return r.success === false ? 'error' : 'done'
|
||||
}
|
||||
|
||||
// — Live trace: builds up during pending ————————————————————————————
|
||||
|
||||
type LiveNode = { id: string; Icon: React.ElementType; label: string }
|
||||
|
||||
function LiveWorkflowTrace({ lastUserMsg }: { lastUserMsg: string }) {
|
||||
const m = lastUserMsg.toLowerCase()
|
||||
if (/plugin|tool|build.*plugin|write.*plugin/.test(m))
|
||||
return ['Thinking…', 'Writing plugin code…', 'Validating code…', 'Almost ready…']
|
||||
if (/run|execute|launch|start.*agent|dispatch/.test(m))
|
||||
return ['Thinking…', 'Dispatching task…', 'Agent is running…']
|
||||
const likelyTools = /plugin|agent|run|execute|search|integrat|connect|remember|save|fetch/.test(m)
|
||||
|
||||
const allNodes: LiveNode[] = [
|
||||
{ id: 'input', Icon: Send, label: 'Input' },
|
||||
{ id: 'context', Icon: Database, label: 'Context' },
|
||||
{ id: 'llm', Icon: Cpu, label: 'LLM' },
|
||||
...(likelyTools ? [{ id: 'tools', Icon: Zap, label: 'Tools' }] : []),
|
||||
{ id: 'response', Icon: CheckCircle2, label: 'Response' },
|
||||
]
|
||||
|
||||
const [visible, setVisible] = useState(1)
|
||||
const [running, setRunning] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const DELAYS = [150, 900, 2200, 4200]
|
||||
let cancelled = false
|
||||
DELAYS.forEach((delay, i) => {
|
||||
setTimeout(() => {
|
||||
if (cancelled) return
|
||||
setVisible(i + 2)
|
||||
setRunning(i + 1)
|
||||
}, delay)
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 6, flexWrap: 'nowrap' }}>
|
||||
{allNodes.slice(0, visible).map((node, i) => {
|
||||
const { Icon } = node
|
||||
const isRunning = i === running
|
||||
const isDone = i < running
|
||||
return (
|
||||
<React.Fragment key={node.id}>
|
||||
{i > 0 && (
|
||||
<ChevronRight size={8} style={{ color: 'var(--nbl-border)', flexShrink: 0 }} />
|
||||
)}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 3,
|
||||
padding: '2px 6px', borderRadius: 20, whiteSpace: 'nowrap',
|
||||
fontSize: 10, fontWeight: 500,
|
||||
background: isDone
|
||||
? 'color-mix(in srgb, var(--nbl-green) 10%, transparent)'
|
||||
: isRunning
|
||||
? 'color-mix(in srgb, var(--nbl-nebula) 12%, transparent)'
|
||||
: 'transparent',
|
||||
border: `1px solid ${isDone
|
||||
? 'color-mix(in srgb, var(--nbl-green) 25%, transparent)'
|
||||
: isRunning
|
||||
? 'color-mix(in srgb, var(--nbl-nebula) 30%, transparent)'
|
||||
: 'transparent'}`,
|
||||
color: isDone
|
||||
? 'var(--nbl-green)'
|
||||
: isRunning
|
||||
? 'var(--nbl-nebula)'
|
||||
: 'var(--nbl-text-ghost)',
|
||||
opacity: isDone || isRunning ? 1 : 0.45,
|
||||
}}>
|
||||
{isRunning
|
||||
? <Loader2 size={9} style={{ animation: 'spin 0.8s linear infinite', flexShrink: 0 }} />
|
||||
: <Icon size={9} style={{ flexShrink: 0 }} />}
|
||||
{node.label}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function inferStatusLabel(msg: string): string[] {
|
||||
const m = msg.toLowerCase()
|
||||
if (/plugin|build.*plugin|write.*plugin/.test(m))
|
||||
return ['Writing plugin code…', 'Validating…', 'Almost ready…']
|
||||
if (/run|execute|launch|dispatch/.test(m))
|
||||
return ['Dispatching task…', 'Agent running…']
|
||||
if (/agent|create.*agent|new.*agent/.test(m))
|
||||
return ['Thinking…', 'Checking agents…', 'Preparing response…']
|
||||
return ['Checking agents…', 'Preparing…']
|
||||
if (/remember|save.*this|add.*memory|store/.test(m))
|
||||
return ['Thinking…', 'Checking memory…', 'Saving…']
|
||||
return ['Checking memory…']
|
||||
if (/search|find|look.*up|fetch/.test(m))
|
||||
return ['Thinking…', 'Searching knowledge base…', 'Gathering results…']
|
||||
return ['Searching knowledge…', 'Gathering results…']
|
||||
if (/integrat|connect|slack|github|jira|notion/.test(m))
|
||||
return ['Thinking…', 'Checking integrations…', 'Preparing response…']
|
||||
if (/status|health|running|active/.test(m))
|
||||
return ['Thinking…', 'Querying system state…', 'Preparing summary…']
|
||||
return ['Thinking…', 'Calling tools…', 'Preparing response…']
|
||||
return ['Checking integrations…', 'Preparing…']
|
||||
return ['Calling tools…', 'Preparing response…']
|
||||
}
|
||||
|
||||
function TypingBubble({ lastUserMsg }: { lastUserMsg: string }) {
|
||||
@@ -782,44 +896,161 @@ function TypingBubble({ lastUserMsg }: { lastUserMsg: string }) {
|
||||
const [step, setStep] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const DELAYS = [2000, 3500, 5000]
|
||||
setStep(0)
|
||||
const DELAYS = [2500, 5000]
|
||||
let idx = 0
|
||||
function advance() {
|
||||
idx += 1
|
||||
if (idx >= steps.length) return
|
||||
setStep(idx)
|
||||
if (idx < steps.length - 1) {
|
||||
setTimeout(advance, DELAYS[idx] ?? 4000)
|
||||
}
|
||||
if (idx < steps.length - 1) setTimeout(advance, DELAYS[idx] ?? 4000)
|
||||
}
|
||||
const t = setTimeout(advance, DELAYS[0])
|
||||
return () => clearTimeout(t)
|
||||
}, [steps.length])
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column',
|
||||
alignItems: 'flex-start', gap: 4, marginBottom: 14,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, paddingLeft: 2, marginBottom: 2 }}>
|
||||
<Sparkles size={11} style={{ color: 'var(--nbl-nebula)' }} />
|
||||
<span style={{ fontSize: 10, color: 'var(--nbl-text-ghost)', fontWeight: 500 }}>Nebula</span>
|
||||
</div>
|
||||
<div className="ne-ChatBubble" style={{
|
||||
padding: '10px 14px',
|
||||
borderRadius: '4px 14px 14px 14px',
|
||||
background: 'color-mix(in srgb, var(--nbl-nebula) 6%, var(--nbl-bg-surface))',
|
||||
border: '1px solid color-mix(in srgb, var(--nbl-nebula) 18%, var(--nbl-border))',
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
fontSize: 13,
|
||||
}}>
|
||||
<span className="ne-TypingDots">
|
||||
<span /><span /><span />
|
||||
</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--nbl-text-ghost)', transition: 'opacity 0.3s' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 6, marginBottom: 14, paddingLeft: 2 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Sparkles size={10} style={{ color: 'var(--nbl-nebula)' }} />
|
||||
<span className="ne-TypingDots"><span /><span /><span /></span>
|
||||
<span style={{ fontSize: 11, color: 'var(--nbl-text-ghost)', fontStyle: 'italic' }}>
|
||||
{steps[step]}
|
||||
</span>
|
||||
</div>
|
||||
<LiveWorkflowTrace lastUserMsg={lastUserMsg} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// — Completed workflow trace: attached to the AI message after response ——————
|
||||
|
||||
interface TraceNodeDef {
|
||||
id: string
|
||||
Icon: React.ElementType
|
||||
label: string
|
||||
detail?: string
|
||||
color: string
|
||||
}
|
||||
|
||||
function WorkflowTrace({
|
||||
toolActions, chunksUsed, latencyMs, modelUsed,
|
||||
}: {
|
||||
toolActions?: ToolAction[]
|
||||
chunksUsed?: number
|
||||
latencyMs?: number
|
||||
modelUsed?: string
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const nodes: TraceNodeDef[] = []
|
||||
|
||||
nodes.push({ id: 'input', Icon: Send, label: 'Input', color: 'var(--nbl-cyan)' })
|
||||
|
||||
if (chunksUsed && chunksUsed > 0) {
|
||||
nodes.push({
|
||||
id: 'rag', Icon: BookOpen, label: 'Knowledge',
|
||||
detail: `${chunksUsed} chunk${chunksUsed !== 1 ? 's' : ''}`,
|
||||
color: 'var(--nbl-green)',
|
||||
})
|
||||
}
|
||||
|
||||
const hasTools = toolActions && toolActions.length > 0
|
||||
nodes.push({
|
||||
id: 'llm1', Icon: Cpu,
|
||||
label: modelUsed ? modelUsed.replace(/^gpt-/, '') : 'LLM',
|
||||
detail: hasTools ? undefined : latencyMs ? `${latencyMs}ms` : undefined,
|
||||
color: 'var(--nbl-nebula)',
|
||||
})
|
||||
|
||||
if (hasTools) {
|
||||
for (const ta of toolActions) {
|
||||
nodes.push({
|
||||
id: `tool-${ta.tool}`, Icon: TOOL_ICON[ta.tool] ?? Code2,
|
||||
label: TOOL_LABEL[ta.tool] ?? ta.tool,
|
||||
detail: toolDetail(ta),
|
||||
color: 'var(--nbl-amber, #f59e0b)',
|
||||
})
|
||||
}
|
||||
nodes.push({
|
||||
id: 'synth', Icon: Sparkles, label: 'Synthesis',
|
||||
detail: latencyMs ? `${latencyMs}ms` : undefined,
|
||||
color: 'var(--nbl-nebula)',
|
||||
})
|
||||
}
|
||||
|
||||
nodes.push({ id: 'done', Icon: CheckCircle2, label: 'Done', color: 'var(--nbl-green)' })
|
||||
|
||||
const parts = [
|
||||
latencyMs ? `${latencyMs}ms` : null,
|
||||
modelUsed ? modelUsed.replace(/^(openai\/|anthropic\/|gpt-)/, '') : null,
|
||||
hasTools ? `${toolActions.length} tool${toolActions.length !== 1 ? 's' : ''}` : null,
|
||||
chunksUsed ? `${chunksUsed} chunk${chunksUsed !== 1 ? 's' : ''}` : null,
|
||||
].filter(Boolean)
|
||||
|
||||
if (parts.length === 0) return null
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<button
|
||||
onClick={() => setOpen(o => !o)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5,
|
||||
fontSize: 10, color: 'var(--nbl-text-ghost)',
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: 0,
|
||||
fontFamily: 'monospace', letterSpacing: '0.01em',
|
||||
}}
|
||||
>
|
||||
<Zap size={9} style={{ color: 'var(--nbl-amber, #f59e0b)', flexShrink: 0 }} />
|
||||
{parts.join(' · ')}
|
||||
<ChevronRight
|
||||
size={9}
|
||||
style={{
|
||||
transform: open ? 'rotate(90deg)' : 'none',
|
||||
transition: 'transform 0.15s',
|
||||
opacity: 0.6,
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div style={{ marginTop: 8, overflowX: 'auto', paddingBottom: 2 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 4, minWidth: 'max-content' }}>
|
||||
{nodes.map((node, i) => (
|
||||
<React.Fragment key={node.id}>
|
||||
{i > 0 && (
|
||||
<div style={{
|
||||
alignSelf: 'flex-start', marginTop: 10,
|
||||
color: 'var(--nbl-border)', flexShrink: 0, fontSize: 12,
|
||||
}}>›</div>
|
||||
)}
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
gap: 3, minWidth: 52,
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 24, height: 24, borderRadius: '50%',
|
||||
background: `color-mix(in srgb, ${node.color} 12%, transparent)`,
|
||||
border: `1px solid color-mix(in srgb, ${node.color} 30%, transparent)`,
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<node.Icon size={11} style={{ color: node.color }} />
|
||||
</div>
|
||||
<span style={{ fontSize: 9, fontWeight: 600, color: 'var(--nbl-text-primary)', textAlign: 'center', lineHeight: 1.2 }}>
|
||||
{node.label}
|
||||
</span>
|
||||
{node.detail && (
|
||||
<span style={{ fontSize: 9, color: 'var(--nbl-text-ghost)', textAlign: 'center', lineHeight: 1.2 }}>
|
||||
{node.detail}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1033,6 +1264,14 @@ function MessageBubble({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{msg.mode === 'nebula' && msg.role === 'agent' && (
|
||||
<WorkflowTrace
|
||||
toolActions={msg.toolActions}
|
||||
chunksUsed={msg.chunksUsed}
|
||||
latencyMs={msg.latencyMs}
|
||||
modelUsed={msg.modelUsed}
|
||||
/>
|
||||
)}
|
||||
{stepsOpen && msg.taskId && (
|
||||
<div style={{ maxWidth: '88%' }}>
|
||||
<InlineSteps taskId={msg.taskId} />
|
||||
@@ -1531,6 +1770,7 @@ export function ChatWorkspace() {
|
||||
toolActions: resp.tool_actions,
|
||||
chunksUsed: resp.chunks_used,
|
||||
latencyMs: resp.latency_ms,
|
||||
modelUsed: resp.model_used,
|
||||
createdAt: new Date().toISOString(),
|
||||
tryItData,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user