Skip to content

Commit 4c628a1

Browse files
Implement optimized inputs/outputs formatter (GH-4)
2 parents 7fb119b + e76906d commit 4c628a1

File tree

6 files changed

+226
-120
lines changed

6 files changed

+226
-120
lines changed

langgraphics-web/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
"@xyflow/react": "^12.10.0",
1414
"antd": "^6.3.0",
1515
"react": "^19.2.0",
16-
"react-dom": "^19.2.0"
16+
"react-dom": "^19.2.0",
17+
"react-markdown": "^10.1.0"
1718
},
1819
"devDependencies": {
1920
"@types/node": "^24.10.1",

langgraphics-web/src/components/InspectPanel.tsx

Lines changed: 58 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import Tree from "antd/es/tree";
22
import type {TreeDataNode} from "antd";
3+
import ReactMarkdown from "react-markdown";
34
import {useCallback, useEffect, useMemo, useState} from "react";
45
import type {NodeEntry} from "../types";
56

@@ -14,6 +15,22 @@ export function InspectPanel({nodeEntries}: { nodeEntries: NodeEntry[] }) {
1415
return nodeEntries.find(({run_id}) => run_id === selectedKey);
1516
}, [nodeEntries, selectedKey]);
1617

18+
const system = useMemo(() => {
19+
const inputs = JSON.parse(selectedEntry?.input ?? "[]");
20+
const system = inputs[0] || {};
21+
return system.role === "system" && inputs.length < 3 ? system : null;
22+
}, [selectedEntry])
23+
24+
const input = useMemo(() => {
25+
const input = JSON.parse(selectedEntry?.input ?? "[]");
26+
return input[input.length - 1] || null;
27+
}, [selectedEntry])
28+
29+
const output = useMemo(() => {
30+
const output = JSON.parse(selectedEntry?.output ?? "[]");
31+
return output[output.length - 1] || null;
32+
}, [selectedEntry])
33+
1734
const getChildren = useCallback((parent: NodeEntry) => {
1835
return nodeEntries.filter(({parent_run_id}) => parent_run_id === parent.run_id).map(child => {
1936
const children: TreeDataNode[] = getChildren(child);
@@ -23,16 +40,13 @@ export function InspectPanel({nodeEntries}: { nodeEntries: NodeEntry[] }) {
2340
key: child.run_id,
2441
isLeaf: children.length === 0,
2542
title: (
26-
<span className="inspect-step-label">
27-
{child.node_kind
28-
? <img
29-
alt={child.node_kind}
30-
className="inspect-step-icon"
31-
src={`/icons/${child.node_kind}.svg`}
32-
/>
33-
: <span className={`inspect-step-status${child.status === "error" ? " error" : ""}`}/>
34-
}
35-
<span className="inspect-step-name">{child.node_id ?? "step"}</span>
43+
<span className={`inspect-step-label ${child.status ?? ""}`}>
44+
<img
45+
alt={child.node_kind ?? ""}
46+
className="inspect-step-icon"
47+
src={`/icons/${child.node_kind}.svg`}
48+
/>
49+
{child.node_id ?? "step"}
3650
</span>
3751
),
3852
}
@@ -44,7 +58,7 @@ export function InspectPanel({nodeEntries}: { nodeEntries: NodeEntry[] }) {
4458
key: entry.run_id,
4559
children: getChildren(entry),
4660
title: (
47-
<span className="inspect-node-label">
61+
<span className={`inspect-node-label ${entry.status ?? ""}`}>
4862
{entry.node_kind && <img src={`/icons/${entry.node_kind}.svg`} alt={entry.node_kind}/>}
4963
{entry.node_id}
5064
</span>
@@ -78,16 +92,43 @@ export function InspectPanel({nodeEntries}: { nodeEntries: NodeEntry[] }) {
7892
<div className="inspect-detail-pane">
7993
{selectedEntry && (
8094
<>
81-
{selectedEntry.input && (
95+
{system && (
8296
<div className="inspect-detail-section">
83-
<span className="inspect-section-label">Input</span>
84-
<div className="inspect-detail-text">{selectedEntry.input}</div>
97+
<span className={`inspect-section-label ${system.role ?? ""}`}>
98+
<span>System</span>
99+
<span className="tag">{system.role ?? "unknown"}</span>
100+
</span>
101+
<div className="inspect-detail-text">
102+
{system.role
103+
? <ReactMarkdown children={system.content.trim()}/>
104+
: <pre>{JSON.stringify(system, null, 4)}</pre>}
105+
</div>
85106
</div>
86107
)}
87-
{selectedEntry.output && (
108+
{input && (
88109
<div className="inspect-detail-section">
89-
<span className="inspect-section-label">Output</span>
90-
<div className="inspect-detail-text">{selectedEntry.output}</div>
110+
<span className={`inspect-section-label ${input.role ?? ""}`}>
111+
<span>Input</span>
112+
<span className="tag">{input.role ?? "unknown"}</span>
113+
</span>
114+
<div className="inspect-detail-text">
115+
{input.role
116+
? <ReactMarkdown children={input.content.trim()}/>
117+
: <pre>{JSON.stringify(input, null, 4)}</pre>}
118+
</div>
119+
</div>
120+
)}
121+
{output && (
122+
<div className={`inspect-detail-section ${selectedEntry.node_kind ?? ""}`}>
123+
<span className={`inspect-section-label ${output.role ?? ""}`}>
124+
<span>Output</span>
125+
<span className="tag">{output.role ?? "unknown"}</span>
126+
</span>
127+
<div className={`inspect-detail-text ${selectedEntry.status ?? ""}`}>
128+
{output.role
129+
? <ReactMarkdown children={output.content.trim()}/>
130+
: <pre>{JSON.stringify(output, null, 4)}</pre>}
131+
</div>
91132
</div>
92133
)}
93134
</>

langgraphics-web/src/index.css

Lines changed: 64 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
* {
2-
margin: 0;
3-
padding: 0;
42
box-sizing: border-box;
53
}
64

75
html, body, #root {
6+
margin: 0;
7+
padding: 0;
88
width: 100%;
99
height: 100%;
1010
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
@@ -15,6 +15,9 @@ html, body, #root {
1515
--xy-node-color-default: #3c3c3c;
1616
--xy-background-color-default: #ffffff;
1717
--xy-node-border-default: 1px solid #c8c8c8;
18+
--xy-node-tag-background: #3c3c3c26;
19+
--xy-node-border-color: #C8C8C880;
20+
--xy-node-tag-color: #3c3c3ccc;
1821
}
1922

2023
.react-flow .css-var-root {
@@ -27,6 +30,9 @@ html, body, #root {
2730
.react-flow.dark {
2831
--xy-node-color-default: #c8c8c8;
2932
--xy-node-border-default: 1px solid #3c3c3c;
33+
--xy-node-tag-background: #c8c8c826;
34+
--xy-node-border-color: #3C3C3C80;
35+
--xy-node-tag-color: #c8c8c8cc;
3036
}
3137

3238
.react-flow.dark .css-var-root {
@@ -274,36 +280,22 @@ html, body, #root {
274280
.inspect-step-label {
275281
gap: 5px;
276282
display: flex;
283+
flex: 1 1 auto;
277284
font-size: 11px;
285+
overflow: hidden;
278286
font-weight: 400;
279287
font-style: italic;
280288
align-items: center;
281-
}
282-
283-
.inspect-step-status {
284-
width: 6px;
285-
height: 6px;
286-
flex-shrink: 0;
287-
border-radius: 50%;
288-
background: #c8c8c8;
289-
}
290-
291-
.inspect-step-status.error {
292-
background: #ef4444;
293-
}
294-
295-
.ant-tree-node-content-wrapper:not(.ant-tree-node-selected) .inspect-step-status:not(.error) {
296-
background: #3b82f6;
297-
}
298-
299-
.inspect-step-name {
300-
min-width: 0;
301-
flex: 1 1 auto;
302-
overflow: hidden;
303289
white-space: nowrap;
304290
text-overflow: ellipsis;
305291
}
306292

293+
.inspect-node-label.error,
294+
.inspect-step-label.error,
295+
.inspect-detail-text.error {
296+
color: #ef4444;
297+
}
298+
307299
.inspect-step-icon {
308300
width: 12px;
309301
height: 12px;
@@ -317,39 +309,72 @@ html, body, #root {
317309
border-radius: 6px;
318310
flex-direction: column;
319311
border: var(--xy-node-border-default);
312+
border-color: var(--xy-node-border-color);
320313
}
321314

322315
.inspect-section-label {
316+
display: flex;
323317
font-size: 10px;
324318
font-weight: 700;
325319
padding: 5px 10px;
320+
align-items: center;
326321
letter-spacing: 0.05em;
327322
text-transform: uppercase;
328323
background: var(--xy-node-border-default);
329324
border-bottom: var(--xy-node-border-default);
325+
border-color: var(--xy-node-border-color);
330326
}
331327

332328
.inspect-detail-section > :not(.inspect-section-label) {
333329
padding: 8px 10px;
334330
}
335331

336-
.inspect-detail-json {
337-
margin: 0;
338-
font-size: 11px;
339-
overflow-y: auto;
340-
white-space: pre-wrap;
341-
word-break: break-all;
342-
font-family: Consolas, monospace, Menlo, "SFMono-Regular", "Liberation Mono";
343-
}
344-
345332
.inspect-detail-text {
346-
gap: 3px;
347333
width: 100%;
348-
display: flex;
349334
font-size: 12px;
350-
line-height: 1.5;
351335
overflow-y: auto;
352-
white-space: pre-wrap;
353-
flex-direction: column;
354-
word-break: break-word;
336+
}
337+
338+
.inspect-detail-text > *:last-child,
339+
.inspect-detail-text > *:first-child {
340+
margin: 0;
341+
}
342+
343+
.inspect-section-label .tag {
344+
border: 1px solid var(--xy-node-tag-color);
345+
background: var(--xy-node-tag-background);
346+
color: var(--xy-node-tag-color);
347+
border-radius: 10px;
348+
margin-left: 8px;
349+
padding: 1px 5px;
350+
font-size: 7px;
351+
}
352+
353+
.inspect-detail-section.tool .inspect-detail-text:not(.error) {
354+
color: #ea580ccc;
355+
}
356+
357+
.inspect-detail-section.chain .inspect-detail-text:not(.error) {
358+
color: #db2777cc;
359+
}
360+
361+
.inspect-detail-section.prompt .inspect-detail-text:not(.error) {
362+
color: #9332eacc;
363+
}
364+
365+
.inspect-detail-section.parser .inspect-detail-text:not(.error) {
366+
color: #6467f2cc;
367+
}
368+
369+
.inspect-detail-section.embedding .inspect-detail-text:not(.error) {
370+
color: #d67506cc;
371+
}
372+
373+
.inspect-detail-section.retriever .inspect-detail-text:not(.error) {
374+
color: #0d9488cc;
375+
}
376+
377+
.inspect-detail-section.llm .inspect-detail-text:not(.error),
378+
.inspect-detail-section.chat_model .inspect-detail-text:not(.error) {
379+
color: #4f46e5cc;
355380
}

langgraphics/formatter.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import json
2+
from typing import Any
3+
4+
from langchain_core.messages import messages_to_dict
5+
from langchain_core.tracers.schemas import Run
6+
from langsmith.utils import (
7+
get_message_generation_from_outputs,
8+
get_llm_generation_from_outputs,
9+
get_messages_from_inputs,
10+
get_prompt_from_inputs,
11+
_convert_message,
12+
)
13+
14+
15+
class Formatter:
16+
@staticmethod
17+
def serialise(func):
18+
def wrapper(*args, **kwargs):
19+
return json.dumps(
20+
ensure_ascii=False,
21+
obj=func(*args, **kwargs),
22+
default=lambda x: x.__dict__,
23+
)
24+
25+
return wrapper
26+
27+
@staticmethod
28+
def norm(msg: dict[str, Any]) -> dict[str, Any]:
29+
data: dict[str, Any] = msg.get("data", {})
30+
if tool_calls := data.get("tool_calls", []):
31+
fmt_tool = lambda tc: f"{tc.get('name', '?')}({tc.get('args', {})})"
32+
return {
33+
"role": msg.get("type", "unknown"),
34+
"content": ", ".join(map(fmt_tool, tool_calls)),
35+
}
36+
role = msg.get("type", "unknown")
37+
content = data.get("content", "")
38+
if content and isinstance(content, list):
39+
content = content[-1].get("text", "")
40+
return {"role": role, "content": str(content)}
41+
42+
@classmethod
43+
@serialise
44+
def inputs(cls, run: Run) -> list[dict[str, Any]]:
45+
data: dict[str, Any] = run.inputs or {}
46+
if run.run_type == "chat_model":
47+
try:
48+
messages = messages_to_dict(data["messages"])
49+
except AttributeError:
50+
messages = data["messages"][0]
51+
except KeyError:
52+
return [data]
53+
return list(map(cls.norm, get_messages_from_inputs({"messages": messages})))
54+
elif run.run_type == "chain":
55+
messages = messages_to_dict(data.get("messages", []))
56+
return list(map(cls.norm, get_messages_from_inputs({"messages": messages})))
57+
elif run.run_type == "tool":
58+
return [{"role": "input", "content": str(data.get("input", ""))}]
59+
elif run.run_type == "retriever":
60+
return [{"role": "query", "content": str(data.get("query", ""))}]
61+
elif run.run_type == "llm":
62+
return [{"role": "prompt", "content": get_prompt_from_inputs(data)}]
63+
return []
64+
65+
@classmethod
66+
@serialise
67+
def outputs(cls, run: Run) -> list[dict[str, Any]]:
68+
if run.error:
69+
return [{"role": "error", "content": str(run.error)}]
70+
data: dict[str, Any] = run.outputs or {}
71+
if run.run_type == "chat_model":
72+
try:
73+
message = run.outputs["generations"][0][0]["message"]
74+
generation = _convert_message(message)
75+
except IndexError:
76+
generation = get_message_generation_from_outputs(data)
77+
return [cls.norm(generation)]
78+
elif run.run_type == "chain":
79+
try:
80+
messages = messages_to_dict(data["messages"].value)
81+
except AttributeError:
82+
messages = messages_to_dict(data["messages"])
83+
except KeyError:
84+
return [data]
85+
return list(map(cls.norm, get_messages_from_inputs({"messages": messages})))
86+
elif run.run_type == "llm":
87+
return [{"role": "text", "content": get_llm_generation_from_outputs(data)}]
88+
elif run.run_type == "tool":
89+
out = data.get("output", "")
90+
return [{"role": "output", "content": getattr(out, "content", str(out))}]
91+
elif run.run_type == "retriever":
92+
return [
93+
{"role": "document", "content": getattr(d, "page_content", str(d))}
94+
for d in data.get("documents", [])
95+
]
96+
return []

0 commit comments

Comments
 (0)