diff --git a/blocks/sheet.tsx b/blocks/sheet.tsx new file mode 100644 index 0000000..24e4873 --- /dev/null +++ b/blocks/sheet.tsx @@ -0,0 +1,115 @@ +import { Block } from '@/components/create-block'; +import { + CopyIcon, + LineChartIcon, + RedoIcon, + SparklesIcon, + UndoIcon, +} from '@/components/icons'; +import { SpreadsheetEditor } from '@/components/sheet-editor'; +import { parse, unparse } from 'papaparse'; +import { toast } from 'sonner'; + +type Metadata = any; + +export const sheetBlock = new Block<'sheet', Metadata>({ + kind: 'sheet', + description: 'Useful for working with spreadsheets', + initialize: async () => {}, + onStreamPart: ({ setBlock, streamPart }) => { + if (streamPart.type === 'sheet-delta') { + setBlock((draftBlock) => ({ + ...draftBlock, + content: streamPart.content as string, + isVisible: true, + status: 'streaming', + })); + } + }, + content: ({ + content, + currentVersionIndex, + isCurrentVersion, + onSaveContent, + status, + }) => { + return ( + + ); + }, + actions: [ + { + 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 as .csv', + onClick: ({ content }) => { + const parsed = parse(content, { skipEmptyLines: true }); + + const nonEmptyRows = parsed.data.filter((row) => + row.some((cell) => cell.trim() !== ''), + ); + + const cleanedCsv = unparse(nonEmptyRows); + + navigator.clipboard.writeText(cleanedCsv); + toast.success('Copied csv to clipboard!'); + }, + }, + ], + toolbar: [ + { + description: 'Format and clean data', + icon: , + onClick: ({ appendMessage }) => { + appendMessage({ + role: 'user', + content: 'Can you please format and clean the data?', + }); + }, + }, + { + description: 'Analyze and visualize data', + icon: , + onClick: ({ appendMessage }) => { + appendMessage({ + role: 'user', + content: + 'Can you please analyze and visualize the data by creating a new code block in python?', + }); + }, + }, + ], +}); diff --git a/components/block.tsx b/components/block.tsx index f633018..890ad8f 100644 --- a/components/block.tsx +++ b/components/block.tsx @@ -30,8 +30,9 @@ import { textBlock } from '@/blocks/text'; import { imageBlock } from '@/blocks/image'; import { codeBlock } from '@/blocks/code'; import equal from 'fast-deep-equal'; +import { sheetBlock } from '@/blocks/sheet'; -export const blockDefinitions = [textBlock, codeBlock, imageBlock] as const; +export const blockDefinitions = [textBlock, codeBlock, imageBlock, sheetBlock]; export type BlockKind = (typeof blockDefinitions)[number]['kind']; export interface UIBlock { diff --git a/components/data-stream-handler.tsx b/components/data-stream-handler.tsx index 1328a36..d46c84d 100644 --- a/components/data-stream-handler.tsx +++ b/components/data-stream-handler.tsx @@ -10,6 +10,7 @@ export type DataStreamDelta = { type: | 'text-delta' | 'code-delta' + | 'sheet-delta' | 'image-delta' | 'title' | 'id' diff --git a/components/document-preview.tsx b/components/document-preview.tsx index 24e4674..cc95012 100644 --- a/components/document-preview.tsx +++ b/components/document-preview.tsx @@ -19,6 +19,7 @@ import { DocumentToolCall, DocumentToolResult } from './document'; import { CodeEditor } from './code-editor'; import { useBlock } from '@/hooks/use-block'; import equal from 'fast-deep-equal'; +import { SpreadsheetEditor } from './sheet-editor'; import { ImageEditor } from './image-editor'; interface DocumentPreviewProps { @@ -43,6 +44,7 @@ export function DocumentPreview({ useEffect(() => { const boundingBox = hitboxRef.current?.getBoundingClientRect(); + if (block.documentId && boundingBox) { setBlock((block) => ({ ...block, @@ -256,6 +258,12 @@ const DocumentContent = ({ document }: { document: Document }) => { {}} /> + ) : document.kind === 'sheet' ? ( +
+
+ +
+
) : document.kind === 'image' ? ( ( > ); + +export const DownloadIcon = ({ size = 16 }: { size?: number }) => ( + + + +); + +export const LineChartIcon = ({ size = 16 }: { size?: number }) => ( + + + +); diff --git a/components/sheet-editor.tsx b/components/sheet-editor.tsx new file mode 100644 index 0000000..7c290c1 --- /dev/null +++ b/components/sheet-editor.tsx @@ -0,0 +1,143 @@ +'use client'; + +import React, { memo, useEffect, useMemo, useState } from 'react'; +import DataGrid, { textEditor } from 'react-data-grid'; +import { parse, unparse } from 'papaparse'; +import { useTheme } from 'next-themes'; +import { cn } from '@/lib/utils'; + +import 'react-data-grid/lib/styles.css'; + +type SheetEditorProps = { + content: string; + saveContent: (content: string, isCurrentVersion: boolean) => void; + status: string; + isCurrentVersion: boolean; + currentVersionIndex: number; +}; + +const MIN_ROWS = 50; +const MIN_COLS = 26; + +const PureSpreadsheetEditor = ({ + content, + saveContent, + status, + isCurrentVersion, +}: SheetEditorProps) => { + const { theme } = useTheme(); + + const parseData = useMemo(() => { + if (!content) return Array(MIN_ROWS).fill(Array(MIN_COLS).fill('')); + const result = parse(content, { skipEmptyLines: true }); + + const paddedData = result.data.map((row) => { + const paddedRow = [...row]; + while (paddedRow.length < MIN_COLS) { + paddedRow.push(''); + } + return paddedRow; + }); + + while (paddedData.length < MIN_ROWS) { + paddedData.push(Array(MIN_COLS).fill('')); + } + + return paddedData; + }, [content]); + + const columns = useMemo(() => { + const rowNumberColumn = { + key: 'rowNumber', + name: '', + frozen: true, + width: 50, + renderCell: ({ rowIdx }: { rowIdx: number }) => rowIdx + 1, + cellClass: 'border-t border-r dark:bg-zinc-950 dark:text-zinc-50', + headerCellClass: 'border-t border-r dark:bg-zinc-900 dark:text-zinc-50', + }; + + const dataColumns = Array.from({ length: MIN_COLS }, (_, i) => ({ + key: i.toString(), + name: String.fromCharCode(65 + i), + renderEditCell: textEditor, + width: 120, + cellClass: cn(`border-t dark:bg-zinc-950 dark:text-zinc-50`, { + 'border-l': i !== 0, + }), + headerCellClass: cn(`border-t dark:bg-zinc-900 dark:text-zinc-50`, { + 'border-l': i !== 0, + }), + })); + + return [rowNumberColumn, ...dataColumns]; + }, []); + + const initialRows = useMemo(() => { + return parseData.map((row, rowIndex) => { + const rowData: any = { + id: rowIndex, + rowNumber: rowIndex + 1, + }; + + columns.slice(1).forEach((col, colIndex) => { + rowData[col.key] = row[colIndex] || ''; + }); + + return rowData; + }); + }, [parseData, columns]); + + const [localRows, setLocalRows] = useState(initialRows); + + useEffect(() => { + setLocalRows(initialRows); + }, [initialRows]); + + const generateCsv = (data: any[][]) => { + return unparse(data); + }; + + const handleRowsChange = (newRows: any[]) => { + setLocalRows(newRows); + + const updatedData = newRows.map((row) => { + return columns.slice(1).map((col) => row[col.key] || ''); + }); + + const newCsvContent = generateCsv(updatedData); + saveContent(newCsvContent, true); + }; + + return ( + { + if (args.column.key !== 'rowNumber') { + args.selectCell(true); + } + }} + style={{ height: '100%' }} + defaultColumnOptions={{ + resizable: true, + sortable: true, + }} + /> + ); +}; + +function areEqual(prevProps: SheetEditorProps, nextProps: SheetEditorProps) { + return ( + prevProps.currentVersionIndex === nextProps.currentVersionIndex && + prevProps.isCurrentVersion === nextProps.isCurrentVersion && + !(prevProps.status === 'streaming' && nextProps.status === 'streaming') && + prevProps.content === nextProps.content && + prevProps.saveContent === nextProps.saveContent + ); +} + +export const SpreadsheetEditor = memo(PureSpreadsheetEditor, areEqual); diff --git a/components/toolbar.tsx b/components/toolbar.tsx index 2ed0673..ee08ad2 100644 --- a/components/toolbar.tsx +++ b/components/toolbar.tsx @@ -33,6 +33,7 @@ import { LogsIcon, MessageIcon, PenIcon, + SparklesIcon, StopIcon, SummarizeIcon, } from './icons'; diff --git a/lib/ai/prompts.ts b/lib/ai/prompts.ts index 1af3fc6..07ae18f 100644 --- a/lib/ai/prompts.ts +++ b/lib/ai/prompts.ts @@ -64,6 +64,10 @@ print(f"Factorial of 5 is: {factorial(5)}") \`\`\` `; +export const sheetPrompt = ` +You are a spreadsheet creation assistant. Create a spreadsheet in csv format based on the given prompt. The spreadsheet should contain meaningful column headers and data. +`; + export const updateDocumentPrompt = ( currentContent: string | null, type: BlockKind, @@ -80,4 +84,10 @@ Improve the following code snippet based on the given prompt. ${currentContent} ` - : ''; + : type === 'sheet' + ? `\ +Improve the following spreadsheet based on the given prompt. + +${currentContent} +` + : ''; diff --git a/lib/ai/tools/create-document.ts b/lib/ai/tools/create-document.ts index e7818ed..0fb0cff 100644 --- a/lib/ai/tools/create-document.ts +++ b/lib/ai/tools/create-document.ts @@ -9,7 +9,7 @@ import { } from 'ai'; import { z } from 'zod'; import { customModel, imageGenerationModel } from '..'; -import { codePrompt } from '../prompts'; +import { codePrompt, sheetPrompt } from '../prompts'; import { saveDocument } from '@/lib/db/queries'; import { Session } from 'next-auth'; import { Model } from '../models'; @@ -27,10 +27,10 @@ export const createDocument = ({ }: CreateDocumentProps) => tool({ description: - 'Create a document for a writing or content creation activities like image generation. This tool will call other functions that will generate the contents of the document based on the title and kind.', + 'Create a document for a writing or content creation activities. This tool will call other functions that will generate the contents of the document based on the title and kind.', parameters: z.object({ title: z.string(), - kind: z.enum(['text', 'code', 'image']), + kind: z.enum(['text', 'code', 'image', 'sheet']), }), execute: async ({ title, kind }) => { const id = generateUUID(); @@ -123,6 +123,40 @@ export const createDocument = ({ content: image.base64, }); + dataStream.writeData({ type: 'finish', content: '' }); + } else if (kind === 'sheet') { + const { fullStream } = streamObject({ + model: customModel(model.apiIdentifier), + system: sheetPrompt, + prompt: title, + schema: z.object({ + csv: z.string().describe('CSV data'), + }), + }); + + for await (const delta of fullStream) { + const { type } = delta; + + if (type === 'object') { + const { object } = delta; + const { csv } = object; + + if (csv) { + dataStream.writeData({ + type: 'sheet-delta', + content: csv, + }); + + draftText = csv; + } + } + } + + dataStream.writeData({ + type: 'sheet-delta', + content: draftText, + }); + dataStream.writeData({ type: 'finish', content: '' }); } diff --git a/lib/ai/tools/update-document.ts b/lib/ai/tools/update-document.ts index 579105f..3bfabb0 100644 --- a/lib/ai/tools/update-document.ts +++ b/lib/ai/tools/update-document.ts @@ -123,6 +123,35 @@ export const updateDocument = ({ content: image.base64, }); + dataStream.writeData({ type: 'finish', content: '' }); + } else if (document.kind === 'sheet') { + const { fullStream } = streamObject({ + model: customModel(model.apiIdentifier), + system: updateDocumentPrompt(currentContent, 'sheet'), + prompt: description, + schema: z.object({ + csv: z.string(), + }), + }); + + for await (const delta of fullStream) { + const { type } = delta; + + if (type === 'object') { + const { object } = delta; + const { csv } = object; + + if (csv) { + dataStream.writeData({ + type: 'sheet-delta', + content: csv, + }); + + draftText = csv; + } + } + } + dataStream.writeData({ type: 'finish', content: '' }); } diff --git a/lib/db/schema.ts b/lib/db/schema.ts index be5e80f..202086c 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -72,7 +72,7 @@ export const document = pgTable( createdAt: timestamp('createdAt').notNull(), title: text('title').notNull(), content: text('content'), - kind: varchar('text', { enum: ['text', 'code', 'image'] }) + kind: varchar('text', { enum: ['text', 'code', 'image', 'sheet'] }) .notNull() .default('text'), userId: uuid('userId') diff --git a/package.json b/package.json index b9a9a26..dece591 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "next-auth": "5.0.0-beta.25", "next-themes": "^0.3.0", "orderedmap": "^2.1.1", + "papaparse": "^5.5.2", "postgres": "^3.4.4", "prosemirror-example-setup": "^1.2.3", "prosemirror-inputrules": "^1.4.0", @@ -66,6 +67,7 @@ "prosemirror-state": "^1.4.3", "prosemirror-view": "^1.34.3", "react": "19.0.0-rc-45804af1-20241021", + "react-data-grid": "7.0.0-beta.47", "react-dom": "19.0.0-rc-45804af1-20241021", "react-markdown": "^9.0.1", "react-resizable-panels": "^2.1.7", @@ -83,6 +85,7 @@ "@tailwindcss/typography": "^0.5.15", "@types/d3-scale": "^4.0.8", "@types/node": "^22.8.6", + "@types/papaparse": "^5.3.15", "@types/pdf-parse": "^1.1.4", "@types/react": "^18", "@types/react-dom": "^18", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6df2096..11d5d04 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -122,6 +122,9 @@ importers: orderedmap: specifier: ^2.1.1 version: 2.1.1 + papaparse: + specifier: ^5.5.2 + version: 5.5.2 postgres: specifier: ^3.4.4 version: 3.4.5 @@ -152,6 +155,9 @@ importers: react: specifier: 19.0.0-rc-45804af1-20241021 version: 19.0.0-rc-45804af1-20241021 + react-data-grid: + specifier: 7.0.0-beta.47 + version: 7.0.0-beta.47(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021) react-dom: specifier: 19.0.0-rc-45804af1-20241021 version: 19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021) @@ -198,6 +204,9 @@ importers: '@types/node': specifier: ^22.8.6 version: 22.9.0 + '@types/papaparse': + specifier: ^5.3.15 + version: 5.3.15 '@types/pdf-parse': specifier: ^1.1.4 version: 1.1.4 @@ -1542,6 +1551,9 @@ packages: '@types/node@22.9.0': resolution: {integrity: sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==} + '@types/papaparse@5.3.15': + resolution: {integrity: sha512-JHe6vF6x/8Z85nCX4yFdDslN11d+1pr12E526X8WAfhadOeaOTx5AuIkvDKIBopfvlzpzkdMx4YyvSKCM9oqtw==} + '@types/pdf-parse@1.1.4': resolution: {integrity: sha512-+gbBHbNCVGGYw1S9lAIIvrHW47UYOhMIFUsJcMkMrzy1Jf0vulBN3XQIjPgnoOXveMuHnF3b57fXROnY/Or7eg==} @@ -3016,6 +3028,9 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + papaparse@5.5.2: + resolution: {integrity: sha512-PZXg8UuAc4PcVwLosEEDYjPyfWnTEhOrUfdv+3Bx+NuAb+5NhDmXzg5fHWmdCh1mP5p7JAZfFr3IMQfcntNAdA==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -3229,6 +3244,12 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + react-data-grid@7.0.0-beta.47: + resolution: {integrity: sha512-28kjsmwQGD/9RXYC50zn5Zv/SQMhBBoSvG5seq0fM8XXi9TZ0zr9Z5T3YJqLwcEtoNzTOq3y0njkmdujGkIwQQ==} + peerDependencies: + react: ^18.0 || ^19.0 + react-dom: ^18.0 || ^19.0 + react-dom@19.0.0-rc-45804af1-20241021: resolution: {integrity: sha512-8hOckEFO7Vxo+nH/EEddIGdencOFT0/3iJqF3mKrqv71n1xxhYcp0595JbT/DP31G8bHfDcBSMWVhIvyCGWy/A==} peerDependencies: @@ -4803,6 +4824,10 @@ snapshots: dependencies: undici-types: 6.19.8 + '@types/papaparse@5.3.15': + dependencies: + '@types/node': 22.9.0 + '@types/pdf-parse@1.1.4': {} '@types/pg@8.11.6': @@ -6630,6 +6655,8 @@ snapshots: package-json-from-dist@1.0.1: {} + papaparse@5.5.2: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -6864,6 +6891,12 @@ snapshots: queue-microtask@1.2.3: {} + react-data-grid@7.0.0-beta.47(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021): + dependencies: + clsx: 2.1.1 + react: 19.0.0-rc-45804af1-20241021 + react-dom: 19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021) + react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021): dependencies: react: 19.0.0-rc-45804af1-20241021 diff --git a/postcss.config.mjs b/postcss.config.mjs index 1a69fd2..7d8d2e9 100644 --- a/postcss.config.mjs +++ b/postcss.config.mjs @@ -2,6 +2,7 @@ const config = { plugins: { tailwindcss: {}, + 'tailwindcss/nesting': {}, }, };