feat: support image outputs during code execution (#674)

Co-authored-by: Bailey Simrell <baileysimrell@gmail.com>
This commit is contained in:
Jeremy 2025-01-08 00:00:21 +05:30 committed by GitHub
parent 50fbc0dab2
commit 50bd4604b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 313 additions and 117 deletions

View file

@ -1,18 +1,12 @@
import { cn, generateUUID } from '@/lib/utils';
import { ClockRewind, CopyIcon, PlayIcon, RedoIcon, UndoIcon } from './icons';
import { cn } from '@/lib/utils';
import { ClockRewind, CopyIcon, RedoIcon, UndoIcon } from './icons';
import { Button } from './ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { useCopyToClipboard } from 'usehooks-ts';
import { toast } from 'sonner';
import { ConsoleOutput, UIBlock } from './block';
import {
Dispatch,
memo,
SetStateAction,
startTransition,
useCallback,
useState,
} from 'react';
import { Dispatch, memo, SetStateAction } from 'react';
import { RunCodeButton } from './run-code-button';
interface BlockActionsProps {
block: UIBlock;
@ -23,98 +17,6 @@ interface BlockActionsProps {
setConsoleOutputs: Dispatch<SetStateAction<Array<ConsoleOutput>>>;
}
export function RunCodeButton({
block,
setConsoleOutputs,
}: {
block: UIBlock;
setConsoleOutputs: Dispatch<SetStateAction<Array<ConsoleOutput>>>;
}) {
const [pyodide, setPyodide] = useState<any>(null);
const isPython = true;
const codeContent = block.content;
const updateConsoleOutput = useCallback(
(runId: string, content: string | null, status: 'completed' | 'failed') => {
setConsoleOutputs((consoleOutputs) => {
const index = consoleOutputs.findIndex((output) => output.id === runId);
if (index === -1) return consoleOutputs;
const updatedOutputs = [...consoleOutputs];
updatedOutputs[index] = {
id: runId,
content,
status,
};
return updatedOutputs;
});
},
[setConsoleOutputs],
);
const loadAndRunPython = useCallback(async () => {
const runId = generateUUID();
setConsoleOutputs((consoleOutputs) => [
...consoleOutputs,
{
id: runId,
content: null,
status: 'in_progress',
},
]);
let currentPyodideInstance = pyodide;
if (isPython) {
if (!currentPyodideInstance) {
// @ts-expect-error - pyodide is not defined
const newPyodideInstance = await loadPyodide({
indexURL: 'https://cdn.jsdelivr.net/pyodide/v0.23.4/full/',
});
setPyodide(newPyodideInstance);
currentPyodideInstance = newPyodideInstance;
}
try {
await currentPyodideInstance.runPythonAsync(`
import sys
import io
sys.stdout = io.StringIO()
`);
await currentPyodideInstance.runPythonAsync(codeContent);
const output: string = await currentPyodideInstance.runPythonAsync(
`sys.stdout.getvalue()`,
);
updateConsoleOutput(runId, output, 'completed');
} catch (error: any) {
updateConsoleOutput(runId, error.message, 'failed');
}
}
}, [pyodide, codeContent, isPython, setConsoleOutputs, updateConsoleOutput]);
return (
<Button
variant="outline"
className="py-1.5 px-2 h-fit dark:hover:bg-zinc-700"
onClick={() => {
startTransition(() => {
loadAndRunPython();
});
}}
disabled={block.status === 'streaming'}
>
<PlayIcon size={18} /> Run
</Button>
);
}
function PureBlockActions({
block,
handleVersionChange,

View file

@ -52,10 +52,15 @@ export interface UIBlock {
};
}
export interface ConsoleOutputContent {
type: 'text' | 'image';
value: string;
}
export interface ConsoleOutput {
id: string;
status: 'in_progress' | 'completed' | 'failed';
content: string | null;
status: 'in_progress' | 'loading_packages' | 'completed' | 'failed';
contents: Array<ConsoleOutputContent>;
}
function PureBlock({

View file

@ -101,21 +101,51 @@ export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
>
<div
className={cn('w-12 shrink-0', {
'text-muted-foreground':
consoleOutput.status === 'in_progress',
'text-muted-foreground': [
'in_progress',
'loading_packages',
].includes(consoleOutput.status),
'text-emerald-500': consoleOutput.status === 'completed',
'text-red-400': consoleOutput.status === 'failed',
})}
>
[{index + 1}]
</div>
{consoleOutput.status === 'in_progress' ? (
<div className="animate-spin size-fit self-center">
<LoaderIcon />
{['in_progress', 'loading_packages'].includes(
consoleOutput.status,
) ? (
<div className="flex flex-row gap-2">
<div className="animate-spin size-fit self-center">
<LoaderIcon />
</div>
<div className="text-muted-foreground">
{consoleOutput.status === 'in_progress'
? 'Initializing...'
: consoleOutput.status === 'loading_packages'
? 'Loading Packages...'
: null}
</div>
</div>
) : (
<div className="dark:text-zinc-50 text-zinc-900 whitespace-pre-line">
{consoleOutput.content}
<div className="dark:text-zinc-50 text-zinc-900 w-full flex flex-col gap-2">
{consoleOutput.contents.map((content, index) =>
content.type === 'image' ? (
<picture key={`${consoleOutput.id}-${index}`}>
<img
src={content.value}
alt="output"
className="rounded-md max-w-[600px] w-full"
/>
</picture>
) : (
<div
key={`${consoleOutput.id}-${index}`}
className="whitespace-pre-line"
>
{content.value}
</div>
),
)}
</div>
)}
</div>

View file

@ -0,0 +1,246 @@
import { generateUUID } from '@/lib/utils';
import {
type Dispatch,
type SetStateAction,
startTransition,
useCallback,
useState,
useEffect,
} from 'react';
import type { ConsoleOutput, UIBlock } from './block';
import { Button } from './ui/button';
import { PlayIcon } from './icons';
function detectPythonImports(code: string, pyodide: any): Set<string> {
const imports = new Set<string>();
const importPatterns = [
/import\s+(\w+)(?:\s+as\s+\w+)?/g,
/from\s+(\w+(?:\.\w+)*)\s+import/g,
/import\s+(\w+(?:\.\w+)*)/g,
];
for (const pattern of importPatterns) {
let match: RegExpExecArray | null;
while (true) {
match = pattern.exec(code);
if (match === null) break;
const rootPackage = match[1].split('.')[0];
if (rootPackage) {
imports.add(rootPackage);
}
}
}
// Get standard libraries dynamically when initializing Pyodide
let standardLibs = new Set<string>();
if (pyodide) {
const stdLibModules = pyodide.runPython(`
import sys
list(sys.stdlib_module_names)
`);
standardLibs = new Set(stdLibModules);
}
return new Set(Array.from(imports).filter((pkg) => !standardLibs.has(pkg)));
}
const OUTPUT_HANDLERS = {
matplotlib: `
from matplotlib import pyplot as plt
# Clear any existing plots
plt.clf()
plt.close('all')
# Switch to agg backend
plt.switch_backend('agg')
def setup_matplotlib_output():
def custom_show():
if plt.gcf().get_size_inches().prod() * plt.gcf().dpi ** 2 > 25_000_000:
print("Warning: Plot size too large, reducing quality")
plt.gcf().set_dpi(100)
png_buf = io.BytesIO()
plt.savefig(png_buf, format='png')
png_buf.seek(0)
png_base64 = base64.b64encode(png_buf.read()).decode('utf-8')
print(f'data:image/png;base64,{png_base64}')
png_buf.close()
plt.clf()
plt.close('all')
plt.show = custom_show
`,
basic: `
# Basic output capture setup
`,
};
function detectRequiredHandlers(code: string): string[] {
const handlers: string[] = ['basic'];
if (code.includes('matplotlib') || code.includes('plt.')) {
handlers.push('matplotlib');
}
return handlers;
}
export function RunCodeButton({
block,
setConsoleOutputs,
}: {
block: UIBlock;
setConsoleOutputs: Dispatch<SetStateAction<Array<ConsoleOutput>>>;
}) {
const [pyodide, setPyodide] = useState<any>(null);
const isPython = true;
const codeContent = block.content;
const loadAndRunPython = useCallback(async () => {
const runId = generateUUID();
setConsoleOutputs((outputs) => [
...outputs,
{
id: runId,
contents: [],
status: 'in_progress',
},
]);
let currentPyodideInstance = pyodide;
if (isPython) {
try {
if (!currentPyodideInstance) {
// @ts-expect-error - loadPyodide is not defined
const newPyodideInstance = await loadPyodide({
indexURL: 'https://cdn.jsdelivr.net/pyodide/v0.23.4/full/',
});
setPyodide(newPyodideInstance);
currentPyodideInstance = newPyodideInstance;
}
await currentPyodideInstance.runPythonAsync(`
import sys, io, gc
import base64
sys.stdout = io.StringIO()
`);
// Detect and load required packages
const requiredPackages = detectPythonImports(
codeContent,
currentPyodideInstance,
);
if (requiredPackages.size > 0) {
setConsoleOutputs((outputs) => [
...outputs.filter((output) => output.id !== runId),
{
id: runId,
contents: [],
status: 'loading_packages',
},
]);
await currentPyodideInstance.loadPackage(
Array.from(requiredPackages),
);
}
const requiredHandlers = detectRequiredHandlers(codeContent);
for (const handler of requiredHandlers) {
if (OUTPUT_HANDLERS[handler as keyof typeof OUTPUT_HANDLERS]) {
await currentPyodideInstance.runPythonAsync(
OUTPUT_HANDLERS[handler as keyof typeof OUTPUT_HANDLERS],
);
if (handler === 'matplotlib') {
await currentPyodideInstance.runPythonAsync(
'setup_matplotlib_output()',
);
}
}
}
await currentPyodideInstance.runPythonAsync(codeContent);
const runOutput = await currentPyodideInstance.runPythonAsync(
`sys.stdout.getvalue()`,
);
const runOutputByLines: string[] = runOutput.split('\n');
setConsoleOutputs((outputs) => [
...outputs.filter((output) => output.id !== runId),
{
id: generateUUID(),
contents: runOutputByLines
.filter((line) => line.trim().length)
.map((line) => ({
type: line.startsWith('data:image/png;base64')
? 'image'
: 'text',
value: line,
})),
status: 'completed',
},
]);
} catch (error: any) {
setConsoleOutputs((outputs) => [
...outputs.filter((output) => output.id !== runId),
{
id: runId,
contents: [{ type: 'text', value: error.message }],
status: 'failed',
},
]);
}
}
}, [pyodide, codeContent, isPython, setConsoleOutputs]);
useEffect(() => {
return () => {
if (pyodide) {
try {
pyodide.runPythonAsync(`
import sys
has_plt = 'matplotlib.pyplot' in sys.modules
if has_plt:
import matplotlib.pyplot as plt
plt.clf()
plt.close('all')
gc.collect()
`);
} catch (error) {
console.warn('Cleanup failed:', error);
}
}
};
}, [pyodide]);
return (
<Button
variant="outline"
className="py-1.5 px-2 h-fit dark:hover:bg-zinc-700"
onClick={() => {
startTransition(() => {
loadAndRunPython();
});
}}
disabled={block.status === 'streaming'}
>
<PlayIcon size={18} /> Run
</Button>
);
}