@nextsparkjs/plugin-langchain 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/.env.example +41 -0
  2. package/api/observability/metrics/route.ts +110 -0
  3. package/api/observability/traces/[traceId]/route.ts +398 -0
  4. package/api/observability/traces/route.ts +205 -0
  5. package/api/sessions/route.ts +332 -0
  6. package/components/observability/CollapsibleJson.tsx +71 -0
  7. package/components/observability/CompactTimeline.tsx +75 -0
  8. package/components/observability/ConversationFlow.tsx +271 -0
  9. package/components/observability/DisabledMessage.tsx +21 -0
  10. package/components/observability/FiltersPanel.tsx +82 -0
  11. package/components/observability/ObservabilityDashboard.tsx +230 -0
  12. package/components/observability/SpansList.tsx +210 -0
  13. package/components/observability/TraceDetail.tsx +335 -0
  14. package/components/observability/TraceStatusBadge.tsx +39 -0
  15. package/components/observability/TracesTable.tsx +97 -0
  16. package/components/observability/index.ts +7 -0
  17. package/docs/01-getting-started/01-overview.md +196 -0
  18. package/docs/01-getting-started/02-installation.md +368 -0
  19. package/docs/01-getting-started/03-configuration.md +794 -0
  20. package/docs/02-core-concepts/01-architecture.md +566 -0
  21. package/docs/02-core-concepts/02-agents.md +597 -0
  22. package/docs/02-core-concepts/03-tools.md +689 -0
  23. package/docs/03-orchestration/01-graph-orchestrator.md +809 -0
  24. package/docs/03-orchestration/02-legacy-react.md +650 -0
  25. package/docs/04-advanced/01-observability.md +645 -0
  26. package/docs/04-advanced/02-token-tracking.md +469 -0
  27. package/docs/04-advanced/03-streaming.md +476 -0
  28. package/docs/04-advanced/04-guardrails.md +597 -0
  29. package/docs/05-reference/01-api-reference.md +1403 -0
  30. package/docs/05-reference/02-customization.md +646 -0
  31. package/docs/05-reference/03-examples.md +881 -0
  32. package/docs/index.md +85 -0
  33. package/hooks/observability/useMetrics.ts +31 -0
  34. package/hooks/observability/useTraceDetail.ts +48 -0
  35. package/hooks/observability/useTraces.ts +59 -0
  36. package/lib/agent-factory.ts +354 -0
  37. package/lib/agent-helpers.ts +201 -0
  38. package/lib/db-memory-store.ts +417 -0
  39. package/lib/graph/index.ts +58 -0
  40. package/lib/graph/nodes/combiner.ts +399 -0
  41. package/lib/graph/nodes/router.ts +440 -0
  42. package/lib/graph/orchestrator-graph.ts +386 -0
  43. package/lib/graph/prompts/combiner.md +131 -0
  44. package/lib/graph/prompts/router.md +193 -0
  45. package/lib/graph/types.ts +365 -0
  46. package/lib/guardrails.ts +230 -0
  47. package/lib/index.ts +44 -0
  48. package/lib/logger.ts +70 -0
  49. package/lib/memory-store.ts +168 -0
  50. package/lib/message-serializer.ts +110 -0
  51. package/lib/prompt-renderer.ts +94 -0
  52. package/lib/providers.ts +226 -0
  53. package/lib/streaming.ts +232 -0
  54. package/lib/token-tracker.ts +298 -0
  55. package/lib/tools-builder.ts +192 -0
  56. package/lib/tracer-callbacks.ts +342 -0
  57. package/lib/tracer.ts +350 -0
  58. package/migrations/001_langchain_memory.sql +83 -0
  59. package/migrations/002_token_usage.sql +127 -0
  60. package/migrations/003_observability.sql +257 -0
  61. package/package.json +28 -0
  62. package/plugin.config.ts +170 -0
  63. package/presets/lib/langchain.config.ts.preset +142 -0
  64. package/presets/templates/sector7/ai-observability/[traceId]/page.tsx +91 -0
  65. package/presets/templates/sector7/ai-observability/page.tsx +54 -0
  66. package/types/langchain.types.ts +274 -0
  67. package/types/observability.types.ts +270 -0
@@ -0,0 +1,205 @@
1
+ /**
2
+ * GET /api/langchain/observability/traces
3
+ *
4
+ * List traces with pagination and filters.
5
+ * Admin access required (superadmin or developer).
6
+ */
7
+
8
+ import { NextRequest, NextResponse } from 'next/server'
9
+ import { authenticateRequest } from '@nextsparkjs/core/lib/api/auth/dual-auth'
10
+ import { queryWithRLS } from '@nextsparkjs/core/lib/db'
11
+ import type { Trace } from '../../../types/observability.types'
12
+
13
+ interface TraceRow {
14
+ traceId: string
15
+ userId: string
16
+ teamId: string
17
+ sessionId: string | null
18
+ agentName: string
19
+ agentType: string | null
20
+ parentId: string | null
21
+ input: string
22
+ output: string | null
23
+ status: 'running' | 'success' | 'error'
24
+ error: string | null
25
+ errorType: string | null
26
+ errorStack: string | null
27
+ startedAt: Date
28
+ endedAt: Date | null
29
+ durationMs: number | null
30
+ inputTokens: number
31
+ outputTokens: number
32
+ totalTokens: number
33
+ totalCost: number
34
+ llmCalls: number
35
+ toolCalls: number
36
+ metadata: Record<string, unknown>
37
+ tags: string[] | null
38
+ createdAt: Date
39
+ }
40
+
41
+ export async function GET(req: NextRequest) {
42
+ // 1. Authenticate (superadmin only)
43
+ const authResult = await authenticateRequest(req)
44
+ if (!authResult.success || !authResult.user) {
45
+ return NextResponse.json(
46
+ { success: false, error: 'Unauthorized' },
47
+ { status: 401 }
48
+ )
49
+ }
50
+
51
+ // Check if user has admin-level access (superadmin or developer)
52
+ const adminRoles = ['superadmin', 'developer']
53
+ if (!adminRoles.includes(authResult.user.role)) {
54
+ return NextResponse.json(
55
+ { success: false, error: 'Forbidden: Admin access required' },
56
+ { status: 403 }
57
+ )
58
+ }
59
+
60
+ try {
61
+ // 2. Parse query parameters
62
+ const { searchParams } = new URL(req.url)
63
+ const status = searchParams.get('status')
64
+ const agent = searchParams.get('agent')
65
+ const teamId = searchParams.get('teamId')
66
+ const from = searchParams.get('from')
67
+ const to = searchParams.get('to')
68
+ const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 100)
69
+ const cursor = searchParams.get('cursor')
70
+
71
+ // 3. Build query
72
+ const conditions: string[] = []
73
+ const params: unknown[] = []
74
+ let paramIndex = 1
75
+
76
+ if (status) {
77
+ conditions.push(`status = $${paramIndex}`)
78
+ params.push(status)
79
+ paramIndex++
80
+ }
81
+
82
+ if (agent) {
83
+ conditions.push(`"agentName" = $${paramIndex}`)
84
+ params.push(agent)
85
+ paramIndex++
86
+ }
87
+
88
+ if (teamId) {
89
+ conditions.push(`"teamId" = $${paramIndex}`)
90
+ params.push(teamId)
91
+ paramIndex++
92
+ }
93
+
94
+ if (from) {
95
+ conditions.push(`"startedAt" >= $${paramIndex}`)
96
+ params.push(from)
97
+ paramIndex++
98
+ }
99
+
100
+ if (to) {
101
+ conditions.push(`"startedAt" <= $${paramIndex}`)
102
+ params.push(to)
103
+ paramIndex++
104
+ }
105
+
106
+ if (cursor) {
107
+ conditions.push(`"createdAt" < $${paramIndex}`)
108
+ params.push(cursor)
109
+ paramIndex++
110
+ }
111
+
112
+ // Always filter to show only root traces (no parent)
113
+ conditions.push(`"parentId" IS NULL`)
114
+
115
+ const whereClause = `WHERE ${conditions.join(' AND ')}`
116
+
117
+ // Add limit + 1 to check if there are more results
118
+ params.push(limit + 1)
119
+ const limitClause = `LIMIT $${paramIndex}`
120
+
121
+ const query = `
122
+ SELECT
123
+ "traceId",
124
+ "userId",
125
+ "teamId",
126
+ "sessionId",
127
+ "agentName",
128
+ "agentType",
129
+ "parentId",
130
+ input,
131
+ output,
132
+ status,
133
+ error,
134
+ "errorType",
135
+ "errorStack",
136
+ "startedAt",
137
+ "endedAt",
138
+ "durationMs",
139
+ "inputTokens",
140
+ "outputTokens",
141
+ "totalTokens",
142
+ "totalCost",
143
+ "llmCalls",
144
+ "toolCalls",
145
+ metadata,
146
+ tags,
147
+ "createdAt"
148
+ FROM public."langchain_traces"
149
+ ${whereClause}
150
+ ORDER BY "createdAt" DESC
151
+ ${limitClause}
152
+ `
153
+
154
+ const rows = await queryWithRLS<TraceRow>(query, params, authResult.user.id)
155
+
156
+ // Check if there are more results
157
+ const hasMore = rows.length > limit
158
+ const traces = hasMore ? rows.slice(0, limit) : rows
159
+ const nextCursor = hasMore ? traces[traces.length - 1].createdAt.toISOString() : undefined
160
+
161
+ // 4. Format response
162
+ const formattedTraces: Trace[] = traces.map((row) => ({
163
+ traceId: row.traceId,
164
+ userId: row.userId,
165
+ teamId: row.teamId,
166
+ sessionId: row.sessionId || undefined,
167
+ agentName: row.agentName,
168
+ agentType: row.agentType || undefined,
169
+ parentId: row.parentId || undefined,
170
+ input: row.input,
171
+ output: row.output || undefined,
172
+ status: row.status,
173
+ error: row.error || undefined,
174
+ errorType: row.errorType || undefined,
175
+ errorStack: row.errorStack || undefined,
176
+ startedAt: row.startedAt.toISOString(),
177
+ endedAt: row.endedAt?.toISOString(),
178
+ durationMs: row.durationMs || undefined,
179
+ inputTokens: row.inputTokens,
180
+ outputTokens: row.outputTokens,
181
+ totalTokens: row.totalTokens,
182
+ totalCost: row.totalCost,
183
+ llmCalls: row.llmCalls,
184
+ toolCalls: row.toolCalls,
185
+ metadata: row.metadata,
186
+ tags: row.tags || undefined,
187
+ createdAt: row.createdAt.toISOString(),
188
+ }))
189
+
190
+ return NextResponse.json({
191
+ success: true,
192
+ data: {
193
+ traces: formattedTraces,
194
+ hasMore,
195
+ nextCursor,
196
+ },
197
+ })
198
+ } catch (error) {
199
+ console.error('[Observability API] List traces error:', error)
200
+ return NextResponse.json(
201
+ { success: false, error: 'Failed to list traces' },
202
+ { status: 500 }
203
+ )
204
+ }
205
+ }
@@ -0,0 +1,332 @@
1
+ import { NextRequest, NextResponse } from 'next/server'
2
+ import { authenticateRequest } from '@nextsparkjs/core/lib/api/auth/dual-auth'
3
+ import { dbMemoryStore, CONVERSATION_LIMITS } from '../../lib/db-memory-store'
4
+ import { config } from '../../plugin.config'
5
+ import type {
6
+ CreateConversationRequest,
7
+ UpdateConversationRequest,
8
+ ConversationInfo,
9
+ } from '../../types/langchain.types'
10
+
11
+ /**
12
+ * Convert Date to ISO string for API responses
13
+ */
14
+ function toApiConversationInfo(conv: {
15
+ sessionId: string
16
+ name: string | null
17
+ messageCount: number
18
+ firstMessage: string | null
19
+ isPinned: boolean
20
+ createdAt: Date
21
+ updatedAt: Date
22
+ }): ConversationInfo {
23
+ return {
24
+ ...conv,
25
+ createdAt: conv.createdAt.toISOString(),
26
+ updatedAt: conv.updatedAt.toISOString(),
27
+ }
28
+ }
29
+
30
+ /**
31
+ * GET - List all conversations or get single conversation
32
+ *
33
+ * Query params:
34
+ * - id: Session ID to get specific conversation
35
+ *
36
+ * Without id: returns list of all conversations
37
+ * With id: returns single conversation details
38
+ */
39
+ export async function GET(req: NextRequest) {
40
+ // 1. Auth
41
+ const authResult = await authenticateRequest(req)
42
+ if (!authResult.success || !authResult.user) {
43
+ return NextResponse.json(
44
+ { success: false, error: 'Unauthorized' },
45
+ { status: 401 }
46
+ )
47
+ }
48
+
49
+ const userId = authResult.user.id
50
+
51
+ // 2. Team context
52
+ const teamId = req.headers.get('x-team-id')
53
+ if (!teamId) {
54
+ return NextResponse.json(
55
+ { success: false, error: 'Team context required', code: 'TEAM_CONTEXT_REQUIRED' },
56
+ { status: 400 }
57
+ )
58
+ }
59
+
60
+ const context = { userId, teamId }
61
+
62
+ try {
63
+ // Check if requesting specific session
64
+ const { searchParams } = new URL(req.url)
65
+ const sessionId = searchParams.get('id')
66
+
67
+ if (sessionId) {
68
+ // Get single conversation
69
+ const session = await dbMemoryStore.getSession(sessionId, context)
70
+
71
+ if (!session) {
72
+ return NextResponse.json(
73
+ { success: false, error: 'Conversation not found' },
74
+ { status: 404 }
75
+ )
76
+ }
77
+
78
+ return NextResponse.json({
79
+ success: true,
80
+ data: toApiConversationInfo(session),
81
+ })
82
+ }
83
+
84
+ // List all conversations
85
+ const sessions = await dbMemoryStore.listSessions(context)
86
+
87
+ return NextResponse.json({
88
+ success: true,
89
+ data: {
90
+ sessions: sessions.map(toApiConversationInfo),
91
+ count: sessions.length,
92
+ maxAllowed: CONVERSATION_LIMITS.MAX_CONVERSATIONS,
93
+ },
94
+ })
95
+ } catch (error) {
96
+ if (config.debug) {
97
+ console.error('[LangChain Plugin] List sessions error:', error)
98
+ }
99
+ return NextResponse.json(
100
+ { success: false, error: 'Failed to list conversations' },
101
+ { status: 500 }
102
+ )
103
+ }
104
+ }
105
+
106
+ /**
107
+ * POST - Create a new conversation
108
+ *
109
+ * Body:
110
+ * - name: Optional name for the conversation
111
+ */
112
+ export async function POST(req: NextRequest) {
113
+ // 1. Auth
114
+ const authResult = await authenticateRequest(req)
115
+ if (!authResult.success || !authResult.user) {
116
+ return NextResponse.json(
117
+ { success: false, error: 'Unauthorized' },
118
+ { status: 401 }
119
+ )
120
+ }
121
+
122
+ const userId = authResult.user.id
123
+
124
+ // 2. Team context
125
+ const teamId = req.headers.get('x-team-id')
126
+ if (!teamId) {
127
+ return NextResponse.json(
128
+ { success: false, error: 'Team context required', code: 'TEAM_CONTEXT_REQUIRED' },
129
+ { status: 400 }
130
+ )
131
+ }
132
+
133
+ const context = { userId, teamId }
134
+
135
+ try {
136
+ // 3. Check conversation limit
137
+ const currentCount = await dbMemoryStore.countSessions(context)
138
+
139
+ if (currentCount >= CONVERSATION_LIMITS.MAX_CONVERSATIONS) {
140
+ const oldestSession = await dbMemoryStore.getOldestSession(context)
141
+
142
+ return NextResponse.json({
143
+ success: false,
144
+ error: 'CONVERSATION_LIMIT_REACHED',
145
+ message: `Maximum of ${CONVERSATION_LIMITS.MAX_CONVERSATIONS} conversations reached. Delete an existing conversation to create a new one.`,
146
+ data: {
147
+ currentCount,
148
+ maxAllowed: CONVERSATION_LIMITS.MAX_CONVERSATIONS,
149
+ oldestSession: oldestSession
150
+ ? {
151
+ sessionId: oldestSession.sessionId,
152
+ name: oldestSession.name,
153
+ updatedAt: oldestSession.updatedAt.toISOString(),
154
+ }
155
+ : null,
156
+ },
157
+ }, { status: 400 })
158
+ }
159
+
160
+ // 4. Parse body
161
+ const body: CreateConversationRequest = await req.json().catch(() => ({}))
162
+ const { name } = body
163
+
164
+ // 5. Create session
165
+ const result = await dbMemoryStore.createSession(context, name)
166
+
167
+ return NextResponse.json({
168
+ success: true,
169
+ data: {
170
+ sessionId: result.sessionId,
171
+ name: name || null,
172
+ createdAt: result.createdAt.toISOString(),
173
+ },
174
+ }, { status: 201 })
175
+ } catch (error) {
176
+ if (config.debug) {
177
+ console.error('[LangChain Plugin] Create session error:', error)
178
+ }
179
+ return NextResponse.json(
180
+ { success: false, error: 'Failed to create conversation' },
181
+ { status: 500 }
182
+ )
183
+ }
184
+ }
185
+
186
+ /**
187
+ * PATCH - Update a conversation (rename, pin/unpin)
188
+ *
189
+ * Body:
190
+ * - sessionId: Session ID to update (required)
191
+ * - name: New name (optional)
192
+ * - isPinned: New pin status (optional)
193
+ */
194
+ export async function PATCH(req: NextRequest) {
195
+ // 1. Auth
196
+ const authResult = await authenticateRequest(req)
197
+ if (!authResult.success || !authResult.user) {
198
+ return NextResponse.json(
199
+ { success: false, error: 'Unauthorized' },
200
+ { status: 401 }
201
+ )
202
+ }
203
+
204
+ const userId = authResult.user.id
205
+
206
+ // 2. Team context
207
+ const teamId = req.headers.get('x-team-id')
208
+ if (!teamId) {
209
+ return NextResponse.json(
210
+ { success: false, error: 'Team context required', code: 'TEAM_CONTEXT_REQUIRED' },
211
+ { status: 400 }
212
+ )
213
+ }
214
+
215
+ const context = { userId, teamId }
216
+
217
+ try {
218
+ // 3. Parse body
219
+ const body: UpdateConversationRequest = await req.json()
220
+ const { sessionId, name, isPinned } = body
221
+
222
+ if (!sessionId) {
223
+ return NextResponse.json(
224
+ { success: false, error: 'Session ID is required' },
225
+ { status: 400 }
226
+ )
227
+ }
228
+
229
+ // 4. Check if session exists
230
+ const existingSession = await dbMemoryStore.getSession(sessionId, context)
231
+ if (!existingSession) {
232
+ return NextResponse.json(
233
+ { success: false, error: 'Conversation not found' },
234
+ { status: 404 }
235
+ )
236
+ }
237
+
238
+ // 5. Update fields
239
+ if (name !== undefined) {
240
+ await dbMemoryStore.renameSession(sessionId, name, context)
241
+ }
242
+
243
+ if (isPinned !== undefined) {
244
+ await dbMemoryStore.togglePinSession(sessionId, isPinned, context)
245
+ }
246
+
247
+ // 6. Get updated session
248
+ const updatedSession = await dbMemoryStore.getSession(sessionId, context)
249
+
250
+ return NextResponse.json({
251
+ success: true,
252
+ data: updatedSession ? toApiConversationInfo(updatedSession) : null,
253
+ })
254
+ } catch (error) {
255
+ if (config.debug) {
256
+ console.error('[LangChain Plugin] Update session error:', error)
257
+ }
258
+ return NextResponse.json(
259
+ { success: false, error: 'Failed to update conversation' },
260
+ { status: 500 }
261
+ )
262
+ }
263
+ }
264
+
265
+ /**
266
+ * DELETE - Delete a conversation
267
+ *
268
+ * Body:
269
+ * - sessionId: Session ID to delete (required)
270
+ */
271
+ export async function DELETE(req: NextRequest) {
272
+ // 1. Auth
273
+ const authResult = await authenticateRequest(req)
274
+ if (!authResult.success || !authResult.user) {
275
+ return NextResponse.json(
276
+ { success: false, error: 'Unauthorized' },
277
+ { status: 401 }
278
+ )
279
+ }
280
+
281
+ const userId = authResult.user.id
282
+
283
+ // 2. Team context
284
+ const teamId = req.headers.get('x-team-id')
285
+ if (!teamId) {
286
+ return NextResponse.json(
287
+ { success: false, error: 'Team context required', code: 'TEAM_CONTEXT_REQUIRED' },
288
+ { status: 400 }
289
+ )
290
+ }
291
+
292
+ const context = { userId, teamId }
293
+
294
+ try {
295
+ // 3. Parse body
296
+ const body: { sessionId?: string } = await req.json()
297
+ const { sessionId } = body
298
+
299
+ if (!sessionId) {
300
+ return NextResponse.json(
301
+ { success: false, error: 'Session ID is required' },
302
+ { status: 400 }
303
+ )
304
+ }
305
+
306
+ // 4. Check if session exists
307
+ const existingSession = await dbMemoryStore.getSession(sessionId, context)
308
+ if (!existingSession) {
309
+ return NextResponse.json(
310
+ { success: false, error: 'Conversation not found' },
311
+ { status: 404 }
312
+ )
313
+ }
314
+
315
+ // 5. Delete session
316
+ await dbMemoryStore.clearSession(sessionId, context)
317
+
318
+ return NextResponse.json({
319
+ success: true,
320
+ message: 'Conversation deleted successfully',
321
+ sessionId,
322
+ })
323
+ } catch (error) {
324
+ if (config.debug) {
325
+ console.error('[LangChain Plugin] Delete session error:', error)
326
+ }
327
+ return NextResponse.json(
328
+ { success: false, error: 'Failed to delete conversation' },
329
+ { status: 500 }
330
+ )
331
+ }
332
+ }
@@ -0,0 +1,71 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import { useTranslations } from 'next-intl'
5
+ import { Button } from '@nextsparkjs/core/components/ui/button'
6
+
7
+ interface CollapsibleJsonProps {
8
+ data: unknown
9
+ maxPreviewLength?: number
10
+ className?: string
11
+ }
12
+
13
+ /**
14
+ * Collapsible JSON viewer that shows a truncated preview by default.
15
+ * Useful for long JSON objects in conversation flows.
16
+ */
17
+ export function CollapsibleJson({ data, maxPreviewLength = 100, className = '' }: CollapsibleJsonProps) {
18
+ const t = useTranslations('observability')
19
+ const [isExpanded, setIsExpanded] = useState(false)
20
+
21
+ // Format the JSON
22
+ const formattedJson = JSON.stringify(data, null, 2)
23
+
24
+ // Check if content is long enough to need collapsing
25
+ const needsCollapsing = formattedJson.length > maxPreviewLength
26
+
27
+ // Create preview (first maxPreviewLength chars + ellipsis)
28
+ const preview = needsCollapsing
29
+ ? formattedJson.slice(0, maxPreviewLength) + '...'
30
+ : formattedJson
31
+
32
+ // Check if content contains error-related keywords
33
+ const hasError =
34
+ formattedJson.toLowerCase().includes('"error"') ||
35
+ formattedJson.toLowerCase().includes('"error_type"') ||
36
+ formattedJson.toLowerCase().includes('failed') ||
37
+ formattedJson.toLowerCase().includes('exception')
38
+
39
+ const textColorClass = hasError
40
+ ? 'text-destructive'
41
+ : 'text-foreground'
42
+
43
+ const bgColorClass = hasError
44
+ ? 'bg-destructive/10'
45
+ : 'bg-muted'
46
+
47
+ if (!needsCollapsing) {
48
+ return (
49
+ <pre className={`text-xs p-2 rounded overflow-x-auto whitespace-pre-wrap ${bgColorClass} ${textColorClass} ${className}`}>
50
+ {formattedJson}
51
+ </pre>
52
+ )
53
+ }
54
+
55
+ return (
56
+ <div className={className}>
57
+ <pre className={`text-xs p-2 rounded overflow-x-auto whitespace-pre-wrap ${bgColorClass} ${textColorClass}`}>
58
+ {isExpanded ? formattedJson : preview}
59
+ </pre>
60
+ <Button
61
+ variant="ghost"
62
+ size="sm"
63
+ onClick={() => setIsExpanded(!isExpanded)}
64
+ className="mt-1 h-6 text-xs"
65
+ data-cy="toggle-json"
66
+ >
67
+ {isExpanded ? t('flow.collapse') : t('flow.showFull')}
68
+ </Button>
69
+ </div>
70
+ )
71
+ }
@@ -0,0 +1,75 @@
1
+ 'use client'
2
+
3
+ import type { Span } from '../../types/observability.types'
4
+
5
+ interface CompactTimelineProps {
6
+ spans: Span[]
7
+ className?: string
8
+ }
9
+
10
+ /**
11
+ * Compact horizontal timeline showing execution flow with icons.
12
+ * Shows: User → LLM → Tool (name) ✓/✗ → LLM → Response
13
+ */
14
+ export function CompactTimeline({ spans, className = '' }: CompactTimelineProps) {
15
+ // Filter to show only meaningful spans (LLM and tool)
16
+ const meaningfulSpans = spans.filter(
17
+ (span) => span.type === 'llm' || span.type === 'tool'
18
+ )
19
+
20
+ if (meaningfulSpans.length === 0) {
21
+ return null
22
+ }
23
+
24
+ const getSpanIcon = (span: Span) => {
25
+ if (span.type === 'llm') return '🧠'
26
+ if (span.type === 'tool') return '🔧'
27
+ return '📦'
28
+ }
29
+
30
+ const getStatusIcon = (status: string) => {
31
+ if (status === 'success') return '✓'
32
+ if (status === 'error') return '✗'
33
+ return '...'
34
+ }
35
+
36
+ const getStatusColor = (status: string) => {
37
+ if (status === 'success') return 'text-green-600 dark:text-green-400'
38
+ if (status === 'error') return 'text-red-600 dark:text-red-400'
39
+ return 'text-yellow-600 dark:text-yellow-400'
40
+ }
41
+
42
+ return (
43
+ <div className={`flex flex-wrap items-center gap-1 text-sm ${className}`} data-cy="compact-timeline">
44
+ {/* User icon at start */}
45
+ <span className="text-base" title="User input">👤</span>
46
+ <span className="text-muted-foreground">→</span>
47
+
48
+ {meaningfulSpans.map((span, index) => (
49
+ <div key={span.spanId} className="flex items-center gap-1">
50
+ <span className="text-base" title={span.name}>
51
+ {getSpanIcon(span)}
52
+ </span>
53
+
54
+ {span.type === 'tool' && span.toolName && (
55
+ <span className="text-xs text-muted-foreground bg-muted px-1 rounded">
56
+ {span.toolName}
57
+ </span>
58
+ )}
59
+
60
+ <span className={`text-xs font-bold ${getStatusColor(span.status)}`}>
61
+ {getStatusIcon(span.status)}
62
+ </span>
63
+
64
+ {index < meaningfulSpans.length - 1 && (
65
+ <span className="text-muted-foreground ml-1">→</span>
66
+ )}
67
+ </div>
68
+ ))}
69
+
70
+ {/* Response icon at end */}
71
+ <span className="text-muted-foreground">→</span>
72
+ <span className="text-base" title="AI Response">🤖</span>
73
+ </div>
74
+ )
75
+ }