Created
February 17, 2024 02:42
-
-
Save oeway/b881ec5ef5709ff80576608c74e776d8 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<docs lang="markdown"> | |
[TODO: write documentation for this plugin.] | |
</docs> | |
<config lang="json"> | |
{ | |
"name": "CodeInterpreter", | |
"type": "window", | |
"tags": [], | |
"ui": "", | |
"version": "0.1.0", | |
"cover": "", | |
"description": "Run Python code in a separate thread using Web Worker.", | |
"icon": "extension", | |
"inputs": null, | |
"outputs": null, | |
"api_version": "0.1.8", | |
"env": "", | |
"permissions": [], | |
"requirements": ["https://cdnjs.cloudflare.com/ajax/libs/ace/1.4.12/ace.js"], | |
"dependencies": [], | |
"defaults": {"w": 20, "h": 10} | |
} | |
</config> | |
<script lang="javascript"> | |
// Description: This file is used to run Python code in a separate thread using Web Worker. | |
// Code adapted from https://ijc8.me/2021/06/02/runnable-posts/ | |
// Create Web Worker to run Python code in a separate thread. | |
async function createWorker(){ | |
const response = await api.getAttachment('pyodide_worker.js') | |
let blob; | |
try { | |
blob = new Blob([response], {type: 'application/javascript'}); | |
} catch (e) { // Backwards-compatibility | |
window.BlobBuilder = window.BlobBuilder || window.WebKitBlobBuilder || window.MozBlobBuilder; | |
blob = new BlobBuilder(); | |
blob.append(response); | |
blob = blob.getBlob(); | |
} | |
const worker = new Worker(URL.createObjectURL(blob)); | |
// const pyodideWorker = new Worker(window.location.origin + '/assistants/pyodide_worker.js') | |
await new Promise((resolve)=> worker.onmessage = resolve) | |
return worker | |
} | |
class CodeBox { | |
constructor(worker, container, code) { | |
this.worker = worker | |
const editorContainer = document.createElement("div") | |
editorContainer.textContent = container.textContent.trim() | |
container.textContent = "" | |
container.appendChild(editorContainer) | |
this.editor = ace.edit(editorContainer, { | |
maxLines: 30, | |
}); | |
this.editor.setTheme("ace/theme/chrome") | |
this.editor.session.setMode("ace/mode/python") | |
this.editor.commands.addCommand({ | |
name: "run", | |
bindKey: { win: "Ctrl-Enter", mac: "Command-Enter" }, | |
exec: () => this.run(), | |
}) | |
if(code){ | |
this.editor.setValue(code) | |
} | |
this.button = document.createElement("button") | |
this.button.classList.add("run") | |
this.button.classList.add("loading") | |
this.button.disabled = true | |
this.button.onclick = () => this.run() | |
container.appendChild(this.button) | |
this.output = document.createElement("div") | |
this.output.classList.add("output") | |
container.appendChild(this.output) | |
} | |
executeScript(script) { | |
return new Promise((resolve, reject) => { | |
this.worker.onerror = reject | |
const handler = (e) => { | |
if (e.data.output !== undefined) { | |
const pre = document.createElement("pre") | |
pre.textContent = e.data.output | |
this.output.appendChild(pre) | |
// scroll to the element | |
setTimeout(()=> pre.scrollIntoView({ behavior: "smooth", block: "end", inline: "nearest" }), 500) | |
} else if (e.data.url !== undefined) { | |
const el = document.createElement(e.data.type) | |
el.src = e.data.url | |
if (e.data.type === "audio") { | |
el.controls = true | |
} | |
for (const [attr, value] of e.data.attrs ?? []) { | |
el[attr] = value | |
} | |
this.output.appendChild(el) | |
setTimeout(()=> el.scrollIntoView({ behavior: "smooth", block: "end", inline: "nearest" }), 500) | |
} else { | |
this.worker.removeEventListener("message", handler) | |
resolve(e.data) | |
} | |
} | |
this.worker.addEventListener("message", handler) | |
this.worker.postMessage({source: script}) | |
}) | |
} | |
async run() { | |
// Don't change the button state unless the computation takes at least 30ms. | |
for (const button of document.querySelectorAll(".runnable .run")) { | |
button.disabled = true | |
} | |
const timer = setTimeout(() => this.button.classList.add("running"), 30) | |
this.output.innerText = "" | |
const result = await this.executeScript(this.editor.getValue(), this.output) | |
clearTimeout(timer) | |
this.button.classList.remove("running") | |
for (const button of document.querySelectorAll(".runnable .run")) { | |
button.disabled = false | |
} | |
return result | |
} | |
} | |
async function setupExtension(runScript){ | |
await api.registerChatbotExtension({ | |
_rintf: true, | |
name: "CodeInterpreter", | |
description: "Execute Python3 code in the browser with Pyodide, standard outputs, errors and stack trace will be returned as the result.", | |
async get_schema() { | |
return { | |
type: "object", | |
title: "CodeInterpreter", | |
description: "Execute Python3 code in the browser with Pyodide, standard outputs, errors and stack trace will be returned as the result.", | |
properties: { | |
code: { | |
type: "string", | |
title: "code", | |
description: "The Python3 code to execute", | |
} | |
}, | |
required: ["code"], | |
allow_additional_properties: false, | |
}; | |
}, | |
async execute(config) { | |
const code = config["code"]; | |
console.log("CodeInterpreter running code:", code) | |
const result = await runScript(code) | |
console.log("CodeInterpreter result:", result) | |
return result; | |
}, | |
}) | |
} | |
let worker = null | |
class ImJoyPlugin { | |
async setup() { | |
worker = await createWorker() | |
if(api.registerChatbotExtension) | |
setupExtension(this.runScript.bind(this)) | |
// this.mountNativeFs('/drive') | |
} | |
async mountNativeFs(mountPoint) { | |
const dirHandle = await showDirectoryPicker(); | |
if ((await dirHandle.queryPermission({ mode: "readwrite" })) !== "granted") { | |
if ( | |
(await dirHandle.requestPermission({ mode: "readwrite" })) !== "granted" | |
) { | |
throw Error("Unable to read and write directory"); | |
} | |
} | |
worker.postMessage({ mount: { mountPoint, dirHandle} }) | |
} | |
async runScript(code){ | |
const appContainer = document.getElementById('app') | |
const container = document.createElement("div") | |
container.classList.add("runnable") | |
appContainer.appendChild(container) | |
const codebox = new CodeBox(worker, container, code) | |
codebox.button.classList.remove("loading") | |
codebox.button.disabled = false | |
return await codebox.run() | |
} | |
async run(ctx){ | |
const code = (ctx.data && ctx.data.code) || "import sys\nprint(sys.version)\n" | |
const results = await this.runScript(code) | |
console.log(results) | |
} | |
} | |
api.export(new ImJoyPlugin()) | |
</script> | |
<window lang="html"> | |
<div> | |
<div id="app"> | |
<h3>Code Interpreter</h3> | |
</div> | |
</div> | |
</window> | |
<style lang="css"> | |
body { | |
margin: 10; | |
font-family: Merriweather,Helvetica,Arial,sans-serif; | |
font-size: 16px; | |
} | |
.runnable .ace_editor { | |
border-bottom: 1px solid #ddd; | |
font-size: 16px; | |
} | |
.runnable { | |
border: 1px solid black; | |
border-radius: 5px; | |
} | |
.runnable .run { | |
background-color: green; | |
color: white; | |
border: none; | |
width: 100%; | |
height: 28px; | |
line-height: 16px; | |
vertical-align: middle; | |
} | |
.runnable .run:not(:disabled):hover { | |
opacity: 90%; | |
} | |
.runnable .run:active { | |
opacity: 110% !important; | |
} | |
.runnable .run::before { | |
content: "▶\00a0\00a0"; | |
font-size: 10px; | |
line-height: 16px; | |
vertical-align: middle; | |
} | |
.runnable .run::after { | |
content: "Run"; | |
} | |
.runnable .run.loading { | |
background-color: gray; | |
} | |
.runnable .run.loading::before { | |
content: ""; | |
} | |
.runnable .run.loading::after { | |
content: "Loading..."; | |
} | |
.runnable .run.running { | |
background-color: gray; | |
} | |
.runnable .run.running::before { | |
content: ""; | |
} | |
.runnable .run.running::after { | |
content: "Running..."; | |
} | |
.runnable .run.error { | |
background-color: #ba6565; | |
} | |
.runnable .run.error:after { | |
content: "Load failed. Try closing and re-opening this tab; some browsers do not garbage collect on refresh."; | |
} | |
.runnable .output:not(:empty) { | |
overflow: auto; | |
margin: 5px 41px 5px 41px; | |
font-size: 12px; | |
font-family: monospace; | |
} | |
.runnable .output pre { | |
margin: 0; | |
padding: 0; | |
display: inline; | |
} | |
.runnable .output img { | |
display: block; | |
margin-left: auto; | |
margin-right: auto; | |
} | |
</style> | |
<attachment name="pyodide_worker.js"> | |
importScripts('https://cdn.jsdelivr.net/pyodide/v0.18.0/full/pyodide.js'); | |
(async () => { | |
self.pyodide = await loadPyodide({ indexURL: 'https://cdn.jsdelivr.net/pyodide/v0.18.0/full/' }) | |
// NOTE: We intentionally avoid runPythonAsync here because we don't want this to pre-load extra modules like matplotlib. | |
self.pyodide.runPython(setupCode) | |
self.postMessage({loading: true}) // Inform the main thread that we finished loading. | |
})() | |
let outputs = [] | |
function write(output) { | |
self.postMessage({ output }) | |
outputs.push(output) | |
return output.length | |
} | |
function show(type, url, attrs) { | |
const turl = url.length > 32 ? url.slice(0, 32) + "..." : url | |
outputs.push({type, url: turl, attrs: attrs?.toJs()}) | |
self.postMessage({ type, url, attrs: attrs?.toJs() }) | |
} | |
// Stand-in for `time.sleep`, which does not actually sleep. | |
// To avoid a busy loop, instead import asyncio and await asyncio.sleep(). | |
function spin(seconds) { | |
const time = performance.now() + seconds * 1000 | |
while (performance.now() < time); | |
} | |
// NOTE: eval(compile(source, "<string>", "exec", ast.PyCF_ALLOW_TOP_LEVEL_AWAIT)) | |
// returns a coroutine if `source` contains a top-level await, and None otherwise. | |
const setupCode = ` | |
import array | |
import ast | |
import base64 | |
import contextlib | |
import io | |
import js | |
import pyodide | |
import sys | |
import time | |
import traceback | |
import wave | |
time.sleep = js.spin | |
# For redirecting stdout and stderr later. | |
class JSWriter(io.TextIOBase): | |
def write(self, s): | |
return js.write(s) | |
def setup_matplotlib(): | |
import matplotlib | |
matplotlib.use('Agg') | |
import matplotlib.pyplot as plt | |
def show(): | |
buf = io.BytesIO() | |
plt.savefig(buf, format='png') | |
img = 'data:image/png;base64,' + base64.b64encode(buf.getvalue()).decode('utf-8') | |
js.show("img", img) | |
plt.clf() | |
plt.show = show | |
def show_image(image, **attrs): | |
from PIL import Image | |
if not isinstance(image, Image.Image): | |
image = Image.fromarray(image) | |
buf = io.BytesIO() | |
image.save(buf, format='png') | |
data = 'data:image/png;base64,' + base64.b64encode(buf.getvalue()).decode('utf-8') | |
js.show("img", data, attrs) | |
def show_animation(frames, duration=100, format="apng", loop=0, **attrs): | |
from PIL import Image | |
buf = io.BytesIO() | |
img, *imgs = [frame if isinstance(frame, Image.Image) else Image.fromarray(frame) for frame in frames] | |
img.save(buf, format='png' if format == "apng" else format, save_all=True, append_images=imgs, duration=duration, loop=0) | |
img = f'data:image/{format};base64,' + base64.b64encode(buf.getvalue()).decode('utf-8') | |
js.show("img", img, attrs) | |
def convert_audio(data): | |
try: | |
import numpy as np | |
is_numpy = isinstance(data, np.ndarray) | |
except ImportError: | |
is_numpy = False | |
if is_numpy: | |
if len(data.shape) == 1: | |
channels = 1 | |
if len(data.shape) == 2: | |
channels = data.shape[0] | |
data = data.T.ravel() | |
else: | |
raise ValueError("Too many dimensions (expected 1 or 2).") | |
return ((data * (2**15 - 1)).astype("<h").tobytes(), channels) | |
else: | |
data = array.array('h', (int(x * (2**15 - 1)) for x in data)) | |
if sys.byteorder == 'big': | |
data.byteswap() | |
return (data.tobytes(), 1) | |
def show_audio(samples, rate): | |
bytes, channels = convert_audio(samples) | |
buf = io.BytesIO() | |
with wave.open(buf, mode='wb') as w: | |
w.setnchannels(channels) | |
w.setframerate(rate) | |
w.setsampwidth(2) | |
w.setcomptype('NONE', 'NONE') | |
w.writeframes(bytes) | |
audio = 'data:audio/wav;base64,' + base64.b64encode(buf.getvalue()).decode('utf-8') | |
js.show("audio", audio) | |
# HACK: Prevent 'wave' import from failing because audioop is not included with pyodide. | |
import types | |
embed = types.ModuleType('embed') | |
sys.modules['embed'] = embed | |
embed.image = show_image | |
embed.animation = show_animation | |
embed.audio = show_audio | |
async def run(source): | |
out = JSWriter() | |
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(out): | |
try: | |
imports = pyodide.find_imports(source) | |
await js.pyodide.loadPackagesFromImports(source) | |
if "matplotlib" in imports or "skimage" in imports: | |
setup_matplotlib() | |
if "embed" in imports: | |
await js.pyodide.loadPackagesFromImports("import numpy, PIL") | |
code = compile(source, "<string>", "exec", ast.PyCF_ALLOW_TOP_LEVEL_AWAIT) | |
result = eval(code, {}) | |
if result: | |
await result | |
except: | |
traceback.print_exc() | |
` | |
self.onmessage = async (event) => { | |
if(event.data.source){ | |
self.pyodide.globals.set("source", event.data.source) | |
outputs = [] | |
await self.pyodide.runPythonAsync("await run(source)") | |
self.postMessage({ done: true, outputs }) | |
} | |
if(event.data.mount){ | |
const { mountPoint, dirHandle } = event.data.mount | |
const nativefs = await pyodide.mountNativeFS(mountPoint, dirHandle) | |
console.log("Native FS mounted:", nativefs) | |
} | |
} | |
</attachment> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment