Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

(feat/canvas): Add Spreadsheet Support (wip) #688

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 140 additions & 1 deletion app/(chat)/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ export async function POST(request: Request) {
'Create a document for a writing activity. 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']),
kind: z.enum(['text', 'code', 'spreadsheet']),
}),
execute: async ({ title, kind }) => {
const id = generateUUID();
Expand Down Expand Up @@ -204,6 +204,67 @@ export async function POST(request: Request) {
}
}

dataStream.writeData({ type: 'finish', content: '' });
} else if (kind === 'spreadsheet') {
const { fullStream } = streamObject({
model: customModel(model.apiIdentifier),
system: `You are a spreadsheet initialization assistant. Create a spreadsheet structure based on the title/description and the chat history.
- Create meaningful column headers based on the context and chat history
- Keep data types consistent within columns
- If the title doesn't suggest specific columns, create a general-purpose structure`,
prompt:
title +
'\n\nChat History:\n' +
coreMessages.map((msg) => msg.content).join('\n'),
schema: z.object({
headers: z
.array(z.string())
.describe('Column headers for the spreadsheet'),
rows: z.array(z.array(z.string())).describe('Data rows'),
}),
});

let spreadsheetData: { headers: string[]; rows: string[][] } = {
headers: [],
rows: [[], []],
};

for await (const delta of fullStream) {
const { type } = delta;

if (type === 'object') {
const { object } = delta;
if (
object &&
Array.isArray(object.headers) &&
Array.isArray(object.rows)
) {
// Validate and normalize the data
const headers = object.headers.map((h) =>
String(h || ''),
);
const rows = object.rows.map((row) => {
// Handle undefined row by creating empty array
const safeRow = (row || []).map((cell) =>
String(cell || ''),
);
// Ensure row length matches headers
while (safeRow.length < headers.length)
safeRow.push('');
return safeRow.slice(0, headers.length);
});

spreadsheetData = { headers, rows };
}
}
}

draftText = JSON.stringify(spreadsheetData);
dataStream.writeData({
type: 'spreadsheet-delta',
content: draftText,
});

dataStream.writeData({ type: 'finish', content: '' });
}

Expand Down Expand Up @@ -309,6 +370,84 @@ export async function POST(request: Request) {
}
}

dataStream.writeData({ type: 'finish', content: '' });
} else if (document.kind === 'spreadsheet') {
// Parse the current content as spreadsheet data
let currentSpreadsheetData = { headers: [], rows: [] };
try {
if (currentContent) {
currentSpreadsheetData = JSON.parse(currentContent);
}
} catch {
// Keep default empty structure
}

const { fullStream } = streamObject({
model: customModel(model.apiIdentifier),
system: `You are a spreadsheet manipulation assistant. The current spreadsheet has the following structure:
Headers: ${JSON.stringify(currentSpreadsheetData.headers)}
Current rows: ${JSON.stringify(currentSpreadsheetData.rows)}

When modifying the spreadsheet:
1. You can add, remove, or modify columns (headers)
2. When adding columns, add empty values to existing rows for the new columns
3. When removing columns, remove the corresponding values from all rows
4. Return the COMPLETE spreadsheet data including ALL headers and rows
5. Format response as valid JSON with 'headers' and 'rows' arrays

Example response format:
{"headers":["Name","Email","Phone"],"rows":[["John","[email protected]","123-456-7890"],["Jane","[email protected]","098-765-4321"]]}`,
prompt: `${description}\n\nChat History:\n${coreMessages.map((msg) => msg.content).join('\n')}`,
schema: z.object({
headers: z
.array(z.string())
.describe('Column headers for the spreadsheet'),
rows: z
.array(z.array(z.string()))
.describe('Sample data rows'),
}),
});

let updatedContent = '';
draftText = JSON.stringify(currentSpreadsheetData);

for await (const delta of fullStream) {
const { type } = delta;

if (type === 'object') {
const { object } = delta;
if (
object &&
Array.isArray(object.headers) &&
Array.isArray(object.rows)
) {
// Validate and normalize the data
const headers = object.headers.map((h: any) =>
String(h || ''),
);
const rows = object.rows.map(
(row: (string | undefined)[] | undefined) => {
const normalizedRow = (row || []).map((cell: any) =>
String(cell || ''),
);
// Ensure row length matches new headers length
while (normalizedRow.length < headers.length) {
normalizedRow.push('');
}
return normalizedRow.slice(0, headers.length);
},
);

const newData = { headers, rows };
draftText = JSON.stringify(newData);
dataStream.writeData({
type: 'spreadsheet-delta',
content: draftText,
});
}
}
}

dataStream.writeData({ type: 'finish', content: '' });
}

Expand Down
36 changes: 35 additions & 1 deletion components/block-actions.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import { cn } from '@/lib/utils';
import { ClockRewind, CopyIcon, RedoIcon, UndoIcon } from './icons';
import {
ClockRewind,
CopyIcon,
RedoIcon,
UndoIcon,
ArrowUpIcon,
DownloadIcon,
} from './icons';
import { Button } from './ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { useCopyToClipboard } from 'usehooks-ts';
import { toast } from 'sonner';
import { ConsoleOutput, UIBlock } from './block';
import { Dispatch, memo, SetStateAction } from 'react';
import { RunCodeButton } from './run-code-button';
import { exportToCSV } from '@/lib/spreadsheet';

interface BlockActionsProps {
block: UIBlock;
Expand All @@ -27,12 +35,38 @@ function PureBlockActions({
}: BlockActionsProps) {
const [_, copyToClipboard] = useCopyToClipboard();

const handleExportCSV = () => {
try {
exportToCSV(block.content);
toast.success('CSV file downloaded!');
} catch (error) {
console.error(error);
toast.error('Failed to export CSV');
}
};

return (
<div className="flex flex-row gap-1">
{block.kind === 'code' && (
<RunCodeButton block={block} setConsoleOutputs={setConsoleOutputs} />
)}

{block.kind === 'spreadsheet' && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
className="p-2 h-fit dark:hover:bg-zinc-700 !pointer-events-auto"
onClick={handleExportCSV}
disabled={block.status === 'streaming'}
>
<DownloadIcon size={18} />
</Button>
</TooltipTrigger>
<TooltipContent>Download as CSV</TooltipContent>
</Tooltip>
)}

{block.kind === 'text' && (
<Tooltip>
<TooltipTrigger asChild>
Expand Down
20 changes: 18 additions & 2 deletions components/block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,12 @@ import { Console } from './console';
import { useSidebar } from './ui/sidebar';
import { useBlock } from '@/hooks/use-block';
import equal from 'fast-deep-equal';
import { SpreadsheetEditor } from './spreadsheet-editor';

export type BlockKind = 'text' | 'code';
/** Type representing the possible block kinds: 'text' | 'code' | 'spreadsheet' */
export type BlockKind = 'text' | 'code' | 'spreadsheet';

export const BLOCK_KINDS: BlockKind[] = ['text', 'code', 'spreadsheet'];

export interface UIBlock {
title: string;
Expand Down Expand Up @@ -466,6 +470,7 @@ function PureBlock({
{
'py-2 px-2': block.kind === 'code',
'py-8 md:p-20 px-4': block.kind === 'text',
'w-full': block.kind === 'spreadsheet',
},
)}
>
Expand Down Expand Up @@ -512,8 +517,19 @@ function PureBlock({
newContent={getDocumentContentById(currentVersionIndex)}
/>
)
) : block.kind === 'spreadsheet' ? (
<SpreadsheetEditor
content={
isCurrentVersion
? block.content
: getDocumentContentById(currentVersionIndex)
}
isCurrentVersion={isCurrentVersion}
currentVersionIndex={currentVersionIndex}
status={block.status}
saveContent={saveContent}
/>
) : null}

{suggestions ? (
<div className="md:hidden h-dvh w-12 shrink-0" />
) : null}
Expand Down
8 changes: 8 additions & 0 deletions components/data-stream-handler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ type DataStreamDelta = {
type:
| 'text-delta'
| 'code-delta'
| 'spreadsheet-delta'
| 'title'
| 'id'
| 'suggestion'
Expand Down Expand Up @@ -92,6 +93,13 @@ export function DataStreamHandler({ id }: { id: string }) {
: draftBlock.isVisible,
status: 'streaming',
};
case 'spreadsheet-delta':
return {
...draftBlock,
content: delta.content as string,
isVisible: true,
status: 'streaming',
};

case 'clear':
return {
Expand Down
22 changes: 12 additions & 10 deletions components/document-preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
useMemo,
useRef,
} from 'react';
import { UIBlock } from './block';
import { BlockKind, UIBlock } from './block';
import { FileIcon, FullscreenIcon, LoaderIcon } from './icons';
import { cn, fetcher } from '@/lib/utils';
import { Document } from '@/lib/db/schema';
Expand All @@ -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 './spreadsheet-editor';

interface DocumentPreviewProps {
isReadonly: boolean;
Expand Down Expand Up @@ -145,7 +146,6 @@ const PureHitboxLayer = ({
? { ...block, isVisible: true }
: {
...block,
title: result.title,
documentId: result.id,
kind: result.kind,
isVisible: true,
Expand All @@ -168,13 +168,7 @@ const PureHitboxLayer = ({
onClick={handleClick}
role="presentation"
aria-hidden="true"
>
<div className="w-full p-4 flex justify-end items-center">
<div className="absolute right-[9px] top-[13px] p-2 hover:dark:bg-zinc-700 rounded-md hover:bg-zinc-100">
<FullscreenIcon />
</div>
</div>
</div>
/>
);
};

Expand Down Expand Up @@ -203,7 +197,9 @@ const PureDocumentHeader = ({
</div>
<div className="-translate-y-1 sm:translate-y-0 font-medium">{title}</div>
</div>
<div className="w-8" />
<div>
<FullscreenIcon />
</div>
</div>
);

Expand Down Expand Up @@ -244,6 +240,12 @@ const DocumentContent = ({ document }: { document: Document }) => {
<CodeEditor {...commonProps} />
</div>
</div>
) : document.kind === 'spreadsheet' ? (
<div className="flex flex-1 relative w-full p-4">
<div className="absolute inset-0">
<SpreadsheetEditor {...commonProps} />
</div>
</div>
) : null}
</div>
);
Expand Down
17 changes: 17 additions & 0 deletions components/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1119,3 +1119,20 @@ export const FullscreenIcon = ({ size = 16 }: { size?: number }) => (
></path>
</svg>
);
export const DownloadIcon = ({ size = 16 }: { size?: number }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" x2="12" y1="15" y2="3" />
</svg>
);
Loading