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': {},
},
};