diff --git a/blocks/actions.ts b/blocks/actions.ts new file mode 100644 index 000000000..36016ac1f --- /dev/null +++ b/blocks/actions.ts @@ -0,0 +1,8 @@ +'use server'; + +import { getSuggestionsByDocumentId } from '@/lib/db/queries'; + +export async function getSuggestions({ documentId }: { documentId: string }) { + const suggestions = await getSuggestionsByDocumentId({ documentId }); + return suggestions; +} diff --git a/blocks/code.tsx b/blocks/code.tsx new file mode 100644 index 000000000..681e9c2c9 --- /dev/null +++ b/blocks/code.tsx @@ -0,0 +1,154 @@ +import { Block } from '@/components/create-block'; +import { CodeEditor } from '@/components/code-editor'; +import { + CopyIcon, + LogsIcon, + MessageIcon, + PlayIcon, + RedoIcon, + UndoIcon, +} from '@/components/icons'; +import { toast } from 'sonner'; +import { generateUUID } from '@/lib/utils'; +import { Console, ConsoleOutput } from '@/components/console'; + +interface Metadata { + outputs: Array; +} + +export const codeBlock = new Block<'code', Metadata>({ + kind: 'code', + description: + 'Useful for code generation; Code execution is only available for python code.', + initialize: () => ({ + outputs: [], + }), + onStreamPart: ({ streamPart, setBlock }) => { + if (streamPart.type === 'code-delta') { + setBlock((draftBlock) => ({ + ...draftBlock, + content: streamPart.content as string, + isVisible: + draftBlock.status === 'streaming' && + draftBlock.content.length > 300 && + draftBlock.content.length < 310 + ? true + : draftBlock.isVisible, + status: 'streaming', + })); + } + }, + content: ({ metadata, setMetadata, ...props }) => { + return ( + <> + + + {metadata?.outputs && ( + { + setMetadata({ + ...metadata, + outputs: [], + }); + }} + /> + )} + + ); + }, + actions: [ + { + icon: , + label: 'Run', + description: 'Execute code', + onClick: async ({ content, setMetadata }) => { + const runId = generateUUID(); + const outputs: any[] = []; + + // @ts-expect-error - loadPyodide is not defined + const currentPyodideInstance = await globalThis.loadPyodide({ + indexURL: 'https://cdn.jsdelivr.net/pyodide/v0.23.4/full/', + }); + + currentPyodideInstance.setStdout({ + batched: (output: string) => { + outputs.push({ + id: runId, + contents: [ + { + type: output.startsWith('data:image/png;base64') + ? 'image' + : 'text', + value: output, + }, + ], + status: 'completed', + }); + }, + }); + + await currentPyodideInstance.loadPackagesFromImports(content, { + messageCallback: (message: string) => { + outputs.push({ + id: runId, + contents: [{ type: 'text', value: message }], + status: 'loading_packages', + }); + }, + }); + + await currentPyodideInstance.runPythonAsync(content); + + setMetadata((metadata: any) => ({ + ...metadata, + outputs, + })); + }, + }, + { + icon: , + description: 'View Previous version', + onClick: ({ handleVersionChange }) => { + handleVersionChange('prev'); + }, + }, + { + icon: , + description: 'View Next version', + onClick: ({ handleVersionChange }) => { + handleVersionChange('next'); + }, + }, + { + icon: , + description: 'Copy code to clipboard', + onClick: ({ content }) => { + navigator.clipboard.writeText(content); + toast.success('Copied to clipboard!'); + }, + }, + ], + toolbar: [ + { + icon: , + description: 'Add comments', + onClick: ({ appendMessage }) => { + appendMessage({ + role: 'user', + content: 'Add comments to the code snippet for understanding', + }); + }, + }, + { + icon: , + description: 'Add logs', + onClick: ({ appendMessage }) => { + appendMessage({ + role: 'user', + content: 'Add logs to the code snippet for debugging', + }); + }, + }, + ], +}); diff --git a/blocks/image.tsx b/blocks/image.tsx new file mode 100644 index 000000000..3bdf1aa78 --- /dev/null +++ b/blocks/image.tsx @@ -0,0 +1,59 @@ +import { Block } from '@/components/create-block'; +import { CopyIcon, RedoIcon, UndoIcon } from '@/components/icons'; +import { ImageEditor } from '@/components/image-editor'; + +export const imageBlock = new Block({ + kind: 'image', + description: 'Useful for image generation', + onStreamPart: ({ streamPart, setBlock }) => { + if (streamPart.type === 'image-delta') { + setBlock((draftBlock) => ({ + ...draftBlock, + content: streamPart.content as string, + isVisible: true, + status: 'streaming', + })); + } + }, + content: ImageEditor, + actions: [ + { + icon: , + description: 'View Previous version', + onClick: ({ handleVersionChange }) => { + handleVersionChange('prev'); + }, + }, + { + icon: , + description: 'View Next version', + onClick: ({ handleVersionChange }) => { + handleVersionChange('next'); + }, + }, + { + icon: , + description: 'Copy image to clipboard', + onClick: ({ content }) => { + const img = new Image(); + img.src = `data:image/png;base64,${content}`; + + img.onload = () => { + const canvas = document.createElement('canvas'); + canvas.width = img.width; + canvas.height = img.height; + const ctx = canvas.getContext('2d'); + ctx?.drawImage(img, 0, 0); + canvas.toBlob((blob) => { + if (blob) { + navigator.clipboard.write([ + new ClipboardItem({ 'image/png': blob }), + ]); + } + }, 'image/png'); + }; + }, + }, + ], + toolbar: [], +}); diff --git a/blocks/text.tsx b/blocks/text.tsx new file mode 100644 index 000000000..ad3739dad --- /dev/null +++ b/blocks/text.tsx @@ -0,0 +1,174 @@ +import { Block } from '@/components/create-block'; +import { DiffView } from '@/components/diffview'; +import { DocumentSkeleton } from '@/components/document-skeleton'; +import { Editor } from '@/components/editor'; +import { + ClockRewind, + CopyIcon, + MessageIcon, + PenIcon, + RedoIcon, + UndoIcon, +} from '@/components/icons'; +import { Suggestion } from '@/lib/db/schema'; +import { toast } from 'sonner'; +import { getSuggestions } from './actions'; + +interface TextBlockMetadata { + suggestions: Array; +} + +export const textBlock = new Block<'text', TextBlockMetadata>({ + kind: 'text', + description: 'Useful for text content, like drafting essays and emails.', + initialize: async ({ documentId, setMetadata }) => { + const suggestions = await getSuggestions({ documentId }); + + setMetadata({ + suggestions, + }); + }, + onStreamPart: ({ streamPart, setMetadata, setBlock }) => { + if (streamPart.type === 'suggestion') { + setMetadata((metadata) => { + return { + suggestions: [ + ...metadata.suggestions, + streamPart.content as Suggestion, + ], + }; + }); + } + + if (streamPart.type === 'text-delta') { + setBlock((draftBlock) => { + return { + ...draftBlock, + content: draftBlock.content + (streamPart.content as string), + isVisible: + draftBlock.status === 'streaming' && + draftBlock.content.length > 400 && + draftBlock.content.length < 450 + ? true + : draftBlock.isVisible, + status: 'streaming', + }; + }); + } + }, + content: ({ + mode, + status, + content, + isCurrentVersion, + currentVersionIndex, + onSaveContent, + getDocumentContentById, + isLoading, + metadata, + }) => { + if (isLoading) { + return ; + } + + if (mode === 'diff') { + const oldContent = getDocumentContentById(currentVersionIndex - 1); + const newContent = getDocumentContentById(currentVersionIndex); + + return ; + } + + return ( + <> + + + {metadata && metadata.suggestions && metadata.suggestions.length > 0 ? ( +
+ ) : null} + + ); + }, + actions: [ + { + icon: , + description: 'View changes', + onClick: ({ handleVersionChange }) => { + handleVersionChange('toggle'); + }, + isDisabled: ({ currentVersionIndex, setMetadata }) => { + if (currentVersionIndex === 0) { + return true; + } + + return false; + }, + }, + { + icon: , + description: 'View Previous version', + onClick: ({ handleVersionChange }) => { + handleVersionChange('prev'); + }, + isDisabled: ({ currentVersionIndex }) => { + if (currentVersionIndex === 0) { + return true; + } + + return false; + }, + }, + { + icon: , + description: 'View Next version', + onClick: ({ handleVersionChange }) => { + handleVersionChange('next'); + }, + isDisabled: ({ isCurrentVersion }) => { + if (isCurrentVersion) { + return true; + } + + return false; + }, + }, + { + icon: , + description: 'Copy to clipboard', + onClick: ({ content }) => { + navigator.clipboard.writeText(content); + toast.success('Copied to clipboard!'); + }, + }, + ], + toolbar: [ + { + icon: , + description: 'Add final polish', + onClick: ({ appendMessage }) => { + appendMessage({ + role: 'user', + content: + 'Please add final polish and check for grammar, add section titles for better structure, and ensure everything reads smoothly.', + }); + }, + }, + { + icon: , + description: 'Request suggestions', + onClick: ({ appendMessage }) => { + appendMessage({ + role: 'user', + content: + 'Please add suggestions you have that could improve the writing.', + }); + }, + }, + ], +}); diff --git a/components/block-actions.tsx b/components/block-actions.tsx index 5051d19a8..d355f35be 100644 --- a/components/block-actions.tsx +++ b/components/block-actions.tsx @@ -1,20 +1,18 @@ -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 { toast } from 'sonner'; -import { ConsoleOutput, UIBlock } from './block'; +import { blockDefinitions, UIBlock } from './block'; import { Dispatch, memo, SetStateAction } from 'react'; -import { RunCodeButton } from './run-code-button'; -import { useMultimodalCopyToClipboard } from '@/hooks/use-multimodal-copy-to-clipboard'; +import { BlockActionContext } from './create-block'; +import { cn } from '@/lib/utils'; interface BlockActionsProps { block: UIBlock; handleVersionChange: (type: 'next' | 'prev' | 'toggle' | 'latest') => void; currentVersionIndex: number; isCurrentVersion: boolean; - mode: 'read-only' | 'edit' | 'diff'; - setConsoleOutputs: Dispatch>>; + mode: 'edit' | 'diff'; + metadata: any; + setMetadata: Dispatch>; } function PureBlockActions({ @@ -23,95 +21,50 @@ function PureBlockActions({ currentVersionIndex, isCurrentVersion, mode, - setConsoleOutputs, + metadata, + setMetadata, }: BlockActionsProps) { - const { copyTextToClipboard, copyImageToClipboard } = - useMultimodalCopyToClipboard(); + const blockDefinition = blockDefinitions.find( + (definition) => definition.kind === block.kind, + ); + + if (!blockDefinition) { + throw new Error('Block definition not found!'); + } + + const actionContext: BlockActionContext = { + content: block.content, + handleVersionChange, + currentVersionIndex, + isCurrentVersion, + mode, + metadata, + setMetadata, + }; return (
- {block.kind === 'code' && ( - - )} - - {block.kind === 'text' && ( - + {blockDefinition.actions.map((action) => ( + - View changes + {action.description} - )} - - - - - - View Previous version - - - - - - - View Next version - - - - - - - Copy to clipboard - + ))}
); } diff --git a/components/block.tsx b/components/block.tsx index 98c99e3fb..2c3950813 100644 --- a/components/block.tsx +++ b/components/block.tsx @@ -16,27 +16,23 @@ import { } from 'react'; import useSWR, { useSWRConfig } from 'swr'; import { useDebounceCallback, useWindowSize } from 'usehooks-ts'; - -import type { Document, Suggestion, Vote } from '@/lib/db/schema'; +import type { Document, Vote } from '@/lib/db/schema'; import { cn, fetcher } from '@/lib/utils'; - -import { DiffView } from './diffview'; -import { DocumentSkeleton } from './document-skeleton'; -import { Editor } from './editor'; import { MultimodalInput } from './multimodal-input'; import { Toolbar } from './toolbar'; import { VersionFooter } from './version-footer'; import { BlockActions } from './block-actions'; import { BlockCloseButton } from './block-close-button'; import { BlockMessages } from './block-messages'; -import { CodeEditor } from './code-editor'; -import { Console } from './console'; import { useSidebar } from './ui/sidebar'; import { useBlock } from '@/hooks/use-block'; import equal from 'fast-deep-equal'; -import { ImageEditor } from './image-editor'; +import { textBlock } from '@/blocks/text'; +import { imageBlock } from '@/blocks/image'; +import { codeBlock } from '@/blocks/code'; -export type BlockKind = 'text' | 'code' | 'image'; +export const blockDefinitions = [textBlock, codeBlock, imageBlock] as const; +export type BlockKind = (typeof blockDefinitions)[number]['kind']; export interface UIBlock { title: string; @@ -53,17 +49,6 @@ export interface UIBlock { }; } -export interface ConsoleOutputContent { - type: 'text' | 'image'; - value: string; -} - -export interface ConsoleOutput { - id: string; - status: 'in_progress' | 'loading_packages' | 'completed' | 'failed'; - contents: Array; -} - function PureBlock({ chatId, input, @@ -105,7 +90,7 @@ function PureBlock({ ) => Promise; isReadonly: boolean; }) { - const { block, setBlock } = useBlock(); + const { block, setBlock, metadata, setMetadata } = useBlock(); const { data: documents, @@ -118,22 +103,9 @@ function PureBlock({ fetcher ); - const { data: suggestions } = useSWR>( - documents && block && block.status !== 'streaming' - ? `/api/suggestions?documentId=${block.documentId}` - : null, - fetcher, - { - dedupingInterval: 5000, - } - ); - const [mode, setMode] = useState<'edit' | 'diff'>('edit'); const [document, setDocument] = useState(null); const [currentVersionIndex, setCurrentVersionIndex] = useState(-1); - const [consoleOutputs, setConsoleOutputs] = useState>( - [] - ); const { open: isSidebarOpen } = useSidebar(); @@ -268,6 +240,23 @@ function PureBlock({ const { width: windowWidth, height: windowHeight } = useWindowSize(); const isMobile = windowWidth ? windowWidth < 768 : false; + const blockDefinition = blockDefinitions.find( + (definition) => definition.kind === block.kind + ); + + if (!blockDefinition) { + throw new Error('Block definition not found!'); + } + + useEffect(() => { + if (block && block.documentId !== 'init') { + blockDefinition.initialize({ + documentId: block.documentId, + setMetadata, + }); + } + }, [block, blockDefinition, setMetadata]); + return ( {block.isVisible && ( @@ -457,7 +446,8 @@ function PureBlock({ handleVersionChange={handleVersionChange} isCurrentVersion={isCurrentVersion} mode={mode} - setConsoleOutputs={setConsoleOutputs} + metadata={metadata} + setMetadata={setMetadata} />
@@ -465,7 +455,7 @@ function PureBlock({ className={cn( 'dark:bg-muted bg-background h-full overflow-y-scroll !max-w-full pb-40 items-center', { - 'py-2 px-2': block.kind === 'code', + '': block.kind === 'code', 'py-8 md:p-20 px-4': block.kind === 'text', } )} @@ -476,61 +466,25 @@ function PureBlock({ 'mx-auto max-w-[600px]': block.kind === 'text', })} > - {isDocumentsFetching && !block.content ? ( - - ) : block.kind === 'code' ? ( - - ) : block.kind === 'text' ? ( - mode === 'edit' ? ( - - ) : ( - - ) - ) : block.kind === 'image' ? ( - - ) : null} - - {suggestions && suggestions.length > 0 ? ( -
- ) : null} + {isCurrentVersion && ( @@ -557,13 +511,6 @@ function PureBlock({ /> )} - - - - )} diff --git a/components/code-editor.tsx b/components/code-editor.tsx index 8e5e21e8a..35ae44c97 100644 --- a/components/code-editor.tsx +++ b/components/code-editor.tsx @@ -10,14 +10,14 @@ import { Suggestion } from '@/lib/db/schema'; type EditorProps = { content: string; - saveContent: (updatedContent: string, debounce: boolean) => void; + onSaveContent: (updatedContent: string, debounce: boolean) => void; status: 'streaming' | 'idle'; isCurrentVersion: boolean; currentVersionIndex: number; suggestions: Array; }; -function PureCodeEditor({ content, saveContent, status }: EditorProps) { +function PureCodeEditor({ content, onSaveContent, status }: EditorProps) { const containerRef = useRef(null); const editorRef = useRef(null); @@ -54,7 +54,7 @@ function PureCodeEditor({ content, saveContent, status }: EditorProps) { if (transaction) { const newContent = update.state.doc.toString(); - saveContent(newContent, true); + onSaveContent(newContent, true); } } }); @@ -69,7 +69,7 @@ function PureCodeEditor({ content, saveContent, status }: EditorProps) { editorRef.current.setState(newState); } - }, [saveContent]); + }, [onSaveContent]); useEffect(() => { if (editorRef.current && content) { diff --git a/components/console.tsx b/components/console.tsx index 8d9a51b69..48747d403 100644 --- a/components/console.tsx +++ b/components/console.tsx @@ -8,10 +8,20 @@ import { useRef, useState, } from 'react'; -import { ConsoleOutput } from './block'; import { cn } from '@/lib/utils'; import { useBlockSelector } from '@/hooks/use-block'; +export interface ConsoleOutputContent { + type: 'text' | 'image'; + value: string; +} + +export interface ConsoleOutput { + id: string; + status: 'in_progress' | 'loading_packages' | 'completed' | 'failed'; + contents: Array; +} + interface ConsoleProps { consoleOutputs: Array; setConsoleOutputs: Dispatch>>; diff --git a/components/create-block.tsx b/components/create-block.tsx new file mode 100644 index 000000000..542b8a1dd --- /dev/null +++ b/components/create-block.tsx @@ -0,0 +1,100 @@ +import { Suggestion } from '@/lib/db/schema'; +import { UseChatHelpers } from 'ai/react'; +import { ComponentType, Dispatch, ReactNode, SetStateAction } from 'react'; +import { DataStreamDelta } from './data-stream-handler'; +import { UIBlock } from './block'; + +export type BlockActionContext = { + content: string; + handleVersionChange: (type: 'next' | 'prev' | 'toggle' | 'latest') => void; + currentVersionIndex: number; + isCurrentVersion: boolean; + mode: 'edit' | 'diff'; + metadata: any; + setMetadata: Dispatch>; +}; + +type BlockAction = { + icon: ReactNode; + label?: string; + description: string; + onClick: (context: BlockActionContext) => void; + isDisabled?: (context: BlockActionContext) => boolean; +}; + +export type BlockToolbarContext = { + appendMessage: UseChatHelpers['append']; +}; + +export type BlockToolbarItem = { + description: string; + icon: ReactNode; + onClick: (context: BlockToolbarContext) => void; +}; + +type BlockContent = { + title: string; + content: string; + mode: 'edit' | 'diff'; + isCurrentVersion: boolean; + currentVersionIndex: number; + status: 'streaming' | 'idle'; + suggestions: Array; + onSaveContent: (updatedContent: string, debounce: boolean) => void; + isInline: boolean; + getDocumentContentById: (index: number) => string; + isLoading: boolean; + metadata: any; + setMetadata: Dispatch>; +}; + +interface InitializeParameters { + documentId: string; + setMetadata: Dispatch>; +} + +type BlockConfig = { + kind: T; + description: string; + content: ComponentType< + Omit & { + metadata: M; + setMetadata: Dispatch>; + } + >; + actions?: BlockAction[]; + toolbar?: BlockToolbarItem[]; + metadata?: M; + initialize?: (parameters: InitializeParameters) => void; + onStreamPart?: (args: { + setMetadata: Dispatch>; + setBlock: Dispatch>; + streamPart: DataStreamDelta; + }) => void; +}; + +export class Block { + readonly kind: T; + readonly description: string; + readonly content: ComponentType; + readonly actions: BlockAction[]; + readonly toolbar: BlockToolbarItem[]; + readonly metadata: M; + readonly initialize: (parameters: InitializeParameters) => void; + readonly onStreamPart?: (args: { + setMetadata: Dispatch>; + setBlock: Dispatch>; + streamPart: DataStreamDelta; + }) => void; + + constructor(config: BlockConfig) { + this.kind = config.kind; + this.description = config.description; + this.content = config.content; + this.actions = config.actions || []; + this.toolbar = config.toolbar || []; + this.metadata = config.metadata as M; + this.initialize = config.initialize || (async () => ({})); + this.onStreamPart = config.onStreamPart; + } +} diff --git a/components/data-stream-handler.tsx b/components/data-stream-handler.tsx index 120416e06..02a66eea0 100644 --- a/components/data-stream-handler.tsx +++ b/components/data-stream-handler.tsx @@ -1,14 +1,13 @@ 'use client'; import { useChat } from 'ai/react'; -import { useEffect, useRef, useState } from 'react'; -import { BlockKind } from './block'; +import { useEffect, useRef } from 'react'; +import { blockDefinitions, BlockKind } from './block'; import { Suggestion } from '@/lib/db/schema'; import { initialBlockData, useBlock } from '@/hooks/use-block'; import { useUserMessageId } from '@/hooks/use-user-message-id'; -import { useSWRConfig } from 'swr'; -type DataStreamDelta = { +export type DataStreamDelta = { type: | 'text-delta' | 'code-delta' @@ -26,22 +25,9 @@ type DataStreamDelta = { export function DataStreamHandler({ id }: { id: string }) { const { data: dataStream } = useChat({ id }); const { setUserMessageIdFromServer } = useUserMessageId(); - const { setBlock } = useBlock(); + const { block, setBlock, setMetadata } = useBlock(); const lastProcessedIndex = useRef(-1); - const { mutate } = useSWRConfig(); - const [optimisticSuggestions, setOptimisticSuggestions] = useState< - Array - >([]); - - useEffect(() => { - if (optimisticSuggestions && optimisticSuggestions.length > 0) { - const [optimisticSuggestion] = optimisticSuggestions; - const url = `/api/suggestions?documentId=${optimisticSuggestion.documentId}`; - mutate(url, optimisticSuggestions, false); - } - }, [optimisticSuggestions, mutate]); - useEffect(() => { if (!dataStream?.length) return; @@ -54,6 +40,18 @@ export function DataStreamHandler({ id }: { id: string }) { return; } + const blockDefinition = blockDefinitions.find( + (blockDefinition) => blockDefinition.kind === block.kind, + ); + + if (blockDefinition?.onStreamPart) { + blockDefinition.onStreamPart({ + streamPart: delta, + setBlock, + setMetadata, + }); + } + setBlock((draftBlock) => { if (!draftBlock) { return { ...initialBlockData, status: 'streaming' }; @@ -81,50 +79,6 @@ export function DataStreamHandler({ id }: { id: string }) { status: 'streaming', }; - case 'text-delta': - return { - ...draftBlock, - content: draftBlock.content + (delta.content as string), - isVisible: - draftBlock.status === 'streaming' && - draftBlock.content.length > 400 && - draftBlock.content.length < 450 - ? true - : draftBlock.isVisible, - status: 'streaming', - }; - - case 'code-delta': - return { - ...draftBlock, - content: delta.content as string, - isVisible: - draftBlock.status === 'streaming' && - draftBlock.content.length > 300 && - draftBlock.content.length < 310 - ? true - : draftBlock.isVisible, - status: 'streaming', - }; - - case 'image-delta': - return { - ...draftBlock, - content: delta.content as string, - isVisible: true, - status: 'streaming', - }; - - case 'suggestion': - setTimeout(() => { - setOptimisticSuggestions((currentSuggestions) => [ - ...currentSuggestions, - delta.content as Suggestion, - ]); - }, 0); - - return draftBlock; - case 'clear': return { ...draftBlock, @@ -143,7 +97,7 @@ export function DataStreamHandler({ id }: { id: string }) { } }); }); - }, [dataStream, setBlock, setUserMessageIdFromServer]); + }, [dataStream, setBlock, setUserMessageIdFromServer, setMetadata, block]); return null; } diff --git a/components/document-preview.tsx b/components/document-preview.tsx index 247ed081a..24e4674bf 100644 --- a/components/document-preview.tsx +++ b/components/document-preview.tsx @@ -249,11 +249,11 @@ const DocumentContent = ({ document }: { document: Document }) => { return (
{document.kind === 'text' ? ( - + {}} /> ) : document.kind === 'code' ? (
- + {}} />
) : document.kind === 'image' ? ( diff --git a/components/editor.tsx b/components/editor.tsx index f88131fae..c24dd33e6 100644 --- a/components/editor.tsx +++ b/components/editor.tsx @@ -25,7 +25,7 @@ import { type EditorProps = { content: string; - saveContent: (updatedContent: string, debounce: boolean) => void; + onSaveContent: (updatedContent: string, debounce: boolean) => void; status: 'streaming' | 'idle'; isCurrentVersion: boolean; currentVersionIndex: number; @@ -34,7 +34,7 @@ type EditorProps = { function PureEditor({ content, - saveContent, + onSaveContent, suggestions, status, }: EditorProps) { @@ -80,11 +80,15 @@ function PureEditor({ if (editorRef.current) { editorRef.current.setProps({ dispatchTransaction: (transaction) => { - handleTransaction({ transaction, editorRef, saveContent }); + handleTransaction({ + transaction, + editorRef, + onSaveContent, + }); }, }); } - }, [saveContent]); + }, [onSaveContent]); useEffect(() => { if (editorRef.current && content) { @@ -153,7 +157,7 @@ function areEqual(prevProps: EditorProps, nextProps: EditorProps) { prevProps.isCurrentVersion === nextProps.isCurrentVersion && !(prevProps.status === 'streaming' && nextProps.status === 'streaming') && prevProps.content === nextProps.content && - prevProps.saveContent === nextProps.saveContent + prevProps.onSaveContent === nextProps.onSaveContent ); } diff --git a/components/image-editor.tsx b/components/image-editor.tsx index 7376fefa8..9676d4877 100644 --- a/components/image-editor.tsx +++ b/components/image-editor.tsx @@ -13,8 +13,6 @@ interface ImageEditorProps { export function ImageEditor({ title, content, - isCurrentVersion, - currentVersionIndex, status, isInline, }: ImageEditorProps) { diff --git a/components/run-code-button.tsx b/components/run-code-button.tsx deleted file mode 100644 index cce465479..000000000 --- a/components/run-code-button.tsx +++ /dev/null @@ -1,210 +0,0 @@ -import { generateUUID } from '@/lib/utils'; -import { - type Dispatch, - type SetStateAction, - startTransition, - useCallback, - useState, - useEffect, - memo, -} from 'react'; -import type { ConsoleOutput, ConsoleOutputContent, UIBlock } from './block'; -import { Button } from './ui/button'; -import { PlayIcon } from './icons'; -import { useBlockSelector } from '@/hooks/use-block'; - -const OUTPUT_HANDLERS = { - matplotlib: ` - import io - import base64 - 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 PureRunCodeButton({ - setConsoleOutputs, -}: { - block: UIBlock; - setConsoleOutputs: Dispatch>>; -}) { - const isPython = true; - const [pyodide, setPyodide] = useState(null); - - const codeContent = useBlockSelector((state) => state.content); - const isBlockStreaming = useBlockSelector( - (state) => state.status === 'streaming', - ); - - const loadAndRunPython = useCallback(async () => { - const runId = generateUUID(); - const stdOutputs: Array = []; - - 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 globalThis.loadPyodide({ - indexURL: 'https://cdn.jsdelivr.net/pyodide/v0.23.4/full/', - }); - - setPyodide(null); - setPyodide(newPyodideInstance); - currentPyodideInstance = newPyodideInstance; - } - - currentPyodideInstance.setStdout({ - batched: (output: string) => { - stdOutputs.push({ - type: output.startsWith('data:image/png;base64') - ? 'image' - : 'text', - value: output, - }); - }, - }); - - await currentPyodideInstance.loadPackagesFromImports(codeContent, { - messageCallback: (message: string) => { - setConsoleOutputs((outputs) => [ - ...outputs.filter((output) => output.id !== runId), - { - id: runId, - contents: [{ type: 'text', value: message }], - status: 'loading_packages', - }, - ]); - }, - }); - - 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); - - setConsoleOutputs((outputs) => [ - ...outputs.filter((output) => output.id !== runId), - { - id: generateUUID(), - contents: stdOutputs.filter((output) => output.value.trim().length), - 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 - import gc - - 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 ( - - ); -} - -export const RunCodeButton = memo(PureRunCodeButton, (prevProps, nextProps) => { - if (prevProps.block.status !== nextProps.block.status) return false; - - return true; -}); diff --git a/components/toolbar.tsx b/components/toolbar.tsx index 64b54413e..2ed067370 100644 --- a/components/toolbar.tsx +++ b/components/toolbar.tsx @@ -11,6 +11,7 @@ import { import { type Dispatch, memo, + ReactNode, type SetStateAction, useEffect, useRef, @@ -32,22 +33,16 @@ import { LogsIcon, MessageIcon, PenIcon, - SparklesIcon, StopIcon, SummarizeIcon, } from './icons'; -import { BlockKind } from './block'; +import { blockDefinitions, BlockKind } from './block'; +import { BlockToolbarItem } from './create-block'; +import { UseChatHelpers } from 'ai/react'; type ToolProps = { - type: - | 'final-polish' - | 'request-suggestions' - | 'adjust-reading-level' - | 'code-review' - | 'add-comments' - | 'add-logs'; description: string; - icon: JSX.Element; + icon: ReactNode; selectedTool: string | null; setSelectedTool: Dispatch>; isToolbarVisible?: boolean; @@ -57,10 +52,14 @@ type ToolProps = { message: Message | CreateMessage, chatRequestOptions?: ChatRequestOptions, ) => Promise; + onClick: ({ + appendMessage, + }: { + appendMessage: UseChatHelpers['append']; + }) => void; }; const Tool = ({ - type, description, icon, selectedTool, @@ -69,14 +68,15 @@ const Tool = ({ setIsToolbarVisible, isAnimating, append, + onClick, }: ToolProps) => { const [isHovered, setIsHovered] = useState(false); useEffect(() => { - if (selectedTool !== type) { + if (selectedTool !== description) { setIsHovered(false); } - }, [selectedTool, type]); + }, [selectedTool, description]); const handleSelect = () => { if (!isToolbarVisible && setIsToolbarVisible) { @@ -86,44 +86,15 @@ const Tool = ({ if (!selectedTool) { setIsHovered(true); - setSelectedTool(type); + setSelectedTool(description); return; } - if (selectedTool !== type) { - setSelectedTool(type); + if (selectedTool !== description) { + setSelectedTool(description); } else { - if (type === 'final-polish') { - append({ - role: 'user', - content: - 'Please add final polish and check for grammar, add section titles for better structure, and ensure everything reads smoothly.', - }); - - setSelectedTool(null); - } else if (type === 'request-suggestions') { - append({ - role: 'user', - content: - 'Please add suggestions you have that could improve the writing.', - }); - - setSelectedTool(null); - } else if (type === 'add-comments') { - append({ - role: 'user', - content: 'Please add comments to explain the code.', - }); - - setSelectedTool(null); - } else if (type === 'add-logs') { - append({ - role: 'user', - content: 'Please add logs to help debug the code.', - }); - - setSelectedTool(null); - } + setSelectedTool(null); + onClick({ appendMessage: append }); } }; @@ -132,13 +103,13 @@ const Tool = ({ { setIsHovered(true); }} onHoverEnd={() => { - if (selectedTool !== type) setIsHovered(false); + if (selectedTool !== description) setIsHovered(false); }} onKeyDown={(event) => { if (event.key === 'Enter') { @@ -158,7 +129,7 @@ const Tool = ({ handleSelect(); }} > - {selectedTool === type ? : icon} + {selectedTool === description ? : icon} -> = { - text: [ - { - type: 'final-polish', - description: 'Add final polish', - icon: , - }, - { - type: 'adjust-reading-level', - description: 'Adjust reading level', - icon: , - }, - { - type: 'request-suggestions', - description: 'Request suggestions', - icon: , - }, - ], - code: [ - { - type: 'add-comments', - description: 'Add comments', - icon: , - }, - { - type: 'add-logs', - description: 'Add logs', - icon: , - }, - ], - image: [], -}; - export const Tools = ({ isToolbarVisible, selectedTool, @@ -336,7 +261,7 @@ export const Tools = ({ append, isAnimating, setIsToolbarVisible, - blockKind, + tools, }: { isToolbarVisible: boolean; selectedTool: string | null; @@ -347,9 +272,9 @@ export const Tools = ({ ) => Promise; isAnimating: boolean; setIsToolbarVisible: Dispatch>; - blockKind: BlockKind; + tools: Array; }) => { - const [primaryTool, ...secondaryTools] = toolsByBlockKind[blockKind]; + const [primaryTool, ...secondaryTools] = tools; return ( ( ))} ); @@ -451,7 +376,17 @@ const PureToolbar = ({ } }, [isLoading, setIsToolbarVisible]); - if (toolsByBlockKind[blockKind].length === 0) { + const blockDefinition = blockDefinitions.find( + (definition) => definition.kind === blockKind, + ); + + if (!blockDefinition) { + throw new Error('Block definition not found!'); + } + + const toolsByBlockKind = blockDefinition.toolbar; + + if (toolsByBlockKind.length === 0) { return null; } @@ -473,7 +408,7 @@ const PureToolbar = ({ : { opacity: 1, y: 0, - height: toolsByBlockKind[blockKind].length * 50, + height: toolsByBlockKind.length * 50, transition: { delay: 0 }, scale: 1, } @@ -530,7 +465,7 @@ const PureToolbar = ({ selectedTool={selectedTool} setIsToolbarVisible={setIsToolbarVisible} setSelectedTool={setSelectedTool} - blockKind={blockKind} + tools={toolsByBlockKind} /> )} diff --git a/hooks/use-block.ts b/hooks/use-block.ts index 32b267707..9e6d491b2 100644 --- a/hooks/use-block.ts +++ b/hooks/use-block.ts @@ -1,8 +1,8 @@ 'use client'; +import useSWR from 'swr'; import { UIBlock } from '@/components/block'; import { useCallback, useMemo } from 'react'; -import useSWR from 'swr'; export const initialBlockData: UIBlock = { documentId: 'init', @@ -19,7 +19,6 @@ export const initialBlockData: UIBlock = { }, }; -// Add type for selector function type Selector = (state: UIBlock) => T; export function useBlockSelector(selector: Selector) { @@ -64,5 +63,22 @@ export function useBlock() { [setLocalBlock], ); - return useMemo(() => ({ block, setBlock }), [block, setBlock]); + const { data: localBlockMetadata, mutate: setLocalBlockMetadata } = + useSWR( + () => (block.documentId ? `block-metadata-${block.documentId}` : null), + null, + { + fallbackData: null, + }, + ); + + return useMemo( + () => ({ + block, + setBlock, + metadata: localBlockMetadata, + setMetadata: setLocalBlockMetadata, + }), + [block, setBlock, localBlockMetadata, setLocalBlockMetadata], + ); } diff --git a/hooks/use-multimodal-copy-to-clipboard.ts b/hooks/use-multimodal-copy-to-clipboard.ts deleted file mode 100644 index 1fd9a92ff..000000000 --- a/hooks/use-multimodal-copy-to-clipboard.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { useCopyToClipboard } from 'usehooks-ts'; - -async function copyImageToClipboard(base64String: string) { - try { - const blob = await fetch(`data:image/png;base64,${base64String}`).then( - (res) => res.blob(), - ); - - const item = new ClipboardItem({ - 'image/png': blob, - }); - - await navigator.clipboard.write([item]); - } catch (error) { - console.error('Failed to copy image to clipboard:', error); - } -} - -export function useMultimodalCopyToClipboard() { - const [_, copyTextToClipboard] = useCopyToClipboard(); - return { copyTextToClipboard, copyImageToClipboard }; -} diff --git a/lib/editor/config.ts b/lib/editor/config.ts index 60d8cf5d6..c171cac39 100644 --- a/lib/editor/config.ts +++ b/lib/editor/config.ts @@ -24,11 +24,11 @@ export function headingRule(level: number) { export const handleTransaction = ({ transaction, editorRef, - saveContent, + onSaveContent, }: { transaction: Transaction; editorRef: MutableRefObject; - saveContent: (updatedContent: string, debounce: boolean) => void; + onSaveContent: (updatedContent: string, debounce: boolean) => void; }) => { if (!editorRef || !editorRef.current) return; @@ -39,9 +39,9 @@ export const handleTransaction = ({ const updatedContent = buildContentFromDocument(newState.doc); if (transaction.getMeta('no-debounce')) { - saveContent(updatedContent, false); + onSaveContent(updatedContent, false); } else { - saveContent(updatedContent, true); + onSaveContent(updatedContent, true); } } };