Skip to content

Commit ae2f8da

Browse files
committed
dashboard/app: Add new AI trajectory visualization
This adds a trajectory visualization that's more fancy and vibey.
1 parent cdeabb9 commit ae2f8da

File tree

5 files changed

+1640
-84
lines changed

5 files changed

+1640
-84
lines changed

dashboard/app/ai.go

Lines changed: 88 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,6 @@ type uiAIJobsPage struct {
3232
CurrentWorkflow string
3333
}
3434

35-
type uiAIJobPage struct {
36-
Header *uiHeader
37-
Job *uiAIJob
38-
// The slice contains the same single Job, just for HTML templates convenience.
39-
Jobs []*uiAIJob
40-
CrashReport template.HTML
41-
Trajectory []*uiAITrajectorySpan
42-
}
43-
4435
type uiAIJob struct {
4536
ID string
4637
Link string
@@ -63,6 +54,20 @@ type uiAIResult struct {
6354
Value any
6455
}
6556

57+
type uiAIJobPage struct {
58+
Header *uiHeader
59+
Job *uiAIJob
60+
// The slice contains the same single Job, just for HTML templates convenience.
61+
Jobs []*uiAIJob
62+
CrashReport template.HTML
63+
Trajectory []*uiAITrajectoryNode
64+
}
65+
66+
type uiAITrajectoryNode struct {
67+
*uiAITrajectorySpan
68+
Children []*uiAITrajectoryNode
69+
}
70+
6671
type uiAITrajectorySpan struct {
6772
Started time.Time
6873
Seq int64
@@ -228,34 +233,81 @@ func makeUIAIJob(job *aidb.Job) *uiAIJob {
228233
}
229234
}
230235

231-
func makeUIAITrajectory(trajetory []*aidb.TrajectorySpan) []*uiAITrajectorySpan {
232-
var res []*uiAITrajectorySpan
233-
for _, span := range trajetory {
236+
func makeUIAITrajectory(trajectory []*aidb.TrajectorySpan) []*uiAITrajectoryNode {
237+
// We need to reconstruct the tree from the flat list of spans.
238+
// We assume that the spans are sorted by Seq and Nesting is consistent.
239+
// We use a stack to keep track of the current path in the tree.
240+
// The stack contains pointers to the nodes.
241+
// stack[0] is the root (virtual).
242+
// stack[i] is the parent of stack[i+1].
243+
// root is a virtual node to hold top-level nodes.
244+
root := &uiAITrajectoryNode{}
245+
stack := []*uiAITrajectoryNode{root}
246+
247+
for _, span := range trajectory {
234248
var duration time.Duration
235249
if span.Finished.Valid {
236250
duration = span.Finished.Time.Sub(span.Started)
237251
}
238-
res = append(res, &uiAITrajectorySpan{
239-
Started: span.Started,
240-
Seq: span.Seq,
241-
Nesting: span.Nesting,
242-
Type: span.Type,
243-
Name: span.Name,
244-
Model: span.Model,
245-
Duration: duration,
246-
Error: nullString(span.Error),
247-
Args: nullJSON(span.Args),
248-
Results: nullJSON(span.Results),
249-
Instruction: nullString(span.Instruction),
250-
Prompt: nullString(span.Prompt),
251-
Reply: nullString(span.Reply),
252-
Thoughts: nullString(span.Thoughts),
253-
InputTokens: nullInt64(span.InputTokens),
254-
OutputTokens: nullInt64(span.OutputTokens),
255-
OutputThoughtsTokens: nullInt64(span.OutputThoughtsTokens),
256-
})
257-
}
258-
return res
252+
node := &uiAITrajectoryNode{
253+
uiAITrajectorySpan: &uiAITrajectorySpan{
254+
Started: span.Started,
255+
Seq: span.Seq,
256+
Nesting: span.Nesting,
257+
Type: span.Type,
258+
Name: span.Name,
259+
Model: span.Model,
260+
Duration: duration,
261+
Error: nullString(span.Error),
262+
Args: nullJSON(span.Args),
263+
Results: nullJSON(span.Results),
264+
Instruction: nullString(span.Instruction),
265+
Prompt: nullString(span.Prompt),
266+
Reply: nullString(span.Reply),
267+
Thoughts: nullString(span.Thoughts),
268+
InputTokens: nullInt64(span.InputTokens),
269+
OutputTokens: nullInt64(span.OutputTokens),
270+
OutputThoughtsTokens: nullInt64(span.OutputThoughtsTokens),
271+
},
272+
}
273+
274+
// Adjust stack to the correct nesting level.
275+
// Nesting 0 means direct children of root (stack len 1)
276+
// Nesting N means direct children of stack[N] (stack len N+1)
277+
targetLen := int(span.Nesting) + 1
278+
targetLen = max(targetLen, 1)
279+
// This implies missing intermediate levels or jump in nesting.
280+
// Ideally shouldn't happen for strict tree traversal, but we just append to current parent.
281+
targetLen = min(targetLen, len(stack))
282+
stack = stack[:targetLen]
283+
284+
parent := stack[len(stack)-1]
285+
parent.Children = append(parent.Children, node)
286+
stack = append(stack, node)
287+
}
288+
289+
// Propagate errors specifically for:
290+
// "If an agent only has a single entry that's a failed tool call, it should be considered failed / red as well."
291+
var propagateErrors func(nodes []*uiAITrajectoryNode)
292+
propagateErrors = func(nodes []*uiAITrajectoryNode) {
293+
for _, node := range nodes {
294+
if len(node.Children) > 0 {
295+
propagateErrors(node.Children)
296+
// If this node is an Agent (or just a parent) and has exactly 1 child
297+
// and that child has an Error, propagate it if the parent doesn't have an error.
298+
if len(node.Children) == 1 && node.Children[0].Error != "" && node.Error == "" {
299+
// We copy the child's error to indicate failure at this level too.
300+
// Or maybe we want a distinct message? The user said "considered failed / red as well".
301+
// Copying the error makes it show up in the UI as if this node failed.
302+
// Let's use a pointer or just copy the string.
303+
node.Error = node.Children[0].Error
304+
}
305+
}
306+
}
307+
}
308+
propagateErrors(root.Children)
309+
310+
return root.Children
259311
}
260312

261313
func apiAIJobPoll(ctx context.Context, req *dashapi.AIJobPollReq) (any, error) {
@@ -646,6 +698,9 @@ func nullJSON(v spanner.NullJSON) string {
646698
if !v.Valid {
647699
return ""
648700
}
701+
if b, err := json.Marshal(v.Value); err == nil {
702+
return string(b)
703+
}
649704
return fmt.Sprint(v.Value)
650705
}
651706

0 commit comments

Comments
 (0)