Skip to content

Commit 6ebd09c

Browse files
rgbkrkclaude
andauthored
Add MediaProvider context and OutputModel widget (#113)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6b192e2 commit 6ebd09c

File tree

10 files changed

+757
-4
lines changed

10 files changed

+757
-4
lines changed
Lines changed: 366 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,366 @@
1+
"use client";
2+
3+
/**
4+
* Demo component for the OutputModel widget.
5+
*
6+
* Creates OutputModel widgets with sample Jupyter outputs pumped into state,
7+
* showing how captured outputs render inside the widget tree.
8+
*/
9+
10+
import { useCallback, useState } from "react";
11+
import { Button } from "@/components/ui/button";
12+
import { MediaProvider } from "@/registry/outputs/media-provider";
13+
import {
14+
type JupyterCommMessage,
15+
useWidgetModels,
16+
useWidgetStoreRequired,
17+
WidgetStoreProvider,
18+
} from "@/registry/widgets/widget-store-context";
19+
import { WidgetView } from "@/registry/widgets/widget-view";
20+
21+
// Import to register built-in widgets (includes OutputModel)
22+
import "@/registry/widgets/controls";
23+
24+
// === Sample Messages ===
25+
26+
function createWidgetMessage(
27+
commId: string,
28+
modelName: string,
29+
modelModule: string,
30+
state: Record<string, unknown>,
31+
): JupyterCommMessage {
32+
return {
33+
header: {
34+
msg_id: crypto.randomUUID(),
35+
msg_type: "comm_open",
36+
},
37+
content: {
38+
comm_id: commId,
39+
target_name: "jupyter.widget",
40+
data: {
41+
state: {
42+
_model_name: modelName,
43+
_model_module: modelModule,
44+
_view_name: modelName.replace("Model", "View"),
45+
_view_module: modelModule,
46+
...state,
47+
},
48+
},
49+
},
50+
};
51+
}
52+
53+
function updateWidgetState(
54+
commId: string,
55+
state: Record<string, unknown>,
56+
): JupyterCommMessage {
57+
return {
58+
header: { msg_id: crypto.randomUUID(), msg_type: "comm_msg" },
59+
content: {
60+
comm_id: commId,
61+
data: { method: "update", state },
62+
},
63+
};
64+
}
65+
66+
// === Sample Output Data ===
67+
68+
const STREAM_OUTPUTS = [
69+
{
70+
output_type: "stream" as const,
71+
name: "stdout" as const,
72+
text: "Training model...\nEpoch 1/3: loss=0.482\nEpoch 2/3: loss=0.231\nEpoch 3/3: loss=0.089\n",
73+
},
74+
{
75+
output_type: "stream" as const,
76+
name: "stderr" as const,
77+
text: "WARNING: GPU memory usage at 85%\n",
78+
},
79+
];
80+
81+
const RICH_OUTPUTS = [
82+
{
83+
output_type: "execute_result" as const,
84+
data: {
85+
"text/html":
86+
"<table><thead><tr><th></th><th>name</th><th>score</th></tr></thead><tbody><tr><td>0</td><td>Alice</td><td>95</td></tr><tr><td>1</td><td>Bob</td><td>87</td></tr><tr><td>2</td><td>Carol</td><td>92</td></tr></tbody></table>",
87+
"text/plain":
88+
" name score\n0 Alice 95\n1 Bob 87\n2 Carol 92",
89+
},
90+
execution_count: 1,
91+
},
92+
];
93+
94+
const ERROR_OUTPUTS = [
95+
{
96+
output_type: "stream" as const,
97+
name: "stdout" as const,
98+
text: "Attempting connection...\n",
99+
},
100+
{
101+
output_type: "error" as const,
102+
ename: "ConnectionError",
103+
evalue: "Failed to connect to database on port 5432",
104+
traceback: [
105+
"\u001b[0;31m---------------------------------------------------------------------------\u001b[0m",
106+
"\u001b[0;31mConnectionError\u001b[0m Traceback (most recent call last)",
107+
"Cell \u001b[0;32mIn[3], line 2\u001b[0m\n\u001b[1;32m 1\u001b[0m \u001b[38;5;28;01mimport\u001b[39;00m \u001b[38;5;21;01mpsycopg2\u001b[39;00m\n\u001b[0;32m----> 2\u001b[0m conn \u001b[38;5;241m=\u001b[39m psycopg2\u001b[38;5;241m.\u001b[39mconnect(host\u001b[38;5;241m=\u001b[39m\u001b[38;5;124m'\u001b[39m\u001b[38;5;124mlocalhost\u001b[39m\u001b[38;5;124m'\u001b[39m)\n",
108+
"\u001b[0;31mConnectionError\u001b[0m: Failed to connect to database on port 5432",
109+
],
110+
},
111+
];
112+
113+
const MIXED_OUTPUTS = [
114+
{
115+
output_type: "stream" as const,
116+
name: "stdout" as const,
117+
text: "Results:\n",
118+
},
119+
{
120+
output_type: "display_data" as const,
121+
data: {
122+
"application/json": {
123+
accuracy: 0.95,
124+
precision: 0.93,
125+
recall: 0.97,
126+
f1: 0.95,
127+
},
128+
"text/plain":
129+
"{'accuracy': 0.95, 'precision': 0.93, 'recall': 0.97, 'f1': 0.95}",
130+
},
131+
metadata: {},
132+
},
133+
{
134+
output_type: "display_data" as const,
135+
data: {
136+
"text/markdown":
137+
"### Summary\n\nModel training **complete**. The model achieved:\n- 95% accuracy\n- 0.95 F1 score\n\n> Ready for deployment.",
138+
"text/plain": "Summary: Model training complete.",
139+
},
140+
metadata: {},
141+
},
142+
];
143+
144+
// === Demo Components ===
145+
146+
function DemoControls() {
147+
const { handleMessage } = useWidgetStoreRequired();
148+
const models = useWidgetModels();
149+
const [created, setCreated] = useState(false);
150+
151+
const createOutputWidgets = () => {
152+
// Output widget with stream outputs
153+
handleMessage(
154+
createWidgetMessage(
155+
"output-streams",
156+
"OutputModel",
157+
"@jupyter-widgets/output",
158+
{
159+
outputs: STREAM_OUTPUTS,
160+
msg_id: "",
161+
},
162+
),
163+
);
164+
165+
// Output widget with rich HTML (DataFrame)
166+
handleMessage(
167+
createWidgetMessage(
168+
"output-rich",
169+
"OutputModel",
170+
"@jupyter-widgets/output",
171+
{
172+
outputs: RICH_OUTPUTS,
173+
msg_id: "",
174+
},
175+
),
176+
);
177+
178+
// Output widget with error traceback
179+
handleMessage(
180+
createWidgetMessage(
181+
"output-error",
182+
"OutputModel",
183+
"@jupyter-widgets/output",
184+
{
185+
outputs: ERROR_OUTPUTS,
186+
msg_id: "",
187+
},
188+
),
189+
);
190+
191+
// Output widget with mixed content
192+
handleMessage(
193+
createWidgetMessage(
194+
"output-mixed",
195+
"OutputModel",
196+
"@jupyter-widgets/output",
197+
{
198+
outputs: MIXED_OUTPUTS,
199+
msg_id: "",
200+
},
201+
),
202+
);
203+
204+
// A VBox containing a label + output widget (the common pattern)
205+
handleMessage(
206+
createWidgetMessage(
207+
"vbox-label",
208+
"LabelModel",
209+
"@jupyter-widgets/controls",
210+
{
211+
value: "Captured Output:",
212+
},
213+
),
214+
);
215+
handleMessage(
216+
createWidgetMessage(
217+
"output-in-vbox",
218+
"OutputModel",
219+
"@jupyter-widgets/output",
220+
{
221+
outputs: RICH_OUTPUTS,
222+
msg_id: "",
223+
},
224+
),
225+
);
226+
handleMessage(
227+
createWidgetMessage(
228+
"demo-vbox",
229+
"VBoxModel",
230+
"@jupyter-widgets/controls",
231+
{
232+
children: ["IPY_MODEL_vbox-label", "IPY_MODEL_output-in-vbox"],
233+
box_style: "info",
234+
},
235+
),
236+
);
237+
238+
setCreated(true);
239+
};
240+
241+
const appendOutput = () => {
242+
const model = models.get("output-streams");
243+
if (!model) return;
244+
const currentOutputs = (model.state.outputs as typeof STREAM_OUTPUTS) || [];
245+
handleMessage(
246+
updateWidgetState("output-streams", {
247+
outputs: [
248+
...currentOutputs,
249+
{
250+
output_type: "stream",
251+
name: "stdout",
252+
text: `Step ${currentOutputs.length + 1}: checkpoint saved.\n`,
253+
},
254+
],
255+
}),
256+
);
257+
};
258+
259+
const clearOutputs = () => {
260+
handleMessage(updateWidgetState("output-streams", { outputs: [] }));
261+
};
262+
263+
return (
264+
<div className="flex flex-wrap gap-2">
265+
{!created ? (
266+
<Button onClick={createOutputWidgets} variant="default">
267+
Create Output Widgets
268+
</Button>
269+
) : (
270+
<>
271+
<Button onClick={appendOutput} variant="secondary">
272+
Append Stream Output
273+
</Button>
274+
<Button onClick={clearOutputs} variant="outline">
275+
Clear Stream Widget
276+
</Button>
277+
<Button onClick={createOutputWidgets} variant="outline">
278+
Reset All
279+
</Button>
280+
</>
281+
)}
282+
</div>
283+
);
284+
}
285+
286+
const DEMO_SECTIONS = [
287+
{
288+
id: "output-streams",
289+
label: "Stream Outputs",
290+
description: "stdout + stderr",
291+
},
292+
{ id: "output-rich", label: "Rich Output", description: "HTML DataFrame" },
293+
{
294+
id: "output-error",
295+
label: "Error Traceback",
296+
description: "stream + error",
297+
},
298+
{
299+
id: "output-mixed",
300+
label: "Mixed Content",
301+
description: "stream + JSON + markdown",
302+
},
303+
{ id: "demo-vbox", label: "In Layout", description: "VBox(Label, Output)" },
304+
];
305+
306+
function WidgetDisplay() {
307+
const models = useWidgetModels();
308+
309+
if (models.size === 0) {
310+
return (
311+
<div className="text-muted-foreground italic text-sm">
312+
Click &quot;Create Output Widgets&quot; to see OutputModel in action.
313+
</div>
314+
);
315+
}
316+
317+
return (
318+
<div className="space-y-4">
319+
{DEMO_SECTIONS.map(({ id, label, description }) => {
320+
if (!models.has(id)) return null;
321+
return (
322+
<div key={id} className="border rounded-lg p-4">
323+
<div className="flex items-baseline gap-2 mb-2">
324+
<span className="text-sm font-medium">{label}</span>
325+
<span className="text-xs text-muted-foreground">
326+
{description}
327+
</span>
328+
</div>
329+
<WidgetView modelId={id} />
330+
</div>
331+
);
332+
})}
333+
</div>
334+
);
335+
}
336+
337+
function OutputWidgetDemoContent() {
338+
return (
339+
<div className="space-y-6">
340+
<DemoControls />
341+
<WidgetDisplay />
342+
</div>
343+
);
344+
}
345+
346+
/**
347+
* Exported demo component with provider wrappers.
348+
*
349+
* Wraps with WidgetStoreProvider and MediaProvider so the OutputWidget
350+
* can render all output types through the standard media pipeline.
351+
*/
352+
export function OutputWidgetDemo() {
353+
const sendMessage = useCallback((msg: JupyterCommMessage) => {
354+
console.log("Widget → Kernel:", msg);
355+
}, []);
356+
357+
return (
358+
<WidgetStoreProvider sendMessage={sendMessage}>
359+
<MediaProvider>
360+
<OutputWidgetDemoContent />
361+
</MediaProvider>
362+
</WidgetStoreProvider>
363+
);
364+
}
365+
366+
export default OutputWidgetDemo;

0 commit comments

Comments
 (0)