From b4cba84fa99faa76c94921ceaffc797ce18a689e Mon Sep 17 00:00:00 2001 From: "Chris Van Pelt (CVP)" Date: Wed, 4 Jun 2025 15:08:34 -0700 Subject: [PATCH] Refactor UI state handling --- frontend/src/components/Chat.tsx | 322 +++++++++---------- frontend/src/components/CodeEditor.tsx | 9 +- frontend/src/components/CurrentUiContext.tsx | 116 ------- frontend/src/components/HtmlAnnotator.tsx | 24 +- frontend/src/components/Prompt.tsx | 112 ++----- frontend/src/hooks/index.ts | 4 + frontend/src/hooks/useLLM.ts | 77 +++++ frontend/src/hooks/useUI.ts | 26 ++ frontend/src/hooks/useUIEngine.ts | 56 ++++ frontend/src/state/index.ts | 1 + 10 files changed, 366 insertions(+), 381 deletions(-) delete mode 100644 frontend/src/components/CurrentUiContext.tsx create mode 100644 frontend/src/hooks/useLLM.ts create mode 100644 frontend/src/hooks/useUI.ts create mode 100644 frontend/src/hooks/useUIEngine.ts diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx index ee498e5e..9ba72a47 100644 --- a/frontend/src/components/Chat.tsx +++ b/frontend/src/components/Chat.tsx @@ -35,7 +35,7 @@ import { uiStateAtom, uiThemeAtom } from 'state' -import { CurrentUIProvider } from './CurrentUiContext' +import { useUIEngine } from 'hooks' import ShareDialog from './ShareDialog' import { Button } from './ui/button' @@ -148,179 +148,175 @@ export default function Chat({ isShared = false }: { isShared: boolean }) { } }, [onDropFile]) + useUIEngine() return ( - -
setDragging(true)} - onDragExit={() => setDragging(false)} - onDrop={(e: React.DragEvent) => { - e.preventDefault() - setDragging(false) - if (!modelSupportsImages) { - console.warn('Model does not have vision capabilities') - return - } +
setDragging(true)} + onDragExit={() => setDragging(false)} + onDrop={(e: React.DragEvent) => { + e.preventDefault() + setDragging(false) + if (!modelSupportsImages) { + console.warn('Model does not have vision capabilities') + return + } - let file: File | null | undefined - let fileType: string | undefined - if (e.dataTransfer.items.length > 0) { - for (const it of e.dataTransfer.items) { - if (it.kind === 'file') { - file = it.getAsFile() - fileType = file?.type - if (file?.type.startsWith('image/')) break - else file = undefined - } - } - } else { - for (const f of e.dataTransfer.files) { + let file: File | null | undefined + let fileType: string | undefined + if (e.dataTransfer.items.length > 0) { + for (const it of e.dataTransfer.items) { + if (it.kind === 'file') { + file = it.getAsFile() fileType = file?.type - if (f.type.startsWith('image/')) break + if (file?.type.startsWith('image/')) break else file = undefined } } - e.dataTransfer.clearData() - - if (file) { - onDropFile(file) - } else { - console.warn('File type not supported', fileType) + } else { + for (const f of e.dataTransfer.files) { + fileType = file?.type + if (f.type.startsWith('image/')) break + else file = undefined } - }} - onDragOver={(e: React.DragEvent) => { - e.preventDefault() - }} - className={cn( - 'overflow-y-none relative flex h-[calc(100vh-12em)] max-h-[calc(100vh-6em)] w-full flex-col gap-3 px-[3%] pb-4 align-middle md:px-[10%]', - sidebarState === 'closed' ? '' : 'md:px-[3%]' - )} - > - {id === 'new' ? ( -
- {image ? ( - Screenshot - ) : undefined} -

- {t('Chat Header')} -

- {modelSupportsImages ? ( -

- {t('Pro Tip')} -

- ) : undefined} -
- ) : ( -
- {/* DANGER, SETTING MARGIN HERE IS WONKUS */} -
-
- - - -
- - {lastRender && - uiState.prompt.startsWith("Let's make") ? ( - - ) : undefined} - {uiState.prompt === '' ? ( - - ) : ( - uiState.prompt - )} - -
- - Version {item.version(versionIdx)} - -
- -
- {(image ?? lastRender) ? ( -
- Screenshot -
+ } + e.dataTransfer.clearData() + + if (file) { + onDropFile(file) + } else { + console.warn('File type not supported', fileType) + } + }} + onDragOver={(e: React.DragEvent) => { + e.preventDefault() + }} + className={cn( + 'overflow-y-none relative flex h-[calc(100vh-12em)] max-h-[calc(100vh-6em)] w-full flex-col gap-3 px-[3%] pb-4 align-middle md:px-[10%]', + sidebarState === 'closed' ? '' : 'md:px-[3%]' + )} + > + {id === 'new' ? ( +
+ {image ? ( + Screenshot + ) : undefined} +

+ {t('Chat Header')} +

+ {modelSupportsImages ? ( +

+ {t('Pro Tip')} +

+ ) : undefined} +
+ ) : ( +
+ {/* DANGER, SETTING MARGIN HERE IS WONKUS */} +
+
+ + + +
+ + {lastRender && uiState.prompt.startsWith("Let's make") ? ( + ) : undefined} -
-

{uiState.prompt}

+ {uiState.prompt === '' ? ( + + ) : ( + uiState.prompt + )} + +
+ + Version {item.version(versionIdx)} + + + +
+ {(image ?? lastRender) ? ( +
+ Screenshot
+ ) : undefined} +
+

{uiState.prompt}

- - -
-
- {!isShared && } - - - - - - Download the HTML - - - - - - - - Toggle version history - - -
+
+ +
+
+
+ {!isShared && } + + + + + Download the HTML + + + + + + + Toggle version history + +
-
- )} - -
- + +
+ )} + +
) } diff --git a/frontend/src/components/CodeEditor.tsx b/frontend/src/components/CodeEditor.tsx index 7a5882e6..7451f671 100644 --- a/frontend/src/components/CodeEditor.tsx +++ b/frontend/src/components/CodeEditor.tsx @@ -1,6 +1,6 @@ import type { Monaco } from '@monaco-editor/react' import Editor, { loader } from '@monaco-editor/react' -import { useThrottle, useVersion } from 'hooks' +import { useThrottle, useVersion, useUIActions } from 'hooks' import { useAtom, useAtomValue } from 'jotai' import anysphere from 'lib/anysphere' import { mimeTypeAndExtension } from 'lib/utils' @@ -8,7 +8,7 @@ import type { Position, editor } from 'monaco-editor' import { configureMonacoTailwindcss, tailwindcssData } from 'monaco-tailwindcss' import type { Plugin } from 'prettier' import prettierPluginEstree from 'prettier/plugins/estree' -import { useContext, useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { useParams } from 'react-router-dom' import { ItemWrapper, @@ -17,7 +17,6 @@ import { useSaveHistory, type Framework } from 'state' -import CurrentUiContext from './CurrentUiContext' import 'monaco-editor/esm/vs/basic-languages/css/css.contribution' import 'monaco-editor/esm/vs/basic-languages/html/html.contribution' @@ -112,7 +111,7 @@ export default function CodeEditor({ const printWidth = 200 const params = useParams() const id = params.id ?? 'new' - const uiContext = useContext(CurrentUiContext) + const { updateState } = useUIActions() const [readOnly, setReadOnly] = useState(framework !== 'html') const editor = useRef() @@ -193,7 +192,7 @@ export default function CodeEditor({ clearTimeout(bufferTimer.current) if (bufferedCode !== '') { bufferTimer.current = setTimeout(() => { - uiContext.emit('ui-state', { editedHTML: bufferedCode }) + updateState({ editedHTML: bufferedCode }) item.editChapter(bufferedCode, versionIdx) }, 2000) } diff --git a/frontend/src/components/CurrentUiContext.tsx b/frontend/src/components/CurrentUiContext.tsx deleted file mode 100644 index b044f3b8..00000000 --- a/frontend/src/components/CurrentUiContext.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { useThrottle, useVersion } from 'hooks' -import { useAtom } from 'jotai' -import eventEmitter, { type EventTarget } from 'lib/events' -import { parseHTML, type HTMLAndJS } from 'lib/html' -import type React from 'react' -import { createContext, useEffect, useMemo } from 'react' -import { useParams } from 'react-router-dom' -import { - ItemWrapper, - cleanUiState, - historyAtomFamily, - uiStateAtom -} from 'state' - -const CurrentUIContext = createContext(eventEmitter) - -export const CurrentUIProvider = ({ - children -}: { - children: React.ReactNode -}) => { - const { id } = useParams() - const [rawItem, setRawItem] = useAtom(historyAtomFamily({ id: id ?? 'new' })) - const item = useMemo( - () => new ItemWrapper(rawItem, setRawItem), - [rawItem, setRawItem] - ) - const [versionIdx] = useVersion(item) - - const [uiState, setUiState] = useAtom(uiStateAtom) - const htmlToParse = useThrottle( - uiState.editedHTML || uiState.pureHTML || '', - 1000 - ) - - // Reset our UI state when the id changes, then parse our pureHTML - useEffect(() => { - if (item.markdown) { - const html = item.pureHTML(versionIdx) - const update: { pureHTML: string; error?: string; prompt: string } = { - pureHTML: html ?? '', - error: undefined, - prompt: item.prompt(versionIdx) ?? '' - } - if (update.pureHTML === '') { - update.error = `No HTML in LLM response, received: \n${item.markdown}` - } - setUiState({ ...cleanUiState, ...update }) - } else if (id === 'new') { - setUiState(cleanUiState) - } - // We only want to trigger this when our ID or version changes, item will be updated - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [id, versionIdx, setUiState]) - - // Parse our HTML at most once per second - useEffect(() => { - if (htmlToParse) { - // Only process images when we aren't rendering - parseHTML(htmlToParse as string, !uiState.rendering) - .then((html): void => { - setUiState(state => ({ ...state, renderedHTML: html })) - // Push our state out - eventEmitter.emit(`html-updated:${id}`, html) - }) - .catch((error: unknown) => { - console.error('HTML Parse error', error) - }) - } - // We don't include `id` because it causes us to reset state on a new page - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [htmlToParse, uiState.rendering, setUiState]) - - // To simplify modifying state and propagating changes to the UI - // We do it all with this the event emitter. One day we could switch - // To an actual reducer... - useEffect(() => { - const uiStateHandler = (ev: unknown) => { - const event = ev as { - annotatedHTML?: string - editedHTML?: string - pureHTML?: string - rendering?: boolean - renderedHTML?: HTMLAndJS - } - // TODO: we might want to refactor this - if (event.annotatedHTML) { - parseHTML(event.annotatedHTML, false) - .then(html => { - setUiState(state => ({ ...state, annotatedHTML: html.html })) - }) - .catch((error: unknown) => { - console.error('HTML Parse error', error) - }) - } else { - setUiState(state => ({ - ...state, - ...event - })) - } - } - eventEmitter.on('ui-state', uiStateHandler) - - return () => { - eventEmitter.off('ui-state', uiStateHandler) - } - }, [setUiState]) - - return ( - - {children} - - ) -} - -export default CurrentUIContext diff --git a/frontend/src/components/HtmlAnnotator.tsx b/frontend/src/components/HtmlAnnotator.tsx index aa279c93..c9522666 100644 --- a/frontend/src/components/HtmlAnnotator.tsx +++ b/frontend/src/components/HtmlAnnotator.tsx @@ -1,11 +1,10 @@ import { Button } from 'components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from 'components/ui/tooltip' -import { useVersion } from 'hooks' +import { useVersion, useUIActions } from 'hooks' import { useAtom, useAtomValue, useSetAtom } from 'jotai' import type React from 'react' import { useCallback, - useContext, useEffect, useLayoutEffect, useMemo, @@ -19,6 +18,7 @@ import { facetsAtom, historyAtomFamily, historySidebarStateAtom, + iframeResetAtom, imageDB, inspectorEnabledAtom, modelSupportsImagesAtom, @@ -50,7 +50,6 @@ import { import { nanoid } from 'nanoid' import { useNavigate } from 'react-router-dom' import CodeViewer from './CodeViewer' -import CurrentUIContext from './CurrentUiContext' import { Checkbox } from './ui/checkbox' import { Label } from './ui/label' import { Popover, PopoverContent, PopoverTrigger } from './ui/popover' @@ -90,7 +89,7 @@ export interface IFrameEvent { } export default function HTMLAnnotator({ error, id }: HTMLAnnotatorProps) { - const currentUI = useContext(CurrentUIContext) + const { updateState } = useUIActions() // Only point to our local annotator in development / running locally otherwise use github pages const iframeSrc = /127\.0\.0\.1|localhost/.test(document.location.hostname) @@ -115,6 +114,7 @@ export default function HTMLAnnotator({ error, id }: HTMLAnnotatorProps) { const [inspectorEnabled, setInspectorEnabled] = useAtom(inspectorEnabledAtom) const setSidebarState = useSetAtom(historySidebarStateAtom) const uiState = useAtomValue(uiStateAtom) + const resetCount = useAtomValue(iframeResetAtom) const [uiTheme, setUiTheme] = useAtom(uiThemeAtom) const modelSupportsImages = useAtomValue(modelSupportsImagesAtom) const [image, setImage] = useAtom( @@ -175,14 +175,8 @@ export default function HTMLAnnotator({ error, id }: HTMLAnnotatorProps) { ) useEffect(() => { - function reset() { - iframeRef.current?.contentWindow?.postMessage({ action: 'reset' }, '*') - } - currentUI.on('iframe-reset', reset) - return () => { - currentUI.off('iframe-reset', reset) - } - }, [currentUI]) + iframeRef.current?.contentWindow?.postMessage({ action: 'reset' }, '*') + }, [resetCount]) // Reset iframe on UI change useEffect(() => { @@ -295,7 +289,7 @@ export default function HTMLAnnotator({ error, id }: HTMLAnnotatorProps) { ) } else if (event.data.comment) { setComments([...comments, event.data.comment]) - currentUI.emit('ui-state', { + updateState({ annotatedHTML: formatHTML(event.data.html.trim()) }) setInspectorEnabled(false) @@ -316,9 +310,9 @@ export default function HTMLAnnotator({ error, id }: HTMLAnnotatorProps) { iframeSrc, iframeId, setInspectorEnabled, - currentUI, setImage, - image + image, + updateState ]) const createVote = useCallback( diff --git a/frontend/src/components/Prompt.tsx b/frontend/src/components/Prompt.tsx index c6d97eb5..a4b05b8d 100644 --- a/frontend/src/components/Prompt.tsx +++ b/frontend/src/components/Prompt.tsx @@ -1,20 +1,13 @@ -import { convert, createOrRefine, systemPrompt, type Action } from 'api/openai' +import { convert, type Action } from 'api/openai' import { Tooltip, TooltipContent, TooltipTrigger } from 'components/ui/tooltip' -import { useThrottle, useVersion } from 'hooks' +import { useThrottle, useVersion, useLLM, useUIActions } from 'hooks' import { useAtom, useAtomValue, useSetAtom } from 'jotai' import { EXAMPLES } from 'lib/constants' import { newChapter, parseMarkdown } from 'lib/markdown' import { cn, resizeImage } from 'lib/utils' import { ArrowUpIcon, ImageIcon, RefreshCwIcon } from 'lucide-react' import { nanoid } from 'nanoid' -import { - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState -} from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Form, useNavigate, useParams, useSearchParams } from 'react-router-dom' import { ItemWrapper, @@ -26,7 +19,6 @@ import { historyAtomFamily, historyIdsAtom, historySidebarStateAtom, - imageDB, inspectorEnabledAtom, modelAtom, modelSupportsImagesAtom, @@ -35,7 +27,6 @@ import { uiStateAtom, useSaveHistory } from 'state' -import CurrentUIContext from './CurrentUiContext' import { Button } from './ui/button' import { Textarea } from './ui/textarea' @@ -46,7 +37,7 @@ export default function Prompt({ isEditing: boolean imageUploadRef: React.RefObject }) { - const currentUI = useContext(CurrentUIContext) + const { updateState, resetIframe } = useUIActions() const params = useParams() const [searchParams, setSearchParams] = useSearchParams() @@ -68,10 +59,8 @@ export default function Prompt({ const [sidebarState, setSidebarState] = useAtom(historySidebarStateAtom) const modelSupportsImages = useAtomValue(modelSupportsImagesAtom) const [comments, setComments] = useAtom(commentsAtom) - const currentRender = useAtomValue( - imageDB.item(`screenshot-${id}-${versionIdx}`) - ) - const setImage = useSetAtom(imageDB.item(`image-${id}-${versionIdx}`)) + const action: Action = isEditing ? 'refine' : 'create' + const { streamResponse, setImage } = useLLM(id, versionIdx, action) const uiState = useAtomValue(uiStateAtom) const dragging = useAtomValue(draggingAtom) const { rendering, annotatedHTML } = uiState @@ -99,7 +88,6 @@ export default function Prompt({ const [animate, setAnimate] = useState(false) const [textareaHeight, setTextareaHeight] = useState() - const action: Action = isEditing ? 'refine' : 'create' // Save our streamed markdown const saveMarkdown = useCallback( (final: string) => { @@ -115,82 +103,42 @@ export default function Prompt({ }, [saveHistory, setItem] ) - const streamResponse = useCallback( + const runStream = useCallback( (query: string, existingHTML?: string, clearSession = false) => { console.log('STREAMING RESPONSE:', query) - // Reset our search params to ensure we're at a new version setSearchParams(new URLSearchParams(), { preventScrollReset: true, replace: true }) updateVersion(-1) - currentUI.emit('ui-state', { - ...cleanUiState, - rendering: true, - prompt: query + updateState({ ...cleanUiState, rendering: true, prompt: query }) + resetIframe() + streamResponse(query, existingHTML, clearSession, md => { + setLiveMarkdown(prev => (prev || '') + md) }) - currentUI.emit('iframe-reset', {}) - let imageToUse: string | undefined = screenshot - if (!modelSupportsImages) { - imageToUse = undefined - } else if (currentRender) { - imageToUse = currentRender.url - } - createOrRefine( - { - query, - model, - action, - systemPrompt, - html: clearSession ? undefined : existingHTML, - image: clearSession ? undefined : imageToUse, - temperature - }, - md => { - setLiveMarkdown(prevMD => (prevMD || '') + md) - } - ) .then(final => { - setScreenshot('') setLiveMarkdown(final) - currentUI.emit('ui-state', { rendering: false }) - // TODO: make sure unsplash runs here - console.log('Rendering complete, saving markdown') saveMarkdown(final) - if (queryRef.current) { - queryRef.current.value = '' - } - // SetLLMHidden(true) + if (queryRef.current) queryRef.current.value = '' }) .catch((error: unknown) => { - setScreenshot('') - setLiveMarkdown('') console.error(error) - let { message } = error as Error - // Ollama vision error + let message = (error as Error).message if ( message.includes('Object of type bytes is not JSON serializable') ) { message = 'OpenUI currently only supports llava or moondream vision models from Ollama' } - currentUI.emit('ui-state', { - rendering: false, - error: message - }) + updateState({ rendering: false, error: message }) }) }, [ setSearchParams, updateVersion, - currentUI, - screenshot, - modelSupportsImages, - currentRender, - model, - action, - temperature, - setScreenshot, + updateState, + resetIframe, + streamResponse, saveMarkdown ] ) @@ -212,7 +160,7 @@ export default function Prompt({ const clear = searchParams.get('clear') === 'true' const gen = searchParams.get('gen') === '1' if (id !== 'new' && gen && !rendering) { - streamResponse(item.prompt, undefined, clear) + runStream(item.prompt, undefined, clear) } else if (id === 'new') { // Reset our examples randomExample(example) @@ -232,7 +180,7 @@ export default function Prompt({ markdown: it.markdown + newChapter(prompt) })) saveHistory() - streamResponse(prompt, wrappedItem.pureHTML(versionIdx)) + runStream(prompt, wrappedItem.pureHTML(versionIdx)) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchParams.get('regen')]) @@ -258,20 +206,20 @@ export default function Prompt({ if (result.html) { // TODO: this is getting called three times on render, refactor setItem(it => ({ ...it, ...result })) - currentUI.emit('ui-state', { + updateState({ pureHTML: result.html, rendering, error: undefined }) } else if (!rendering) { - currentUI.emit('ui-state', { + updateState({ rendering: false, error: `No HTML in LLM response, received: \n${liveMarkdown}` }) } } catch (error) { setItem(it => ({ ...it, name: 'Error' })) - currentUI.emit('ui-state', { + updateState({ rendering: false, error: 'Error parsing response, see console.' }) @@ -311,7 +259,7 @@ export default function Prompt({ prompts: [...(it.prompts ?? [it.prompt]), query] })) saveHistory() - streamResponse(query, html) + runStream(query, html) }, [ action, @@ -321,7 +269,7 @@ export default function Prompt({ screenshot, setItem, saveHistory, - streamResponse, + runStream, setSidebarState, sidebarState ] @@ -334,7 +282,7 @@ export default function Prompt({ } const toFramework = convertFramework setConvertFramework(undefined) - currentUI.emit('ui-state', { rendering: true, error: undefined }) + updateState({ rendering: true, error: undefined }) const curComponents = item.components ?? {} convert( { @@ -355,11 +303,11 @@ export default function Prompt({ ) .then(() => { saveHistory() - currentUI.emit('ui-state', { rendering: false }) + updateState({ rendering: false }) }) .catch((error: unknown) => { console.error(error) - currentUI.emit('ui-state', { + updateState({ rendering: false, error: (error as Error).message }) @@ -373,7 +321,7 @@ export default function Prompt({ html, model, temperature, - currentUI + updateState ]) // Auto submit for annotations @@ -386,9 +334,9 @@ export default function Prompt({ it.markdown + newChapter(comments.at(-1) ?? 'Edit from comment') })) setComments([]) - streamResponse('', annotatedHTML) + runStream('', annotatedHTML) } - }, [annotatedHTML, comments, setComments, setItem, streamResponse]) + }, [annotatedHTML, comments, setComments, setItem, runStream]) // Random example on mount useEffect(() => { diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index 31cf609c..5f30fc07 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -104,3 +104,7 @@ export function useVersion( return [version, updateVersion] } + +export * from './useUI' +export * from './useLLM' +export * from './useUIEngine' diff --git a/frontend/src/hooks/useLLM.ts b/frontend/src/hooks/useLLM.ts new file mode 100644 index 00000000..64e97a98 --- /dev/null +++ b/frontend/src/hooks/useLLM.ts @@ -0,0 +1,77 @@ +import { useCallback } from 'react' +import { useAtom, useAtomValue, useSetAtom } from 'jotai' +import { createOrRefine, type Action, systemPrompt } from '../api/openai' +import { + screenshotAtom, + imageDB, + modelAtom, + modelSupportsImagesAtom, + temperatureAtom +} from '../state' +import { useUIActions } from './useUI' + +export function useLLM(id: string, versionIdx: number, action: Action) { + const { updateState, resetIframe } = useUIActions() + const [screenshot, setScreenshot] = useAtom(screenshotAtom) + const currentRender = useAtomValue( + imageDB.item(`screenshot-${id}-${versionIdx}`) + ) + const setImage = useSetAtom(imageDB.item(`image-${id}-${versionIdx}`)) + const model = useAtomValue(modelAtom) + const modelSupportsImages = useAtomValue(modelSupportsImagesAtom) + const temperature = useAtomValue(temperatureAtom) + + const streamResponse = useCallback( + async ( + query: string, + existingHTML?: string, + clearSession = false, + onData?: (md: string) => void + ) => { + updateState({ rendering: true, prompt: query }) + resetIframe() + let imageToUse: string | undefined = screenshot + if (!modelSupportsImages) { + imageToUse = undefined + } else if (currentRender) { + imageToUse = currentRender.url + } + try { + const final = await createOrRefine( + { + query, + model, + action, + systemPrompt, + html: clearSession ? undefined : existingHTML, + image: clearSession ? undefined : imageToUse, + temperature + }, + md => { + if (onData) onData(md) + } + ) + setScreenshot('') + updateState({ rendering: false }) + return final + } catch (err) { + setScreenshot('') + updateState({ rendering: false, error: (err as Error).message }) + throw err + } + }, + [ + updateState, + resetIframe, + screenshot, + modelSupportsImages, + currentRender, + model, + action, + temperature, + setScreenshot + ] + ) + + return { streamResponse, setImage } +} diff --git a/frontend/src/hooks/useUI.ts b/frontend/src/hooks/useUI.ts new file mode 100644 index 00000000..b0b6bff1 --- /dev/null +++ b/frontend/src/hooks/useUI.ts @@ -0,0 +1,26 @@ +import { useCallback } from 'react' +import { useSetAtom } from 'jotai' +import type { CurrentUIState } from '../state' +import { uiStateAtom, cleanUiState, iframeResetAtom } from '../state' + +export function useUIActions() { + const setUIState = useSetAtom(uiStateAtom) + const bumpReset = useSetAtom(iframeResetAtom) + + const updateState = useCallback( + (update: Partial) => { + setUIState(prev => ({ ...prev, ...update })) + }, + [setUIState] + ) + + const resetIframe = useCallback(() => { + bumpReset(v => v + 1) + }, [bumpReset]) + + const resetUI = useCallback(() => { + setUIState(cleanUiState) + }, [setUIState]) + + return { updateState, resetIframe, resetUI } +} diff --git a/frontend/src/hooks/useUIEngine.ts b/frontend/src/hooks/useUIEngine.ts new file mode 100644 index 00000000..7e8fe448 --- /dev/null +++ b/frontend/src/hooks/useUIEngine.ts @@ -0,0 +1,56 @@ +import { useEffect, useMemo } from 'react' +import { useAtom } from 'jotai' +import { useParams } from 'react-router-dom' +import { parseHTML } from '../lib/html' +import { useThrottle, useVersion } from './index' +import { + ItemWrapper, + historyAtomFamily, + uiStateAtom, + cleanUiState +} from '../state' + +export function useUIEngine() { + const { id } = useParams() + const [rawItem, setRawItem] = useAtom(historyAtomFamily({ id: id ?? 'new' })) + const item = useMemo( + () => new ItemWrapper(rawItem, setRawItem), + [rawItem, setRawItem] + ) + const [versionIdx] = useVersion(item) + const [uiState, setUiState] = useAtom(uiStateAtom) + const htmlToParse = useThrottle( + uiState.editedHTML || uiState.pureHTML || '', + 1000 + ) + + useEffect(() => { + if (item.markdown) { + const html = item.pureHTML(versionIdx) + const update: { pureHTML: string; error?: string; prompt: string } = { + pureHTML: html ?? '', + error: undefined, + prompt: item.prompt(versionIdx) ?? '' + } + if (update.pureHTML === '') { + update.error = `No HTML in LLM response, received: \n${item.markdown}` + } + setUiState({ ...cleanUiState, ...update }) + } else if (id === 'new') { + setUiState(cleanUiState) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id, versionIdx, setUiState]) + + useEffect(() => { + if (htmlToParse) { + parseHTML(htmlToParse as string, !uiState.rendering) + .then(html => { + setUiState(state => ({ ...state, renderedHTML: html })) + }) + .catch(error => { + console.error('HTML Parse error', error) + }) + } + }, [htmlToParse, uiState.rendering, setUiState]) +} diff --git a/frontend/src/state/index.ts b/frontend/src/state/index.ts index 002e755b..e8bdac2a 100644 --- a/frontend/src/state/index.ts +++ b/frontend/src/state/index.ts @@ -19,6 +19,7 @@ export const historySidebarStateAtom = atom<'closed' | 'history' | 'versions'>( 'closed' ) export const screenshotAtom = atom('') +export const iframeResetAtom = atom(0) export const facetsAtom = atomWithStorage('facets', []) export const commentsAtom = atom([]) const theme = undefined