chore: cleanup to remove extra tools

This commit is contained in:
Musa 2026-01-12 10:59:30 -08:00
parent f72ef94809
commit 7ee25c07f2
64 changed files with 28 additions and 15052 deletions

View file

@ -1,25 +0,0 @@
name: Lint
on:
push:
jobs:
build:
runs-on: ubuntu-22.04
strategy:
matrix:
node-version: [20]
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.12.3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: "pnpm"
- name: Install dependencies
run: pnpm install
- name: Run lint
run: pnpm lint

View file

@ -1,73 +0,0 @@
name: Playwright Tests
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
test:
timeout-minutes: 30
runs-on: ubuntu-latest
env:
AUTH_SECRET: ${{ secrets.AUTH_SECRET }}
POSTGRES_URL: ${{ secrets.POSTGRES_URL }}
BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }}
REDIS_URL: ${{ secrets.REDIS_URL }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: latest
run_install: false
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
- uses: actions/cache@v3
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- uses: actions/setup-node@v4
with:
node-version: lts/*
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Cache Playwright browsers
uses: actions/cache@v3
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('**/pnpm-lock.yaml') }}
- name: Install Playwright Browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: pnpm exec playwright install --with-deps chromium
- name: Run Playwright tests
run: pnpm test
- uses: actions/upload-artifact@v4
if: always() && !cancelled()
with:
name: playwright-report
path: playwright-report/
retention-days: 7

View file

@ -1,13 +0,0 @@
Copyright 2024 Vercel, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View file

@ -1,126 +0,0 @@
import { auth } from "@/app/(auth)/auth";
import type { ArtifactKind } from "@/components/artifact";
import {
deleteDocumentsByIdAfterTimestamp,
getDocumentsById,
saveDocument,
} from "@/lib/db/queries";
import { ChatSDKError } from "@/lib/errors";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
if (!id) {
return new ChatSDKError(
"bad_request:api",
"Parameter id is missing"
).toResponse();
}
const session = await auth();
if (!session?.user) {
return new ChatSDKError("unauthorized:document").toResponse();
}
const documents = await getDocumentsById({ id });
const [document] = documents;
if (!document) {
return new ChatSDKError("not_found:document").toResponse();
}
if (document.userId !== session.user.id) {
return new ChatSDKError("forbidden:document").toResponse();
}
return Response.json(documents, { status: 200 });
}
export async function POST(request: Request) {
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
if (!id) {
return new ChatSDKError(
"bad_request:api",
"Parameter id is required."
).toResponse();
}
const session = await auth();
if (!session?.user) {
return new ChatSDKError("not_found:document").toResponse();
}
const {
content,
title,
kind,
}: { content: string; title: string; kind: ArtifactKind } =
await request.json();
const documents = await getDocumentsById({ id });
if (documents.length > 0) {
const [doc] = documents;
if (doc.userId !== session.user.id) {
return new ChatSDKError("forbidden:document").toResponse();
}
}
const document = await saveDocument({
id,
content,
title,
kind,
userId: session.user.id,
});
return Response.json(document, { status: 200 });
}
export async function DELETE(request: Request) {
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
const timestamp = searchParams.get("timestamp");
if (!id) {
return new ChatSDKError(
"bad_request:api",
"Parameter id is required."
).toResponse();
}
if (!timestamp) {
return new ChatSDKError(
"bad_request:api",
"Parameter timestamp is required."
).toResponse();
}
const session = await auth();
if (!session?.user) {
return new ChatSDKError("unauthorized:document").toResponse();
}
const documents = await getDocumentsById({ id });
const [document] = documents;
if (document.userId !== session.user.id) {
return new ChatSDKError("forbidden:document").toResponse();
}
const documentsDeleted = await deleteDocumentsByIdAfterTimestamp({
id,
timestamp: new Date(timestamp),
});
return Response.json(documentsDeleted, { status: 200 });
}

View file

@ -1,37 +0,0 @@
import { auth } from "@/app/(auth)/auth";
import { getSuggestionsByDocumentId } from "@/lib/db/queries";
import { ChatSDKError } from "@/lib/errors";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const documentId = searchParams.get("documentId");
if (!documentId) {
return new ChatSDKError(
"bad_request:api",
"Parameter documentId is required."
).toResponse();
}
const session = await auth();
if (!session?.user) {
return new ChatSDKError("unauthorized:suggestions").toResponse();
}
const suggestions = await getSuggestionsByDocumentId({
documentId,
});
const [suggestion] = suggestions;
if (!suggestion) {
return Response.json([], { status: 200 });
}
if (suggestion.userId !== session.user.id) {
return new ChatSDKError("forbidden:api").toResponse();
}
return Response.json(suggestions, { status: 200 });
}

View file

@ -1,8 +0,0 @@
"use server";
import { getSuggestionsByDocumentId } from "@/lib/db/queries";
export async function getSuggestions({ documentId }: { documentId: string }) {
const suggestions = await getSuggestionsByDocumentId({ documentId });
return suggestions ?? [];
}

View file

@ -1,280 +0,0 @@
import { toast } from "sonner";
import { CodeEditor } from "@/components/code-editor";
import {
Console,
type ConsoleOutput,
type ConsoleOutputContent,
} from "@/components/console";
import { Artifact } from "@/components/create-artifact";
import {
CopyIcon,
LogsIcon,
MessageIcon,
PlayIcon,
RedoIcon,
UndoIcon,
} from "@/components/icons";
import { generateUUID } from "@/lib/utils";
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;
}
type Metadata = {
outputs: ConsoleOutput[];
};
export const codeArtifact = new Artifact<"code", Metadata>({
kind: "code",
description:
"Useful for code generation; Code execution is only available for python code.",
initialize: ({ setMetadata }) => {
setMetadata({
outputs: [],
});
},
onStreamPart: ({ streamPart, setArtifact }) => {
if (streamPart.type === "data-codeDelta") {
setArtifact((draftArtifact) => ({
...draftArtifact,
content: streamPart.data,
isVisible:
draftArtifact.status === "streaming" &&
draftArtifact.content.length > 300 &&
draftArtifact.content.length < 310
? true
: draftArtifact.isVisible,
status: "streaming",
}));
}
},
content: ({ metadata, setMetadata, ...props }) => {
return (
<>
<div className="px-1">
<CodeEditor {...props} />
</div>
{metadata?.outputs && (
<Console
consoleOutputs={metadata.outputs}
setConsoleOutputs={() => {
setMetadata({
...metadata,
outputs: [],
});
}}
/>
)}
</>
);
},
actions: [
{
icon: <PlayIcon size={18} />,
label: "Run",
description: "Execute code",
onClick: async ({ content, setMetadata }) => {
const runId = generateUUID();
const outputContent: ConsoleOutputContent[] = [];
setMetadata((metadata) => ({
...metadata,
outputs: [
...metadata.outputs,
{
id: runId,
contents: [],
status: "in_progress",
},
],
}));
try {
// @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) => {
outputContent.push({
type: output.startsWith("data:image/png;base64")
? "image"
: "text",
value: output,
});
},
});
await currentPyodideInstance.loadPackagesFromImports(content, {
messageCallback: (message: string) => {
setMetadata((metadata) => ({
...metadata,
outputs: [
...metadata.outputs.filter((output) => output.id !== runId),
{
id: runId,
contents: [{ type: "text", value: message }],
status: "loading_packages",
},
],
}));
},
});
const requiredHandlers = detectRequiredHandlers(content);
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(content);
setMetadata((metadata) => ({
...metadata,
outputs: [
...metadata.outputs.filter((output) => output.id !== runId),
{
id: runId,
contents: outputContent,
status: "completed",
},
],
}));
} catch (error: any) {
setMetadata((metadata) => ({
...metadata,
outputs: [
...metadata.outputs.filter((output) => output.id !== runId),
{
id: runId,
contents: [{ type: "text", value: error.message }],
status: "failed",
},
],
}));
}
},
},
{
icon: <UndoIcon size={18} />,
description: "View Previous version",
onClick: ({ handleVersionChange }) => {
handleVersionChange("prev");
},
isDisabled: ({ currentVersionIndex }) => {
if (currentVersionIndex === 0) {
return true;
}
return false;
},
},
{
icon: <RedoIcon size={18} />,
description: "View Next version",
onClick: ({ handleVersionChange }) => {
handleVersionChange("next");
},
isDisabled: ({ isCurrentVersion }) => {
if (isCurrentVersion) {
return true;
}
return false;
},
},
{
icon: <CopyIcon size={18} />,
description: "Copy code to clipboard",
onClick: ({ content }) => {
navigator.clipboard.writeText(content);
toast.success("Copied to clipboard!");
},
},
],
toolbar: [
{
icon: <MessageIcon />,
description: "Add comments",
onClick: ({ sendMessage }) => {
sendMessage({
role: "user",
parts: [
{
type: "text",
text: "Add comments to the code snippet for understanding",
},
],
});
},
},
{
icon: <LogsIcon />,
description: "Add logs",
onClick: ({ sendMessage }) => {
sendMessage({
role: "user",
parts: [
{
type: "text",
text: "Add logs to the code snippet for debugging",
},
],
});
},
},
],
});

View file

@ -1,75 +0,0 @@
import { streamObject } from "ai";
import { z } from "zod";
import { codePrompt, updateDocumentPrompt } from "@/lib/ai/prompts";
import { getArtifactModel } from "@/lib/ai/providers";
import { createDocumentHandler } from "@/lib/artifacts/server";
export const codeDocumentHandler = createDocumentHandler<"code">({
kind: "code",
onCreateDocument: async ({ title, dataStream }) => {
let draftContent = "";
const { fullStream } = streamObject({
model: getArtifactModel(),
system: codePrompt,
prompt: title,
schema: z.object({
code: z.string(),
}),
});
for await (const delta of fullStream) {
const { type } = delta;
if (type === "object") {
const { object } = delta;
const { code } = object;
if (code) {
dataStream.write({
type: "data-codeDelta",
data: code ?? "",
transient: true,
});
draftContent = code;
}
}
}
return draftContent;
},
onUpdateDocument: async ({ document, description, dataStream }) => {
let draftContent = "";
const { fullStream } = streamObject({
model: getArtifactModel(),
system: updateDocumentPrompt(document.content, "code"),
prompt: description,
schema: z.object({
code: z.string(),
}),
});
for await (const delta of fullStream) {
const { type } = delta;
if (type === "object") {
const { object } = delta;
const { code } = object;
if (code) {
dataStream.write({
type: "data-codeDelta",
data: code ?? "",
transient: true,
});
draftContent = code;
}
}
}
return draftContent;
},
});

View file

@ -1,76 +0,0 @@
import { toast } from "sonner";
import { Artifact } from "@/components/create-artifact";
import { CopyIcon, RedoIcon, UndoIcon } from "@/components/icons";
import { ImageEditor } from "@/components/image-editor";
export const imageArtifact = new Artifact({
kind: "image",
description: "Useful for image generation",
onStreamPart: ({ streamPart, setArtifact }) => {
if (streamPart.type === "data-imageDelta") {
setArtifact((draftArtifact) => ({
...draftArtifact,
content: streamPart.data,
isVisible: true,
status: "streaming",
}));
}
},
content: ImageEditor,
actions: [
{
icon: <UndoIcon size={18} />,
description: "View Previous version",
onClick: ({ handleVersionChange }) => {
handleVersionChange("prev");
},
isDisabled: ({ currentVersionIndex }) => {
if (currentVersionIndex === 0) {
return true;
}
return false;
},
},
{
icon: <RedoIcon size={18} />,
description: "View Next version",
onClick: ({ handleVersionChange }) => {
handleVersionChange("next");
},
isDisabled: ({ isCurrentVersion }) => {
if (isCurrentVersion) {
return true;
}
return false;
},
},
{
icon: <CopyIcon size={18} />,
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");
};
toast.success("Copied image to clipboard!");
},
},
],
toolbar: [],
});

View file

@ -1,115 +0,0 @@
import { parse, unparse } from "papaparse";
import { toast } from "sonner";
import { Artifact } from "@/components/create-artifact";
import {
CopyIcon,
LineChartIcon,
RedoIcon,
SparklesIcon,
UndoIcon,
} from "@/components/icons";
import { SpreadsheetEditor } from "@/components/sheet-editor";
type Metadata = any;
export const sheetArtifact = new Artifact<"sheet", Metadata>({
kind: "sheet",
description: "Useful for working with spreadsheets",
initialize: () => null,
onStreamPart: ({ setArtifact, streamPart }) => {
if (streamPart.type === "data-sheetDelta") {
setArtifact((draftArtifact) => ({
...draftArtifact,
content: streamPart.data,
isVisible: true,
status: "streaming",
}));
}
},
content: ({ content, currentVersionIndex, onSaveContent, status }) => {
return (
<SpreadsheetEditor
content={content}
currentVersionIndex={currentVersionIndex}
isCurrentVersion={true}
saveContent={onSaveContent}
status={status}
/>
);
},
actions: [
{
icon: <UndoIcon size={18} />,
description: "View Previous version",
onClick: ({ handleVersionChange }) => {
handleVersionChange("prev");
},
isDisabled: ({ currentVersionIndex }) => {
if (currentVersionIndex === 0) {
return true;
}
return false;
},
},
{
icon: <RedoIcon size={18} />,
description: "View Next version",
onClick: ({ handleVersionChange }) => {
handleVersionChange("next");
},
isDisabled: ({ isCurrentVersion }) => {
if (isCurrentVersion) {
return true;
}
return false;
},
},
{
icon: <CopyIcon />,
description: "Copy as .csv",
onClick: ({ content }) => {
const parsed = parse<string[]>(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: <SparklesIcon />,
onClick: ({ sendMessage }) => {
sendMessage({
role: "user",
parts: [
{ type: "text", text: "Can you please format and clean the data?" },
],
});
},
},
{
description: "Analyze and visualize data",
icon: <LineChartIcon />,
onClick: ({ sendMessage }) => {
sendMessage({
role: "user",
parts: [
{
type: "text",
text: "Can you please analyze and visualize the data by creating a new code artifact in python?",
},
],
});
},
},
],
});

View file

@ -1,81 +0,0 @@
import { streamObject } from "ai";
import { z } from "zod";
import { sheetPrompt, updateDocumentPrompt } from "@/lib/ai/prompts";
import { getArtifactModel } from "@/lib/ai/providers";
import { createDocumentHandler } from "@/lib/artifacts/server";
export const sheetDocumentHandler = createDocumentHandler<"sheet">({
kind: "sheet",
onCreateDocument: async ({ title, dataStream }) => {
let draftContent = "";
const { fullStream } = streamObject({
model: getArtifactModel(),
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.write({
type: "data-sheetDelta",
data: csv,
transient: true,
});
draftContent = csv;
}
}
}
dataStream.write({
type: "data-sheetDelta",
data: draftContent,
transient: true,
});
return draftContent;
},
onUpdateDocument: async ({ document, description, dataStream }) => {
let draftContent = "";
const { fullStream } = streamObject({
model: getArtifactModel(),
system: updateDocumentPrompt(document.content, "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.write({
type: "data-sheetDelta",
data: csv,
transient: true,
});
draftContent = csv;
}
}
}
return draftContent;
},
});

View file

@ -1,179 +0,0 @@
import { toast } from "sonner";
import { Artifact } from "@/components/create-artifact";
import { DiffView } from "@/components/diffview";
import { DocumentSkeleton } from "@/components/document-skeleton";
import {
ClockRewind,
CopyIcon,
MessageIcon,
PenIcon,
RedoIcon,
UndoIcon,
} from "@/components/icons";
import { Editor } from "@/components/text-editor";
import type { Suggestion } from "@/lib/db/schema";
import { getSuggestions } from "../actions";
type TextArtifactMetadata = {
suggestions: Suggestion[];
};
export const textArtifact = new Artifact<"text", TextArtifactMetadata>({
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, setArtifact }) => {
if (streamPart.type === "data-suggestion") {
setMetadata((metadata) => {
return {
suggestions: [...metadata.suggestions, streamPart.data],
};
});
}
if (streamPart.type === "data-textDelta") {
setArtifact((draftArtifact) => {
return {
...draftArtifact,
content: draftArtifact.content + streamPart.data,
isVisible:
draftArtifact.status === "streaming" &&
draftArtifact.content.length > 400 &&
draftArtifact.content.length < 450
? true
: draftArtifact.isVisible,
status: "streaming",
};
});
}
},
content: ({
mode,
status,
content,
isCurrentVersion,
currentVersionIndex,
onSaveContent,
getDocumentContentById,
isLoading,
metadata,
}) => {
if (isLoading) {
return <DocumentSkeleton artifactKind="text" />;
}
if (mode === "diff") {
const oldContent = getDocumentContentById(currentVersionIndex - 1);
const newContent = getDocumentContentById(currentVersionIndex);
return <DiffView newContent={newContent} oldContent={oldContent} />;
}
return (
<div className="flex flex-row px-4 py-8 md:p-20">
<Editor
content={content}
currentVersionIndex={currentVersionIndex}
isCurrentVersion={isCurrentVersion}
onSaveContent={onSaveContent}
status={status}
suggestions={metadata ? metadata.suggestions : []}
/>
{metadata?.suggestions && metadata.suggestions.length > 0 ? (
<div className="h-dvh w-12 shrink-0 md:hidden" />
) : null}
</div>
);
},
actions: [
{
icon: <ClockRewind size={18} />,
description: "View changes",
onClick: ({ handleVersionChange }) => {
handleVersionChange("toggle");
},
isDisabled: ({ currentVersionIndex }) => {
if (currentVersionIndex === 0) {
return true;
}
return false;
},
},
{
icon: <UndoIcon size={18} />,
description: "View Previous version",
onClick: ({ handleVersionChange }) => {
handleVersionChange("prev");
},
isDisabled: ({ currentVersionIndex }) => {
if (currentVersionIndex === 0) {
return true;
}
return false;
},
},
{
icon: <RedoIcon size={18} />,
description: "View Next version",
onClick: ({ handleVersionChange }) => {
handleVersionChange("next");
},
isDisabled: ({ isCurrentVersion }) => {
if (isCurrentVersion) {
return true;
}
return false;
},
},
{
icon: <CopyIcon size={18} />,
description: "Copy to clipboard",
onClick: ({ content }) => {
navigator.clipboard.writeText(content);
toast.success("Copied to clipboard!");
},
},
],
toolbar: [
{
icon: <PenIcon />,
description: "Add final polish",
onClick: ({ sendMessage }) => {
sendMessage({
role: "user",
parts: [
{
type: "text",
text: "Please add final polish and check for grammar, add section titles for better structure, and ensure everything reads smoothly.",
},
],
});
},
},
{
icon: <MessageIcon />,
description: "Request suggestions",
onClick: ({ sendMessage }) => {
sendMessage({
role: "user",
parts: [
{
type: "text",
text: "Please add suggestions you have that could improve the writing.",
},
],
});
},
},
],
});

View file

@ -1,73 +0,0 @@
import { smoothStream, streamText } from "ai";
import { updateDocumentPrompt } from "@/lib/ai/prompts";
import { getArtifactModel } from "@/lib/ai/providers";
import { createDocumentHandler } from "@/lib/artifacts/server";
export const textDocumentHandler = createDocumentHandler<"text">({
kind: "text",
onCreateDocument: async ({ title, dataStream }) => {
let draftContent = "";
const { fullStream } = streamText({
model: getArtifactModel(),
system:
"Write about the given topic. Markdown is supported. Use headings wherever appropriate.",
experimental_transform: smoothStream({ chunking: "word" }),
prompt: title,
});
for await (const delta of fullStream) {
const { type } = delta;
if (type === "text-delta") {
const { text } = delta;
draftContent += text;
dataStream.write({
type: "data-textDelta",
data: text,
transient: true,
});
}
}
return draftContent;
},
onUpdateDocument: async ({ document, description, dataStream }) => {
let draftContent = "";
const { fullStream } = streamText({
model: getArtifactModel(),
system: updateDocumentPrompt(document.content, "text"),
experimental_transform: smoothStream({ chunking: "word" }),
prompt: description,
providerOptions: {
openai: {
prediction: {
type: "content",
content: document.content,
},
},
},
});
for await (const delta of fullStream) {
const { type } = delta;
if (type === "text-delta") {
const { text } = delta;
draftContent += text;
dataStream.write({
type: "data-textDelta",
data: text,
transient: true,
});
}
}
return draftContent;
},
});

View file

@ -1,107 +0,0 @@
import { type Dispatch, memo, type SetStateAction, useState } from "react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { artifactDefinitions, type UIArtifact } from "./artifact";
import type { ArtifactActionContext } from "./create-artifact";
import { Button } from "./ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
type ArtifactActionsProps = {
artifact: UIArtifact;
handleVersionChange: (type: "next" | "prev" | "toggle" | "latest") => void;
currentVersionIndex: number;
isCurrentVersion: boolean;
mode: "edit" | "diff";
metadata: any;
setMetadata: Dispatch<SetStateAction<any>>;
};
function PureArtifactActions({
artifact,
handleVersionChange,
currentVersionIndex,
isCurrentVersion,
mode,
metadata,
setMetadata,
}: ArtifactActionsProps) {
const [isLoading, setIsLoading] = useState(false);
const artifactDefinition = artifactDefinitions.find(
(definition) => definition.kind === artifact.kind
);
if (!artifactDefinition) {
throw new Error("Artifact definition not found!");
}
const actionContext: ArtifactActionContext = {
content: artifact.content,
handleVersionChange,
currentVersionIndex,
isCurrentVersion,
mode,
metadata,
setMetadata,
};
return (
<div className="flex flex-row gap-1">
{artifactDefinition.actions.map((action) => (
<Tooltip key={action.description}>
<TooltipTrigger asChild>
<Button
className={cn("h-fit dark:hover:bg-zinc-700", {
"p-2": !action.label,
"px-2 py-1.5": action.label,
})}
disabled={
isLoading || artifact.status === "streaming"
? true
: action.isDisabled
? action.isDisabled(actionContext)
: false
}
onClick={async () => {
setIsLoading(true);
try {
await Promise.resolve(action.onClick(actionContext));
} catch (_error) {
toast.error("Failed to execute action");
} finally {
setIsLoading(false);
}
}}
variant="outline"
>
{action.icon}
{action.label}
</Button>
</TooltipTrigger>
<TooltipContent>{action.description}</TooltipContent>
</Tooltip>
))}
</div>
);
}
export const ArtifactActions = memo(
PureArtifactActions,
(prevProps, nextProps) => {
if (prevProps.artifact.status !== nextProps.artifact.status) {
return false;
}
if (prevProps.currentVersionIndex !== nextProps.currentVersionIndex) {
return false;
}
if (prevProps.isCurrentVersion !== nextProps.isCurrentVersion) {
return false;
}
if (prevProps.artifact.content !== nextProps.artifact.content) {
return false;
}
return true;
}
);

View file

@ -1,30 +0,0 @@
import { memo } from "react";
import { initialArtifactData, useArtifact } from "@/hooks/use-artifact";
import { CrossIcon } from "./icons";
import { Button } from "./ui/button";
function PureArtifactCloseButton() {
const { setArtifact } = useArtifact();
return (
<Button
className="h-fit p-2 dark:hover:bg-zinc-700"
data-testid="artifact-close-button"
onClick={() => {
setArtifact((currentArtifact) =>
currentArtifact.status === "streaming"
? {
...currentArtifact,
isVisible: false,
}
: { ...initialArtifactData, status: "idle" }
);
}}
variant="outline"
>
<CrossIcon size={18} />
</Button>
);
}
export const ArtifactCloseButton = memo(PureArtifactCloseButton, () => true);

View file

@ -1,115 +0,0 @@
import type { UseChatHelpers } from "@ai-sdk/react";
import equal from "fast-deep-equal";
import { AnimatePresence, motion } from "framer-motion";
import { memo } from "react";
import { useMessages } from "@/hooks/use-messages";
import type { Vote } from "@/lib/db/schema";
import type { ChatMessage } from "@/lib/types";
import type { UIArtifact } from "./artifact";
import { PreviewMessage, ThinkingMessage } from "./message";
type ArtifactMessagesProps = {
addToolApprovalResponse: UseChatHelpers<ChatMessage>["addToolApprovalResponse"];
chatId: string;
status: UseChatHelpers<ChatMessage>["status"];
votes: Vote[] | undefined;
messages: ChatMessage[];
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
isReadonly: boolean;
artifactStatus: UIArtifact["status"];
};
function PureArtifactMessages({
addToolApprovalResponse,
chatId,
status,
votes,
messages,
setMessages,
regenerate,
isReadonly,
}: ArtifactMessagesProps) {
const {
containerRef: messagesContainerRef,
endRef: messagesEndRef,
onViewportEnter,
onViewportLeave,
hasSentMessage,
} = useMessages({
status,
});
return (
<div
className="flex h-full flex-col items-center gap-4 overflow-y-scroll px-4 pt-20"
ref={messagesContainerRef}
>
{messages.map((message, index) => (
<PreviewMessage
addToolApprovalResponse={addToolApprovalResponse}
chatId={chatId}
isLoading={status === "streaming" && index === messages.length - 1}
isReadonly={isReadonly}
key={message.id}
message={message}
regenerate={regenerate}
requiresScrollPadding={
hasSentMessage && index === messages.length - 1
}
setMessages={setMessages}
vote={
votes
? votes.find((vote) => vote.messageId === message.id)
: undefined
}
/>
))}
<AnimatePresence mode="wait">
{status === "submitted" &&
!messages.some((msg) =>
msg.parts?.some(
(part) => "state" in part && part.state === "approval-responded"
)
) && <ThinkingMessage key="thinking" />}
</AnimatePresence>
<motion.div
className="min-h-[24px] min-w-[24px] shrink-0"
onViewportEnter={onViewportEnter}
onViewportLeave={onViewportLeave}
ref={messagesEndRef}
/>
</div>
);
}
function areEqual(
prevProps: ArtifactMessagesProps,
nextProps: ArtifactMessagesProps
) {
if (
prevProps.artifactStatus === "streaming" &&
nextProps.artifactStatus === "streaming"
) {
return true;
}
if (prevProps.status !== nextProps.status) {
return false;
}
if (prevProps.status && nextProps.status) {
return false;
}
if (prevProps.messages.length !== nextProps.messages.length) {
return false;
}
if (!equal(prevProps.votes, nextProps.votes)) {
return false;
}
return true;
}
export const ArtifactMessages = memo(PureArtifactMessages, areEqual);

View file

@ -1,532 +0,0 @@
import type { UseChatHelpers } from "@ai-sdk/react";
import { formatDistance } from "date-fns";
import equal from "fast-deep-equal";
import { AnimatePresence, motion } from "framer-motion";
import {
type Dispatch,
memo,
type SetStateAction,
useCallback,
useEffect,
useState,
} from "react";
import useSWR, { useSWRConfig } from "swr";
import { useDebounceCallback, useWindowSize } from "usehooks-ts";
import { codeArtifact } from "@/artifacts/code/client";
import { imageArtifact } from "@/artifacts/image/client";
import { sheetArtifact } from "@/artifacts/sheet/client";
import { textArtifact } from "@/artifacts/text/client";
import { useArtifact } from "@/hooks/use-artifact";
import type { Document, Vote } from "@/lib/db/schema";
import type { Attachment, ChatMessage } from "@/lib/types";
import { fetcher } from "@/lib/utils";
import { ArtifactActions } from "./artifact-actions";
import { ArtifactCloseButton } from "./artifact-close-button";
import { ArtifactMessages } from "./artifact-messages";
import { MultimodalInput } from "./multimodal-input";
import { Toolbar } from "./toolbar";
import { useSidebar } from "./ui/sidebar";
import { VersionFooter } from "./version-footer";
import type { VisibilityType } from "./visibility-selector";
export const artifactDefinitions = [
textArtifact,
codeArtifact,
imageArtifact,
sheetArtifact,
];
export type ArtifactKind = (typeof artifactDefinitions)[number]["kind"];
export type UIArtifact = {
title: string;
documentId: string;
kind: ArtifactKind;
content: string;
isVisible: boolean;
status: "streaming" | "idle";
boundingBox: {
top: number;
left: number;
width: number;
height: number;
};
};
function PureArtifact({
addToolApprovalResponse,
chatId,
input,
setInput,
status,
stop,
attachments,
setAttachments,
sendMessage,
messages,
setMessages,
regenerate,
votes,
isReadonly,
selectedVisibilityType,
selectedModelId,
}: {
addToolApprovalResponse: UseChatHelpers<ChatMessage>["addToolApprovalResponse"];
chatId: string;
input: string;
setInput: Dispatch<SetStateAction<string>>;
status: UseChatHelpers<ChatMessage>["status"];
stop: UseChatHelpers<ChatMessage>["stop"];
attachments: Attachment[];
setAttachments: Dispatch<SetStateAction<Attachment[]>>;
messages: ChatMessage[];
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
votes: Vote[] | undefined;
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
isReadonly: boolean;
selectedVisibilityType: VisibilityType;
selectedModelId: string;
}) {
const { artifact, setArtifact, metadata, setMetadata } = useArtifact();
const {
data: documents,
isLoading: isDocumentsFetching,
mutate: mutateDocuments,
} = useSWR<Document[]>(
artifact.documentId !== "init" && artifact.status !== "streaming"
? `/api/document?id=${artifact.documentId}`
: null,
fetcher
);
const [mode, setMode] = useState<"edit" | "diff">("edit");
const [document, setDocument] = useState<Document | null>(null);
const [currentVersionIndex, setCurrentVersionIndex] = useState(-1);
const { open: isSidebarOpen } = useSidebar();
useEffect(() => {
if (documents && documents.length > 0) {
const mostRecentDocument = documents.at(-1);
if (mostRecentDocument) {
setDocument(mostRecentDocument);
setCurrentVersionIndex(documents.length - 1);
setArtifact((currentArtifact) => ({
...currentArtifact,
content: mostRecentDocument.content ?? "",
}));
}
}
}, [documents, setArtifact]);
useEffect(() => {
mutateDocuments();
}, [mutateDocuments]);
const { mutate } = useSWRConfig();
const [isContentDirty, setIsContentDirty] = useState(false);
const handleContentChange = useCallback(
(updatedContent: string) => {
if (!artifact) {
return;
}
mutate<Document[]>(
`/api/document?id=${artifact.documentId}`,
async (currentDocuments) => {
if (!currentDocuments) {
return [];
}
const currentDocument = currentDocuments.at(-1);
if (!currentDocument || !currentDocument.content) {
setIsContentDirty(false);
return currentDocuments;
}
if (currentDocument.content !== updatedContent) {
await fetch(`/api/document?id=${artifact.documentId}`, {
method: "POST",
body: JSON.stringify({
title: artifact.title,
content: updatedContent,
kind: artifact.kind,
}),
});
setIsContentDirty(false);
const newDocument = {
...currentDocument,
content: updatedContent,
createdAt: new Date(),
};
return [...currentDocuments, newDocument];
}
return currentDocuments;
},
{ revalidate: false }
);
},
[artifact, mutate]
);
const debouncedHandleContentChange = useDebounceCallback(
handleContentChange,
2000
);
const saveContent = useCallback(
(updatedContent: string, debounce: boolean) => {
if (document && updatedContent !== document.content) {
setIsContentDirty(true);
if (debounce) {
debouncedHandleContentChange(updatedContent);
} else {
handleContentChange(updatedContent);
}
}
},
[document, debouncedHandleContentChange, handleContentChange]
);
function getDocumentContentById(index: number) {
if (!documents) {
return "";
}
if (!documents[index]) {
return "";
}
return documents[index].content ?? "";
}
const handleVersionChange = (type: "next" | "prev" | "toggle" | "latest") => {
if (!documents) {
return;
}
if (type === "latest") {
setCurrentVersionIndex(documents.length - 1);
setMode("edit");
}
if (type === "toggle") {
setMode((currentMode) => (currentMode === "edit" ? "diff" : "edit"));
}
if (type === "prev") {
if (currentVersionIndex > 0) {
setCurrentVersionIndex((index) => index - 1);
}
} else if (type === "next" && currentVersionIndex < documents.length - 1) {
setCurrentVersionIndex((index) => index + 1);
}
};
const [isToolbarVisible, setIsToolbarVisible] = useState(false);
/*
* NOTE: if there are no documents, or if
* the documents are being fetched, then
* we mark it as the current version.
*/
const isCurrentVersion =
documents && documents.length > 0
? currentVersionIndex === documents.length - 1
: true;
const { width: windowWidth, height: windowHeight } = useWindowSize();
const isMobile = windowWidth ? windowWidth < 768 : false;
const artifactDefinition = artifactDefinitions.find(
(definition) => definition.kind === artifact.kind
);
if (!artifactDefinition) {
throw new Error("Artifact definition not found!");
}
useEffect(() => {
if (artifact.documentId !== "init" && artifactDefinition.initialize) {
artifactDefinition.initialize({
documentId: artifact.documentId,
setMetadata,
});
}
}, [artifact.documentId, artifactDefinition, setMetadata]);
return (
<AnimatePresence>
{artifact.isVisible && (
<motion.div
animate={{ opacity: 1 }}
className="fixed top-0 left-0 z-50 flex h-dvh w-dvw flex-row bg-transparent"
data-testid="artifact"
exit={{ opacity: 0, transition: { delay: 0.4 } }}
initial={{ opacity: 1 }}
>
{!isMobile && (
<motion.div
animate={{ width: windowWidth, right: 0 }}
className="fixed h-dvh bg-background"
exit={{
width: isSidebarOpen ? windowWidth - 256 : windowWidth,
right: 0,
}}
initial={{
width: isSidebarOpen ? windowWidth - 256 : windowWidth,
right: 0,
}}
/>
)}
{!isMobile && (
<motion.div
animate={{
opacity: 1,
x: 0,
scale: 1,
transition: {
delay: 0.1,
type: "spring",
stiffness: 300,
damping: 30,
},
}}
className="relative h-dvh w-[400px] shrink-0 bg-muted dark:bg-background"
exit={{
opacity: 0,
x: 0,
scale: 1,
transition: { duration: 0 },
}}
initial={{ opacity: 0, x: 10, scale: 1 }}
>
<AnimatePresence>
{!isCurrentVersion && (
<motion.div
animate={{ opacity: 1 }}
className="absolute top-0 left-0 z-50 h-dvh w-[400px] bg-zinc-900/50"
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
/>
)}
</AnimatePresence>
<div className="flex h-full flex-col items-center justify-between">
<ArtifactMessages
addToolApprovalResponse={addToolApprovalResponse}
artifactStatus={artifact.status}
chatId={chatId}
isReadonly={isReadonly}
messages={messages}
regenerate={regenerate}
setMessages={setMessages}
status={status}
votes={votes}
/>
<div className="relative flex w-full flex-row items-end gap-2 px-4 pb-4">
<MultimodalInput
attachments={attachments}
chatId={chatId}
className="bg-background dark:bg-muted"
input={input}
messages={messages}
selectedModelId={selectedModelId}
selectedVisibilityType={selectedVisibilityType}
sendMessage={sendMessage}
setAttachments={setAttachments}
setInput={setInput}
setMessages={setMessages}
status={status}
stop={stop}
/>
</div>
</div>
</motion.div>
)}
<motion.div
animate={
isMobile
? {
opacity: 1,
x: 0,
y: 0,
height: windowHeight,
width: windowWidth ? windowWidth : "calc(100dvw)",
borderRadius: 0,
transition: {
delay: 0,
type: "spring",
stiffness: 300,
damping: 30,
duration: 0.8,
},
}
: {
opacity: 1,
x: 400,
y: 0,
height: windowHeight,
width: windowWidth
? windowWidth - 400
: "calc(100dvw-400px)",
borderRadius: 0,
transition: {
delay: 0,
type: "spring",
stiffness: 300,
damping: 30,
duration: 0.8,
},
}
}
className="fixed flex h-dvh flex-col overflow-y-scroll border-zinc-200 bg-background md:border-l dark:border-zinc-700 dark:bg-muted"
exit={{
opacity: 0,
scale: 0.5,
transition: {
delay: 0.1,
type: "spring",
stiffness: 600,
damping: 30,
},
}}
initial={
isMobile
? {
opacity: 1,
x: artifact.boundingBox.left,
y: artifact.boundingBox.top,
height: artifact.boundingBox.height,
width: artifact.boundingBox.width,
borderRadius: 50,
}
: {
opacity: 1,
x: artifact.boundingBox.left,
y: artifact.boundingBox.top,
height: artifact.boundingBox.height,
width: artifact.boundingBox.width,
borderRadius: 50,
}
}
>
<div className="flex flex-row items-start justify-between p-2">
<div className="flex flex-row items-start gap-4">
<ArtifactCloseButton />
<div className="flex flex-col">
<div className="font-medium">{artifact.title}</div>
{isContentDirty ? (
<div className="text-muted-foreground text-sm">
Saving changes...
</div>
) : document ? (
<div className="text-muted-foreground text-sm">
{`Updated ${formatDistance(
new Date(document.createdAt),
new Date(),
{
addSuffix: true,
}
)}`}
</div>
) : (
<div className="mt-2 h-3 w-32 animate-pulse rounded-md bg-muted-foreground/20" />
)}
</div>
</div>
<ArtifactActions
artifact={artifact}
currentVersionIndex={currentVersionIndex}
handleVersionChange={handleVersionChange}
isCurrentVersion={isCurrentVersion}
metadata={metadata}
mode={mode}
setMetadata={setMetadata}
/>
</div>
<div className="h-full max-w-full! items-center overflow-y-scroll bg-background dark:bg-muted">
<artifactDefinition.content
content={
isCurrentVersion
? artifact.content
: getDocumentContentById(currentVersionIndex)
}
currentVersionIndex={currentVersionIndex}
getDocumentContentById={getDocumentContentById}
isCurrentVersion={isCurrentVersion}
isInline={false}
isLoading={isDocumentsFetching && !artifact.content}
metadata={metadata}
mode={mode}
onSaveContent={saveContent}
setMetadata={setMetadata}
status={artifact.status}
suggestions={[]}
title={artifact.title}
/>
<AnimatePresence>
{isCurrentVersion && (
<Toolbar
artifactKind={artifact.kind}
isToolbarVisible={isToolbarVisible}
sendMessage={sendMessage}
setIsToolbarVisible={setIsToolbarVisible}
setMessages={setMessages}
status={status}
stop={stop}
/>
)}
</AnimatePresence>
</div>
<AnimatePresence>
{!isCurrentVersion && (
<VersionFooter
currentVersionIndex={currentVersionIndex}
documents={documents}
handleVersionChange={handleVersionChange}
/>
)}
</AnimatePresence>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
export const Artifact = memo(PureArtifact, (prevProps, nextProps) => {
if (prevProps.status !== nextProps.status) {
return false;
}
if (!equal(prevProps.votes, nextProps.votes)) {
return false;
}
if (prevProps.input !== nextProps.input) {
return false;
}
if (!equal(prevProps.messages, nextProps.messages.length)) {
return false;
}
if (prevProps.selectedVisibilityType !== nextProps.selectedVisibilityType) {
return false;
}
return true;
});

View file

@ -17,14 +17,12 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useArtifactSelector } from "@/hooks/use-artifact";
import { useAutoResume } from "@/hooks/use-auto-resume";
import { useChatVisibility } from "@/hooks/use-chat-visibility";
import type { Vote } from "@/lib/db/schema";
import { ChatSDKError } from "@/lib/errors";
import type { Attachment, ChatMessage } from "@/lib/types";
import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils";
import { Artifact } from "./artifact";
import { useDataStream } from "./data-stream-provider";
import { Messages } from "./messages";
import { MultimodalInput } from "./multimodal-input";
@ -186,7 +184,6 @@ export function Chat({
);
const [attachments, setAttachments] = useState<Attachment[]>([]);
const isArtifactVisible = useArtifactSelector((state) => state.isVisible);
useAutoResume({
autoResume,
@ -207,7 +204,6 @@ export function Chat({
<Messages
addToolApprovalResponse={addToolApprovalResponse}
chatId={id}
isArtifactVisible={isArtifactVisible}
isReadonly={isReadonly}
messages={messages}
regenerate={regenerate}
@ -238,25 +234,6 @@ export function Chat({
</div>
</div>
<Artifact
addToolApprovalResponse={addToolApprovalResponse}
attachments={attachments}
chatId={id}
input={input}
isReadonly={isReadonly}
messages={messages}
regenerate={regenerate}
selectedModelId={currentModelId}
selectedVisibilityType={visibilityType}
sendMessage={sendMessage}
setAttachments={setAttachments}
setInput={setInput}
setMessages={setMessages}
status={status}
stop={stop}
votes={votes}
/>
<AlertDialog
onOpenChange={setShowCreditCardAlert}
open={showCreditCardAlert}

View file

@ -1,121 +0,0 @@
"use client";
import { python } from "@codemirror/lang-python";
import { EditorState, Transaction } from "@codemirror/state";
import { oneDark } from "@codemirror/theme-one-dark";
import { EditorView } from "@codemirror/view";
import { basicSetup } from "codemirror";
import { memo, useEffect, useRef } from "react";
import type { Suggestion } from "@/lib/db/schema";
type EditorProps = {
content: string;
onSaveContent: (updatedContent: string, debounce: boolean) => void;
status: "streaming" | "idle";
isCurrentVersion: boolean;
currentVersionIndex: number;
suggestions: Suggestion[];
};
function PureCodeEditor({ content, onSaveContent, status }: EditorProps) {
const containerRef = useRef<HTMLDivElement>(null);
const editorRef = useRef<EditorView | null>(null);
useEffect(() => {
if (containerRef.current && !editorRef.current) {
const startState = EditorState.create({
doc: content,
extensions: [basicSetup, python(), oneDark],
});
editorRef.current = new EditorView({
state: startState,
parent: containerRef.current,
});
}
return () => {
if (editorRef.current) {
editorRef.current.destroy();
editorRef.current = null;
}
};
// NOTE: we only want to run this effect once
// eslint-disable-next-line
}, [content]);
useEffect(() => {
if (editorRef.current) {
const updateListener = EditorView.updateListener.of((update) => {
if (update.docChanged) {
const transaction = update.transactions.find(
(tr) => !tr.annotation(Transaction.remote)
);
if (transaction) {
const newContent = update.state.doc.toString();
onSaveContent(newContent, true);
}
}
});
const currentSelection = editorRef.current.state.selection;
const newState = EditorState.create({
doc: editorRef.current.state.doc,
extensions: [basicSetup, python(), oneDark, updateListener],
selection: currentSelection,
});
editorRef.current.setState(newState);
}
}, [onSaveContent]);
useEffect(() => {
if (editorRef.current && content) {
const currentContent = editorRef.current.state.doc.toString();
if (status === "streaming" || currentContent !== content) {
const transaction = editorRef.current.state.update({
changes: {
from: 0,
to: currentContent.length,
insert: content,
},
annotations: [Transaction.remote.of(true)],
});
editorRef.current.dispatch(transaction);
}
}
}, [content, status]);
return (
<div
className="not-prose relative w-full pb-[calc(80dvh)] text-sm"
ref={containerRef}
/>
);
}
function areEqual(prevProps: EditorProps, nextProps: EditorProps) {
if (prevProps.suggestions !== nextProps.suggestions) {
return false;
}
if (prevProps.currentVersionIndex !== nextProps.currentVersionIndex) {
return false;
}
if (prevProps.isCurrentVersion !== nextProps.isCurrentVersion) {
return false;
}
if (prevProps.status === "streaming" && nextProps.status === "streaming") {
return false;
}
if (prevProps.content !== nextProps.content) {
return false;
}
return true;
}
export const CodeEditor = memo(PureCodeEditor, areEqual);

View file

@ -1,193 +0,0 @@
import {
type Dispatch,
type SetStateAction,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { useArtifactSelector } from "@/hooks/use-artifact";
import { cn } from "@/lib/utils";
import { Loader } from "./elements/loader";
import { CrossSmallIcon, TerminalWindowIcon } from "./icons";
import { Button } from "./ui/button";
export type ConsoleOutputContent = {
type: "text" | "image";
value: string;
};
export type ConsoleOutput = {
id: string;
status: "in_progress" | "loading_packages" | "completed" | "failed";
contents: ConsoleOutputContent[];
};
type ConsoleProps = {
consoleOutputs: ConsoleOutput[];
setConsoleOutputs: Dispatch<SetStateAction<ConsoleOutput[]>>;
};
export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
const [height, setHeight] = useState<number>(300);
const [isResizing, setIsResizing] = useState(false);
const consoleEndRef = useRef<HTMLDivElement>(null);
const isArtifactVisible = useArtifactSelector((state) => state.isVisible);
const minHeight = 100;
const maxHeight = 800;
const startResizing = useCallback(() => {
setIsResizing(true);
}, []);
const stopResizing = useCallback(() => {
setIsResizing(false);
}, []);
const resize = useCallback(
(e: MouseEvent) => {
if (isResizing) {
const newHeight = window.innerHeight - e.clientY;
if (newHeight >= minHeight && newHeight <= maxHeight) {
setHeight(newHeight);
}
}
},
[isResizing]
);
useEffect(() => {
window.addEventListener("mousemove", resize);
window.addEventListener("mouseup", stopResizing);
return () => {
window.removeEventListener("mousemove", resize);
window.removeEventListener("mouseup", stopResizing);
};
}, [resize, stopResizing]);
useEffect(() => {
consoleEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, []);
useEffect(() => {
if (!isArtifactVisible) {
setConsoleOutputs([]);
}
}, [isArtifactVisible, setConsoleOutputs]);
return consoleOutputs.length > 0 ? (
<>
<div
aria-label="Resize console"
aria-orientation="horizontal"
aria-valuemax={maxHeight}
aria-valuemin={minHeight}
aria-valuenow={height}
className="fixed z-50 h-2 w-full cursor-ns-resize"
onKeyDown={(e) => {
if (e.key === "ArrowUp") {
setHeight((prev) => Math.min(prev + 10, maxHeight));
} else if (e.key === "ArrowDown") {
setHeight((prev) => Math.max(prev - 10, minHeight));
}
}}
onMouseDown={startResizing}
role="slider"
style={{ bottom: height - 4 }}
tabIndex={0}
/>
<div
className={cn(
"fixed bottom-0 z-40 flex w-full flex-col overflow-x-hidden overflow-y-scroll border-zinc-200 border-t bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900",
{
"select-none": isResizing,
}
)}
style={{ height }}
>
<div className="sticky top-0 z-50 flex h-fit w-full flex-row items-center justify-between border-zinc-200 border-b bg-muted px-2 py-1 dark:border-zinc-700">
<div className="flex flex-row items-center gap-3 pl-2 text-sm text-zinc-800 dark:text-zinc-50">
<div className="text-muted-foreground">
<TerminalWindowIcon />
</div>
<div>Console</div>
</div>
<Button
className="size-fit p-1 hover:bg-zinc-200 dark:hover:bg-zinc-700"
onClick={() => setConsoleOutputs([])}
size="icon"
variant="ghost"
>
<CrossSmallIcon />
</Button>
</div>
<div>
{consoleOutputs.map((consoleOutput, index) => (
<div
className="flex flex-row border-zinc-200 border-b bg-zinc-50 px-4 py-2 font-mono text-sm dark:border-zinc-700 dark:bg-zinc-900"
key={consoleOutput.id}
>
<div
className={cn("w-12 shrink-0", {
"text-muted-foreground": [
"in_progress",
"loading_packages",
].includes(consoleOutput.status),
"text-emerald-500": consoleOutput.status === "completed",
"text-red-400": consoleOutput.status === "failed",
})}
>
[{index + 1}]
</div>
{["in_progress", "loading_packages"].includes(
consoleOutput.status
) ? (
<div className="flex flex-row gap-2">
<div className="mt-0.5 mb-auto size-fit self-center">
<Loader size={16} />
</div>
<div className="text-muted-foreground">
{consoleOutput.status === "in_progress"
? "Initializing..."
: consoleOutput.status === "loading_packages"
? consoleOutput.contents.map((content) =>
content.type === "text" ? content.value : null
)
: null}
</div>
</div>
) : (
<div className="flex w-full flex-col gap-2 overflow-x-scroll text-zinc-900 dark:text-zinc-50">
{consoleOutput.contents.map((content, contentIndex) =>
content.type === "image" ? (
<picture key={`${consoleOutput.id}-${contentIndex}`}>
{/** biome-ignore lint/nursery/useImageSize: "Generated image without explicit size" */}
<img
alt="output"
className="w-full max-w-(--breakpoint-toast-mobile) rounded-md"
src={content.value}
/>
</picture>
) : (
<div
className="w-full whitespace-pre-line break-words"
key={`${consoleOutput.id}-${contentIndex}`}
>
{content.value}
</div>
)
)}
</div>
)}
</div>
))}
<div ref={consoleEndRef} />
</div>
</div>
</>
) : null;
}

View file

@ -1,93 +0,0 @@
import type { UseChatHelpers } from "@ai-sdk/react";
import type { DataUIPart } from "ai";
import type { ComponentType, Dispatch, ReactNode, SetStateAction } from "react";
import type { Suggestion } from "@/lib/db/schema";
import type { ChatMessage, CustomUIDataTypes } from "@/lib/types";
import type { UIArtifact } from "./artifact";
export type ArtifactActionContext<M = any> = {
content: string;
handleVersionChange: (type: "next" | "prev" | "toggle" | "latest") => void;
currentVersionIndex: number;
isCurrentVersion: boolean;
mode: "edit" | "diff";
metadata: M;
setMetadata: Dispatch<SetStateAction<M>>;
};
type ArtifactAction<M = any> = {
icon: ReactNode;
label?: string;
description: string;
onClick: (context: ArtifactActionContext<M>) => Promise<void> | void;
isDisabled?: (context: ArtifactActionContext<M>) => boolean;
};
export type ArtifactToolbarContext = {
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
};
export type ArtifactToolbarItem = {
description: string;
icon: ReactNode;
onClick: (context: ArtifactToolbarContext) => void;
};
type ArtifactContent<M = any> = {
title: string;
content: string;
mode: "edit" | "diff";
isCurrentVersion: boolean;
currentVersionIndex: number;
status: "streaming" | "idle";
suggestions: Suggestion[];
onSaveContent: (updatedContent: string, debounce: boolean) => void;
isInline: boolean;
getDocumentContentById: (index: number) => string;
isLoading: boolean;
metadata: M;
setMetadata: Dispatch<SetStateAction<M>>;
};
type InitializeParameters<M = any> = {
documentId: string;
setMetadata: Dispatch<SetStateAction<M>>;
};
type ArtifactConfig<T extends string, M = any> = {
kind: T;
description: string;
content: ComponentType<ArtifactContent<M>>;
actions: ArtifactAction<M>[];
toolbar: ArtifactToolbarItem[];
initialize?: (parameters: InitializeParameters<M>) => void;
onStreamPart: (args: {
setMetadata: Dispatch<SetStateAction<M>>;
setArtifact: Dispatch<SetStateAction<UIArtifact>>;
streamPart: DataUIPart<CustomUIDataTypes>;
}) => void;
};
export class Artifact<T extends string, M = any> {
readonly kind: T;
readonly description: string;
readonly content: ComponentType<ArtifactContent<M>>;
readonly actions: ArtifactAction<M>[];
readonly toolbar: ArtifactToolbarItem[];
readonly initialize?: (parameters: InitializeParameters) => void;
readonly onStreamPart: (args: {
setMetadata: Dispatch<SetStateAction<M>>;
setArtifact: Dispatch<SetStateAction<UIArtifact>>;
streamPart: DataUIPart<CustomUIDataTypes>;
}) => void;
constructor(config: ArtifactConfig<T, M>) {
this.kind = config.kind;
this.description = config.description;
this.content = config.content;
this.actions = config.actions || [];
this.toolbar = config.toolbar || [];
this.initialize = config.initialize || (async () => ({}));
this.onStreamPart = config.onStreamPart;
}
}

View file

@ -181,10 +181,6 @@ export function CurrencyExchange({
</div>
</>
)}
<div className="mt-2 text-center text-emerald-700 text-xs dark:text-emerald-300">
Powered by Frankfurter API
</div>
</div>
</div>
);

View file

@ -3,8 +3,6 @@
import { useEffect } from "react";
import { useSWRConfig } from "swr";
import { unstable_serialize } from "swr/infinite";
import { initialArtifactData, useArtifact } from "@/hooks/use-artifact";
import { artifactDefinitions } from "./artifact";
import { useDataStream } from "./data-stream-provider";
import { getChatHistoryPaginationKey } from "./sidebar-history";
@ -12,8 +10,6 @@ export function DataStreamHandler() {
const { dataStream, setDataStream } = useDataStream();
const { mutate } = useSWRConfig();
const { artifact, setArtifact, setMetadata } = useArtifact();
useEffect(() => {
if (!dataStream?.length) {
return;
@ -26,67 +22,9 @@ export function DataStreamHandler() {
// Handle chat title updates
if (delta.type === "data-chat-title") {
mutate(unstable_serialize(getChatHistoryPaginationKey));
continue;
}
const artifactDefinition = artifactDefinitions.find(
(currentArtifactDefinition) =>
currentArtifactDefinition.kind === artifact.kind
);
if (artifactDefinition?.onStreamPart) {
artifactDefinition.onStreamPart({
streamPart: delta,
setArtifact,
setMetadata,
});
}
setArtifact((draftArtifact) => {
if (!draftArtifact) {
return { ...initialArtifactData, status: "streaming" };
}
switch (delta.type) {
case "data-id":
return {
...draftArtifact,
documentId: delta.data,
status: "streaming",
};
case "data-title":
return {
...draftArtifact,
title: delta.data,
status: "streaming",
};
case "data-kind":
return {
...draftArtifact,
kind: delta.data,
status: "streaming",
};
case "data-clear":
return {
...draftArtifact,
content: "",
status: "streaming",
};
case "data-finish":
return {
...draftArtifact,
status: "idle",
};
default:
return draftArtifact;
}
});
}
}, [dataStream, setArtifact, setMetadata, artifact, setDataStream, mutate]);
}, [dataStream, setDataStream, mutate]);
return null;
}

View file

@ -1,100 +0,0 @@
import OrderedMap from "orderedmap";
import {
DOMParser,
type MarkSpec,
type Node as ProsemirrorNode,
Schema,
} from "prosemirror-model";
import { schema } from "prosemirror-schema-basic";
import { addListNodes } from "prosemirror-schema-list";
import { EditorState } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { useEffect, useRef } from "react";
import { renderToString } from "react-dom/server";
import { Streamdown } from "streamdown";
import { DiffType, diffEditor } from "@/lib/editor/diff";
const diffSchema = new Schema({
nodes: addListNodes(schema.spec.nodes, "paragraph block*", "block"),
marks: OrderedMap.from({
...schema.spec.marks.toObject(),
diffMark: {
attrs: { type: { default: "" } },
toDOM(mark) {
let className = "";
switch (mark.attrs.type) {
case DiffType.Inserted:
className =
"bg-green-100 text-green-700 dark:bg-green-500/70 dark:text-green-300";
break;
case DiffType.Deleted:
className =
"bg-red-100 line-through text-red-600 dark:bg-red-500/70 dark:text-red-300";
break;
default:
className = "";
}
return ["span", { class: className }, 0];
},
} as MarkSpec,
}),
});
function computeDiff(oldDoc: ProsemirrorNode, newDoc: ProsemirrorNode) {
return diffEditor(diffSchema, oldDoc.toJSON(), newDoc.toJSON());
}
type DiffEditorProps = {
oldContent: string;
newContent: string;
};
export const DiffView = ({ oldContent, newContent }: DiffEditorProps) => {
const editorRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
useEffect(() => {
if (editorRef.current && !viewRef.current) {
const parser = DOMParser.fromSchema(diffSchema);
const oldHtmlContent = renderToString(
<Streamdown>{oldContent}</Streamdown>
);
const newHtmlContent = renderToString(
<Streamdown>{newContent}</Streamdown>
);
const oldContainer = document.createElement("div");
oldContainer.innerHTML = oldHtmlContent;
const newContainer = document.createElement("div");
newContainer.innerHTML = newHtmlContent;
const oldDoc = parser.parse(oldContainer);
const newDoc = parser.parse(newContainer);
const diffedDoc = computeDiff(oldDoc, newDoc);
const state = EditorState.create({
doc: diffedDoc,
plugins: [],
});
viewRef.current = new EditorView(editorRef.current, {
state,
editable: () => false,
});
}
return () => {
if (viewRef.current) {
viewRef.current.destroy();
viewRef.current = null;
}
};
}, [oldContent, newContent]);
return <div className="diff-editor" ref={editorRef} />;
};

View file

@ -1,295 +0,0 @@
"use client";
import equal from "fast-deep-equal";
import {
type MouseEvent,
memo,
useCallback,
useEffect,
useMemo,
useRef,
} from "react";
import useSWR from "swr";
import { useArtifact } from "@/hooks/use-artifact";
import type { Document } from "@/lib/db/schema";
import { cn, fetcher } from "@/lib/utils";
import type { ArtifactKind, UIArtifact } from "./artifact";
import { CodeEditor } from "./code-editor";
import { DocumentToolCall, DocumentToolResult } from "./document";
import { InlineDocumentSkeleton } from "./document-skeleton";
import { FileIcon, FullscreenIcon, ImageIcon, LoaderIcon } from "./icons";
import { ImageEditor } from "./image-editor";
import { SpreadsheetEditor } from "./sheet-editor";
import { Editor } from "./text-editor";
type DocumentPreviewProps = {
isReadonly: boolean;
result?: any;
args?: any;
};
export function DocumentPreview({
isReadonly,
result,
args,
}: DocumentPreviewProps) {
const { artifact, setArtifact } = useArtifact();
const { data: documents, isLoading: isDocumentsFetching } = useSWR<
Document[]
>(result ? `/api/document?id=${result.id}` : null, fetcher);
const previewDocument = useMemo(() => documents?.[0], [documents]);
const hitboxRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const boundingBox = hitboxRef.current?.getBoundingClientRect();
if (artifact.documentId && boundingBox) {
setArtifact((currentArtifact) => ({
...currentArtifact,
boundingBox: {
left: boundingBox.x,
top: boundingBox.y,
width: boundingBox.width,
height: boundingBox.height,
},
}));
}
}, [artifact.documentId, setArtifact]);
if (artifact.isVisible) {
if (result) {
return (
<DocumentToolResult
isReadonly={isReadonly}
result={{ id: result.id, title: result.title, kind: result.kind }}
type="create"
/>
);
}
if (args) {
return (
<DocumentToolCall
args={{ title: args.title, kind: args.kind }}
isReadonly={isReadonly}
type="create"
/>
);
}
}
if (isDocumentsFetching) {
return <LoadingSkeleton artifactKind={result.kind ?? args.kind} />;
}
const document: Document | null = previewDocument
? previewDocument
: artifact.status === "streaming"
? {
title: artifact.title,
kind: artifact.kind,
content: artifact.content,
id: artifact.documentId,
createdAt: new Date(),
userId: "noop",
}
: null;
if (!document) {
return <LoadingSkeleton artifactKind={artifact.kind} />;
}
return (
<div className="relative w-full max-w-[450px] cursor-pointer">
<HitboxLayer
hitboxRef={hitboxRef}
result={result}
setArtifact={setArtifact}
/>
<DocumentHeader
isStreaming={artifact.status === "streaming"}
kind={document.kind}
title={document.title}
/>
<DocumentContent document={document} />
</div>
);
}
const LoadingSkeleton = ({ artifactKind }: { artifactKind: ArtifactKind }) => (
<div className="w-full max-w-[450px]">
<div className="flex h-[57px] flex-row items-center justify-between gap-2 rounded-t-2xl border border-b-0 p-4 dark:border-zinc-700 dark:bg-muted">
<div className="flex flex-row items-center gap-3">
<div className="text-muted-foreground">
<div className="size-4 animate-pulse rounded-md bg-muted-foreground/20" />
</div>
<div className="h-4 w-24 animate-pulse rounded-lg bg-muted-foreground/20" />
</div>
<div>
<FullscreenIcon />
</div>
</div>
{artifactKind === "image" ? (
<div className="overflow-y-scroll rounded-b-2xl border border-t-0 bg-muted dark:border-zinc-700">
<div className="h-[257px] w-full animate-pulse bg-muted-foreground/20" />
</div>
) : (
<div className="overflow-y-scroll rounded-b-2xl border border-t-0 bg-muted p-8 pt-4 dark:border-zinc-700">
<InlineDocumentSkeleton />
</div>
)}
</div>
);
const PureHitboxLayer = ({
hitboxRef,
result,
setArtifact,
}: {
hitboxRef: React.RefObject<HTMLDivElement>;
result: any;
setArtifact: (
updaterFn: UIArtifact | ((currentArtifact: UIArtifact) => UIArtifact)
) => void;
}) => {
const handleClick = useCallback(
(event: MouseEvent<HTMLElement>) => {
const boundingBox = event.currentTarget.getBoundingClientRect();
setArtifact((artifact) =>
artifact.status === "streaming"
? { ...artifact, isVisible: true }
: {
...artifact,
title: result.title,
documentId: result.id,
kind: result.kind,
isVisible: true,
boundingBox: {
left: boundingBox.x,
top: boundingBox.y,
width: boundingBox.width,
height: boundingBox.height,
},
}
);
},
[setArtifact, result]
);
return (
<div
aria-hidden="true"
className="absolute top-0 left-0 z-10 size-full rounded-xl"
onClick={handleClick}
ref={hitboxRef}
role="presentation"
>
<div className="flex w-full items-center justify-end p-4">
<div className="absolute top-[13px] right-[9px] rounded-md p-2 hover:bg-zinc-100 dark:hover:bg-zinc-700">
<FullscreenIcon />
</div>
</div>
</div>
);
};
const HitboxLayer = memo(PureHitboxLayer, (prevProps, nextProps) => {
if (!equal(prevProps.result, nextProps.result)) {
return false;
}
return true;
});
const PureDocumentHeader = ({
title,
kind,
isStreaming,
}: {
title: string;
kind: ArtifactKind;
isStreaming: boolean;
}) => (
<div className="flex flex-row items-start justify-between gap-2 rounded-t-2xl border border-b-0 p-4 sm:items-center dark:border-zinc-700 dark:bg-muted">
<div className="flex flex-row items-start gap-3 sm:items-center">
<div className="text-muted-foreground">
{isStreaming ? (
<div className="animate-spin">
<LoaderIcon />
</div>
) : kind === "image" ? (
<ImageIcon />
) : (
<FileIcon />
)}
</div>
<div className="-translate-y-1 font-medium sm:translate-y-0">{title}</div>
</div>
<div className="w-8" />
</div>
);
const DocumentHeader = memo(PureDocumentHeader, (prevProps, nextProps) => {
if (prevProps.title !== nextProps.title) {
return false;
}
if (prevProps.isStreaming !== nextProps.isStreaming) {
return false;
}
return true;
});
const DocumentContent = ({ document }: { document: Document }) => {
const { artifact } = useArtifact();
const containerClassName = cn(
"h-[257px] overflow-y-scroll rounded-b-2xl border border-t-0 dark:border-zinc-700 dark:bg-muted",
{
"p-4 sm:px-14 sm:py-16": document.kind === "text",
"p-0": document.kind === "code",
}
);
const commonProps = {
content: document.content ?? "",
isCurrentVersion: true,
currentVersionIndex: 0,
status: artifact.status,
saveContent: () => null,
suggestions: [],
};
const handleSaveContent = () => null;
return (
<div className={containerClassName}>
{document.kind === "text" ? (
<Editor {...commonProps} onSaveContent={handleSaveContent} />
) : document.kind === "code" ? (
<div className="relative flex w-full flex-1">
<div className="absolute inset-0">
<CodeEditor {...commonProps} onSaveContent={handleSaveContent} />
</div>
</div>
) : document.kind === "sheet" ? (
<div className="relative flex size-full flex-1 p-4">
<div className="absolute inset-0">
<SpreadsheetEditor {...commonProps} />
</div>
</div>
) : document.kind === "image" ? (
<ImageEditor
content={document.content ?? ""}
currentVersionIndex={0}
isCurrentVersion={true}
isInline={true}
status={artifact.status}
title={document.title}
/>
) : null}
</div>
);
};

View file

@ -1,39 +0,0 @@
"use client";
import type { ArtifactKind } from "./artifact";
export const DocumentSkeleton = ({
artifactKind,
}: {
artifactKind: ArtifactKind;
}) => {
return artifactKind === "image" ? (
<div className="flex h-[calc(100dvh-60px)] w-full flex-col items-center justify-center gap-4">
<div className="size-96 animate-pulse rounded-lg bg-muted-foreground/20" />
</div>
) : (
<div className="flex w-full flex-col gap-4">
<div className="h-12 w-1/2 animate-pulse rounded-lg bg-muted-foreground/20" />
<div className="h-5 w-full animate-pulse rounded-lg bg-muted-foreground/20" />
<div className="h-5 w-full animate-pulse rounded-lg bg-muted-foreground/20" />
<div className="h-5 w-1/3 animate-pulse rounded-lg bg-muted-foreground/20" />
<div className="h-5 w-52 animate-pulse rounded-lg bg-transparent" />
<div className="h-8 w-52 animate-pulse rounded-lg bg-muted-foreground/20" />
<div className="h-5 w-2/3 animate-pulse rounded-lg bg-muted-foreground/20" />
</div>
);
};
export const InlineDocumentSkeleton = () => {
return (
<div className="flex w-full flex-col gap-4">
<div className="h-4 w-48 animate-pulse rounded-lg bg-muted-foreground/20" />
<div className="h-4 w-3/4 animate-pulse rounded-lg bg-muted-foreground/20" />
<div className="h-4 w-1/2 animate-pulse rounded-lg bg-muted-foreground/20" />
<div className="h-4 w-64 animate-pulse rounded-lg bg-muted-foreground/20" />
<div className="h-4 w-40 animate-pulse rounded-lg bg-muted-foreground/20" />
<div className="h-4 w-36 animate-pulse rounded-lg bg-muted-foreground/20" />
<div className="h-4 w-64 animate-pulse rounded-lg bg-muted-foreground/20" />
</div>
);
};

View file

@ -1,161 +0,0 @@
import { memo } from "react";
import { toast } from "sonner";
import { useArtifact } from "@/hooks/use-artifact";
import type { ArtifactKind } from "./artifact";
import { FileIcon, LoaderIcon, MessageIcon, PencilEditIcon } from "./icons";
const getActionText = (
type: "create" | "update" | "request-suggestions",
tense: "present" | "past"
) => {
switch (type) {
case "create":
return tense === "present" ? "Creating" : "Created";
case "update":
return tense === "present" ? "Updating" : "Updated";
case "request-suggestions":
return tense === "present"
? "Adding suggestions"
: "Added suggestions to";
default:
return null;
}
};
type DocumentToolResultProps = {
type: "create" | "update" | "request-suggestions";
result: { id: string; title: string; kind: ArtifactKind };
isReadonly: boolean;
};
function PureDocumentToolResult({
type,
result,
isReadonly,
}: DocumentToolResultProps) {
const { setArtifact } = useArtifact();
return (
<button
className="flex w-fit cursor-pointer flex-row items-start gap-3 rounded-xl border bg-background px-3 py-2"
onClick={(event) => {
if (isReadonly) {
toast.error(
"Viewing files in shared chats is currently not supported."
);
return;
}
const rect = event.currentTarget.getBoundingClientRect();
const boundingBox = {
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
};
setArtifact((currentArtifact) => ({
documentId: result.id,
kind: result.kind,
content: currentArtifact.content,
title: result.title,
isVisible: true,
status: "idle",
boundingBox,
}));
}}
type="button"
>
<div className="mt-1 text-muted-foreground">
{type === "create" ? (
<FileIcon />
) : type === "update" ? (
<PencilEditIcon />
) : type === "request-suggestions" ? (
<MessageIcon />
) : null}
</div>
<div className="text-left">
{`${getActionText(type, "past")} "${result.title}"`}
</div>
</button>
);
}
export const DocumentToolResult = memo(PureDocumentToolResult, () => true);
type DocumentToolCallProps = {
type: "create" | "update" | "request-suggestions";
args:
| { title: string; kind: ArtifactKind } // for create
| { id: string; description: string } // for update
| { documentId: string }; // for request-suggestions
isReadonly: boolean;
};
function PureDocumentToolCall({
type,
args,
isReadonly,
}: DocumentToolCallProps) {
const { setArtifact } = useArtifact();
return (
<button
className="cursor pointer flex w-fit flex-row items-start justify-between gap-3 rounded-xl border px-3 py-2"
onClick={(event) => {
if (isReadonly) {
toast.error(
"Viewing files in shared chats is currently not supported."
);
return;
}
const rect = event.currentTarget.getBoundingClientRect();
const boundingBox = {
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
};
setArtifact((currentArtifact) => ({
...currentArtifact,
isVisible: true,
boundingBox,
}));
}}
type="button"
>
<div className="flex flex-row items-start gap-3">
<div className="mt-1 text-zinc-500">
{type === "create" ? (
<FileIcon />
) : type === "update" ? (
<PencilEditIcon />
) : type === "request-suggestions" ? (
<MessageIcon />
) : null}
</div>
<div className="text-left">
{`${getActionText(type, "present")} ${
type === "create" && "title" in args && args.title
? `"${args.title}"`
: type === "update" && "description" in args
? `"${args.description}"`
: type === "request-suggestions"
? "for document"
: ""
}`}
</div>
</div>
<div className="mt-1 animate-spin">{<LoaderIcon />}</div>
</button>
);
}
export const DocumentToolCall = memo(PureDocumentToolCall, () => true);

View file

@ -1,49 +0,0 @@
import cn from "classnames";
import { LoaderIcon } from "./icons";
type ImageEditorProps = {
title: string;
content: string;
isCurrentVersion: boolean;
currentVersionIndex: number;
status: string;
isInline: boolean;
};
export function ImageEditor({
title,
content,
status,
isInline,
}: ImageEditorProps) {
return (
<div
className={cn("flex w-full flex-row items-center justify-center", {
"h-[calc(100dvh-60px)]": !isInline,
"h-[200px]": isInline,
})}
>
{status === "streaming" ? (
<div className="flex flex-row items-center gap-4">
{!isInline && (
<div className="animate-spin">
<LoaderIcon />
</div>
)}
<div>Generating Image...</div>
</div>
) : (
<picture>
{/** biome-ignore lint/nursery/useImageSize: "Generated image without explicit size" */}
<img
alt={title}
className={cn("h-fit w-full max-w-[800px]", {
"p-0 md:p-20": !isInline,
})}
src={`data:image/png;base64,${content}`}
/>
</picture>
)}
</div>
);
}

View file

@ -18,7 +18,6 @@ type MessagesProps = {
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
isReadonly: boolean;
isArtifactVisible: boolean;
selectedModelId: string;
};
@ -108,10 +107,6 @@ function PureMessages({
}
export const Messages = memo(PureMessages, (prevProps, nextProps) => {
if (prevProps.isArtifactVisible && nextProps.isArtifactVisible) {
return true;
}
if (prevProps.status !== nextProps.status) {
return false;
}

View file

@ -1,140 +0,0 @@
"use client";
import { useTheme } from "next-themes";
import { parse, unparse } from "papaparse";
import { memo, useEffect, useMemo, useState } from "react";
import DataGrid, { textEditor } from "react-data-grid";
import { cn } from "@/lib/utils";
import "react-data-grid/lib/styles.css";
type SheetEditorProps = {
content: string;
saveContent: (content: string, isCurrentVersion: boolean) => void;
currentVersionIndex: number;
isCurrentVersion: boolean;
status: string;
};
const MIN_ROWS = 50;
const MIN_COLS = 26;
const PureSpreadsheetEditor = ({ content, saveContent }: SheetEditorProps) => {
const { resolvedTheme } = useTheme();
const parseData = useMemo(() => {
if (!content) {
return new Array(MIN_ROWS).fill(new Array(MIN_COLS).fill(""));
}
const result = parse<string[]>(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(new 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 (
<DataGrid
className={resolvedTheme === "dark" ? "rdg-dark" : "rdg-light"}
columns={columns}
defaultColumnOptions={{
resizable: true,
sortable: true,
}}
enableVirtualization
onCellClick={(args) => {
if (args.column.key !== "rowNumber") {
args.selectCell(true);
}
}}
onRowsChange={handleRowsChange}
rows={localRows}
style={{ height: "100%" }}
/>
);
};
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);

View file

@ -1,77 +0,0 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { useState } from "react";
import { useWindowSize } from "usehooks-ts";
import type { UISuggestion } from "@/lib/editor/suggestions";
import { cn } from "@/lib/utils";
import type { ArtifactKind } from "./artifact";
import { CrossIcon, MessageIcon } from "./icons";
import { Button } from "./ui/button";
export const Suggestion = ({
suggestion,
onApply,
artifactKind,
}: {
suggestion: UISuggestion;
onApply: () => void;
artifactKind: ArtifactKind;
}) => {
const [isExpanded, setIsExpanded] = useState(false);
const { width: windowWidth } = useWindowSize();
return (
<AnimatePresence>
{isExpanded ? (
<motion.div
animate={{ opacity: 1, y: -20 }}
className="-right-12 md:-right-16 absolute z-50 flex w-56 flex-col gap-3 rounded-2xl border bg-background p-3 font-sans text-sm shadow-xl"
exit={{ opacity: 0, y: -10 }}
initial={{ opacity: 0, y: -10 }}
key={suggestion.id}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
whileHover={{ scale: 1.05 }}
>
<div className="flex flex-row items-center justify-between">
<div className="flex flex-row items-center gap-2">
<div className="size-4 rounded-full bg-muted-foreground/25" />
<div className="font-medium">Assistant</div>
</div>
<button
className="cursor-pointer text-gray-500 text-xs"
onClick={() => {
setIsExpanded(false);
}}
type="button"
>
<CrossIcon size={12} />
</button>
</div>
<div>{suggestion.description}</div>
<Button
className="w-fit rounded-full px-3 py-1.5"
onClick={onApply}
variant="outline"
>
Apply
</Button>
</motion.div>
) : (
<motion.div
className={cn("cursor-pointer p-1 text-muted-foreground", {
"-right-8 absolute": artifactKind === "text",
"sticky top-0 right-4": artifactKind === "code",
})}
onClick={() => {
setIsExpanded(true);
}}
whileHover={{ scale: 1.1 }}
>
<MessageIcon size={windowWidth && windowWidth < 768 ? 16 : 14} />
</motion.div>
)}
</AnimatePresence>
);
};

View file

@ -1,164 +0,0 @@
"use client";
import { exampleSetup } from "prosemirror-example-setup";
import { inputRules } from "prosemirror-inputrules";
import { EditorState } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { memo, useEffect, useRef } from "react";
import type { Suggestion } from "@/lib/db/schema";
import {
documentSchema,
handleTransaction,
headingRule,
} from "@/lib/editor/config";
import {
buildContentFromDocument,
buildDocumentFromContent,
createDecorations,
} from "@/lib/editor/functions";
import {
projectWithPositions,
suggestionsPlugin,
suggestionsPluginKey,
} from "@/lib/editor/suggestions";
type EditorProps = {
content: string;
onSaveContent: (updatedContent: string, debounce: boolean) => void;
status: "streaming" | "idle";
isCurrentVersion: boolean;
currentVersionIndex: number;
suggestions: Suggestion[];
};
function PureEditor({
content,
onSaveContent,
suggestions,
status,
}: EditorProps) {
const containerRef = useRef<HTMLDivElement>(null);
const editorRef = useRef<EditorView | null>(null);
useEffect(() => {
if (containerRef.current && !editorRef.current) {
const state = EditorState.create({
doc: buildDocumentFromContent(content),
plugins: [
...exampleSetup({ schema: documentSchema, menuBar: false }),
inputRules({
rules: [
headingRule(1),
headingRule(2),
headingRule(3),
headingRule(4),
headingRule(5),
headingRule(6),
],
}),
suggestionsPlugin,
],
});
editorRef.current = new EditorView(containerRef.current, {
state,
});
}
return () => {
if (editorRef.current) {
editorRef.current.destroy();
editorRef.current = null;
}
};
// NOTE: we only want to run this effect once
// eslint-disable-next-line
}, [content]);
useEffect(() => {
if (editorRef.current) {
editorRef.current.setProps({
dispatchTransaction: (transaction) => {
handleTransaction({
transaction,
editorRef,
onSaveContent,
});
},
});
}
}, [onSaveContent]);
useEffect(() => {
if (editorRef.current && content) {
const currentContent = buildContentFromDocument(
editorRef.current.state.doc
);
if (status === "streaming") {
const newDocument = buildDocumentFromContent(content);
const transaction = editorRef.current.state.tr.replaceWith(
0,
editorRef.current.state.doc.content.size,
newDocument.content
);
transaction.setMeta("no-save", true);
editorRef.current.dispatch(transaction);
return;
}
if (currentContent !== content) {
const newDocument = buildDocumentFromContent(content);
const transaction = editorRef.current.state.tr.replaceWith(
0,
editorRef.current.state.doc.content.size,
newDocument.content
);
transaction.setMeta("no-save", true);
editorRef.current.dispatch(transaction);
}
}
}, [content, status]);
useEffect(() => {
if (editorRef.current?.state.doc && content) {
const projectedSuggestions = projectWithPositions(
editorRef.current.state.doc,
suggestions
).filter(
(suggestion) => suggestion.selectionStart && suggestion.selectionEnd
);
const decorations = createDecorations(
projectedSuggestions,
editorRef.current
);
const transaction = editorRef.current.state.tr;
transaction.setMeta(suggestionsPluginKey, { decorations });
editorRef.current.dispatch(transaction);
}
}, [suggestions, content]);
return (
<div className="prose dark:prose-invert relative" ref={containerRef} />
);
}
function areEqual(prevProps: EditorProps, nextProps: EditorProps) {
return (
prevProps.suggestions === nextProps.suggestions &&
prevProps.currentVersionIndex === nextProps.currentVersionIndex &&
prevProps.isCurrentVersion === nextProps.isCurrentVersion &&
!(prevProps.status === "streaming" && nextProps.status === "streaming") &&
prevProps.content === nextProps.content &&
prevProps.onSaveContent === nextProps.onSaveContent
);
}
export const Editor = memo(PureEditor, areEqual);

View file

@ -1,476 +0,0 @@
"use client";
import type { UseChatHelpers } from "@ai-sdk/react";
import cx from "classnames";
import {
AnimatePresence,
motion,
useMotionValue,
useTransform,
} from "framer-motion";
import { nanoid } from "nanoid";
import {
type Dispatch,
memo,
type ReactNode,
type SetStateAction,
useEffect,
useRef,
useState,
} from "react";
import { useOnClickOutside } from "usehooks-ts";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { ChatMessage } from "@/lib/types";
import { type ArtifactKind, artifactDefinitions } from "./artifact";
import type { ArtifactToolbarItem } from "./create-artifact";
import { ArrowUpIcon, StopIcon, SummarizeIcon } from "./icons";
type ToolProps = {
description: string;
icon: ReactNode;
selectedTool: string | null;
setSelectedTool: Dispatch<SetStateAction<string | null>>;
isToolbarVisible?: boolean;
setIsToolbarVisible?: Dispatch<SetStateAction<boolean>>;
isAnimating: boolean;
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
onClick: ({
sendMessage,
}: {
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
}) => void;
};
const Tool = ({
description,
icon,
selectedTool,
setSelectedTool,
isToolbarVisible,
setIsToolbarVisible,
isAnimating,
sendMessage,
onClick,
}: ToolProps) => {
const [isHovered, setIsHovered] = useState(false);
useEffect(() => {
if (selectedTool !== description) {
setIsHovered(false);
}
}, [selectedTool, description]);
const handleSelect = () => {
if (!isToolbarVisible && setIsToolbarVisible) {
setIsToolbarVisible(true);
return;
}
if (!selectedTool) {
setIsHovered(true);
setSelectedTool(description);
return;
}
if (selectedTool !== description) {
setSelectedTool(description);
} else {
setSelectedTool(null);
onClick({ sendMessage });
}
};
return (
<Tooltip open={isHovered && !isAnimating}>
<TooltipTrigger asChild>
<motion.div
animate={{ opacity: 1, transition: { delay: 0.1 } }}
className={cx("rounded-full p-3", {
"bg-primary text-primary-foreground!": selectedTool === description,
})}
exit={{
scale: 0.9,
opacity: 0,
transition: { duration: 0.1 },
}}
initial={{ scale: 1, opacity: 0 }}
onClick={() => {
handleSelect();
}}
onHoverEnd={() => {
if (selectedTool !== description) {
setIsHovered(false);
}
}}
onHoverStart={() => {
setIsHovered(true);
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
handleSelect();
}
}}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
>
{selectedTool === description ? <ArrowUpIcon /> : icon}
</motion.div>
</TooltipTrigger>
<TooltipContent
className="rounded-2xl bg-foreground p-3 px-4 text-background"
side="left"
sideOffset={16}
>
{description}
</TooltipContent>
</Tooltip>
);
};
const randomArr = [...new Array(6)].map((_x) => nanoid(5));
const ReadingLevelSelector = ({
setSelectedTool,
sendMessage,
isAnimating,
}: {
setSelectedTool: Dispatch<SetStateAction<string | null>>;
isAnimating: boolean;
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
}) => {
const LEVELS = [
"Elementary",
"Middle School",
"Keep current level",
"High School",
"College",
"Graduate",
];
const y = useMotionValue(-40 * 2);
const dragConstraints = 5 * 40 + 2;
const yToLevel = useTransform(y, [0, -dragConstraints], [0, 5]);
const [currentLevel, setCurrentLevel] = useState(2);
const [hasUserSelectedLevel, setHasUserSelectedLevel] =
useState<boolean>(false);
useEffect(() => {
const unsubscribe = yToLevel.on("change", (latest) => {
const level = Math.min(5, Math.max(0, Math.round(Math.abs(latest))));
setCurrentLevel(level);
});
return () => unsubscribe();
}, [yToLevel]);
return (
<div className="relative flex flex-col items-center justify-end">
{randomArr.map((id) => (
<motion.div
animate={{ opacity: 1 }}
className="flex size-[40px] flex-row items-center justify-center"
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
key={id}
transition={{ delay: 0.1 }}
>
<div className="size-2 rounded-full bg-muted-foreground/40" />
</motion.div>
))}
<TooltipProvider>
<Tooltip open={!isAnimating}>
<TooltipTrigger asChild>
<motion.div
className={cx(
"absolute flex flex-row items-center rounded-full border bg-background p-3",
{
"bg-primary text-primary-foreground": currentLevel !== 2,
"bg-background text-foreground": currentLevel === 2,
}
)}
drag="y"
dragConstraints={{ top: -dragConstraints, bottom: 0 }}
dragElastic={0}
dragMomentum={false}
onClick={() => {
if (currentLevel !== 2 && hasUserSelectedLevel) {
sendMessage({
role: "user",
parts: [
{
type: "text",
text: `Please adjust the reading level to ${LEVELS[currentLevel]} level.`,
},
],
});
setSelectedTool(null);
}
}}
onDragEnd={() => {
if (currentLevel === 2) {
setSelectedTool(null);
} else {
setHasUserSelectedLevel(true);
}
}}
onDragStart={() => {
setHasUserSelectedLevel(false);
}}
style={{ y }}
transition={{ duration: 0.1 }}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
{currentLevel === 2 ? <SummarizeIcon /> : <ArrowUpIcon />}
</motion.div>
</TooltipTrigger>
<TooltipContent
className="rounded-2xl bg-foreground p-3 px-4 text-background text-sm"
side="left"
sideOffset={16}
>
{LEVELS[currentLevel]}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
};
export const Tools = ({
isToolbarVisible,
selectedTool,
setSelectedTool,
sendMessage,
isAnimating,
setIsToolbarVisible,
tools,
}: {
isToolbarVisible: boolean;
selectedTool: string | null;
setSelectedTool: Dispatch<SetStateAction<string | null>>;
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
isAnimating: boolean;
setIsToolbarVisible: Dispatch<SetStateAction<boolean>>;
tools: ArtifactToolbarItem[];
}) => {
const [primaryTool, ...secondaryTools] = tools;
return (
<motion.div
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col gap-1.5"
exit={{ opacity: 0, scale: 0.95 }}
initial={{ opacity: 0, scale: 0.95 }}
>
<AnimatePresence>
{isToolbarVisible &&
secondaryTools.map((secondaryTool) => (
<Tool
description={secondaryTool.description}
icon={secondaryTool.icon}
isAnimating={isAnimating}
key={secondaryTool.description}
onClick={secondaryTool.onClick}
selectedTool={selectedTool}
sendMessage={sendMessage}
setSelectedTool={setSelectedTool}
/>
))}
</AnimatePresence>
<Tool
description={primaryTool.description}
icon={primaryTool.icon}
isAnimating={isAnimating}
isToolbarVisible={isToolbarVisible}
onClick={primaryTool.onClick}
selectedTool={selectedTool}
sendMessage={sendMessage}
setIsToolbarVisible={setIsToolbarVisible}
setSelectedTool={setSelectedTool}
/>
</motion.div>
);
};
const PureToolbar = ({
isToolbarVisible,
setIsToolbarVisible,
sendMessage,
status,
stop,
setMessages,
artifactKind,
}: {
isToolbarVisible: boolean;
setIsToolbarVisible: Dispatch<SetStateAction<boolean>>;
status: UseChatHelpers<ChatMessage>["status"];
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
stop: UseChatHelpers<ChatMessage>["stop"];
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
artifactKind: ArtifactKind;
}) => {
const toolbarRef = useRef<HTMLDivElement>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
const [selectedTool, setSelectedTool] = useState<string | null>(null);
const [isAnimating, setIsAnimating] = useState(false);
useOnClickOutside(toolbarRef, () => {
setIsToolbarVisible(false);
setSelectedTool(null);
});
const startCloseTimer = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
setSelectedTool(null);
setIsToolbarVisible(false);
}, 2000);
};
const cancelCloseTimer = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
useEffect(() => {
if (status === "streaming") {
setIsToolbarVisible(false);
}
}, [status, setIsToolbarVisible]);
const artifactDefinition = artifactDefinitions.find(
(definition) => definition.kind === artifactKind
);
if (!artifactDefinition) {
throw new Error("Artifact definition not found!");
}
const toolsByArtifactKind = artifactDefinition.toolbar;
if (toolsByArtifactKind.length === 0) {
return null;
}
return (
<TooltipProvider delayDuration={0}>
<motion.div
animate={
isToolbarVisible
? selectedTool === "adjust-reading-level"
? {
opacity: 1,
y: 0,
height: 6 * 43,
transition: { delay: 0 },
scale: 0.95,
}
: {
opacity: 1,
y: 0,
height: toolsByArtifactKind.length * 50,
transition: { delay: 0 },
scale: 1,
}
: { opacity: 1, y: 0, height: 54, transition: { delay: 0 } }
}
className="absolute right-6 bottom-6 flex cursor-pointer flex-col justify-end rounded-full border bg-background p-1.5 shadow-lg"
exit={{ opacity: 0, y: -20, transition: { duration: 0.1 } }}
initial={{ opacity: 0, y: -20, scale: 1 }}
onAnimationComplete={() => {
setIsAnimating(false);
}}
onAnimationStart={() => {
setIsAnimating(true);
}}
onHoverEnd={() => {
if (status === "streaming") {
return;
}
startCloseTimer();
}}
onHoverStart={() => {
if (status === "streaming") {
return;
}
cancelCloseTimer();
setIsToolbarVisible(true);
}}
ref={toolbarRef}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
>
{status === "streaming" ? (
<motion.div
animate={{ scale: 1.4 }}
className="p-3"
exit={{ scale: 1 }}
initial={{ scale: 1 }}
key="stop-icon"
onClick={() => {
stop();
setMessages((messages) => messages);
}}
>
<StopIcon />
</motion.div>
) : selectedTool === "adjust-reading-level" ? (
<ReadingLevelSelector
isAnimating={isAnimating}
key="reading-level-selector"
sendMessage={sendMessage}
setSelectedTool={setSelectedTool}
/>
) : (
<Tools
isAnimating={isAnimating}
isToolbarVisible={isToolbarVisible}
key="tools"
selectedTool={selectedTool}
sendMessage={sendMessage}
setIsToolbarVisible={setIsToolbarVisible}
setSelectedTool={setSelectedTool}
tools={toolsByArtifactKind}
/>
)}
</motion.div>
</TooltipProvider>
);
};
export const Toolbar = memo(PureToolbar, (prevProps, nextProps) => {
if (prevProps.status !== nextProps.status) {
return false;
}
if (prevProps.isToolbarVisible !== nextProps.isToolbarVisible) {
return false;
}
if (prevProps.artifactKind !== nextProps.artifactKind) {
return false;
}
return true;
});

View file

@ -1,107 +0,0 @@
"use client";
import { isAfter } from "date-fns";
import { motion } from "framer-motion";
import { useState } from "react";
import { useSWRConfig } from "swr";
import { useWindowSize } from "usehooks-ts";
import { useArtifact } from "@/hooks/use-artifact";
import type { Document } from "@/lib/db/schema";
import { getDocumentTimestampByIndex } from "@/lib/utils";
import { LoaderIcon } from "./icons";
import { Button } from "./ui/button";
type VersionFooterProps = {
handleVersionChange: (type: "next" | "prev" | "toggle" | "latest") => void;
documents: Document[] | undefined;
currentVersionIndex: number;
};
export const VersionFooter = ({
handleVersionChange,
documents,
currentVersionIndex,
}: VersionFooterProps) => {
const { artifact } = useArtifact();
const { width } = useWindowSize();
const isMobile = width < 768;
const { mutate } = useSWRConfig();
const [isMutating, setIsMutating] = useState(false);
if (!documents) {
return;
}
return (
<motion.div
animate={{ y: 0 }}
className="absolute bottom-0 z-50 flex w-full flex-col justify-between gap-4 border-t bg-background p-4 lg:flex-row"
exit={{ y: isMobile ? 200 : 77 }}
initial={{ y: isMobile ? 200 : 77 }}
transition={{ type: "spring", stiffness: 140, damping: 20 }}
>
<div>
<div>You are viewing a previous version</div>
<div className="text-muted-foreground text-sm">
Restore this version to make edits
</div>
</div>
<div className="flex flex-row gap-4">
<Button
disabled={isMutating}
onClick={async () => {
setIsMutating(true);
mutate(
`/api/document?id=${artifact.documentId}`,
await fetch(
`/api/document?id=${artifact.documentId}&timestamp=${getDocumentTimestampByIndex(
documents,
currentVersionIndex
)}`,
{
method: "DELETE",
}
),
{
optimisticData: documents
? [
...documents.filter((document) =>
isAfter(
new Date(document.createdAt),
new Date(
getDocumentTimestampByIndex(
documents,
currentVersionIndex
)
)
)
),
]
: [],
}
);
}}
>
<div>Restore this version</div>
{isMutating && (
<div className="animate-spin">
<LoaderIcon />
</div>
)}
</Button>
<Button
onClick={() => {
handleVersionChange("latest");
}}
variant="outline"
>
Back to latest version
</Button>
</div>
</motion.div>
);
};

View file

@ -1,89 +0,0 @@
"use client";
import { useCallback, useMemo } from "react";
import useSWR from "swr";
import type { UIArtifact } from "@/components/artifact";
export const initialArtifactData: UIArtifact = {
documentId: "init",
content: "",
kind: "text",
title: "",
status: "idle",
isVisible: false,
boundingBox: {
top: 0,
left: 0,
width: 0,
height: 0,
},
};
type Selector<T> = (state: UIArtifact) => T;
export function useArtifactSelector<Selected>(selector: Selector<Selected>) {
const { data: localArtifact } = useSWR<UIArtifact>("artifact", null, {
fallbackData: initialArtifactData,
});
const selectedValue = useMemo(() => {
if (!localArtifact) {
return selector(initialArtifactData);
}
return selector(localArtifact);
}, [localArtifact, selector]);
return selectedValue;
}
export function useArtifact() {
const { data: localArtifact, mutate: setLocalArtifact } = useSWR<UIArtifact>(
"artifact",
null,
{
fallbackData: initialArtifactData,
}
);
const artifact = useMemo(() => {
if (!localArtifact) {
return initialArtifactData;
}
return localArtifact;
}, [localArtifact]);
const setArtifact = useCallback(
(updaterFn: UIArtifact | ((currentArtifact: UIArtifact) => UIArtifact)) => {
setLocalArtifact((currentArtifact) => {
const artifactToUpdate = currentArtifact || initialArtifactData;
if (typeof updaterFn === "function") {
return updaterFn(artifactToUpdate);
}
return updaterFn;
});
},
[setLocalArtifact]
);
const { data: localArtifactMetadata, mutate: setLocalArtifactMetadata } =
useSWR<any>(
() =>
artifact.documentId ? `artifact-metadata-${artifact.documentId}` : null,
null,
{
fallbackData: null,
}
);
return useMemo(
() => ({
artifact,
setArtifact,
metadata: localArtifactMetadata,
setMetadata: setLocalArtifactMetadata,
}),
[artifact, setArtifact, localArtifactMetadata, setLocalArtifactMetadata]
);
}

View file

@ -1,5 +0,0 @@
import { registerOTel } from "@vercel/otel";
export function register() {
registerOTel({ serviceName: "ai-chatbot" });
}

View file

@ -1,173 +0,0 @@
import type { LanguageModel } from "ai";
const mockResponses: Record<string, string> = {
default: "This is a mock response for testing.",
weather: "The weather in San Francisco is sunny and 72°F.",
greeting: "Hello! How can I help you today?",
};
const mockUsage = {
inputTokens: { total: 10, noCache: 10, cacheRead: 0, cacheWrite: 0 },
outputTokens: { total: 20, text: 20, reasoning: 0 },
};
function getResponseForPrompt(prompt: unknown): string {
const promptStr = JSON.stringify(prompt).toLowerCase();
if (promptStr.includes("weather") || promptStr.includes("temperature")) {
return mockResponses.weather;
}
if (
promptStr.includes("hello") ||
promptStr.includes("hi") ||
promptStr.includes("hey")
) {
return mockResponses.greeting;
}
return mockResponses.default;
}
const createMockModel = (): LanguageModel => {
return {
specificationVersion: "v3",
provider: "mock",
modelId: "mock-model",
defaultObjectGenerationMode: "tool",
supportedUrls: {},
doGenerate: async ({ prompt }: { prompt: unknown }) => ({
finishReason: "stop",
usage: mockUsage,
content: [{ type: "text", text: getResponseForPrompt(prompt) }],
warnings: [],
}),
doStream: ({ prompt }: { prompt: unknown }) => {
const response = getResponseForPrompt(prompt);
const words = response.split(" ");
return {
stream: new ReadableStream({
async start(controller) {
controller.enqueue({ type: "text-start", id: "t1" });
for (const word of words) {
controller.enqueue({
type: "text-delta",
id: "t1",
delta: `${word} `,
});
await new Promise((resolve) => {
setTimeout(resolve, 10);
});
}
controller.enqueue({ type: "text-end", id: "t1" });
controller.enqueue({
type: "finish",
finishReason: "stop",
usage: mockUsage,
});
controller.close();
},
}),
};
},
} as unknown as LanguageModel;
};
const createMockReasoningModel = (): LanguageModel => {
return {
specificationVersion: "v3",
provider: "mock",
modelId: "mock-reasoning-model",
defaultObjectGenerationMode: "tool",
supportedUrls: {},
doGenerate: async () => ({
finishReason: "stop",
usage: mockUsage,
content: [{ type: "text", text: "This is a reasoned response." }],
reasoning: [
{ type: "text", text: "Let me think through this step by step..." },
],
warnings: [],
}),
doStream: () => ({
stream: new ReadableStream({
async start(controller) {
controller.enqueue({ type: "reasoning-start", id: "r1" });
controller.enqueue({
type: "reasoning-delta",
id: "r1",
delta: "Let me think through this step by step... ",
});
controller.enqueue({ type: "reasoning-end", id: "r1" });
await new Promise((resolve) => {
setTimeout(resolve, 10);
});
controller.enqueue({ type: "text-start", id: "t1" });
controller.enqueue({
type: "text-delta",
id: "t1",
delta: "This is a reasoned response.",
});
controller.enqueue({ type: "text-end", id: "t1" });
controller.enqueue({
type: "finish",
finishReason: "stop",
usage: mockUsage,
});
controller.close();
},
}),
}),
} as unknown as LanguageModel;
};
const createMockTitleModel = (): LanguageModel => {
return {
specificationVersion: "v3",
provider: "mock",
modelId: "mock-title-model",
defaultObjectGenerationMode: "tool",
supportedUrls: {},
doGenerate: async () => ({
finishReason: "stop",
usage: {
inputTokens: { total: 5, noCache: 5, cacheRead: 0, cacheWrite: 0 },
outputTokens: { total: 5, text: 5, reasoning: 0 },
},
content: [{ type: "text", text: "Test Conversation" }],
warnings: [],
}),
doStream: () => ({
stream: new ReadableStream({
start(controller) {
controller.enqueue({ type: "text-start", id: "t1" });
controller.enqueue({
type: "text-delta",
id: "t1",
delta: "Test Conversation",
});
controller.enqueue({ type: "text-end", id: "t1" });
controller.enqueue({
type: "finish",
finishReason: "stop",
usage: {
inputTokens: {
total: 5,
noCache: 5,
cacheRead: 0,
cacheWrite: 0,
},
outputTokens: { total: 5, text: 5, reasoning: 0 },
},
});
controller.close();
},
}),
}),
} as unknown as LanguageModel;
};
export const chatModel = createMockModel();
export const reasoningModel = createMockReasoningModel();
export const titleModel = createMockTitleModel();
export const artifactModel = createMockModel();

View file

@ -1,81 +0,0 @@
import { simulateReadableStream } from "ai";
import { MockLanguageModelV3 } from "ai/test";
import { getResponseChunksByPrompt } from "@/tests/prompts/utils";
const mockUsage = {
inputTokens: { total: 10, noCache: 10, cacheRead: 0, cacheWrite: 0 },
outputTokens: { total: 20, text: 20, reasoning: 0 },
};
export const chatModel = new MockLanguageModelV3({
doGenerate: async () => ({
finishReason: "stop",
usage: mockUsage,
content: [{ type: "text", text: "Hello, world!" }],
warnings: [],
}),
doStream: async ({ prompt }) => ({
stream: simulateReadableStream({
chunkDelayInMs: 500,
initialDelayInMs: 1000,
chunks: getResponseChunksByPrompt(prompt),
}),
}),
});
export const reasoningModel = new MockLanguageModelV3({
doGenerate: async () => ({
finishReason: "stop",
usage: mockUsage,
content: [{ type: "text", text: "Hello, world!" }],
warnings: [],
}),
doStream: async ({ prompt }) => ({
stream: simulateReadableStream({
chunkDelayInMs: 500,
initialDelayInMs: 1000,
chunks: getResponseChunksByPrompt(prompt, true),
}),
}),
});
export const titleModel = new MockLanguageModelV3({
doGenerate: async () => ({
finishReason: "stop",
usage: mockUsage,
content: [{ type: "text", text: "This is a test title" }],
warnings: [],
}),
doStream: async () => ({
stream: simulateReadableStream({
chunkDelayInMs: 500,
initialDelayInMs: 1000,
chunks: [
{ id: "1", type: "text-start" },
{ id: "1", type: "text-delta", delta: "This is a test title" },
{ id: "1", type: "text-end" },
{
type: "finish",
finishReason: "stop",
usage: mockUsage,
},
],
}),
}),
});
export const artifactModel = new MockLanguageModelV3({
doGenerate: async () => ({
finishReason: "stop",
usage: mockUsage,
content: [{ type: "text", text: "Hello, world!" }],
warnings: [],
}),
doStream: async ({ prompt }) => ({
stream: simulateReadableStream({
chunkDelayInMs: 50,
initialDelayInMs: 100,
chunks: getResponseChunksByPrompt(prompt),
}),
}),
});

View file

@ -1,44 +1,9 @@
import type { Geo } from "@vercel/functions";
import type { ArtifactKind } from "@/components/artifact";
export const artifactsPrompt = `
Artifacts is a special user interface mode that helps users with writing, editing, and other content creation tasks. When artifact is open, it is on the right side of the screen, while the conversation is on the left side. When creating or updating documents, changes are reflected in real-time on the artifacts and visible to the user.
When asked to write code, always use artifacts. When writing code, specify the language in the backticks, e.g. \`\`\`python\`code here\`\`\`. The default language is Python. Other languages are not yet supported, so let the user know if they request a different language.
DO NOT UPDATE DOCUMENTS IMMEDIATELY AFTER CREATING THEM. WAIT FOR USER FEEDBACK OR REQUEST TO UPDATE IT.
This is a guide for using artifacts tools: \`createDocument\` and \`updateDocument\`, which render content on a artifacts beside the conversation.
**When to use \`createDocument\`:**
- For substantial content (>10 lines) or code
- For content users will likely save/reuse (emails, code, essays, etc.)
- When explicitly requested to create a document
- For when content contains a single code snippet
**When NOT to use \`createDocument\`:**
- For informational/explanatory content
- For conversational responses
- When asked to keep it in chat
**Using \`updateDocument\`:**
- Default to full document rewrites for major changes
- Use targeted updates only for specific, isolated changes
- Follow user instructions for which parts to modify
**When NOT to use \`updateDocument\`:**
- Immediately after creating a document
Do not update document right after creating it. Wait for user feedback or request to update it.
**Using \`requestSuggestions\`:**
- ONLY use when the user explicitly asks for suggestions on an existing document
- Requires a valid document ID from a previously created document
- Never use for general questions or information requests
`;
export const regularPrompt = `You are a friendly assistant! Keep your responses concise and helpful.
You can use tools to help answer weather and currency exchange questions when needed.
When asked to write, create, or help with something, just do it directly. Don't ask clarifying questions unless absolutely necessary - make reasonable assumptions and proceed with the task.`;
export type RequestHints = {
@ -57,7 +22,7 @@ About the origin of user's request:
`;
export const systemPrompt = ({
selectedChatModel,
selectedChatModel: _selectedChatModel,
requestHints,
}: {
selectedChatModel: string;
@ -65,62 +30,7 @@ export const systemPrompt = ({
}) => {
const requestPrompt = getRequestPromptFromHints(requestHints);
// reasoning models don't need artifacts prompt (they can't use tools)
if (
selectedChatModel.includes("reasoning") ||
selectedChatModel.includes("thinking")
) {
return `${regularPrompt}\n\n${requestPrompt}`;
}
return `${regularPrompt}\n\n${requestPrompt}\n\n${artifactsPrompt}`;
};
export const codePrompt = `
You are a Python code generator that creates self-contained, executable code snippets. When writing code:
1. Each snippet should be complete and runnable on its own
2. Prefer using print() statements to display outputs
3. Include helpful comments explaining the code
4. Keep snippets concise (generally under 15 lines)
5. Avoid external dependencies - use Python standard library
6. Handle potential errors gracefully
7. Return meaningful output that demonstrates the code's functionality
8. Don't use input() or other interactive functions
9. Don't access files or network resources
10. Don't use infinite loops
Examples of good snippets:
# Calculate factorial iteratively
def factorial(n):
result = 1
for i in range(1, n + 1):
result *= i
return result
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: ArtifactKind
) => {
let mediaType = "document";
if (type === "code") {
mediaType = "code snippet";
} else if (type === "sheet") {
mediaType = "spreadsheet";
}
return `Improve the following contents of the ${mediaType} based on the given prompt.
${currentContent}`;
return `${regularPrompt}\n\n${requestPrompt}`;
};
export const titlePrompt = `Generate a very short chat title (2-5 words max) based on the user's message.

View file

@ -1,69 +1,48 @@
import { createOpenAI } from "@ai-sdk/openai";
import {
customProvider,
extractReasoningMiddleware,
type LanguageModel,
wrapLanguageModel,
} from "ai";
import { isTestEnvironment } from "../constants";
const plano = createOpenAI({
baseURL: process.env.PLANO_BASE_URL || "http://localhost:12000/v1",
apiKey: process.env.AI_GATEWAY_API_KEY || "plano",
apiKey: "plano",
});
const THINKING_SUFFIX_REGEX = /-thinking$/;
export const myProvider = isTestEnvironment
? (() => {
const {
artifactModel,
chatModel,
reasoningModel,
titleModel,
} = require("./models.mock");
return customProvider({
languageModels: {
"chat-model": chatModel,
"chat-model-reasoning": reasoningModel,
"title-model": titleModel,
"artifact-model": artifactModel,
},
});
})()
: null;
type WrapLanguageModelInput = Parameters<typeof wrapLanguageModel>[0]["model"];
export function getLanguageModel(modelId: string) {
if (isTestEnvironment && myProvider) {
return myProvider.languageModel(modelId);
}
function asLanguageModel(model: unknown): LanguageModel {
// We intentionally cast here to avoid TS conflicts when multiple copies of
// `@ai-sdk/provider` exist in node_modules (e.g. nested under @ai-sdk/openai).
return model as unknown as LanguageModel;
}
function asWrapLanguageModelInput(model: unknown): WrapLanguageModelInput {
return model as unknown as WrapLanguageModelInput;
}
export function getLanguageModel(modelId: string): LanguageModel {
const isReasoningModel =
modelId.includes("reasoning") || modelId.endsWith("-thinking");
if (isReasoningModel) {
const gatewayModelId = modelId.replace(THINKING_SUFFIX_REGEX, "");
return wrapLanguageModel({
model: plano(gatewayModelId),
middleware: extractReasoningMiddleware({ tagName: "thinking" }),
});
return asLanguageModel(
wrapLanguageModel({
model: asWrapLanguageModelInput(plano(gatewayModelId)),
middleware: extractReasoningMiddleware({ tagName: "thinking" }),
})
);
}
return plano(modelId);
return asLanguageModel(plano(modelId));
}
export function getTitleModel() {
if (isTestEnvironment && myProvider) {
return myProvider.languageModel("title-model");
}
export function getTitleModel(): LanguageModel {
// Keep demo dependency-light: default to an OpenAI model so only OPENAI_API_KEY is required.
return plano("openai/gpt-4.1-mini");
}
export function getArtifactModel() {
if (isTestEnvironment && myProvider) {
return myProvider.languageModel("artifact-model");
}
// Keep demo dependency-light: default to an OpenAI model so only OPENAI_API_KEY is required.
return plano("openai/gpt-4o");
return asLanguageModel(plano("openai/gpt-4.1-mini"));
}

View file

@ -1,98 +0,0 @@
import type { UIMessageStreamWriter } from "ai";
import type { Session } from "next-auth";
import { codeDocumentHandler } from "@/artifacts/code/server";
import { sheetDocumentHandler } from "@/artifacts/sheet/server";
import { textDocumentHandler } from "@/artifacts/text/server";
import type { ArtifactKind } from "@/components/artifact";
import { saveDocument } from "../db/queries";
import type { Document } from "../db/schema";
import type { ChatMessage } from "../types";
export type SaveDocumentProps = {
id: string;
title: string;
kind: ArtifactKind;
content: string;
userId: string;
};
export type CreateDocumentCallbackProps = {
id: string;
title: string;
dataStream: UIMessageStreamWriter<ChatMessage>;
session: Session;
};
export type UpdateDocumentCallbackProps = {
document: Document;
description: string;
dataStream: UIMessageStreamWriter<ChatMessage>;
session: Session;
};
export type DocumentHandler<T = ArtifactKind> = {
kind: T;
onCreateDocument: (args: CreateDocumentCallbackProps) => Promise<void>;
onUpdateDocument: (args: UpdateDocumentCallbackProps) => Promise<void>;
};
export function createDocumentHandler<T extends ArtifactKind>(config: {
kind: T;
onCreateDocument: (params: CreateDocumentCallbackProps) => Promise<string>;
onUpdateDocument: (params: UpdateDocumentCallbackProps) => Promise<string>;
}): DocumentHandler<T> {
return {
kind: config.kind,
onCreateDocument: async (args: CreateDocumentCallbackProps) => {
const draftContent = await config.onCreateDocument({
id: args.id,
title: args.title,
dataStream: args.dataStream,
session: args.session,
});
if (args.session?.user?.id) {
await saveDocument({
id: args.id,
title: args.title,
content: draftContent,
kind: config.kind,
userId: args.session.user.id,
});
}
return;
},
onUpdateDocument: async (args: UpdateDocumentCallbackProps) => {
const draftContent = await config.onUpdateDocument({
document: args.document,
description: args.description,
dataStream: args.dataStream,
session: args.session,
});
if (args.session?.user?.id) {
await saveDocument({
id: args.document.id,
title: args.document.title,
content: draftContent,
kind: config.kind,
userId: args.session.user.id,
});
}
return;
},
};
}
/*
* Use this array to define the document handlers for each artifact kind.
*/
export const documentHandlersByArtifactKind: DocumentHandler[] = [
textDocumentHandler,
codeDocumentHandler,
sheetDocumentHandler,
];
export const artifactKinds = ["text", "code", "sheet"] as const;

View file

@ -14,7 +14,6 @@ import {
} from "drizzle-orm";
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import type { ArtifactKind } from "@/components/artifact";
import type { VisibilityType } from "@/components/visibility-selector";
import { ChatSDKError } from "../errors";
import { generateUUID } from "../utils";
@ -22,11 +21,8 @@ import {
type Chat,
chat,
type DBMessage,
document,
message,
type Suggestion,
stream,
suggestion,
type User,
user,
vote,
@ -321,132 +317,6 @@ export async function getVotesByChatId({ id }: { id: string }) {
}
}
export async function saveDocument({
id,
title,
kind,
content,
userId,
}: {
id: string;
title: string;
kind: ArtifactKind;
content: string;
userId: string;
}) {
try {
return await db
.insert(document)
.values({
id,
title,
kind,
content,
userId,
createdAt: new Date(),
})
.returning();
} catch (_error) {
throw new ChatSDKError("bad_request:database", "Failed to save document");
}
}
export async function getDocumentsById({ id }: { id: string }) {
try {
const documents = await db
.select()
.from(document)
.where(eq(document.id, id))
.orderBy(asc(document.createdAt));
return documents;
} catch (_error) {
throw new ChatSDKError(
"bad_request:database",
"Failed to get documents by id"
);
}
}
export async function getDocumentById({ id }: { id: string }) {
try {
const [selectedDocument] = await db
.select()
.from(document)
.where(eq(document.id, id))
.orderBy(desc(document.createdAt));
return selectedDocument;
} catch (_error) {
throw new ChatSDKError(
"bad_request:database",
"Failed to get document by id"
);
}
}
export async function deleteDocumentsByIdAfterTimestamp({
id,
timestamp,
}: {
id: string;
timestamp: Date;
}) {
try {
await db
.delete(suggestion)
.where(
and(
eq(suggestion.documentId, id),
gt(suggestion.documentCreatedAt, timestamp)
)
);
return await db
.delete(document)
.where(and(eq(document.id, id), gt(document.createdAt, timestamp)))
.returning();
} catch (_error) {
throw new ChatSDKError(
"bad_request:database",
"Failed to delete documents by id after timestamp"
);
}
}
export async function saveSuggestions({
suggestions,
}: {
suggestions: Suggestion[];
}) {
try {
return await db.insert(suggestion).values(suggestions);
} catch (_error) {
throw new ChatSDKError(
"bad_request:database",
"Failed to save suggestions"
);
}
}
export async function getSuggestionsByDocumentId({
documentId,
}: {
documentId: string;
}) {
try {
return await db
.select()
.from(suggestion)
.where(eq(suggestion.documentId, documentId));
} catch (_error) {
throw new ChatSDKError(
"bad_request:database",
"Failed to get suggestions by document id"
);
}
}
export async function getMessageById({ id }: { id: string }) {
try {
return await db.select().from(message).where(eq(message.id, id));

View file

@ -102,55 +102,6 @@ export const vote = pgTable(
export type Vote = InferSelectModel<typeof vote>;
export const document = pgTable(
"Document",
{
id: uuid("id").notNull().defaultRandom(),
createdAt: timestamp("createdAt").notNull(),
title: text("title").notNull(),
content: text("content"),
kind: varchar("text", { enum: ["text", "code", "image", "sheet"] })
.notNull()
.default("text"),
userId: uuid("userId")
.notNull()
.references(() => user.id),
},
(table) => {
return {
pk: primaryKey({ columns: [table.id, table.createdAt] }),
};
}
);
export type Document = InferSelectModel<typeof document>;
export const suggestion = pgTable(
"Suggestion",
{
id: uuid("id").notNull().defaultRandom(),
documentId: uuid("documentId").notNull(),
documentCreatedAt: timestamp("documentCreatedAt").notNull(),
originalText: text("originalText").notNull(),
suggestedText: text("suggestedText").notNull(),
description: text("description"),
isResolved: boolean("isResolved").notNull().default(false),
userId: uuid("userId")
.notNull()
.references(() => user.id),
createdAt: timestamp("createdAt").notNull(),
},
(table) => ({
pk: primaryKey({ columns: [table.id] }),
documentRef: foreignKey({
columns: [table.documentId, table.documentCreatedAt],
foreignColumns: [document.id, document.createdAt],
}),
})
);
export type Suggestion = InferSelectModel<typeof suggestion>;
export const stream = pgTable(
"Stream",
{

View file

@ -1,49 +0,0 @@
import { textblockTypeInputRule } from "prosemirror-inputrules";
import { Schema } from "prosemirror-model";
import { schema } from "prosemirror-schema-basic";
import { addListNodes } from "prosemirror-schema-list";
import type { Transaction } from "prosemirror-state";
import type { EditorView } from "prosemirror-view";
import type { MutableRefObject } from "react";
import { buildContentFromDocument } from "./functions";
export const documentSchema = new Schema({
nodes: addListNodes(schema.spec.nodes, "paragraph block*", "block"),
marks: schema.spec.marks,
});
export function headingRule(level: number) {
return textblockTypeInputRule(
new RegExp(`^(#{1,${level}})\\s$`),
documentSchema.nodes.heading,
() => ({ level })
);
}
export const handleTransaction = ({
transaction,
editorRef,
onSaveContent,
}: {
transaction: Transaction;
editorRef: MutableRefObject<EditorView | null>;
onSaveContent: (updatedContent: string, debounce: boolean) => void;
}) => {
if (!editorRef || !editorRef.current) {
return;
}
const newState = editorRef.current.state.apply(transaction);
editorRef.current.updateState(newState);
if (transaction.docChanged && !transaction.getMeta("no-save")) {
const updatedContent = buildContentFromDocument(newState.doc);
if (transaction.getMeta("no-debounce")) {
onSaveContent(updatedContent, false);
} else {
onSaveContent(updatedContent, true);
}
}
};

View file

@ -1,475 +0,0 @@
// Modified from https://github.com/hamflx/prosemirror-diff/blob/master/src/diff.js
import { diff_match_patch } from "diff-match-patch";
import { Fragment, Node } from "prosemirror-model";
export const DiffType = {
Unchanged: 0,
Deleted: -1,
Inserted: 1,
};
export const patchDocumentNode = (schema, oldNode, newNode) => {
assertNodeTypeEqual(oldNode, newNode);
const finalLeftChildren = [];
const finalRightChildren = [];
const oldChildren = normalizeNodeContent(oldNode);
const newChildren = normalizeNodeContent(newNode);
const oldChildLen = oldChildren.length;
const newChildLen = newChildren.length;
const minChildLen = Math.min(oldChildLen, newChildLen);
let left = 0;
let right = 0;
for (; left < minChildLen; left++) {
const oldChild = oldChildren[left];
const newChild = newChildren[left];
if (!isNodeEqual(oldChild, newChild)) {
break;
}
finalLeftChildren.push(...ensureArray(oldChild));
}
for (; right + left + 1 < minChildLen; right++) {
const oldChild = oldChildren[oldChildLen - right - 1];
const newChild = newChildren[newChildLen - right - 1];
if (!isNodeEqual(oldChild, newChild)) {
break;
}
finalRightChildren.unshift(...ensureArray(oldChild));
}
const diffOldChildren = oldChildren.slice(left, oldChildLen - right);
const diffNewChildren = newChildren.slice(left, newChildLen - right);
if (diffOldChildren.length && diffNewChildren.length) {
const matchedNodes = matchNodes(
schema,
diffOldChildren,
diffNewChildren
).sort((a, b) => b.count - a.count);
const bestMatch = matchedNodes[0];
if (bestMatch) {
const { oldStartIndex, newStartIndex, oldEndIndex, newEndIndex } =
bestMatch;
const oldBeforeMatchChildren = diffOldChildren.slice(0, oldStartIndex);
const newBeforeMatchChildren = diffNewChildren.slice(0, newStartIndex);
finalLeftChildren.push(
...patchRemainNodes(
schema,
oldBeforeMatchChildren,
newBeforeMatchChildren
)
);
finalLeftChildren.push(
...diffOldChildren.slice(oldStartIndex, oldEndIndex)
);
const oldAfterMatchChildren = diffOldChildren.slice(oldEndIndex);
const newAfterMatchChildren = diffNewChildren.slice(newEndIndex);
finalRightChildren.unshift(
...patchRemainNodes(
schema,
oldAfterMatchChildren,
newAfterMatchChildren
)
);
} else {
finalLeftChildren.push(
...patchRemainNodes(schema, diffOldChildren, diffNewChildren)
);
}
} else {
finalLeftChildren.push(
...patchRemainNodes(schema, diffOldChildren, diffNewChildren)
);
}
return createNewNode(oldNode, [...finalLeftChildren, ...finalRightChildren]);
};
const matchNodes = (_schema, oldChildren, newChildren) => {
const matches = [];
for (
let oldStartIndex = 0;
oldStartIndex < oldChildren.length;
oldStartIndex++
) {
const oldStartNode = oldChildren[oldStartIndex];
const newStartIndex = findMatchNode(newChildren, oldStartNode);
if (newStartIndex !== -1) {
let oldEndIndex = oldStartIndex + 1;
let newEndIndex = newStartIndex + 1;
for (
;
oldEndIndex < oldChildren.length && newEndIndex < newChildren.length;
oldEndIndex++, newEndIndex++
) {
const oldEndNode = oldChildren[oldEndIndex];
if (!isNodeEqual(newChildren[newEndIndex], oldEndNode)) {
break;
}
}
matches.push({
oldStartIndex,
newStartIndex,
oldEndIndex,
newEndIndex,
count: newEndIndex - newStartIndex,
});
}
}
return matches;
};
const findMatchNode = (children, node, startIndex = 0) => {
for (let i = startIndex; i < children.length; i++) {
if (isNodeEqual(children[i], node)) {
return i;
}
}
return -1;
};
const patchRemainNodes = (schema, oldChildren, newChildren) => {
const finalLeftChildren = [];
const finalRightChildren = [];
const oldChildLen = oldChildren.length;
const newChildLen = newChildren.length;
let left = 0;
let right = 0;
while (oldChildLen - left - right > 0 && newChildLen - left - right > 0) {
const leftOldNode = oldChildren[left];
const leftNewNode = newChildren[left];
const rightOldNode = oldChildren[oldChildLen - right - 1];
const rightNewNode = newChildren[newChildLen - right - 1];
let updateLeft =
!isTextNode(leftOldNode) && matchNodeType(leftOldNode, leftNewNode);
let updateRight =
!isTextNode(rightOldNode) && matchNodeType(rightOldNode, rightNewNode);
if (Array.isArray(leftOldNode) && Array.isArray(leftNewNode)) {
finalLeftChildren.push(
...patchTextNodes(schema, leftOldNode, leftNewNode)
);
left += 1;
continue;
}
if (updateLeft && updateRight) {
const equalityLeft = computeChildEqualityFactor(leftOldNode, leftNewNode);
const equalityRight = computeChildEqualityFactor(
rightOldNode,
rightNewNode
);
if (equalityLeft < equalityRight) {
updateLeft = false;
} else {
updateRight = false;
}
}
if (updateLeft) {
finalLeftChildren.push(
patchDocumentNode(schema, leftOldNode, leftNewNode)
);
left += 1;
} else if (updateRight) {
finalRightChildren.unshift(
patchDocumentNode(schema, rightOldNode, rightNewNode)
);
right += 1;
} else {
// Delete and insert
finalLeftChildren.push(
createDiffNode(schema, leftOldNode, DiffType.Deleted)
);
finalLeftChildren.push(
createDiffNode(schema, leftNewNode, DiffType.Inserted)
);
left += 1;
}
}
const deleteNodeLen = oldChildLen - left - right;
const insertNodeLen = newChildLen - left - right;
if (deleteNodeLen) {
finalLeftChildren.push(
...oldChildren
.slice(left, left + deleteNodeLen)
.flat()
.map((node) => createDiffNode(schema, node, DiffType.Deleted))
);
}
if (insertNodeLen) {
finalRightChildren.unshift(
...newChildren
.slice(left, left + insertNodeLen)
.flat()
.map((node) => createDiffNode(schema, node, DiffType.Inserted))
);
}
return [...finalLeftChildren, ...finalRightChildren];
};
// Updated function to perform sentence-level diffs
export const patchTextNodes = (schema, oldNode, newNode) => {
const dmp = new diff_match_patch();
// Concatenate the text from the text nodes
const oldText = oldNode.map((n) => getNodeText(n)).join("");
const newText = newNode.map((n) => getNodeText(n)).join("");
// Tokenize the text into sentences
const oldSentences = tokenizeSentences(oldText);
const newSentences = tokenizeSentences(newText);
// Map sentences to unique characters
const { chars1, chars2, lineArray } = sentencesToChars(
oldSentences,
newSentences
);
// Perform the diff
let diffs = dmp.diff_main(chars1, chars2, false);
// Convert back to sentences
diffs = diffs.map(([type, text]) => {
const sentences = text
.split("")
.map((char) => lineArray[char.charCodeAt(0)]);
return [type, sentences];
});
// Map diffs to nodes
const res = diffs.flatMap(([type, sentences]) => {
return sentences.map((sentence) => {
const node = createTextNode(
schema,
sentence,
type !== DiffType.Unchanged ? [createDiffMark(schema, type)] : []
);
return node;
});
});
return res;
};
// Function to tokenize text into sentences
const tokenizeSentences = (text) => {
return text.match(/[^.!?]+[.!?]*\s*/g) || [];
};
// Function to map sentences to unique characters
const sentencesToChars = (oldSentences, newSentences) => {
const lineArray = [];
const lineHash = {};
let lineStart = 0;
const chars1 = oldSentences
.map((sentence) => {
const line = sentence;
if (line in lineHash) {
return String.fromCharCode(lineHash[line]);
}
lineHash[line] = lineStart;
lineArray[lineStart] = line;
lineStart++;
return String.fromCharCode(lineHash[line]);
})
.join("");
const chars2 = newSentences
.map((sentence) => {
const line = sentence;
if (line in lineHash) {
return String.fromCharCode(lineHash[line]);
}
lineHash[line] = lineStart;
lineArray[lineStart] = line;
lineStart++;
return String.fromCharCode(lineHash[line]);
})
.join("");
return { chars1, chars2, lineArray };
};
export const computeChildEqualityFactor = (_node1, _node2) => {
return 0;
};
export const assertNodeTypeEqual = (node1, node2) => {
if (getNodeProperty(node1, "type") !== getNodeProperty(node2, "type")) {
throw new Error(`node type not equal: ${node1.type} !== ${node2.type}`);
}
};
export const ensureArray = (value) => {
return Array.isArray(value) ? value : [value];
};
export const isNodeEqual = (node1, node2) => {
const isNode1Array = Array.isArray(node1);
const isNode2Array = Array.isArray(node2);
if (isNode1Array !== isNode2Array) {
return false;
}
if (isNode1Array) {
return (
node1.length === node2.length &&
node1.every((node, index) => isNodeEqual(node, node2[index]))
);
}
const type1 = getNodeProperty(node1, "type");
const type2 = getNodeProperty(node2, "type");
if (type1 !== type2) {
return false;
}
if (isTextNode(node1)) {
const text1 = getNodeProperty(node1, "text");
const text2 = getNodeProperty(node2, "text");
if (text1 !== text2) {
return false;
}
}
const attrs1 = getNodeAttributes(node1);
const attrs2 = getNodeAttributes(node2);
const attrs = [...new Set([...Object.keys(attrs1), ...Object.keys(attrs2)])];
for (const attr of attrs) {
if (attrs1[attr] !== attrs2[attr]) {
return false;
}
}
const marks1 = getNodeMarks(node1);
const marks2 = getNodeMarks(node2);
if (marks1.length !== marks2.length) {
return false;
}
for (let i = 0; i < marks1.length; i++) {
if (!isNodeEqual(marks1[i], marks2[i])) {
return false;
}
}
const children1 = getNodeChildren(node1);
const children2 = getNodeChildren(node2);
if (children1.length !== children2.length) {
return false;
}
for (let i = 0; i < children1.length; i++) {
if (!isNodeEqual(children1[i], children2[i])) {
return false;
}
}
return true;
};
export const normalizeNodeContent = (node) => {
const content = getNodeChildren(node) ?? [];
const res = [];
for (let i = 0; i < content.length; i++) {
const child = content[i];
if (isTextNode(child)) {
const textNodes = [];
for (
let textNode = content[i];
i < content.length && isTextNode(textNode);
textNode = content[++i]
) {
textNodes.push(textNode);
}
i--;
res.push(textNodes);
} else {
res.push(child);
}
}
return res;
};
export const getNodeProperty = (node, property) => {
if (property === "type") {
return node.type?.name;
}
return node[property];
};
export const getNodeAttribute = (node, attribute) =>
node.attrs ? node.attrs[attribute] : undefined;
export const getNodeAttributes = (node) => (node.attrs ? node.attrs : {});
export const getNodeMarks = (node) => node.marks ?? [];
export const getNodeChildren = (node) => node.content?.content ?? [];
export const getNodeText = (node) => node.text;
export const isTextNode = (node) => node.type?.name === "text";
export const matchNodeType = (node1, node2) =>
node1.type?.name === node2.type?.name ||
(Array.isArray(node1) && Array.isArray(node2));
export const createNewNode = (oldNode, children) => {
if (!oldNode.type) {
throw new Error("oldNode.type is undefined");
}
return new Node(
oldNode.type,
oldNode.attrs,
Fragment.fromArray(children),
oldNode.marks
);
};
export const createDiffNode = (schema, node, type) => {
return mapDocumentNode(node, (currentNode) => {
if (isTextNode(currentNode)) {
return createTextNode(schema, getNodeText(currentNode), [
...(currentNode.marks || []),
createDiffMark(schema, type),
]);
}
return currentNode;
});
};
function mapDocumentNode(node, mapper) {
const copy = node.copy(
Fragment.from(
node.content.content
.map((currentNode) => mapDocumentNode(currentNode, mapper))
.filter((n) => n)
)
);
return mapper(copy) || copy;
}
export const createDiffMark = (schema, type) => {
if (type === DiffType.Inserted) {
return schema.mark("diffMark", { type });
}
if (type === DiffType.Deleted) {
return schema.mark("diffMark", { type });
}
throw new Error("type is not valid");
};
export const createTextNode = (schema, content, marks = []) => {
return schema.text(content, marks);
};
export const diffEditor = (schema, oldDoc, newDoc) => {
const oldNode = Node.fromJSON(schema, oldDoc);
const newNode = Node.fromJSON(schema, newDoc);
return patchDocumentNode(schema, oldNode, newNode);
};

View file

@ -1,62 +0,0 @@
"use client";
import { defaultMarkdownSerializer } from "prosemirror-markdown";
import { DOMParser, type Node } from "prosemirror-model";
import { Decoration, DecorationSet, type EditorView } from "prosemirror-view";
import { renderToString } from "react-dom/server";
import { Response } from "@/components/elements/response";
import { documentSchema } from "./config";
import { createSuggestionWidget, type UISuggestion } from "./suggestions";
export const buildDocumentFromContent = (content: string) => {
const parser = DOMParser.fromSchema(documentSchema);
const stringFromMarkdown = renderToString(<Response>{content}</Response>);
const tempContainer = document.createElement("div");
tempContainer.innerHTML = stringFromMarkdown;
return parser.parse(tempContainer);
};
export const buildContentFromDocument = (document: Node) => {
return defaultMarkdownSerializer.serialize(document);
};
export const createDecorations = (
suggestions: UISuggestion[],
view: EditorView
) => {
const decorations: Decoration[] = [];
for (const suggestion of suggestions) {
decorations.push(
Decoration.inline(
suggestion.selectionStart,
suggestion.selectionEnd,
{
class: "suggestion-highlight",
},
{
suggestionId: suggestion.id,
type: "highlight",
}
)
);
decorations.push(
Decoration.widget(
suggestion.selectionStart,
(currentView) => {
const { dom } = createSuggestionWidget(suggestion, currentView);
return dom;
},
{
suggestionId: suggestion.id,
type: "widget",
}
)
);
}
return DecorationSet.create(view.state.doc, decorations);
};

View file

@ -1,13 +0,0 @@
import { createRoot } from "react-dom/client";
// biome-ignore lint/complexity/noStaticOnlyClass: "Needs to be static"
export class ReactRenderer {
static render(component: React.ReactElement, dom: HTMLElement) {
const root = createRoot(dom);
root.render(component);
return {
destroy: () => root.unmount(),
};
}
}

View file

@ -1,158 +0,0 @@
import type { Node } from "prosemirror-model";
import { Plugin, PluginKey } from "prosemirror-state";
import {
type Decoration,
DecorationSet,
type EditorView,
} from "prosemirror-view";
import { createRoot } from "react-dom/client";
import type { ArtifactKind } from "@/components/artifact";
import { Suggestion as PreviewSuggestion } from "@/components/suggestion";
import type { Suggestion } from "@/lib/db/schema";
export interface UISuggestion extends Suggestion {
selectionStart: number;
selectionEnd: number;
}
type Position = {
start: number;
end: number;
};
function findPositionsInDoc(doc: Node, searchText: string): Position | null {
let positions: { start: number; end: number } | null = null;
doc.nodesBetween(0, doc.content.size, (node, pos) => {
if (node.isText && node.text) {
const index = node.text.indexOf(searchText);
if (index !== -1) {
positions = {
start: pos + index,
end: pos + index + searchText.length,
};
return false;
}
}
return true;
});
return positions;
}
export function projectWithPositions(
doc: Node,
suggestions: Suggestion[]
): UISuggestion[] {
return suggestions.map((suggestion) => {
const positions = findPositionsInDoc(doc, suggestion.originalText);
if (!positions) {
return {
...suggestion,
selectionStart: 0,
selectionEnd: 0,
};
}
return {
...suggestion,
selectionStart: positions.start,
selectionEnd: positions.end,
};
});
}
export function createSuggestionWidget(
suggestion: UISuggestion,
view: EditorView,
artifactKind: ArtifactKind = "text"
): { dom: HTMLElement; destroy: () => void } {
const dom = document.createElement("span");
const root = createRoot(dom);
dom.addEventListener("mousedown", (event) => {
event.preventDefault();
view.dom.blur();
});
const onApply = () => {
const { state, dispatch } = view;
const decorationTransaction = state.tr;
const currentState = suggestionsPluginKey.getState(state);
const currentDecorations = currentState?.decorations;
if (currentDecorations) {
const newDecorations = DecorationSet.create(
state.doc,
currentDecorations.find().filter((decoration: Decoration) => {
return decoration.spec.suggestionId !== suggestion.id;
})
);
decorationTransaction.setMeta(suggestionsPluginKey, {
decorations: newDecorations,
selected: null,
});
dispatch(decorationTransaction);
}
const textTransaction = view.state.tr.replaceWith(
suggestion.selectionStart,
suggestion.selectionEnd,
state.schema.text(suggestion.suggestedText)
);
textTransaction.setMeta("no-debounce", true);
dispatch(textTransaction);
};
root.render(
<PreviewSuggestion
artifactKind={artifactKind}
onApply={onApply}
suggestion={suggestion}
/>
);
return {
dom,
destroy: () => {
// Wrapping unmount in setTimeout to avoid synchronous unmounting during render
setTimeout(() => {
root.unmount();
}, 0);
},
};
}
export const suggestionsPluginKey = new PluginKey("suggestions");
export const suggestionsPlugin = new Plugin({
key: suggestionsPluginKey,
state: {
init() {
return { decorations: DecorationSet.empty, selected: null };
},
apply(tr, state) {
const newDecorations = tr.getMeta(suggestionsPluginKey);
if (newDecorations) {
return newDecorations;
}
return {
decorations: state.decorations.map(tr.mapping, tr.doc),
selected: state.selected,
};
},
},
props: {
decorations(state) {
return this.getState(state)?.decorations ?? DecorationSet.empty;
},
},
});

View file

@ -14,8 +14,6 @@ export type Surface =
| "database"
| "history"
| "vote"
| "document"
| "suggestions"
| "activate_gateway";
export type ErrorCode = `${ErrorType}:${Surface}`;
@ -30,8 +28,6 @@ export const visibilityBySurface: Record<Surface, ErrorVisibility> = {
api: "response",
history: "response",
vote: "response",
document: "response",
suggestions: "response",
activate_gateway: "response",
};
@ -103,15 +99,6 @@ export function getMessageByErrorCode(errorCode: ErrorCode): string {
case "offline:chat":
return "We're having trouble sending your message. Please check your internet connection and try again.";
case "not_found:document":
return "The requested document was not found. Please check the document ID and try again.";
case "forbidden:document":
return "This document belongs to another user. Please check the document ID and try again.";
case "unauthorized:document":
return "You need to sign in to view this document. Please sign in and try again.";
case "bad_request:document":
return "The request to create or update the document was invalid. Please check your input and try again.";
default:
return "Something went wrong. Please try again later.";
}

View file

@ -1,9 +1,7 @@
import type { InferUITool, UIMessage } from "ai";
import { z } from "zod";
import type { ArtifactKind } from "@/components/artifact";
import type { getWeather } from "./ai/tools/get-weather";
import type { getCurrencyExchange } from "./ai/tools/get-currency-exchange";
import type { Suggestion } from "./db/schema";
export type DataPart = { type: "append-message"; message: string };
@ -22,17 +20,7 @@ export type ChatTools = {
};
export type CustomUIDataTypes = {
textDelta: string;
imageDelta: string;
sheetDelta: string;
codeDelta: string;
suggestion: Suggestion;
appendMessage: string;
id: string;
title: string;
kind: ArtifactKind;
clear: null;
finish: null;
"chat-title": string;
};

View file

@ -7,7 +7,7 @@ import type {
import { type ClassValue, clsx } from 'clsx';
import { formatISO } from 'date-fns';
import { twMerge } from 'tailwind-merge';
import type { DBMessage, Document } from '@/lib/db/schema';
import type { DBMessage } from '@/lib/db/schema';
import { ChatSDKError, type ErrorCode } from './errors';
import type { ChatMessage, ChatTools, CustomUIDataTypes } from './types';
@ -71,16 +71,6 @@ export function getMostRecentUserMessage(messages: UIMessage[]) {
return userMessages.at(-1);
}
export function getDocumentTimestampByIndex(
documents: Document[],
index: number,
) {
if (!documents) { return new Date(); }
if (index > documents.length) { return new Date(); }
return documents[index].createdAt;
}
export function getTrailingMessageId({
messages,
}: {

View file

@ -1,100 +0,0 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
import { config } from "dotenv";
config({
path: ".env.local",
});
/* Use process.env.PORT by default and fallback to port 3000 */
const PORT = process.env.PORT || 3000;
/**
* Set webServer.url and use.baseURL with the location
* of the WebServer respecting the correct set port
*/
const baseURL = `http://localhost:${PORT}`;
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: "./tests",
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: 0,
/* Limit workers to prevent browser crashes */
workers: process.env.CI ? 2 : 2,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "html",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL,
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "retain-on-failure",
},
/* Configure global timeout for each test */
timeout: 240 * 1000, // 120 seconds
expect: {
timeout: 240 * 1000,
},
/* Configure projects */
projects: [
{
name: "e2e",
testMatch: /e2e\/.*.test.ts/,
use: {
...devices["Desktop Chrome"],
},
},
// {
// name: 'firefox',
// use: { ...devices['Desktop Firefox'] },
// },
// {
// name: 'webkit',
// use: { ...devices['Desktop Safari'] },
// },
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
/* Run your local dev server before starting the tests */
webServer: {
command: "pnpm dev",
url: `${baseURL}/ping`,
timeout: 120 * 1000,
reuseExistingServer: !process.env.CI,
},
});

File diff suppressed because it is too large Load diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

View file

@ -1,95 +0,0 @@
import { expect, test } from "@playwright/test";
const CHAT_URL_REGEX = /\/chat\/[\w-]+/;
const ERROR_TEXT_REGEX = /error|failed|trouble/i;
test.describe("Chat API Integration", () => {
test("sends message and receives AI response", async ({ page }) => {
await page.goto("/");
const input = page.getByTestId("multimodal-input");
await input.fill("Hello");
await page.getByTestId("send-button").click();
// Wait for assistant response to appear
const assistantMessage = page.locator("[data-role='assistant']").first();
await expect(assistantMessage).toBeVisible({ timeout: 30_000 });
// Verify it has some text content
const content = await assistantMessage.textContent();
expect(content?.length).toBeGreaterThan(0);
});
test("redirects to /chat/:id after sending message", async ({ page }) => {
await page.goto("/");
const input = page.getByTestId("multimodal-input");
await input.fill("Test redirect");
await page.getByTestId("send-button").click();
// URL should change to /chat/:id format
await expect(page).toHaveURL(CHAT_URL_REGEX, { timeout: 10_000 });
});
test("clears input after sending", async ({ page }) => {
await page.goto("/");
const input = page.getByTestId("multimodal-input");
await input.fill("Test message");
await page.getByTestId("send-button").click();
// Input should be cleared
await expect(input).toHaveValue("");
});
test("shows stop button during generation", async ({ page }) => {
await page.goto("/");
const input = page.getByTestId("multimodal-input");
await input.fill("Test");
await page.getByTestId("send-button").click();
// Stop button should appear during generation
const stopButton = page.getByTestId("stop-button");
await expect(stopButton).toBeVisible({ timeout: 5000 });
});
});
test.describe("Chat Error Handling", () => {
test("handles API error gracefully", async ({ page }) => {
await page.route("**/api/chat", async (route) => {
await route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ error: "Internal server error" }),
});
});
await page.goto("/");
const input = page.getByTestId("multimodal-input");
await input.fill("Test error");
await page.getByTestId("send-button").click();
// Should show error toast or message
await expect(page.getByText(ERROR_TEXT_REGEX).first()).toBeVisible({
timeout: 5000,
});
});
});
test.describe("Suggested Actions", () => {
test("suggested actions are clickable", async ({ page }) => {
await page.goto("/");
const suggestions = page.locator(
"[data-testid='suggested-actions'] button"
);
const count = await suggestions.count();
if (count > 0) {
await suggestions.first().click();
// Should redirect after clicking suggestion
await expect(page).toHaveURL(CHAT_URL_REGEX, { timeout: 10_000 });
}
});
});

View file

@ -1,31 +0,0 @@
import { expect, test } from "@playwright/test";
test.describe("Authentication Pages", () => {
test("login page renders correctly", async ({ page }) => {
await page.goto("/login");
await expect(page.getByPlaceholder("user@acme.com")).toBeVisible();
await expect(page.getByLabel("Password")).toBeVisible();
await expect(page.getByRole("button", { name: "Sign In" })).toBeVisible();
await expect(page.getByText("Don't have an account?")).toBeVisible();
});
test("register page renders correctly", async ({ page }) => {
await page.goto("/register");
await expect(page.getByPlaceholder("user@acme.com")).toBeVisible();
await expect(page.getByLabel("Password")).toBeVisible();
await expect(page.getByRole("button", { name: "Sign Up" })).toBeVisible();
await expect(page.getByText("Already have an account?")).toBeVisible();
});
test("can navigate from login to register", async ({ page }) => {
await page.goto("/login");
await page.getByRole("link", { name: "Sign up" }).click();
await expect(page).toHaveURL("/register");
});
test("can navigate from register to login", async ({ page }) => {
await page.goto("/register");
await page.getByRole("link", { name: "Sign in" }).click();
await expect(page).toHaveURL("/login");
});
});

View file

@ -1,61 +0,0 @@
import { expect, test } from "@playwright/test";
test.describe("Chat Page", () => {
test("home page loads with input field", async ({ page }) => {
await page.goto("/");
await expect(page.getByTestId("multimodal-input")).toBeVisible();
});
test("can type in the input field", async ({ page }) => {
await page.goto("/");
const input = page.getByTestId("multimodal-input");
await input.fill("Hello world");
await expect(input).toHaveValue("Hello world");
});
test("submit button is visible", async ({ page }) => {
await page.goto("/");
await expect(page.getByTestId("send-button")).toBeVisible();
});
test("suggested actions are visible on empty chat", async ({ page }) => {
await page.goto("/");
const suggestions = page.locator("[data-testid='suggested-actions']");
await expect(suggestions).toBeVisible();
});
test("can stop generation with stop button", async ({ page }) => {
await page.goto("/");
// Type and send a message
await page.getByTestId("multimodal-input").fill("Hello");
await page.getByTestId("send-button").click();
// Stop button should appear during generation
const stopButton = page.getByTestId("stop-button");
// If generation starts, stop button appears
// This is a best-effort check since timing depends on API
await stopButton.click({ timeout: 5000 }).catch(() => {
// Generation may have finished before we could click
});
});
});
test.describe("Chat Input Features", () => {
test("input clears after sending", async ({ page }) => {
await page.goto("/");
const input = page.getByTestId("multimodal-input");
await input.fill("Test message");
await page.getByTestId("send-button").click();
// Input should clear after sending
await expect(input).toHaveValue("");
});
test("input supports multiline text", async ({ page }) => {
await page.goto("/");
const input = page.getByTestId("multimodal-input");
await input.fill("Line 1\nLine 2\nLine 3");
await expect(input).toContainText("Line 1");
});
});

View file

@ -1,69 +0,0 @@
import { expect, test } from "@playwright/test";
const MODEL_BUTTON_REGEX = /Gemini|Claude|GPT|Grok/i;
test.describe("Model Selector", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
});
test("displays a model button", async ({ page }) => {
// Look for any button with model-related content
const modelButton = page.locator("button").filter({ hasText: MODEL_BUTTON_REGEX }).first();
await expect(modelButton).toBeVisible();
});
test("opens model selector popover on click", async ({ page }) => {
const modelButton = page.locator("button").filter({ hasText: MODEL_BUTTON_REGEX }).first();
await modelButton.click();
// Search input should be visible in the popover
await expect(page.getByPlaceholder("Search models...")).toBeVisible();
});
test("can search for models", async ({ page }) => {
const modelButton = page.locator("button").filter({ hasText: MODEL_BUTTON_REGEX }).first();
await modelButton.click();
const searchInput = page.getByPlaceholder("Search models...");
await searchInput.fill("Claude");
// Should show at least one Claude model
await expect(page.getByText("Claude Haiku").first()).toBeVisible();
});
test("can close model selector by clicking outside", async ({ page }) => {
const modelButton = page.locator("button").filter({ hasText: MODEL_BUTTON_REGEX }).first();
await modelButton.click();
await expect(page.getByPlaceholder("Search models...")).toBeVisible();
// Click outside to close
await page.keyboard.press("Escape");
await expect(page.getByPlaceholder("Search models...")).not.toBeVisible();
});
test("shows model provider groups", async ({ page }) => {
const modelButton = page.locator("button").filter({ hasText: MODEL_BUTTON_REGEX }).first();
await modelButton.click();
// Should show provider group headers
await expect(page.getByText("Anthropic")).toBeVisible();
await expect(page.getByText("Google")).toBeVisible();
});
test("can select a different model", async ({ page }) => {
const modelButton = page.locator("button").filter({ hasText: MODEL_BUTTON_REGEX }).first();
await modelButton.click();
// Select a specific model
await page.getByText("Claude Haiku").first().click();
// Popover should close
await expect(page.getByPlaceholder("Search models...")).not.toBeVisible();
// Model button should now show the selected model
await expect(page.locator("button").filter({ hasText: "Claude Haiku" }).first()).toBeVisible();
});
});

View file

@ -1,15 +0,0 @@
import { expect as baseExpect, test as baseTest } from "@playwright/test";
import { ChatPage } from "./pages/chat";
type Fixtures = {
chatPage: ChatPage;
};
export const test = baseTest.extend<Fixtures>({
chatPage: async ({ page }, use) => {
const chatPage = new ChatPage(page);
await use(chatPage);
},
});
export const expect = baseExpect;

View file

@ -1,16 +0,0 @@
import { generateId } from "ai";
import { getUnixTime } from "date-fns";
export function generateRandomTestUser() {
const email = `test-${getUnixTime(new Date())}@playwright.com`;
const password = generateId();
return {
email,
password,
};
}
export function generateTestMessage() {
return `Test message ${Date.now()}`;
}

View file

@ -1,71 +0,0 @@
import type { Page } from "@playwright/test";
const MODEL_BUTTON_REGEX = /Gemini|Claude|GPT|Grok/i;
export class ChatPage {
page: Page;
constructor(page: Page) {
this.page = page;
}
async goto() {
await this.page.goto("/");
}
async createNewChat() {
await this.page.goto("/");
await this.page.waitForSelector("[data-testid='multimodal-input']");
}
getInput() {
return this.page.getByTestId("multimodal-input");
}
async typeMessage(message: string) {
const input = this.getInput();
await input.fill(message);
}
async sendMessage() {
await this.page.getByTestId("send-button").click();
}
async sendUserMessage(message: string) {
await this.typeMessage(message);
await this.sendMessage();
}
getSendButton() {
return this.page.getByTestId("send-button");
}
getStopButton() {
return this.page.getByTestId("stop-button");
}
async clickSuggestedAction(index = 0) {
const suggestions = this.page.locator(
"[data-testid='suggested-actions'] button"
);
await suggestions.nth(index).click();
}
async openModelSelector() {
const modelButton = this.page
.locator("button")
.filter({ hasText: MODEL_BUTTON_REGEX })
.first();
await modelButton.click();
}
async selectModel(modelName: string) {
await this.openModelSelector();
await this.page.getByText(modelName).first().click();
}
async searchModels(query: string) {
await this.openModelSelector();
await this.page.getByPlaceholder("Search models...").fill(query);
}
}

View file

@ -1,30 +0,0 @@
import type { LanguageModelV3StreamPart } from "@ai-sdk/provider";
const mockUsage = {
inputTokens: { total: 10, noCache: 10, cacheRead: 0, cacheWrite: 0 },
outputTokens: { total: 20, text: 20, reasoning: 0 },
};
export function getResponseChunksByPrompt(
_prompt: unknown,
includeReasoning = false
): LanguageModelV3StreamPart[] {
const chunks: LanguageModelV3StreamPart[] = [];
if (includeReasoning) {
chunks.push(
{ type: "reasoning-start", id: "r1" },
{ type: "reasoning-delta", id: "r1", delta: "Let me think about this." },
{ type: "reasoning-end", id: "r1" }
);
}
chunks.push(
{ type: "text-start", id: "t1" },
{ type: "text-delta", id: "t1", delta: "Hello, world!" },
{ type: "text-end", id: "t1" },
{ type: "finish", finishReason: "stop", usage: mockUsage }
);
return chunks;
}

View file

@ -1,3 +0,0 @@
{
"framework": "nextjs"
}