mirror of
https://github.com/katanemo/plano.git
synced 2026-06-17 15:25:17 +02:00
chore: cleanup to remove extra tools
This commit is contained in:
parent
f72ef94809
commit
7ee25c07f2
64 changed files with 28 additions and 15052 deletions
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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 ?? [];
|
||||
}
|
||||
|
|
@ -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",
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -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;
|
||||
},
|
||||
});
|
||||
|
|
@ -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: [],
|
||||
});
|
||||
|
|
@ -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?",
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -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;
|
||||
},
|
||||
});
|
||||
|
|
@ -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.",
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -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;
|
||||
},
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
});
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
});
|
||||
|
|
@ -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}×tamp=${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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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]
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { registerOTel } from "@vercel/otel";
|
||||
|
||||
export function register() {
|
||||
registerOTel({ serviceName: "ai-chatbot" });
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -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),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -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.";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
8951
demos/use_cases/vercel-ai-sdk/pnpm-lock.yaml
generated
8951
demos/use_cases/vercel-ai-sdk/pnpm-lock.yaml
generated
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 |
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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()}`;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"framework": "nextjs"
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue