From 483a3bc2adf120f36b7548e8df580fd05e3c4154 Mon Sep 17 00:00:00 2001 From: Xuan Son Nguyen Date: Sat, 8 Feb 2025 13:01:02 +0100 Subject: [PATCH] add python code interpreter --- examples/server/webui/src/App.tsx | 10 +- examples/server/webui/src/Config.ts | 3 + .../src/components/CanvasPyInterpreter.tsx | 113 +++++++++++++++ .../webui/src/components/ChatMessage.tsx | 10 +- .../webui/src/components/ChatScreen.tsx | 134 ++++++++++-------- .../server/webui/src/components/Header.tsx | 12 +- .../webui/src/components/MarkdownDisplay.tsx | 67 ++++++++- .../webui/src/components/SettingDialog.tsx | 65 +++++++-- .../server/webui/src/utils/app.context.tsx | 25 +++- examples/server/webui/src/utils/common.tsx | 38 +++++ examples/server/webui/src/utils/misc.ts | 3 + examples/server/webui/src/utils/types.ts | 11 ++ 12 files changed, 406 insertions(+), 85 deletions(-) create mode 100644 examples/server/webui/src/components/CanvasPyInterpreter.tsx create mode 100644 examples/server/webui/src/utils/common.tsx diff --git a/examples/server/webui/src/App.tsx b/examples/server/webui/src/App.tsx index d151ba291..5b440bc6d 100644 --- a/examples/server/webui/src/App.tsx +++ b/examples/server/webui/src/App.tsx @@ -1,8 +1,9 @@ import { HashRouter, Outlet, Route, Routes } from 'react-router'; import Header from './components/Header'; import Sidebar from './components/Sidebar'; -import { AppContextProvider } from './utils/app.context'; +import { AppContextProvider, useAppContext } from './utils/app.context'; import ChatScreen from './components/ChatScreen'; +import SettingDialog from './components/SettingDialog'; function App() { return ( @@ -22,13 +23,18 @@ function App() { } function AppLayout() { + const { showSettings, setShowSettings } = useAppContext(); return ( <> -
+
+ { setShowSettings(false)} + />} ); } diff --git a/examples/server/webui/src/Config.ts b/examples/server/webui/src/Config.ts index 1860ffcc9..779ed9bf7 100644 --- a/examples/server/webui/src/Config.ts +++ b/examples/server/webui/src/Config.ts @@ -10,6 +10,7 @@ export const BASE_URL = new URL('.', document.baseURI).href export const CONFIG_DEFAULT = { // Note: in order not to introduce breaking changes, please keep the same data type (number, string, etc) if you want to change the default value. Do not use null or undefined for default value. + // Do not use nested objects, keep it single level. Prefix the key if you need to group them. apiKey: '', systemMessage: 'You are a helpful assistant.', showTokensPerSecond: false, @@ -36,6 +37,8 @@ export const CONFIG_DEFAULT = { dry_penalty_last_n: -1, max_tokens: -1, custom: '', // custom json-stringified object + // experimental features + pyIntepreterEnabled: false, }; export const CONFIG_INFO: Record = { apiKey: 'Set the API Key if you are using --api-key option for the server.', diff --git a/examples/server/webui/src/components/CanvasPyInterpreter.tsx b/examples/server/webui/src/components/CanvasPyInterpreter.tsx new file mode 100644 index 000000000..2298df098 --- /dev/null +++ b/examples/server/webui/src/components/CanvasPyInterpreter.tsx @@ -0,0 +1,113 @@ +import { useEffect, useState } from 'react'; +import { useAppContext } from '../utils/app.context'; +import { XCloseButton } from '../utils/common'; +import { delay } from '../utils/misc'; +import StorageUtils from '../utils/storage'; +import { CanvasType } from '../utils/types'; +import { PlayIcon } from '@heroicons/react/24/outline'; + +const PyodideWrapper = { + load: async function () { + // load pyodide from CDN + // @ts-expect-error experimental pyodide + if (window.addedScriptPyodide) return; + // @ts-expect-error experimental pyodide + window.addedScriptPyodide = true; + const scriptElem = document.createElement('script'); + scriptElem.src = 'https://cdn.jsdelivr.net/pyodide/v0.27.2/full/pyodide.js'; + document.body.appendChild(scriptElem); + }, + + run: async function (code: string) { + PyodideWrapper.load(); + + // wait for pyodide to be loaded + // @ts-expect-error experimental pyodide + while (!window.loadPyodide) { + await delay(100); + } + const stdOutAndErr: string[] = []; + // @ts-expect-error experimental pyodide + const pyodide = await window.loadPyodide({ + stdout: (data: string) => stdOutAndErr.push(data), + stderr: (data: string) => stdOutAndErr.push(data), + }); + const result = await pyodide.runPythonAsync(code); + if (result) { + stdOutAndErr.push(result.toString()); + } + return stdOutAndErr.join(''); + }, +}; + +if (StorageUtils.getConfig().pyIntepreterEnabled) { + PyodideWrapper.load(); +} + +export default function CanvasPyInterpreter() { + const { canvasData, setCanvasData } = useAppContext(); + + const [running, setRunning] = useState(false); + const [output, setOutput] = useState(''); + + const runCode = async () => { + const code = canvasData?.content; + if (!code) return; + setRunning(true); + setOutput('Running...'); + const out = await PyodideWrapper.run(code); + setOutput(out); + setRunning(false); + }; + + // run code on mount + useEffect(() => { + runCode(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (canvasData?.type !== CanvasType.PY_INTERPRETER) { + return null; + } + + return ( +
+
+
+ Pyodide + setCanvasData(null)} + /> +
+
+ +
+
+ +
+
+              {output}
+            
+
+
+
+
+ ); +} diff --git a/examples/server/webui/src/components/ChatMessage.tsx b/examples/server/webui/src/components/ChatMessage.tsx index 01d2fb80c..7fae73492 100644 --- a/examples/server/webui/src/components/ChatMessage.tsx +++ b/examples/server/webui/src/components/ChatMessage.tsx @@ -149,11 +149,17 @@ export default function ChatMessage({ )}
- +
)} - +
)} diff --git a/examples/server/webui/src/components/ChatScreen.tsx b/examples/server/webui/src/components/ChatScreen.tsx index d679f4ebb..18f575e19 100644 --- a/examples/server/webui/src/components/ChatScreen.tsx +++ b/examples/server/webui/src/components/ChatScreen.tsx @@ -4,6 +4,8 @@ import StorageUtils from '../utils/storage'; import { useNavigate } from 'react-router'; import ChatMessage from './ChatMessage'; import { PendingMessage } from '../utils/types'; +import { classNames } from '../utils/misc'; +import CanvasPyInterpreter from './CanvasPyInterpreter'; export default function ChatScreen() { const { @@ -12,6 +14,7 @@ export default function ChatScreen() { isGenerating, stopGenerating, pendingMessages, + canvasData, } = useAppContext(); const [inputMsg, setInputMsg] = useState(''); const containerRef = useRef(null); @@ -59,65 +62,82 @@ export default function ChatScreen() { }; return ( - <> - {/* chat messages */} -
-
- {/* placeholder to shift the message to the bottom */} - {viewingConversation ? '' : 'Send a message to start'} +
+
+ {/* chat messages */} +
+
+ {/* placeholder to shift the message to the bottom */} + {viewingConversation ? '' : 'Send a message to start'} +
+ {viewingConversation?.messages.map((msg) => ( + + ))} + + {pendingMsg && ( + + )}
- {viewingConversation?.messages.map((msg) => ( - - ))} - {pendingMsg && ( - - )} + {/* chat input */} +
+ + {isGenerating(currConvId) ? ( + + ) : ( + + )} +
- - {/* chat input */} -
- - {isGenerating(currConvId) ? ( - - ) : ( - - )} -
- + {canvasData && ( +
+ +
+ )} +
); } diff --git a/examples/server/webui/src/components/Header.tsx b/examples/server/webui/src/components/Header.tsx index 015264abc..505350313 100644 --- a/examples/server/webui/src/components/Header.tsx +++ b/examples/server/webui/src/components/Header.tsx @@ -5,12 +5,11 @@ import { classNames } from '../utils/misc'; import daisyuiThemes from 'daisyui/src/theming/themes'; import { THEMES } from '../Config'; import { useNavigate } from 'react-router'; -import SettingDialog from './SettingDialog'; export default function Header() { const navigate = useNavigate(); const [selectedTheme, setSelectedTheme] = useState(StorageUtils.getTheme()); - const [showSettingDialog, setShowSettingDialog] = useState(false); + const { setShowSettings } = useAppContext(); const setTheme = (theme: string) => { StorageUtils.setTheme(theme); @@ -54,7 +53,7 @@ export default function Header() { }; return ( -
+
{/* open sidebar button */}
-
- - setShowSettingDialog(false)} - />
); } diff --git a/examples/server/webui/src/components/MarkdownDisplay.tsx b/examples/server/webui/src/components/MarkdownDisplay.tsx index 814920a74..33a64b2a6 100644 --- a/examples/server/webui/src/components/MarkdownDisplay.tsx +++ b/examples/server/webui/src/components/MarkdownDisplay.tsx @@ -9,8 +9,16 @@ import 'katex/dist/katex.min.css'; import { classNames, copyStr } from '../utils/misc'; import { ElementContent, Root } from 'hast'; import { visit } from 'unist-util-visit'; +import { useAppContext } from '../utils/app.context'; +import { CanvasType } from '../utils/types'; -export default function MarkdownDisplay({ content }: { content: string }) { +export default function MarkdownDisplay({ + content, + isGenerating, +}: { + content: string; + isGenerating?: boolean; +}) { const preprocessedContent = useMemo( () => preprocessLaTeX(content), [content] @@ -21,7 +29,11 @@ export default function MarkdownDisplay({ content }: { content: string }) { rehypePlugins={[rehypeHightlight, rehypeKatex, rehypeCustomCopyButton]} components={{ button: (props) => ( - + ), // note: do not use "pre", "p" or other basic html elements here, it will cause the node to re-render when the message is being generated (this should be a bug with react-markdown, not sure how to fix it) }} @@ -31,11 +43,12 @@ export default function MarkdownDisplay({ content }: { content: string }) { ); } -const CopyCodeButton: React.ElementType< +const CodeBlockButtons: React.ElementType< React.ClassAttributes & React.HTMLAttributes & - ExtraProps & { origContent: string } -> = ({ node, origContent }) => { + ExtraProps & { origContent: string; isGenerating?: boolean } +> = ({ node, origContent, isGenerating }) => { + const { config } = useAppContext(); const startOffset = node?.position?.start.offset ?? 0; const endOffset = node?.position?.end.offset ?? 0; @@ -48,6 +61,19 @@ const CopyCodeButton: React.ElementType< [origContent, startOffset, endOffset] ); + const codeLanguage = useMemo( + () => + origContent + .substring(startOffset, startOffset + 10) + .match(/^```([^\n]+)\n/)?.[1] ?? '', + [origContent, startOffset] + ); + + const canRunCode = + !isGenerating && + config.pyIntepreterEnabled && + codeLanguage.startsWith('py'); + return (
+ {canRunCode && ( + + )}
); }; @@ -82,6 +114,31 @@ export const CopyButton = ({ ); }; +export const RunPyCodeButton = ({ + content, + className, +}: { + content: string; + className?: string; +}) => { + const { setCanvasData } = useAppContext(); + return ( + <> + + + ); +}; + /** * This injects the "button" element before each "pre" element. * The actual button will be replaced with a react component in the MarkdownDisplay. diff --git a/examples/server/webui/src/components/SettingDialog.tsx b/examples/server/webui/src/components/SettingDialog.tsx index d30925e6c..679c3a084 100644 --- a/examples/server/webui/src/components/SettingDialog.tsx +++ b/examples/server/webui/src/components/SettingDialog.tsx @@ -5,12 +5,14 @@ import { isDev } from '../Config'; import StorageUtils from '../utils/storage'; import { classNames, isBoolean, isNumeric, isString } from '../utils/misc'; import { + BeakerIcon, ChatBubbleOvalLeftEllipsisIcon, Cog6ToothIcon, FunnelIcon, HandRaisedIcon, SquaresPlusIcon, } from '@heroicons/react/24/outline'; +import { OpenInNewTab } from '../utils/common'; type SettKey = keyof typeof CONFIG_DEFAULT; @@ -194,14 +196,9 @@ const SETTING_SECTIONS: SettingSection[] = [ label: ( <> Custom JSON config (For more info, refer to{' '} - + server documentation - + ) ), @@ -209,6 +206,56 @@ const SETTING_SECTIONS: SettingSection[] = [ }, ], }, + { + title: ( + <> + + Experimental + + ), + fields: [ + { + type: SettingInputType.CUSTOM, + key: 'custom', // dummy key, won't be used + component: () => ( + <> +

+ Experimental features are not guaranteed to work correctly. +
+
+ If you encounter any problems, create a{' '} + + Bug (misc.) + {' '} + report on Github. Please also specify webui/experimental on + the report title and include screenshots. +
+
+ Some features may require packages downloaded from CDN, so they + need internet connection. +

+ + ), + }, + { + type: SettingInputType.CHECKBOX, + label: ( + <> + Enable Python interpreter +
+ + This feature uses{' '} + pyodide, + downloaded from CDN. To use this feature, ask the LLM to generate + python code inside a markdown code block. You will see a "Run" + button on the code block, near the "Copy" button. + + + ), + key: 'pyIntepreterEnabled', + }, + ], + }, ]; export default function SettingDialog({ @@ -278,7 +325,7 @@ export default function SettingDialog({ }; return ( - +

Settings

@@ -479,7 +526,7 @@ function SettingsModalCheckbox({
onChange(e.target.checked)} /> diff --git a/examples/server/webui/src/utils/app.context.tsx b/examples/server/webui/src/utils/app.context.tsx index d50f825c3..af6bd885f 100644 --- a/examples/server/webui/src/utils/app.context.tsx +++ b/examples/server/webui/src/utils/app.context.tsx @@ -1,5 +1,11 @@ import React, { createContext, useContext, useEffect, useState } from 'react'; -import { APIMessage, Conversation, Message, PendingMessage } from './types'; +import { + APIMessage, + CanvasData, + Conversation, + Message, + PendingMessage, +} from './types'; import StorageUtils from './storage'; import { filterThoughtFromMsgs, @@ -10,6 +16,7 @@ import { BASE_URL, CONFIG_DEFAULT, isDev } from '../Config'; import { matchPath, useLocation } from 'react-router'; interface AppContextValue { + // conversations and messages viewingConversation: Conversation | null; pendingMessages: Record; isGenerating: (convId: string) => boolean; @@ -26,8 +33,15 @@ interface AppContextValue { onChunk?: CallbackGeneratedChunk ) => Promise; + // canvas + canvasData: CanvasData | null; + setCanvasData: (data: CanvasData | null) => void; + + // config config: typeof CONFIG_DEFAULT; saveConfig: (config: typeof CONFIG_DEFAULT) => void; + showSettings: boolean; + setShowSettings: (show: boolean) => void; } // for now, this callback is only used for scrolling to the bottom of the chat @@ -54,8 +68,13 @@ export const AppContextProvider = ({ Record >({}); const [config, setConfig] = useState(StorageUtils.getConfig()); + const [canvasData, setCanvasData] = useState(null); + const [showSettings, setShowSettings] = useState(false); + // handle change when the convId from URL is changed useEffect(() => { + // also reset the canvas data + setCanvasData(null); const handleConversationChange = (changedConvId: string) => { if (changedConvId !== convId) return; setViewingConversation(StorageUtils.getOneConversation(convId)); @@ -292,8 +311,12 @@ export const AppContextProvider = ({ sendMessage, stopGenerating, replaceMessageAndGenerate, + canvasData, + setCanvasData, config, saveConfig, + showSettings, + setShowSettings, }} > {children} diff --git a/examples/server/webui/src/utils/common.tsx b/examples/server/webui/src/utils/common.tsx new file mode 100644 index 000000000..09b08b5c9 --- /dev/null +++ b/examples/server/webui/src/utils/common.tsx @@ -0,0 +1,38 @@ +export const XCloseButton: React.ElementType< + React.ClassAttributes & + React.HTMLAttributes +> = ({ className, ...props }) => ( + +); + +export const OpenInNewTab = ({ + href, + children, +}: { + href: string; + children: string; +}) => ( + + {children} + +); diff --git a/examples/server/webui/src/utils/misc.ts b/examples/server/webui/src/utils/misc.ts index 0c887ee83..e3153fff5 100644 --- a/examples/server/webui/src/utils/misc.ts +++ b/examples/server/webui/src/utils/misc.ts @@ -85,3 +85,6 @@ export function classNames(classes: Record): string { .map(([key, _]) => key) .join(' '); } + +export const delay = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/examples/server/webui/src/utils/types.ts b/examples/server/webui/src/utils/types.ts index d04c6e3dc..7cd12b40a 100644 --- a/examples/server/webui/src/utils/types.ts +++ b/examples/server/webui/src/utils/types.ts @@ -23,3 +23,14 @@ export interface Conversation { export type PendingMessage = Omit & { content: string | null; }; + +export enum CanvasType { + PY_INTERPRETER, +} + +export interface CanvasPyInterpreter { + type: CanvasType.PY_INTERPRETER; + content: string; +} + +export type CanvasData = CanvasPyInterpreter;