Upload folder using huggingface_hub
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .env.example +19 -0
- .eslintrc.json +23 -0
- .gitattributes +1 -0
- .gitignore +43 -0
- .vscode/extensions.json +3 -0
- .vscode/settings.json +17 -0
- Dockerfile +19 -0
- LICENSE +13 -0
- README.md +62 -10
- app/(auth)/actions.ts +84 -0
- app/(auth)/api/auth/[...nextauth]/route.ts +1 -0
- app/(auth)/api/auth/guest/route.ts +21 -0
- app/(auth)/auth.config.ts +13 -0
- app/(auth)/auth.ts +92 -0
- app/(auth)/login/page.tsx +77 -0
- app/(auth)/register/page.tsx +78 -0
- app/(chat)/actions.ts +53 -0
- app/(chat)/api/chat/[id]/stream/route.ts +112 -0
- app/(chat)/api/chat/route.ts +251 -0
- app/(chat)/api/chat/schema.ts +28 -0
- app/(chat)/api/document/route.ts +126 -0
- app/(chat)/api/files/upload/route.ts +68 -0
- app/(chat)/api/history/route.ts +34 -0
- app/(chat)/api/suggestions/route.ts +37 -0
- app/(chat)/api/vote/route.ts +75 -0
- app/(chat)/chat/[id]/page.tsx +76 -0
- app/(chat)/layout.tsx +33 -0
- app/(chat)/opengraph-image.png +3 -0
- app/(chat)/page.tsx +55 -0
- app/(chat)/twitter-image.png +0 -0
- app/favicon.ico +0 -0
- app/globals.css +164 -0
- app/layout.tsx +86 -0
- artifacts/actions.ts +8 -0
- artifacts/code/client.tsx +280 -0
- artifacts/code/server.ts +75 -0
- artifacts/image/client.tsx +76 -0
- artifacts/image/server.ts +45 -0
- artifacts/sheet/client.tsx +121 -0
- artifacts/sheet/server.ts +81 -0
- artifacts/text/client.tsx +181 -0
- artifacts/text/server.ts +73 -0
- biome.jsonc +135 -0
- components.json +20 -0
- components/app-sidebar.tsx +67 -0
- components/artifact-actions.tsx +100 -0
- components/artifact-close-button.tsx +30 -0
- components/artifact-messages.tsx +99 -0
- components/artifact.tsx +511 -0
- components/auth-form.tsx +60 -0
.env.example
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Generate a random secret: https://generate-secret.vercel.app/32 or `openssl rand -base64 32`
|
| 2 |
+
AUTH_SECRET=****
|
| 3 |
+
|
| 4 |
+
# The following keys below are automatically created and
|
| 5 |
+
# added to your environment when you deploy on vercel
|
| 6 |
+
|
| 7 |
+
# Get your xAI API Key here for chat and image models: https://console.x.ai/
|
| 8 |
+
XAI_API_KEY=****
|
| 9 |
+
|
| 10 |
+
# Instructions to create a Vercel Blob Store here: https://vercel.com/docs/storage/vercel-blob
|
| 11 |
+
BLOB_READ_WRITE_TOKEN=****
|
| 12 |
+
|
| 13 |
+
# Instructions to create a PostgreSQL database here: https://vercel.com/docs/storage/vercel-postgres/quickstart
|
| 14 |
+
POSTGRES_URL=****
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
# Instructions to create a Redis store here:
|
| 18 |
+
# https://vercel.com/docs/redis
|
| 19 |
+
REDIS_URL=****
|
.eslintrc.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"extends": [
|
| 3 |
+
"next/core-web-vitals",
|
| 4 |
+
"plugin:import/recommended",
|
| 5 |
+
"plugin:import/typescript",
|
| 6 |
+
"prettier",
|
| 7 |
+
"plugin:tailwindcss/recommended"
|
| 8 |
+
],
|
| 9 |
+
"plugins": ["tailwindcss"],
|
| 10 |
+
"rules": {
|
| 11 |
+
"tailwindcss/no-custom-classname": "off",
|
| 12 |
+
"tailwindcss/classnames-order": "off"
|
| 13 |
+
},
|
| 14 |
+
"settings": {
|
| 15 |
+
"import/resolver": {
|
| 16 |
+
"typescript": {
|
| 17 |
+
"alwaysTryTypes": true,
|
| 18 |
+
"project": "./tsconfig.json"
|
| 19 |
+
}
|
| 20 |
+
}
|
| 21 |
+
},
|
| 22 |
+
"ignorePatterns": ["**/components/ui/**"]
|
| 23 |
+
}
|
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
app/(chat)/opengraph-image.png filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
| 2 |
+
|
| 3 |
+
# dependencies
|
| 4 |
+
node_modules
|
| 5 |
+
.pnp
|
| 6 |
+
.pnp.js
|
| 7 |
+
|
| 8 |
+
# testing
|
| 9 |
+
coverage
|
| 10 |
+
|
| 11 |
+
# next.js
|
| 12 |
+
.next/
|
| 13 |
+
out/
|
| 14 |
+
build
|
| 15 |
+
|
| 16 |
+
# misc
|
| 17 |
+
.DS_Store
|
| 18 |
+
*.pem
|
| 19 |
+
|
| 20 |
+
# debug
|
| 21 |
+
npm-debug.log*
|
| 22 |
+
yarn-debug.log*
|
| 23 |
+
yarn-error.log*
|
| 24 |
+
.pnpm-debug.log*
|
| 25 |
+
|
| 26 |
+
# local env files
|
| 27 |
+
.env.local
|
| 28 |
+
.env.development.local
|
| 29 |
+
.env.test.local
|
| 30 |
+
.env.production.local
|
| 31 |
+
|
| 32 |
+
# turbo
|
| 33 |
+
.turbo
|
| 34 |
+
|
| 35 |
+
.env
|
| 36 |
+
.vercel
|
| 37 |
+
.env*.local
|
| 38 |
+
|
| 39 |
+
# Playwright
|
| 40 |
+
/test-results/
|
| 41 |
+
/playwright-report/
|
| 42 |
+
/blob-report/
|
| 43 |
+
/playwright/*
|
.vscode/extensions.json
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"recommendations": ["biomejs.biome"]
|
| 3 |
+
}
|
.vscode/settings.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"editor.formatOnSave": true,
|
| 3 |
+
"[javascript]": {
|
| 4 |
+
"editor.defaultFormatter": "biomejs.biome"
|
| 5 |
+
},
|
| 6 |
+
"[typescript]": {
|
| 7 |
+
"editor.defaultFormatter": "biomejs.biome"
|
| 8 |
+
},
|
| 9 |
+
"[typescriptreact]": {
|
| 10 |
+
"editor.defaultFormatter": "biomejs.biome"
|
| 11 |
+
},
|
| 12 |
+
"typescript.tsdk": "node_modules/typescript/lib",
|
| 13 |
+
"eslint.workingDirectories": [
|
| 14 |
+
{ "pattern": "app/*" },
|
| 15 |
+
{ "pattern": "packages/*" }
|
| 16 |
+
]
|
| 17 |
+
}
|
Dockerfile
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM node:20-alpine
|
| 2 |
+
USER root
|
| 3 |
+
|
| 4 |
+
USER 1000
|
| 5 |
+
WORKDIR /usr/src/app
|
| 6 |
+
# Copy package.json and package-lock.json to the container
|
| 7 |
+
COPY --chown=1000 package.json package-lock.json ./
|
| 8 |
+
|
| 9 |
+
# Copy the rest of the application files to the container
|
| 10 |
+
COPY --chown=1000 . .
|
| 11 |
+
|
| 12 |
+
RUN npm install
|
| 13 |
+
RUN npm run build
|
| 14 |
+
|
| 15 |
+
# Expose the application port (assuming your app runs on port 3000)
|
| 16 |
+
EXPOSE 3000
|
| 17 |
+
|
| 18 |
+
# Start the application
|
| 19 |
+
CMD ["npm", "start"]
|
LICENSE
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Copyright 2024 Vercel, Inc.
|
| 2 |
+
|
| 3 |
+
Licensed under the Apache License, Version 2.0 (the "License");
|
| 4 |
+
you may not use this file except in compliance with the License.
|
| 5 |
+
You may obtain a copy of the License at
|
| 6 |
+
|
| 7 |
+
http://www.apache.org/licenses/LICENSE-2.0
|
| 8 |
+
|
| 9 |
+
Unless required by applicable law or agreed to in writing, software
|
| 10 |
+
distributed under the License is distributed on an "AS IS" BASIS,
|
| 11 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 12 |
+
See the License for the specific language governing permissions and
|
| 13 |
+
limitations under the License.
|
README.md
CHANGED
|
@@ -1,10 +1,62 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<a href="https://chat.vercel.ai/">
|
| 2 |
+
<img alt="Next.js 14 and App Router-ready AI chatbot." src="app/(chat)/opengraph-image.png">
|
| 3 |
+
<h1 align="center">Chat SDK</h1>
|
| 4 |
+
</a>
|
| 5 |
+
|
| 6 |
+
<p align="center">
|
| 7 |
+
Chat SDK is a free, open-source template built with Next.js and the AI SDK that helps you quickly build powerful chatbot applications.
|
| 8 |
+
</p>
|
| 9 |
+
|
| 10 |
+
<p align="center">
|
| 11 |
+
<a href="https://chat-sdk.dev"><strong>Read Docs</strong></a> ·
|
| 12 |
+
<a href="#features"><strong>Features</strong></a> ·
|
| 13 |
+
<a href="#model-providers"><strong>Model Providers</strong></a> ·
|
| 14 |
+
<a href="#deploy-your-own"><strong>Deploy Your Own</strong></a> ·
|
| 15 |
+
<a href="#running-locally"><strong>Running locally</strong></a>
|
| 16 |
+
</p>
|
| 17 |
+
<br/>
|
| 18 |
+
|
| 19 |
+
## Features
|
| 20 |
+
|
| 21 |
+
- [Next.js](https://nextjs.org) App Router
|
| 22 |
+
- Advanced routing for seamless navigation and performance
|
| 23 |
+
- React Server Components (RSCs) and Server Actions for server-side rendering and increased performance
|
| 24 |
+
- [AI SDK](https://sdk.vercel.ai/docs)
|
| 25 |
+
- Unified API for generating text, structured objects, and tool calls with LLMs
|
| 26 |
+
- Hooks for building dynamic chat and generative user interfaces
|
| 27 |
+
- Supports xAI (default), OpenAI, Fireworks, and other model providers
|
| 28 |
+
- [shadcn/ui](https://ui.shadcn.com)
|
| 29 |
+
- Styling with [Tailwind CSS](https://tailwindcss.com)
|
| 30 |
+
- Component primitives from [Radix UI](https://radix-ui.com) for accessibility and flexibility
|
| 31 |
+
- Data Persistence
|
| 32 |
+
- [Neon Serverless Postgres](https://vercel.com/marketplace/neon) for saving chat history and user data
|
| 33 |
+
- [Vercel Blob](https://vercel.com/storage/blob) for efficient file storage
|
| 34 |
+
- [Auth.js](https://authjs.dev)
|
| 35 |
+
- Simple and secure authentication
|
| 36 |
+
|
| 37 |
+
## Model Providers
|
| 38 |
+
|
| 39 |
+
This template ships with [xAI](https://x.ai) `grok-2-1212` as the default chat model. However, with the [AI SDK](https://sdk.vercel.ai/docs), you can switch LLM providers to [OpenAI](https://openai.com), [Anthropic](https://anthropic.com), [Cohere](https://cohere.com/), and [many more](https://sdk.vercel.ai/providers/ai-sdk-providers) with just a few lines of code.
|
| 40 |
+
|
| 41 |
+
## Deploy Your Own
|
| 42 |
+
|
| 43 |
+
You can deploy your own version of the Next.js AI Chatbot to Vercel with one click:
|
| 44 |
+
|
| 45 |
+
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fai-chatbot&env=AUTH_SECRET&envDescription=Learn+more+about+how+to+get+the+API+Keys+for+the+application&envLink=https%3A%2F%2Fgithub.com%2Fvercel%2Fai-chatbot%2Fblob%2Fmain%2F.env.example&demo-title=AI+Chatbot&demo-description=An+Open-Source+AI+Chatbot+Template+Built+With+Next.js+and+the+AI+SDK+by+Vercel.&demo-url=https%3A%2F%2Fchat.vercel.ai&products=%5B%7B%22type%22%3A%22integration%22%2C%22protocol%22%3A%22ai%22%2C%22productSlug%22%3A%22grok%22%2C%22integrationSlug%22%3A%22xai%22%7D%2C%7B%22type%22%3A%22integration%22%2C%22protocol%22%3A%22storage%22%2C%22productSlug%22%3A%22neon%22%2C%22integrationSlug%22%3A%22neon%22%7D%2C%7B%22type%22%3A%22integration%22%2C%22protocol%22%3A%22storage%22%2C%22productSlug%22%3A%22upstash-kv%22%2C%22integrationSlug%22%3A%22upstash%22%7D%2C%7B%22type%22%3A%22blob%22%7D%5D)
|
| 46 |
+
|
| 47 |
+
## Running locally
|
| 48 |
+
|
| 49 |
+
You will need to use the environment variables [defined in `.env.example`](.env.example) to run Next.js AI Chatbot. It's recommended you use [Vercel Environment Variables](https://vercel.com/docs/projects/environment-variables) for this, but a `.env` file is all that is necessary.
|
| 50 |
+
|
| 51 |
+
> Note: You should not commit your `.env` file or it will expose secrets that will allow others to control access to your various AI and authentication provider accounts.
|
| 52 |
+
|
| 53 |
+
1. Install Vercel CLI: `npm i -g vercel`
|
| 54 |
+
2. Link local instance with Vercel and GitHub accounts (creates `.vercel` directory): `vercel link`
|
| 55 |
+
3. Download your environment variables: `vercel env pull`
|
| 56 |
+
|
| 57 |
+
```bash
|
| 58 |
+
pnpm install
|
| 59 |
+
pnpm dev
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
Your app template should now be running on [localhost:3000](http://localhost:3000).
|
app/(auth)/actions.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use server';
|
| 2 |
+
|
| 3 |
+
import { z } from 'zod';
|
| 4 |
+
|
| 5 |
+
import { createUser, getUser } from '@/lib/db/queries';
|
| 6 |
+
|
| 7 |
+
import { signIn } from './auth';
|
| 8 |
+
|
| 9 |
+
const authFormSchema = z.object({
|
| 10 |
+
email: z.string().email(),
|
| 11 |
+
password: z.string().min(6),
|
| 12 |
+
});
|
| 13 |
+
|
| 14 |
+
export interface LoginActionState {
|
| 15 |
+
status: 'idle' | 'in_progress' | 'success' | 'failed' | 'invalid_data';
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export const login = async (
|
| 19 |
+
_: LoginActionState,
|
| 20 |
+
formData: FormData,
|
| 21 |
+
): Promise<LoginActionState> => {
|
| 22 |
+
try {
|
| 23 |
+
const validatedData = authFormSchema.parse({
|
| 24 |
+
email: formData.get('email'),
|
| 25 |
+
password: formData.get('password'),
|
| 26 |
+
});
|
| 27 |
+
|
| 28 |
+
await signIn('credentials', {
|
| 29 |
+
email: validatedData.email,
|
| 30 |
+
password: validatedData.password,
|
| 31 |
+
redirect: false,
|
| 32 |
+
});
|
| 33 |
+
|
| 34 |
+
return { status: 'success' };
|
| 35 |
+
} catch (error) {
|
| 36 |
+
if (error instanceof z.ZodError) {
|
| 37 |
+
return { status: 'invalid_data' };
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
return { status: 'failed' };
|
| 41 |
+
}
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
+
export interface RegisterActionState {
|
| 45 |
+
status:
|
| 46 |
+
| 'idle'
|
| 47 |
+
| 'in_progress'
|
| 48 |
+
| 'success'
|
| 49 |
+
| 'failed'
|
| 50 |
+
| 'user_exists'
|
| 51 |
+
| 'invalid_data';
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
export const register = async (
|
| 55 |
+
_: RegisterActionState,
|
| 56 |
+
formData: FormData,
|
| 57 |
+
): Promise<RegisterActionState> => {
|
| 58 |
+
try {
|
| 59 |
+
const validatedData = authFormSchema.parse({
|
| 60 |
+
email: formData.get('email'),
|
| 61 |
+
password: formData.get('password'),
|
| 62 |
+
});
|
| 63 |
+
|
| 64 |
+
const [user] = await getUser(validatedData.email);
|
| 65 |
+
|
| 66 |
+
if (user) {
|
| 67 |
+
return { status: 'user_exists' } as RegisterActionState;
|
| 68 |
+
}
|
| 69 |
+
await createUser(validatedData.email, validatedData.password);
|
| 70 |
+
await signIn('credentials', {
|
| 71 |
+
email: validatedData.email,
|
| 72 |
+
password: validatedData.password,
|
| 73 |
+
redirect: false,
|
| 74 |
+
});
|
| 75 |
+
|
| 76 |
+
return { status: 'success' };
|
| 77 |
+
} catch (error) {
|
| 78 |
+
if (error instanceof z.ZodError) {
|
| 79 |
+
return { status: 'invalid_data' };
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
return { status: 'failed' };
|
| 83 |
+
}
|
| 84 |
+
};
|
app/(auth)/api/auth/[...nextauth]/route.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
export { GET, POST } from '@/app/(auth)/auth';
|
app/(auth)/api/auth/guest/route.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { signIn } from '@/app/(auth)/auth';
|
| 2 |
+
import { isDevelopmentEnvironment } from '@/lib/constants';
|
| 3 |
+
import { getToken } from 'next-auth/jwt';
|
| 4 |
+
import { NextResponse } from 'next/server';
|
| 5 |
+
|
| 6 |
+
export async function GET(request: Request) {
|
| 7 |
+
const { searchParams } = new URL(request.url);
|
| 8 |
+
const redirectUrl = searchParams.get('redirectUrl') || '/';
|
| 9 |
+
|
| 10 |
+
const token = await getToken({
|
| 11 |
+
req: request,
|
| 12 |
+
secret: process.env.AUTH_SECRET,
|
| 13 |
+
secureCookie: !isDevelopmentEnvironment,
|
| 14 |
+
});
|
| 15 |
+
|
| 16 |
+
if (token) {
|
| 17 |
+
return NextResponse.redirect(new URL('/', request.url));
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
return signIn('guest', { redirect: true, redirectTo: redirectUrl });
|
| 21 |
+
}
|
app/(auth)/auth.config.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { NextAuthConfig } from 'next-auth';
|
| 2 |
+
|
| 3 |
+
export const authConfig = {
|
| 4 |
+
pages: {
|
| 5 |
+
signIn: '/login',
|
| 6 |
+
newUser: '/',
|
| 7 |
+
},
|
| 8 |
+
providers: [
|
| 9 |
+
// added later in auth.ts since it requires bcrypt which is only compatible with Node.js
|
| 10 |
+
// while this file is also used in non-Node.js environments
|
| 11 |
+
],
|
| 12 |
+
callbacks: {},
|
| 13 |
+
} satisfies NextAuthConfig;
|
app/(auth)/auth.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { compare } from 'bcrypt-ts';
|
| 2 |
+
import NextAuth, { type DefaultSession } from 'next-auth';
|
| 3 |
+
import Credentials from 'next-auth/providers/credentials';
|
| 4 |
+
import { createGuestUser, getUser } from '@/lib/db/queries';
|
| 5 |
+
import { authConfig } from './auth.config';
|
| 6 |
+
import { DUMMY_PASSWORD } from '@/lib/constants';
|
| 7 |
+
import type { DefaultJWT } from 'next-auth/jwt';
|
| 8 |
+
|
| 9 |
+
export type UserType = 'guest' | 'regular';
|
| 10 |
+
|
| 11 |
+
declare module 'next-auth' {
|
| 12 |
+
interface Session extends DefaultSession {
|
| 13 |
+
user: {
|
| 14 |
+
id: string;
|
| 15 |
+
type: UserType;
|
| 16 |
+
} & DefaultSession['user'];
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
interface User {
|
| 20 |
+
id?: string;
|
| 21 |
+
email?: string | null;
|
| 22 |
+
type: UserType;
|
| 23 |
+
}
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
declare module 'next-auth/jwt' {
|
| 27 |
+
interface JWT extends DefaultJWT {
|
| 28 |
+
id: string;
|
| 29 |
+
type: UserType;
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
export const {
|
| 34 |
+
handlers: { GET, POST },
|
| 35 |
+
auth,
|
| 36 |
+
signIn,
|
| 37 |
+
signOut,
|
| 38 |
+
} = NextAuth({
|
| 39 |
+
...authConfig,
|
| 40 |
+
providers: [
|
| 41 |
+
Credentials({
|
| 42 |
+
credentials: {},
|
| 43 |
+
async authorize({ email, password }: any) {
|
| 44 |
+
const users = await getUser(email);
|
| 45 |
+
|
| 46 |
+
if (users.length === 0) {
|
| 47 |
+
await compare(password, DUMMY_PASSWORD);
|
| 48 |
+
return null;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
const [user] = users;
|
| 52 |
+
|
| 53 |
+
if (!user.password) {
|
| 54 |
+
await compare(password, DUMMY_PASSWORD);
|
| 55 |
+
return null;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
const passwordsMatch = await compare(password, user.password);
|
| 59 |
+
|
| 60 |
+
if (!passwordsMatch) return null;
|
| 61 |
+
|
| 62 |
+
return { ...user, type: 'regular' };
|
| 63 |
+
},
|
| 64 |
+
}),
|
| 65 |
+
Credentials({
|
| 66 |
+
id: 'guest',
|
| 67 |
+
credentials: {},
|
| 68 |
+
async authorize() {
|
| 69 |
+
const [guestUser] = await createGuestUser();
|
| 70 |
+
return { ...guestUser, type: 'guest' };
|
| 71 |
+
},
|
| 72 |
+
}),
|
| 73 |
+
],
|
| 74 |
+
callbacks: {
|
| 75 |
+
async jwt({ token, user }) {
|
| 76 |
+
if (user) {
|
| 77 |
+
token.id = user.id as string;
|
| 78 |
+
token.type = user.type;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
return token;
|
| 82 |
+
},
|
| 83 |
+
async session({ session, token }) {
|
| 84 |
+
if (session.user) {
|
| 85 |
+
session.user.id = token.id;
|
| 86 |
+
session.user.type = token.type;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
return session;
|
| 90 |
+
},
|
| 91 |
+
},
|
| 92 |
+
});
|
app/(auth)/login/page.tsx
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import Link from 'next/link';
|
| 4 |
+
import { useRouter } from 'next/navigation';
|
| 5 |
+
import { useActionState, useEffect, useState } from 'react';
|
| 6 |
+
import { toast } from '@/components/toast';
|
| 7 |
+
|
| 8 |
+
import { AuthForm } from '@/components/auth-form';
|
| 9 |
+
import { SubmitButton } from '@/components/submit-button';
|
| 10 |
+
|
| 11 |
+
import { login, type LoginActionState } from '../actions';
|
| 12 |
+
import { useSession } from 'next-auth/react';
|
| 13 |
+
|
| 14 |
+
export default function Page() {
|
| 15 |
+
const router = useRouter();
|
| 16 |
+
|
| 17 |
+
const [email, setEmail] = useState('');
|
| 18 |
+
const [isSuccessful, setIsSuccessful] = useState(false);
|
| 19 |
+
|
| 20 |
+
const [state, formAction] = useActionState<LoginActionState, FormData>(
|
| 21 |
+
login,
|
| 22 |
+
{
|
| 23 |
+
status: 'idle',
|
| 24 |
+
},
|
| 25 |
+
);
|
| 26 |
+
|
| 27 |
+
const { update: updateSession } = useSession();
|
| 28 |
+
|
| 29 |
+
useEffect(() => {
|
| 30 |
+
if (state.status === 'failed') {
|
| 31 |
+
toast({
|
| 32 |
+
type: 'error',
|
| 33 |
+
description: 'Invalid credentials!',
|
| 34 |
+
});
|
| 35 |
+
} else if (state.status === 'invalid_data') {
|
| 36 |
+
toast({
|
| 37 |
+
type: 'error',
|
| 38 |
+
description: 'Failed validating your submission!',
|
| 39 |
+
});
|
| 40 |
+
} else if (state.status === 'success') {
|
| 41 |
+
setIsSuccessful(true);
|
| 42 |
+
updateSession();
|
| 43 |
+
router.refresh();
|
| 44 |
+
}
|
| 45 |
+
}, [state.status]);
|
| 46 |
+
|
| 47 |
+
const handleSubmit = (formData: FormData) => {
|
| 48 |
+
setEmail(formData.get('email') as string);
|
| 49 |
+
formAction(formData);
|
| 50 |
+
};
|
| 51 |
+
|
| 52 |
+
return (
|
| 53 |
+
<div className="flex h-dvh w-screen items-start pt-12 md:pt-0 md:items-center justify-center bg-background">
|
| 54 |
+
<div className="w-full max-w-md overflow-hidden rounded-2xl flex flex-col gap-12">
|
| 55 |
+
<div className="flex flex-col items-center justify-center gap-2 px-4 text-center sm:px-16">
|
| 56 |
+
<h3 className="text-xl font-semibold dark:text-zinc-50">Sign In</h3>
|
| 57 |
+
<p className="text-sm text-gray-500 dark:text-zinc-400">
|
| 58 |
+
Use your email and password to sign in
|
| 59 |
+
</p>
|
| 60 |
+
</div>
|
| 61 |
+
<AuthForm action={handleSubmit} defaultEmail={email}>
|
| 62 |
+
<SubmitButton isSuccessful={isSuccessful}>Sign in</SubmitButton>
|
| 63 |
+
<p className="text-center text-sm text-gray-600 mt-4 dark:text-zinc-400">
|
| 64 |
+
{"Don't have an account? "}
|
| 65 |
+
<Link
|
| 66 |
+
href="/register"
|
| 67 |
+
className="font-semibold text-gray-800 hover:underline dark:text-zinc-200"
|
| 68 |
+
>
|
| 69 |
+
Sign up
|
| 70 |
+
</Link>
|
| 71 |
+
{' for free.'}
|
| 72 |
+
</p>
|
| 73 |
+
</AuthForm>
|
| 74 |
+
</div>
|
| 75 |
+
</div>
|
| 76 |
+
);
|
| 77 |
+
}
|
app/(auth)/register/page.tsx
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import Link from 'next/link';
|
| 4 |
+
import { useRouter } from 'next/navigation';
|
| 5 |
+
import { useActionState, useEffect, useState } from 'react';
|
| 6 |
+
|
| 7 |
+
import { AuthForm } from '@/components/auth-form';
|
| 8 |
+
import { SubmitButton } from '@/components/submit-button';
|
| 9 |
+
|
| 10 |
+
import { register, type RegisterActionState } from '../actions';
|
| 11 |
+
import { toast } from '@/components/toast';
|
| 12 |
+
import { useSession } from 'next-auth/react';
|
| 13 |
+
|
| 14 |
+
export default function Page() {
|
| 15 |
+
const router = useRouter();
|
| 16 |
+
|
| 17 |
+
const [email, setEmail] = useState('');
|
| 18 |
+
const [isSuccessful, setIsSuccessful] = useState(false);
|
| 19 |
+
|
| 20 |
+
const [state, formAction] = useActionState<RegisterActionState, FormData>(
|
| 21 |
+
register,
|
| 22 |
+
{
|
| 23 |
+
status: 'idle',
|
| 24 |
+
},
|
| 25 |
+
);
|
| 26 |
+
|
| 27 |
+
const { update: updateSession } = useSession();
|
| 28 |
+
|
| 29 |
+
useEffect(() => {
|
| 30 |
+
if (state.status === 'user_exists') {
|
| 31 |
+
toast({ type: 'error', description: 'Account already exists!' });
|
| 32 |
+
} else if (state.status === 'failed') {
|
| 33 |
+
toast({ type: 'error', description: 'Failed to create account!' });
|
| 34 |
+
} else if (state.status === 'invalid_data') {
|
| 35 |
+
toast({
|
| 36 |
+
type: 'error',
|
| 37 |
+
description: 'Failed validating your submission!',
|
| 38 |
+
});
|
| 39 |
+
} else if (state.status === 'success') {
|
| 40 |
+
toast({ type: 'success', description: 'Account created successfully!' });
|
| 41 |
+
|
| 42 |
+
setIsSuccessful(true);
|
| 43 |
+
updateSession();
|
| 44 |
+
router.refresh();
|
| 45 |
+
}
|
| 46 |
+
}, [state]);
|
| 47 |
+
|
| 48 |
+
const handleSubmit = (formData: FormData) => {
|
| 49 |
+
setEmail(formData.get('email') as string);
|
| 50 |
+
formAction(formData);
|
| 51 |
+
};
|
| 52 |
+
|
| 53 |
+
return (
|
| 54 |
+
<div className="flex h-dvh w-screen items-start pt-12 md:pt-0 md:items-center justify-center bg-background">
|
| 55 |
+
<div className="w-full max-w-md overflow-hidden rounded-2xl gap-12 flex flex-col">
|
| 56 |
+
<div className="flex flex-col items-center justify-center gap-2 px-4 text-center sm:px-16">
|
| 57 |
+
<h3 className="text-xl font-semibold dark:text-zinc-50">Sign Up</h3>
|
| 58 |
+
<p className="text-sm text-gray-500 dark:text-zinc-400">
|
| 59 |
+
Create an account with your email and password
|
| 60 |
+
</p>
|
| 61 |
+
</div>
|
| 62 |
+
<AuthForm action={handleSubmit} defaultEmail={email}>
|
| 63 |
+
<SubmitButton isSuccessful={isSuccessful}>Sign Up</SubmitButton>
|
| 64 |
+
<p className="text-center text-sm text-gray-600 mt-4 dark:text-zinc-400">
|
| 65 |
+
{'Already have an account? '}
|
| 66 |
+
<Link
|
| 67 |
+
href="/login"
|
| 68 |
+
className="font-semibold text-gray-800 hover:underline dark:text-zinc-200"
|
| 69 |
+
>
|
| 70 |
+
Sign in
|
| 71 |
+
</Link>
|
| 72 |
+
{' instead.'}
|
| 73 |
+
</p>
|
| 74 |
+
</AuthForm>
|
| 75 |
+
</div>
|
| 76 |
+
</div>
|
| 77 |
+
);
|
| 78 |
+
}
|
app/(chat)/actions.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use server';
|
| 2 |
+
|
| 3 |
+
import { generateText, type UIMessage } from 'ai';
|
| 4 |
+
import { cookies } from 'next/headers';
|
| 5 |
+
import {
|
| 6 |
+
deleteMessagesByChatIdAfterTimestamp,
|
| 7 |
+
getMessageById,
|
| 8 |
+
updateChatVisiblityById,
|
| 9 |
+
} from '@/lib/db/queries';
|
| 10 |
+
import type { VisibilityType } from '@/components/visibility-selector';
|
| 11 |
+
import { myProvider } from '@/lib/ai/providers';
|
| 12 |
+
|
| 13 |
+
export async function saveChatModelAsCookie(model: string) {
|
| 14 |
+
const cookieStore = await cookies();
|
| 15 |
+
cookieStore.set('chat-model', model);
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export async function generateTitleFromUserMessage({
|
| 19 |
+
message,
|
| 20 |
+
}: {
|
| 21 |
+
message: UIMessage;
|
| 22 |
+
}) {
|
| 23 |
+
const { text: title } = await generateText({
|
| 24 |
+
model: myProvider.languageModel('title-model'),
|
| 25 |
+
system: `\n
|
| 26 |
+
- you will generate a short title based on the first message a user begins a conversation with
|
| 27 |
+
- ensure it is not more than 80 characters long
|
| 28 |
+
- the title should be a summary of the user's message
|
| 29 |
+
- do not use quotes or colons`,
|
| 30 |
+
prompt: JSON.stringify(message),
|
| 31 |
+
});
|
| 32 |
+
|
| 33 |
+
return title;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
export async function deleteTrailingMessages({ id }: { id: string }) {
|
| 37 |
+
const [message] = await getMessageById({ id });
|
| 38 |
+
|
| 39 |
+
await deleteMessagesByChatIdAfterTimestamp({
|
| 40 |
+
chatId: message.chatId,
|
| 41 |
+
timestamp: message.createdAt,
|
| 42 |
+
});
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
export async function updateChatVisibility({
|
| 46 |
+
chatId,
|
| 47 |
+
visibility,
|
| 48 |
+
}: {
|
| 49 |
+
chatId: string;
|
| 50 |
+
visibility: VisibilityType;
|
| 51 |
+
}) {
|
| 52 |
+
await updateChatVisiblityById({ chatId, visibility });
|
| 53 |
+
}
|
app/(chat)/api/chat/[id]/stream/route.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { auth } from '@/app/(auth)/auth';
|
| 2 |
+
import {
|
| 3 |
+
getChatById,
|
| 4 |
+
getMessagesByChatId,
|
| 5 |
+
getStreamIdsByChatId,
|
| 6 |
+
} from '@/lib/db/queries';
|
| 7 |
+
import type { Chat } from '@/lib/db/schema';
|
| 8 |
+
import { ChatSDKError } from '@/lib/errors';
|
| 9 |
+
import type { ChatMessage } from '@/lib/types';
|
| 10 |
+
import { createUIMessageStream, JsonToSseTransformStream } from 'ai';
|
| 11 |
+
import { getStreamContext } from '../../route';
|
| 12 |
+
import { differenceInSeconds } from 'date-fns';
|
| 13 |
+
|
| 14 |
+
export async function GET(
|
| 15 |
+
_: Request,
|
| 16 |
+
{ params }: { params: Promise<{ id: string }> },
|
| 17 |
+
) {
|
| 18 |
+
const { id: chatId } = await params;
|
| 19 |
+
|
| 20 |
+
const streamContext = getStreamContext();
|
| 21 |
+
const resumeRequestedAt = new Date();
|
| 22 |
+
|
| 23 |
+
if (!streamContext) {
|
| 24 |
+
return new Response(null, { status: 204 });
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
if (!chatId) {
|
| 28 |
+
return new ChatSDKError('bad_request:api').toResponse();
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
const session = await auth();
|
| 32 |
+
|
| 33 |
+
if (!session?.user) {
|
| 34 |
+
return new ChatSDKError('unauthorized:chat').toResponse();
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
let chat: Chat;
|
| 38 |
+
|
| 39 |
+
try {
|
| 40 |
+
chat = await getChatById({ id: chatId });
|
| 41 |
+
} catch {
|
| 42 |
+
return new ChatSDKError('not_found:chat').toResponse();
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
if (!chat) {
|
| 46 |
+
return new ChatSDKError('not_found:chat').toResponse();
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
if (chat.visibility === 'private' && chat.userId !== session.user.id) {
|
| 50 |
+
return new ChatSDKError('forbidden:chat').toResponse();
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
const streamIds = await getStreamIdsByChatId({ chatId });
|
| 54 |
+
|
| 55 |
+
if (!streamIds.length) {
|
| 56 |
+
return new ChatSDKError('not_found:stream').toResponse();
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
const recentStreamId = streamIds.at(-1);
|
| 60 |
+
|
| 61 |
+
if (!recentStreamId) {
|
| 62 |
+
return new ChatSDKError('not_found:stream').toResponse();
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
const emptyDataStream = createUIMessageStream<ChatMessage>({
|
| 66 |
+
execute: () => {},
|
| 67 |
+
});
|
| 68 |
+
|
| 69 |
+
const stream = await streamContext.resumableStream(recentStreamId, () =>
|
| 70 |
+
emptyDataStream.pipeThrough(new JsonToSseTransformStream()),
|
| 71 |
+
);
|
| 72 |
+
|
| 73 |
+
/*
|
| 74 |
+
* For when the generation is streaming during SSR
|
| 75 |
+
* but the resumable stream has concluded at this point.
|
| 76 |
+
*/
|
| 77 |
+
if (!stream) {
|
| 78 |
+
const messages = await getMessagesByChatId({ id: chatId });
|
| 79 |
+
const mostRecentMessage = messages.at(-1);
|
| 80 |
+
|
| 81 |
+
if (!mostRecentMessage) {
|
| 82 |
+
return new Response(emptyDataStream, { status: 200 });
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
if (mostRecentMessage.role !== 'assistant') {
|
| 86 |
+
return new Response(emptyDataStream, { status: 200 });
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
const messageCreatedAt = new Date(mostRecentMessage.createdAt);
|
| 90 |
+
|
| 91 |
+
if (differenceInSeconds(resumeRequestedAt, messageCreatedAt) > 15) {
|
| 92 |
+
return new Response(emptyDataStream, { status: 200 });
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
const restoredStream = createUIMessageStream<ChatMessage>({
|
| 96 |
+
execute: ({ writer }) => {
|
| 97 |
+
writer.write({
|
| 98 |
+
type: 'data-appendMessage',
|
| 99 |
+
data: JSON.stringify(mostRecentMessage),
|
| 100 |
+
transient: true,
|
| 101 |
+
});
|
| 102 |
+
},
|
| 103 |
+
});
|
| 104 |
+
|
| 105 |
+
return new Response(
|
| 106 |
+
restoredStream.pipeThrough(new JsonToSseTransformStream()),
|
| 107 |
+
{ status: 200 },
|
| 108 |
+
);
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
return new Response(stream, { status: 200 });
|
| 112 |
+
}
|
app/(chat)/api/chat/route.ts
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
convertToModelMessages,
|
| 3 |
+
createUIMessageStream,
|
| 4 |
+
JsonToSseTransformStream,
|
| 5 |
+
smoothStream,
|
| 6 |
+
stepCountIs,
|
| 7 |
+
streamText,
|
| 8 |
+
} from 'ai';
|
| 9 |
+
import { auth, type UserType } from '@/app/(auth)/auth';
|
| 10 |
+
import { type RequestHints, systemPrompt } from '@/lib/ai/prompts';
|
| 11 |
+
import {
|
| 12 |
+
createStreamId,
|
| 13 |
+
deleteChatById,
|
| 14 |
+
getChatById,
|
| 15 |
+
getMessageCountByUserId,
|
| 16 |
+
getMessagesByChatId,
|
| 17 |
+
saveChat,
|
| 18 |
+
saveMessages,
|
| 19 |
+
} from '@/lib/db/queries';
|
| 20 |
+
import { convertToUIMessages, generateUUID } from '@/lib/utils';
|
| 21 |
+
import { generateTitleFromUserMessage } from '../../actions';
|
| 22 |
+
import { createDocument } from '@/lib/ai/tools/create-document';
|
| 23 |
+
import { updateDocument } from '@/lib/ai/tools/update-document';
|
| 24 |
+
import { requestSuggestions } from '@/lib/ai/tools/request-suggestions';
|
| 25 |
+
import { getWeather } from '@/lib/ai/tools/get-weather';
|
| 26 |
+
import { isProductionEnvironment } from '@/lib/constants';
|
| 27 |
+
import { myProvider } from '@/lib/ai/providers';
|
| 28 |
+
import { entitlementsByUserType } from '@/lib/ai/entitlements';
|
| 29 |
+
import { postRequestBodySchema, type PostRequestBody } from './schema';
|
| 30 |
+
import { geolocation } from '@vercel/functions';
|
| 31 |
+
import {
|
| 32 |
+
createResumableStreamContext,
|
| 33 |
+
type ResumableStreamContext,
|
| 34 |
+
} from 'resumable-stream';
|
| 35 |
+
import { after } from 'next/server';
|
| 36 |
+
import { ChatSDKError } from '@/lib/errors';
|
| 37 |
+
import type { ChatMessage } from '@/lib/types';
|
| 38 |
+
import type { ChatModel } from '@/lib/ai/models';
|
| 39 |
+
import type { VisibilityType } from '@/components/visibility-selector';
|
| 40 |
+
|
| 41 |
+
export const maxDuration = 60;
|
| 42 |
+
|
| 43 |
+
let globalStreamContext: ResumableStreamContext | null = null;
|
| 44 |
+
|
| 45 |
+
export function getStreamContext() {
|
| 46 |
+
if (!globalStreamContext) {
|
| 47 |
+
try {
|
| 48 |
+
globalStreamContext = createResumableStreamContext({
|
| 49 |
+
waitUntil: after,
|
| 50 |
+
});
|
| 51 |
+
} catch (error: any) {
|
| 52 |
+
if (error.message.includes('REDIS_URL')) {
|
| 53 |
+
console.log(
|
| 54 |
+
' > Resumable streams are disabled due to missing REDIS_URL',
|
| 55 |
+
);
|
| 56 |
+
} else {
|
| 57 |
+
console.error(error);
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
return globalStreamContext;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
export async function POST(request: Request) {
|
| 66 |
+
let requestBody: PostRequestBody;
|
| 67 |
+
|
| 68 |
+
try {
|
| 69 |
+
const json = await request.json();
|
| 70 |
+
requestBody = postRequestBodySchema.parse(json);
|
| 71 |
+
} catch (_) {
|
| 72 |
+
return new ChatSDKError('bad_request:api').toResponse();
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
try {
|
| 76 |
+
const {
|
| 77 |
+
id,
|
| 78 |
+
message,
|
| 79 |
+
selectedChatModel,
|
| 80 |
+
selectedVisibilityType,
|
| 81 |
+
}: {
|
| 82 |
+
id: string;
|
| 83 |
+
message: ChatMessage;
|
| 84 |
+
selectedChatModel: ChatModel['id'];
|
| 85 |
+
selectedVisibilityType: VisibilityType;
|
| 86 |
+
} = requestBody;
|
| 87 |
+
|
| 88 |
+
const session = await auth();
|
| 89 |
+
|
| 90 |
+
if (!session?.user) {
|
| 91 |
+
return new ChatSDKError('unauthorized:chat').toResponse();
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
const userType: UserType = session.user.type;
|
| 95 |
+
|
| 96 |
+
const messageCount = await getMessageCountByUserId({
|
| 97 |
+
id: session.user.id,
|
| 98 |
+
differenceInHours: 24,
|
| 99 |
+
});
|
| 100 |
+
|
| 101 |
+
if (messageCount > entitlementsByUserType[userType].maxMessagesPerDay) {
|
| 102 |
+
return new ChatSDKError('rate_limit:chat').toResponse();
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
const chat = await getChatById({ id });
|
| 106 |
+
|
| 107 |
+
if (!chat) {
|
| 108 |
+
const title = await generateTitleFromUserMessage({
|
| 109 |
+
message,
|
| 110 |
+
});
|
| 111 |
+
|
| 112 |
+
await saveChat({
|
| 113 |
+
id,
|
| 114 |
+
userId: session.user.id,
|
| 115 |
+
title,
|
| 116 |
+
visibility: selectedVisibilityType,
|
| 117 |
+
});
|
| 118 |
+
} else {
|
| 119 |
+
if (chat.userId !== session.user.id) {
|
| 120 |
+
return new ChatSDKError('forbidden:chat').toResponse();
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
const messagesFromDb = await getMessagesByChatId({ id });
|
| 125 |
+
const uiMessages = [...convertToUIMessages(messagesFromDb), message];
|
| 126 |
+
|
| 127 |
+
const { longitude, latitude, city, country } = geolocation(request);
|
| 128 |
+
|
| 129 |
+
const requestHints: RequestHints = {
|
| 130 |
+
longitude,
|
| 131 |
+
latitude,
|
| 132 |
+
city,
|
| 133 |
+
country,
|
| 134 |
+
};
|
| 135 |
+
|
| 136 |
+
await saveMessages({
|
| 137 |
+
messages: [
|
| 138 |
+
{
|
| 139 |
+
chatId: id,
|
| 140 |
+
id: message.id,
|
| 141 |
+
role: 'user',
|
| 142 |
+
parts: message.parts,
|
| 143 |
+
attachments: [],
|
| 144 |
+
createdAt: new Date(),
|
| 145 |
+
},
|
| 146 |
+
],
|
| 147 |
+
});
|
| 148 |
+
|
| 149 |
+
const streamId = generateUUID();
|
| 150 |
+
await createStreamId({ streamId, chatId: id });
|
| 151 |
+
|
| 152 |
+
const stream = createUIMessageStream({
|
| 153 |
+
execute: ({ writer: dataStream }) => {
|
| 154 |
+
const result = streamText({
|
| 155 |
+
model: myProvider.languageModel(selectedChatModel),
|
| 156 |
+
system: systemPrompt({ selectedChatModel, requestHints }),
|
| 157 |
+
messages: convertToModelMessages(uiMessages),
|
| 158 |
+
stopWhen: stepCountIs(5),
|
| 159 |
+
experimental_activeTools:
|
| 160 |
+
selectedChatModel === 'chat-model-reasoning'
|
| 161 |
+
? []
|
| 162 |
+
: [
|
| 163 |
+
'getWeather',
|
| 164 |
+
'createDocument',
|
| 165 |
+
'updateDocument',
|
| 166 |
+
'requestSuggestions',
|
| 167 |
+
],
|
| 168 |
+
experimental_transform: smoothStream({ chunking: 'word' }),
|
| 169 |
+
tools: {
|
| 170 |
+
getWeather,
|
| 171 |
+
createDocument: createDocument({ session, dataStream }),
|
| 172 |
+
updateDocument: updateDocument({ session, dataStream }),
|
| 173 |
+
requestSuggestions: requestSuggestions({
|
| 174 |
+
session,
|
| 175 |
+
dataStream,
|
| 176 |
+
}),
|
| 177 |
+
},
|
| 178 |
+
experimental_telemetry: {
|
| 179 |
+
isEnabled: isProductionEnvironment,
|
| 180 |
+
functionId: 'stream-text',
|
| 181 |
+
},
|
| 182 |
+
});
|
| 183 |
+
|
| 184 |
+
result.consumeStream();
|
| 185 |
+
|
| 186 |
+
dataStream.merge(
|
| 187 |
+
result.toUIMessageStream({
|
| 188 |
+
sendReasoning: true,
|
| 189 |
+
}),
|
| 190 |
+
);
|
| 191 |
+
},
|
| 192 |
+
generateId: generateUUID,
|
| 193 |
+
onFinish: async ({ messages }) => {
|
| 194 |
+
await saveMessages({
|
| 195 |
+
messages: messages.map((message) => ({
|
| 196 |
+
id: message.id,
|
| 197 |
+
role: message.role,
|
| 198 |
+
parts: message.parts,
|
| 199 |
+
createdAt: new Date(),
|
| 200 |
+
attachments: [],
|
| 201 |
+
chatId: id,
|
| 202 |
+
})),
|
| 203 |
+
});
|
| 204 |
+
},
|
| 205 |
+
onError: () => {
|
| 206 |
+
return 'Oops, an error occurred!';
|
| 207 |
+
},
|
| 208 |
+
});
|
| 209 |
+
|
| 210 |
+
const streamContext = getStreamContext();
|
| 211 |
+
|
| 212 |
+
if (streamContext) {
|
| 213 |
+
return new Response(
|
| 214 |
+
await streamContext.resumableStream(streamId, () =>
|
| 215 |
+
stream.pipeThrough(new JsonToSseTransformStream()),
|
| 216 |
+
),
|
| 217 |
+
);
|
| 218 |
+
} else {
|
| 219 |
+
return new Response(stream.pipeThrough(new JsonToSseTransformStream()));
|
| 220 |
+
}
|
| 221 |
+
} catch (error) {
|
| 222 |
+
if (error instanceof ChatSDKError) {
|
| 223 |
+
return error.toResponse();
|
| 224 |
+
}
|
| 225 |
+
}
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
export async function DELETE(request: Request) {
|
| 229 |
+
const { searchParams } = new URL(request.url);
|
| 230 |
+
const id = searchParams.get('id');
|
| 231 |
+
|
| 232 |
+
if (!id) {
|
| 233 |
+
return new ChatSDKError('bad_request:api').toResponse();
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
const session = await auth();
|
| 237 |
+
|
| 238 |
+
if (!session?.user) {
|
| 239 |
+
return new ChatSDKError('unauthorized:chat').toResponse();
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
const chat = await getChatById({ id });
|
| 243 |
+
|
| 244 |
+
if (chat.userId !== session.user.id) {
|
| 245 |
+
return new ChatSDKError('forbidden:chat').toResponse();
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
const deletedChat = await deleteChatById({ id });
|
| 249 |
+
|
| 250 |
+
return Response.json(deletedChat, { status: 200 });
|
| 251 |
+
}
|
app/(chat)/api/chat/schema.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { z } from 'zod';
|
| 2 |
+
|
| 3 |
+
const textPartSchema = z.object({
|
| 4 |
+
type: z.enum(['text']),
|
| 5 |
+
text: z.string().min(1).max(2000),
|
| 6 |
+
});
|
| 7 |
+
|
| 8 |
+
const filePartSchema = z.object({
|
| 9 |
+
type: z.enum(['file']),
|
| 10 |
+
mediaType: z.enum(['image/jpeg', 'image/png']),
|
| 11 |
+
name: z.string().min(1).max(100),
|
| 12 |
+
url: z.string().url(),
|
| 13 |
+
});
|
| 14 |
+
|
| 15 |
+
const partSchema = z.union([textPartSchema, filePartSchema]);
|
| 16 |
+
|
| 17 |
+
export const postRequestBodySchema = z.object({
|
| 18 |
+
id: z.string().uuid(),
|
| 19 |
+
message: z.object({
|
| 20 |
+
id: z.string().uuid(),
|
| 21 |
+
role: z.enum(['user']),
|
| 22 |
+
parts: z.array(partSchema),
|
| 23 |
+
}),
|
| 24 |
+
selectedChatModel: z.enum(['chat-model', 'chat-model-reasoning']),
|
| 25 |
+
selectedVisibilityType: z.enum(['public', 'private']),
|
| 26 |
+
});
|
| 27 |
+
|
| 28 |
+
export type PostRequestBody = z.infer<typeof postRequestBodySchema>;
|
app/(chat)/api/document/route.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { auth } from '@/app/(auth)/auth';
|
| 2 |
+
import type { ArtifactKind } from '@/components/artifact';
|
| 3 |
+
import {
|
| 4 |
+
deleteDocumentsByIdAfterTimestamp,
|
| 5 |
+
getDocumentsById,
|
| 6 |
+
saveDocument,
|
| 7 |
+
} from '@/lib/db/queries';
|
| 8 |
+
import { ChatSDKError } from '@/lib/errors';
|
| 9 |
+
|
| 10 |
+
export async function GET(request: Request) {
|
| 11 |
+
const { searchParams } = new URL(request.url);
|
| 12 |
+
const id = searchParams.get('id');
|
| 13 |
+
|
| 14 |
+
if (!id) {
|
| 15 |
+
return new ChatSDKError(
|
| 16 |
+
'bad_request:api',
|
| 17 |
+
'Parameter id is missing',
|
| 18 |
+
).toResponse();
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
const session = await auth();
|
| 22 |
+
|
| 23 |
+
if (!session?.user) {
|
| 24 |
+
return new ChatSDKError('unauthorized:document').toResponse();
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
const documents = await getDocumentsById({ id });
|
| 28 |
+
|
| 29 |
+
const [document] = documents;
|
| 30 |
+
|
| 31 |
+
if (!document) {
|
| 32 |
+
return new ChatSDKError('not_found:document').toResponse();
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
if (document.userId !== session.user.id) {
|
| 36 |
+
return new ChatSDKError('forbidden:document').toResponse();
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
return Response.json(documents, { status: 200 });
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
export async function POST(request: Request) {
|
| 43 |
+
const { searchParams } = new URL(request.url);
|
| 44 |
+
const id = searchParams.get('id');
|
| 45 |
+
|
| 46 |
+
if (!id) {
|
| 47 |
+
return new ChatSDKError(
|
| 48 |
+
'bad_request:api',
|
| 49 |
+
'Parameter id is required.',
|
| 50 |
+
).toResponse();
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
const session = await auth();
|
| 54 |
+
|
| 55 |
+
if (!session?.user) {
|
| 56 |
+
return new ChatSDKError('not_found:document').toResponse();
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
const {
|
| 60 |
+
content,
|
| 61 |
+
title,
|
| 62 |
+
kind,
|
| 63 |
+
}: { content: string; title: string; kind: ArtifactKind } =
|
| 64 |
+
await request.json();
|
| 65 |
+
|
| 66 |
+
const documents = await getDocumentsById({ id });
|
| 67 |
+
|
| 68 |
+
if (documents.length > 0) {
|
| 69 |
+
const [document] = documents;
|
| 70 |
+
|
| 71 |
+
if (document.userId !== session.user.id) {
|
| 72 |
+
return new ChatSDKError('forbidden:document').toResponse();
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
const document = await saveDocument({
|
| 77 |
+
id,
|
| 78 |
+
content,
|
| 79 |
+
title,
|
| 80 |
+
kind,
|
| 81 |
+
userId: session.user.id,
|
| 82 |
+
});
|
| 83 |
+
|
| 84 |
+
return Response.json(document, { status: 200 });
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
export async function DELETE(request: Request) {
|
| 88 |
+
const { searchParams } = new URL(request.url);
|
| 89 |
+
const id = searchParams.get('id');
|
| 90 |
+
const timestamp = searchParams.get('timestamp');
|
| 91 |
+
|
| 92 |
+
if (!id) {
|
| 93 |
+
return new ChatSDKError(
|
| 94 |
+
'bad_request:api',
|
| 95 |
+
'Parameter id is required.',
|
| 96 |
+
).toResponse();
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
if (!timestamp) {
|
| 100 |
+
return new ChatSDKError(
|
| 101 |
+
'bad_request:api',
|
| 102 |
+
'Parameter timestamp is required.',
|
| 103 |
+
).toResponse();
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
const session = await auth();
|
| 107 |
+
|
| 108 |
+
if (!session?.user) {
|
| 109 |
+
return new ChatSDKError('unauthorized:document').toResponse();
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
const documents = await getDocumentsById({ id });
|
| 113 |
+
|
| 114 |
+
const [document] = documents;
|
| 115 |
+
|
| 116 |
+
if (document.userId !== session.user.id) {
|
| 117 |
+
return new ChatSDKError('forbidden:document').toResponse();
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
const documentsDeleted = await deleteDocumentsByIdAfterTimestamp({
|
| 121 |
+
id,
|
| 122 |
+
timestamp: new Date(timestamp),
|
| 123 |
+
});
|
| 124 |
+
|
| 125 |
+
return Response.json(documentsDeleted, { status: 200 });
|
| 126 |
+
}
|
app/(chat)/api/files/upload/route.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { put } from '@vercel/blob';
|
| 2 |
+
import { NextResponse } from 'next/server';
|
| 3 |
+
import { z } from 'zod';
|
| 4 |
+
|
| 5 |
+
import { auth } from '@/app/(auth)/auth';
|
| 6 |
+
|
| 7 |
+
// Use Blob instead of File since File is not available in Node.js environment
|
| 8 |
+
const FileSchema = z.object({
|
| 9 |
+
file: z
|
| 10 |
+
.instanceof(Blob)
|
| 11 |
+
.refine((file) => file.size <= 5 * 1024 * 1024, {
|
| 12 |
+
message: 'File size should be less than 5MB',
|
| 13 |
+
})
|
| 14 |
+
// Update the file type based on the kind of files you want to accept
|
| 15 |
+
.refine((file) => ['image/jpeg', 'image/png'].includes(file.type), {
|
| 16 |
+
message: 'File type should be JPEG or PNG',
|
| 17 |
+
}),
|
| 18 |
+
});
|
| 19 |
+
|
| 20 |
+
export async function POST(request: Request) {
|
| 21 |
+
const session = await auth();
|
| 22 |
+
|
| 23 |
+
if (!session) {
|
| 24 |
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
if (request.body === null) {
|
| 28 |
+
return new Response('Request body is empty', { status: 400 });
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
try {
|
| 32 |
+
const formData = await request.formData();
|
| 33 |
+
const file = formData.get('file') as Blob;
|
| 34 |
+
|
| 35 |
+
if (!file) {
|
| 36 |
+
return NextResponse.json({ error: 'No file uploaded' }, { status: 400 });
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
const validatedFile = FileSchema.safeParse({ file });
|
| 40 |
+
|
| 41 |
+
if (!validatedFile.success) {
|
| 42 |
+
const errorMessage = validatedFile.error.errors
|
| 43 |
+
.map((error) => error.message)
|
| 44 |
+
.join(', ');
|
| 45 |
+
|
| 46 |
+
return NextResponse.json({ error: errorMessage }, { status: 400 });
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
// Get filename from formData since Blob doesn't have name property
|
| 50 |
+
const filename = (formData.get('file') as File).name;
|
| 51 |
+
const fileBuffer = await file.arrayBuffer();
|
| 52 |
+
|
| 53 |
+
try {
|
| 54 |
+
const data = await put(`${filename}`, fileBuffer, {
|
| 55 |
+
access: 'public',
|
| 56 |
+
});
|
| 57 |
+
|
| 58 |
+
return NextResponse.json(data);
|
| 59 |
+
} catch (error) {
|
| 60 |
+
return NextResponse.json({ error: 'Upload failed' }, { status: 500 });
|
| 61 |
+
}
|
| 62 |
+
} catch (error) {
|
| 63 |
+
return NextResponse.json(
|
| 64 |
+
{ error: 'Failed to process request' },
|
| 65 |
+
{ status: 500 },
|
| 66 |
+
);
|
| 67 |
+
}
|
| 68 |
+
}
|
app/(chat)/api/history/route.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { auth } from '@/app/(auth)/auth';
|
| 2 |
+
import type { NextRequest } from 'next/server';
|
| 3 |
+
import { getChatsByUserId } from '@/lib/db/queries';
|
| 4 |
+
import { ChatSDKError } from '@/lib/errors';
|
| 5 |
+
|
| 6 |
+
export async function GET(request: NextRequest) {
|
| 7 |
+
const { searchParams } = request.nextUrl;
|
| 8 |
+
|
| 9 |
+
const limit = Number.parseInt(searchParams.get('limit') || '10');
|
| 10 |
+
const startingAfter = searchParams.get('starting_after');
|
| 11 |
+
const endingBefore = searchParams.get('ending_before');
|
| 12 |
+
|
| 13 |
+
if (startingAfter && endingBefore) {
|
| 14 |
+
return new ChatSDKError(
|
| 15 |
+
'bad_request:api',
|
| 16 |
+
'Only one of starting_after or ending_before can be provided.',
|
| 17 |
+
).toResponse();
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
const session = await auth();
|
| 21 |
+
|
| 22 |
+
if (!session?.user) {
|
| 23 |
+
return new ChatSDKError('unauthorized:chat').toResponse();
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
const chats = await getChatsByUserId({
|
| 27 |
+
id: session.user.id,
|
| 28 |
+
limit,
|
| 29 |
+
startingAfter,
|
| 30 |
+
endingBefore,
|
| 31 |
+
});
|
| 32 |
+
|
| 33 |
+
return Response.json(chats);
|
| 34 |
+
}
|
app/(chat)/api/suggestions/route.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { auth } from '@/app/(auth)/auth';
|
| 2 |
+
import { getSuggestionsByDocumentId } from '@/lib/db/queries';
|
| 3 |
+
import { ChatSDKError } from '@/lib/errors';
|
| 4 |
+
|
| 5 |
+
export async function GET(request: Request) {
|
| 6 |
+
const { searchParams } = new URL(request.url);
|
| 7 |
+
const documentId = searchParams.get('documentId');
|
| 8 |
+
|
| 9 |
+
if (!documentId) {
|
| 10 |
+
return new ChatSDKError(
|
| 11 |
+
'bad_request:api',
|
| 12 |
+
'Parameter documentId is required.',
|
| 13 |
+
).toResponse();
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
const session = await auth();
|
| 17 |
+
|
| 18 |
+
if (!session?.user) {
|
| 19 |
+
return new ChatSDKError('unauthorized:suggestions').toResponse();
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
const suggestions = await getSuggestionsByDocumentId({
|
| 23 |
+
documentId,
|
| 24 |
+
});
|
| 25 |
+
|
| 26 |
+
const [suggestion] = suggestions;
|
| 27 |
+
|
| 28 |
+
if (!suggestion) {
|
| 29 |
+
return Response.json([], { status: 200 });
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
if (suggestion.userId !== session.user.id) {
|
| 33 |
+
return new ChatSDKError('forbidden:api').toResponse();
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
return Response.json(suggestions, { status: 200 });
|
| 37 |
+
}
|
app/(chat)/api/vote/route.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { auth } from '@/app/(auth)/auth';
|
| 2 |
+
import { getChatById, getVotesByChatId, voteMessage } from '@/lib/db/queries';
|
| 3 |
+
import { ChatSDKError } from '@/lib/errors';
|
| 4 |
+
|
| 5 |
+
export async function GET(request: Request) {
|
| 6 |
+
const { searchParams } = new URL(request.url);
|
| 7 |
+
const chatId = searchParams.get('chatId');
|
| 8 |
+
|
| 9 |
+
if (!chatId) {
|
| 10 |
+
return new ChatSDKError(
|
| 11 |
+
'bad_request:api',
|
| 12 |
+
'Parameter chatId is required.',
|
| 13 |
+
).toResponse();
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
const session = await auth();
|
| 17 |
+
|
| 18 |
+
if (!session?.user) {
|
| 19 |
+
return new ChatSDKError('unauthorized:vote').toResponse();
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
const chat = await getChatById({ id: chatId });
|
| 23 |
+
|
| 24 |
+
if (!chat) {
|
| 25 |
+
return new ChatSDKError('not_found:chat').toResponse();
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
if (chat.userId !== session.user.id) {
|
| 29 |
+
return new ChatSDKError('forbidden:vote').toResponse();
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
const votes = await getVotesByChatId({ id: chatId });
|
| 33 |
+
|
| 34 |
+
return Response.json(votes, { status: 200 });
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
export async function PATCH(request: Request) {
|
| 38 |
+
const {
|
| 39 |
+
chatId,
|
| 40 |
+
messageId,
|
| 41 |
+
type,
|
| 42 |
+
}: { chatId: string; messageId: string; type: 'up' | 'down' } =
|
| 43 |
+
await request.json();
|
| 44 |
+
|
| 45 |
+
if (!chatId || !messageId || !type) {
|
| 46 |
+
return new ChatSDKError(
|
| 47 |
+
'bad_request:api',
|
| 48 |
+
'Parameters chatId, messageId, and type are required.',
|
| 49 |
+
).toResponse();
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
const session = await auth();
|
| 53 |
+
|
| 54 |
+
if (!session?.user) {
|
| 55 |
+
return new ChatSDKError('unauthorized:vote').toResponse();
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
const chat = await getChatById({ id: chatId });
|
| 59 |
+
|
| 60 |
+
if (!chat) {
|
| 61 |
+
return new ChatSDKError('not_found:vote').toResponse();
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
if (chat.userId !== session.user.id) {
|
| 65 |
+
return new ChatSDKError('forbidden:vote').toResponse();
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
await voteMessage({
|
| 69 |
+
chatId,
|
| 70 |
+
messageId,
|
| 71 |
+
type: type,
|
| 72 |
+
});
|
| 73 |
+
|
| 74 |
+
return new Response('Message voted', { status: 200 });
|
| 75 |
+
}
|
app/(chat)/chat/[id]/page.tsx
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { cookies } from 'next/headers';
|
| 2 |
+
import { notFound, redirect } from 'next/navigation';
|
| 3 |
+
|
| 4 |
+
import { auth } from '@/app/(auth)/auth';
|
| 5 |
+
import { Chat } from '@/components/chat';
|
| 6 |
+
import { getChatById, getMessagesByChatId } from '@/lib/db/queries';
|
| 7 |
+
import { DataStreamHandler } from '@/components/data-stream-handler';
|
| 8 |
+
import { DEFAULT_CHAT_MODEL } from '@/lib/ai/models';
|
| 9 |
+
import { convertToUIMessages } from '@/lib/utils';
|
| 10 |
+
|
| 11 |
+
export default async function Page(props: { params: Promise<{ id: string }> }) {
|
| 12 |
+
const params = await props.params;
|
| 13 |
+
const { id } = params;
|
| 14 |
+
const chat = await getChatById({ id });
|
| 15 |
+
|
| 16 |
+
if (!chat) {
|
| 17 |
+
notFound();
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
const session = await auth();
|
| 21 |
+
|
| 22 |
+
if (!session) {
|
| 23 |
+
redirect('/api/auth/guest');
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
if (chat.visibility === 'private') {
|
| 27 |
+
if (!session.user) {
|
| 28 |
+
return notFound();
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
if (session.user.id !== chat.userId) {
|
| 32 |
+
return notFound();
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
const messagesFromDb = await getMessagesByChatId({
|
| 37 |
+
id,
|
| 38 |
+
});
|
| 39 |
+
|
| 40 |
+
const uiMessages = convertToUIMessages(messagesFromDb);
|
| 41 |
+
|
| 42 |
+
const cookieStore = await cookies();
|
| 43 |
+
const chatModelFromCookie = cookieStore.get('chat-model');
|
| 44 |
+
|
| 45 |
+
if (!chatModelFromCookie) {
|
| 46 |
+
return (
|
| 47 |
+
<>
|
| 48 |
+
<Chat
|
| 49 |
+
id={chat.id}
|
| 50 |
+
initialMessages={uiMessages}
|
| 51 |
+
initialChatModel={DEFAULT_CHAT_MODEL}
|
| 52 |
+
initialVisibilityType={chat.visibility}
|
| 53 |
+
isReadonly={session?.user?.id !== chat.userId}
|
| 54 |
+
session={session}
|
| 55 |
+
autoResume={true}
|
| 56 |
+
/>
|
| 57 |
+
<DataStreamHandler />
|
| 58 |
+
</>
|
| 59 |
+
);
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
return (
|
| 63 |
+
<>
|
| 64 |
+
<Chat
|
| 65 |
+
id={chat.id}
|
| 66 |
+
initialMessages={uiMessages}
|
| 67 |
+
initialChatModel={chatModelFromCookie.value}
|
| 68 |
+
initialVisibilityType={chat.visibility}
|
| 69 |
+
isReadonly={session?.user?.id !== chat.userId}
|
| 70 |
+
session={session}
|
| 71 |
+
autoResume={true}
|
| 72 |
+
/>
|
| 73 |
+
<DataStreamHandler />
|
| 74 |
+
</>
|
| 75 |
+
);
|
| 76 |
+
}
|
app/(chat)/layout.tsx
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { cookies } from 'next/headers';
|
| 2 |
+
|
| 3 |
+
import { AppSidebar } from '@/components/app-sidebar';
|
| 4 |
+
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
| 5 |
+
import { auth } from '../(auth)/auth';
|
| 6 |
+
import Script from 'next/script';
|
| 7 |
+
import { DataStreamProvider } from '@/components/data-stream-provider';
|
| 8 |
+
|
| 9 |
+
export const experimental_ppr = true;
|
| 10 |
+
|
| 11 |
+
export default async function Layout({
|
| 12 |
+
children,
|
| 13 |
+
}: {
|
| 14 |
+
children: React.ReactNode;
|
| 15 |
+
}) {
|
| 16 |
+
const [session, cookieStore] = await Promise.all([auth(), cookies()]);
|
| 17 |
+
const isCollapsed = cookieStore.get('sidebar:state')?.value !== 'true';
|
| 18 |
+
|
| 19 |
+
return (
|
| 20 |
+
<>
|
| 21 |
+
<Script
|
| 22 |
+
src="https://cdn.jsdelivr.net/pyodide/v0.23.4/full/pyodide.js"
|
| 23 |
+
strategy="beforeInteractive"
|
| 24 |
+
/>
|
| 25 |
+
<DataStreamProvider>
|
| 26 |
+
<SidebarProvider defaultOpen={!isCollapsed}>
|
| 27 |
+
<AppSidebar user={session?.user} />
|
| 28 |
+
<SidebarInset>{children}</SidebarInset>
|
| 29 |
+
</SidebarProvider>
|
| 30 |
+
</DataStreamProvider>
|
| 31 |
+
</>
|
| 32 |
+
);
|
| 33 |
+
}
|
app/(chat)/opengraph-image.png
ADDED
|
Git LFS Details
|
app/(chat)/page.tsx
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { cookies } from 'next/headers';
|
| 2 |
+
|
| 3 |
+
import { Chat } from '@/components/chat';
|
| 4 |
+
import { DEFAULT_CHAT_MODEL } from '@/lib/ai/models';
|
| 5 |
+
import { generateUUID } from '@/lib/utils';
|
| 6 |
+
import { DataStreamHandler } from '@/components/data-stream-handler';
|
| 7 |
+
import { auth } from '../(auth)/auth';
|
| 8 |
+
import { redirect } from 'next/navigation';
|
| 9 |
+
|
| 10 |
+
export default async function Page() {
|
| 11 |
+
const session = await auth();
|
| 12 |
+
|
| 13 |
+
if (!session) {
|
| 14 |
+
redirect('/api/auth/guest');
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
const id = generateUUID();
|
| 18 |
+
|
| 19 |
+
const cookieStore = await cookies();
|
| 20 |
+
const modelIdFromCookie = cookieStore.get('chat-model');
|
| 21 |
+
|
| 22 |
+
if (!modelIdFromCookie) {
|
| 23 |
+
return (
|
| 24 |
+
<>
|
| 25 |
+
<Chat
|
| 26 |
+
key={id}
|
| 27 |
+
id={id}
|
| 28 |
+
initialMessages={[]}
|
| 29 |
+
initialChatModel={DEFAULT_CHAT_MODEL}
|
| 30 |
+
initialVisibilityType="private"
|
| 31 |
+
isReadonly={false}
|
| 32 |
+
session={session}
|
| 33 |
+
autoResume={false}
|
| 34 |
+
/>
|
| 35 |
+
<DataStreamHandler />
|
| 36 |
+
</>
|
| 37 |
+
);
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
return (
|
| 41 |
+
<>
|
| 42 |
+
<Chat
|
| 43 |
+
key={id}
|
| 44 |
+
id={id}
|
| 45 |
+
initialMessages={[]}
|
| 46 |
+
initialChatModel={modelIdFromCookie.value}
|
| 47 |
+
initialVisibilityType="private"
|
| 48 |
+
isReadonly={false}
|
| 49 |
+
session={session}
|
| 50 |
+
autoResume={false}
|
| 51 |
+
/>
|
| 52 |
+
<DataStreamHandler />
|
| 53 |
+
</>
|
| 54 |
+
);
|
| 55 |
+
}
|
app/(chat)/twitter-image.png
ADDED
|
app/favicon.ico
ADDED
|
|
app/globals.css
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@tailwind base;
|
| 2 |
+
@tailwind components;
|
| 3 |
+
@tailwind utilities;
|
| 4 |
+
|
| 5 |
+
:root {
|
| 6 |
+
--foreground-rgb: 0, 0, 0;
|
| 7 |
+
--background-start-rgb: 214, 219, 220;
|
| 8 |
+
--background-end-rgb: 255, 255, 255;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
@media (prefers-color-scheme: dark) {
|
| 12 |
+
:root {
|
| 13 |
+
--foreground-rgb: 255, 255, 255;
|
| 14 |
+
--background-start-rgb: 0, 0, 0;
|
| 15 |
+
--background-end-rgb: 0, 0, 0;
|
| 16 |
+
}
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
@layer utilities {
|
| 20 |
+
.text-balance {
|
| 21 |
+
text-wrap: balance;
|
| 22 |
+
}
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
@layer base {
|
| 26 |
+
:root {
|
| 27 |
+
--background: 0 0% 100%;
|
| 28 |
+
--foreground: 240 10% 3.9%;
|
| 29 |
+
--card: 0 0% 100%;
|
| 30 |
+
--card-foreground: 240 10% 3.9%;
|
| 31 |
+
--popover: 0 0% 100%;
|
| 32 |
+
--popover-foreground: 240 10% 3.9%;
|
| 33 |
+
--primary: 240 5.9% 10%;
|
| 34 |
+
--primary-foreground: 0 0% 98%;
|
| 35 |
+
--secondary: 240 4.8% 95.9%;
|
| 36 |
+
--secondary-foreground: 240 5.9% 10%;
|
| 37 |
+
--muted: 240 4.8% 95.9%;
|
| 38 |
+
--muted-foreground: 240 3.8% 46.1%;
|
| 39 |
+
--accent: 240 4.8% 95.9%;
|
| 40 |
+
--accent-foreground: 240 5.9% 10%;
|
| 41 |
+
--destructive: 0 84.2% 60.2%;
|
| 42 |
+
--destructive-foreground: 0 0% 98%;
|
| 43 |
+
--border: 240 5.9% 90%;
|
| 44 |
+
--input: 240 5.9% 90%;
|
| 45 |
+
--ring: 240 10% 3.9%;
|
| 46 |
+
--chart-1: 12 76% 61%;
|
| 47 |
+
--chart-2: 173 58% 39%;
|
| 48 |
+
--chart-3: 197 37% 24%;
|
| 49 |
+
--chart-4: 43 74% 66%;
|
| 50 |
+
--chart-5: 27 87% 67%;
|
| 51 |
+
--radius: 0.5rem;
|
| 52 |
+
--sidebar-background: 0 0% 98%;
|
| 53 |
+
--sidebar-foreground: 240 5.3% 26.1%;
|
| 54 |
+
--sidebar-primary: 240 5.9% 10%;
|
| 55 |
+
--sidebar-primary-foreground: 0 0% 98%;
|
| 56 |
+
--sidebar-accent: 240 4.8% 95.9%;
|
| 57 |
+
--sidebar-accent-foreground: 240 5.9% 10%;
|
| 58 |
+
--sidebar-border: 220 13% 91%;
|
| 59 |
+
--sidebar-ring: 217.2 91.2% 59.8%;
|
| 60 |
+
}
|
| 61 |
+
.dark {
|
| 62 |
+
--background: 240 10% 3.9%;
|
| 63 |
+
--foreground: 0 0% 98%;
|
| 64 |
+
--card: 240 10% 3.9%;
|
| 65 |
+
--card-foreground: 0 0% 98%;
|
| 66 |
+
--popover: 240 10% 3.9%;
|
| 67 |
+
--popover-foreground: 0 0% 98%;
|
| 68 |
+
--primary: 0 0% 98%;
|
| 69 |
+
--primary-foreground: 240 5.9% 10%;
|
| 70 |
+
--secondary: 240 3.7% 15.9%;
|
| 71 |
+
--secondary-foreground: 0 0% 98%;
|
| 72 |
+
--muted: 240 3.7% 15.9%;
|
| 73 |
+
--muted-foreground: 240 5% 64.9%;
|
| 74 |
+
--accent: 240 3.7% 15.9%;
|
| 75 |
+
--accent-foreground: 0 0% 98%;
|
| 76 |
+
--destructive: 0 62.8% 30.6%;
|
| 77 |
+
--destructive-foreground: 0 0% 98%;
|
| 78 |
+
--border: 240 3.7% 15.9%;
|
| 79 |
+
--input: 240 3.7% 15.9%;
|
| 80 |
+
--ring: 240 4.9% 83.9%;
|
| 81 |
+
--chart-1: 220 70% 50%;
|
| 82 |
+
--chart-2: 160 60% 45%;
|
| 83 |
+
--chart-3: 30 80% 55%;
|
| 84 |
+
--chart-4: 280 65% 60%;
|
| 85 |
+
--chart-5: 340 75% 55%;
|
| 86 |
+
--sidebar-background: 240 5.9% 10%;
|
| 87 |
+
--sidebar-foreground: 240 4.8% 95.9%;
|
| 88 |
+
--sidebar-primary: 224.3 76.3% 48%;
|
| 89 |
+
--sidebar-primary-foreground: 0 0% 100%;
|
| 90 |
+
--sidebar-accent: 240 3.7% 15.9%;
|
| 91 |
+
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
| 92 |
+
--sidebar-border: 240 3.7% 15.9%;
|
| 93 |
+
--sidebar-ring: 217.2 91.2% 59.8%;
|
| 94 |
+
}
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
@layer base {
|
| 98 |
+
* {
|
| 99 |
+
@apply border-border;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
body {
|
| 103 |
+
@apply bg-background text-foreground;
|
| 104 |
+
}
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
.skeleton {
|
| 108 |
+
* {
|
| 109 |
+
pointer-events: none !important;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
*[class^="text-"] {
|
| 113 |
+
color: transparent;
|
| 114 |
+
@apply rounded-md bg-foreground/20 select-none animate-pulse;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
.skeleton-bg {
|
| 118 |
+
@apply bg-foreground/10;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
.skeleton-div {
|
| 122 |
+
@apply bg-foreground/20 animate-pulse;
|
| 123 |
+
}
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
.ProseMirror {
|
| 127 |
+
outline: none;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
.cm-editor,
|
| 131 |
+
.cm-gutters {
|
| 132 |
+
@apply bg-background dark:bg-zinc-800 outline-none selection:bg-zinc-900 !important;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
.ͼo.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground,
|
| 136 |
+
.ͼo.cm-selectionBackground,
|
| 137 |
+
.ͼo.cm-content::selection {
|
| 138 |
+
@apply bg-zinc-200 dark:bg-zinc-900 !important;
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
.cm-activeLine,
|
| 142 |
+
.cm-activeLineGutter {
|
| 143 |
+
@apply bg-transparent !important;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
.cm-activeLine {
|
| 147 |
+
@apply rounded-r-sm !important;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
.cm-lineNumbers {
|
| 151 |
+
@apply min-w-7;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
.cm-foldGutter {
|
| 155 |
+
@apply min-w-3;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
.cm-lineNumbers .cm-activeLineGutter {
|
| 159 |
+
@apply rounded-l-sm !important;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
.suggestion-highlight {
|
| 163 |
+
@apply bg-blue-200 hover:bg-blue-300 dark:hover:bg-blue-400/50 dark:text-blue-50 dark:bg-blue-500/40;
|
| 164 |
+
}
|
app/layout.tsx
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Toaster } from 'sonner';
|
| 2 |
+
import type { Metadata } from 'next';
|
| 3 |
+
import { Geist, Geist_Mono } from 'next/font/google';
|
| 4 |
+
import { ThemeProvider } from '@/components/theme-provider';
|
| 5 |
+
|
| 6 |
+
import './globals.css';
|
| 7 |
+
import { SessionProvider } from 'next-auth/react';
|
| 8 |
+
|
| 9 |
+
export const metadata: Metadata = {
|
| 10 |
+
metadataBase: new URL('https://chat.vercel.ai'),
|
| 11 |
+
title: 'Next.js Chatbot Template',
|
| 12 |
+
description: 'Next.js chatbot template using the AI SDK.',
|
| 13 |
+
};
|
| 14 |
+
|
| 15 |
+
export const viewport = {
|
| 16 |
+
maximumScale: 1, // Disable auto-zoom on mobile Safari
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
const geist = Geist({
|
| 20 |
+
subsets: ['latin'],
|
| 21 |
+
display: 'swap',
|
| 22 |
+
variable: '--font-geist',
|
| 23 |
+
});
|
| 24 |
+
|
| 25 |
+
const geistMono = Geist_Mono({
|
| 26 |
+
subsets: ['latin'],
|
| 27 |
+
display: 'swap',
|
| 28 |
+
variable: '--font-geist-mono',
|
| 29 |
+
});
|
| 30 |
+
|
| 31 |
+
const LIGHT_THEME_COLOR = 'hsl(0 0% 100%)';
|
| 32 |
+
const DARK_THEME_COLOR = 'hsl(240deg 10% 3.92%)';
|
| 33 |
+
const THEME_COLOR_SCRIPT = `\
|
| 34 |
+
(function() {
|
| 35 |
+
var html = document.documentElement;
|
| 36 |
+
var meta = document.querySelector('meta[name="theme-color"]');
|
| 37 |
+
if (!meta) {
|
| 38 |
+
meta = document.createElement('meta');
|
| 39 |
+
meta.setAttribute('name', 'theme-color');
|
| 40 |
+
document.head.appendChild(meta);
|
| 41 |
+
}
|
| 42 |
+
function updateThemeColor() {
|
| 43 |
+
var isDark = html.classList.contains('dark');
|
| 44 |
+
meta.setAttribute('content', isDark ? '${DARK_THEME_COLOR}' : '${LIGHT_THEME_COLOR}');
|
| 45 |
+
}
|
| 46 |
+
var observer = new MutationObserver(updateThemeColor);
|
| 47 |
+
observer.observe(html, { attributes: true, attributeFilter: ['class'] });
|
| 48 |
+
updateThemeColor();
|
| 49 |
+
})();`;
|
| 50 |
+
|
| 51 |
+
export default async function RootLayout({
|
| 52 |
+
children,
|
| 53 |
+
}: Readonly<{
|
| 54 |
+
children: React.ReactNode;
|
| 55 |
+
}>) {
|
| 56 |
+
return (
|
| 57 |
+
<html
|
| 58 |
+
lang="en"
|
| 59 |
+
// `next-themes` injects an extra classname to the body element to avoid
|
| 60 |
+
// visual flicker before hydration. Hence the `suppressHydrationWarning`
|
| 61 |
+
// prop is necessary to avoid the React hydration mismatch warning.
|
| 62 |
+
// https://github.com/pacocoursey/next-themes?tab=readme-ov-file#with-app
|
| 63 |
+
suppressHydrationWarning
|
| 64 |
+
className={`${geist.variable} ${geistMono.variable}`}
|
| 65 |
+
>
|
| 66 |
+
<head>
|
| 67 |
+
<script
|
| 68 |
+
dangerouslySetInnerHTML={{
|
| 69 |
+
__html: THEME_COLOR_SCRIPT,
|
| 70 |
+
}}
|
| 71 |
+
/>
|
| 72 |
+
</head>
|
| 73 |
+
<body className="antialiased">
|
| 74 |
+
<ThemeProvider
|
| 75 |
+
attribute="class"
|
| 76 |
+
defaultTheme="system"
|
| 77 |
+
enableSystem
|
| 78 |
+
disableTransitionOnChange
|
| 79 |
+
>
|
| 80 |
+
<Toaster position="top-center" />
|
| 81 |
+
<SessionProvider>{children}</SessionProvider>
|
| 82 |
+
</ThemeProvider>
|
| 83 |
+
</body>
|
| 84 |
+
</html>
|
| 85 |
+
);
|
| 86 |
+
}
|
artifacts/actions.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use server';
|
| 2 |
+
|
| 3 |
+
import { getSuggestionsByDocumentId } from '@/lib/db/queries';
|
| 4 |
+
|
| 5 |
+
export async function getSuggestions({ documentId }: { documentId: string }) {
|
| 6 |
+
const suggestions = await getSuggestionsByDocumentId({ documentId });
|
| 7 |
+
return suggestions ?? [];
|
| 8 |
+
}
|
artifacts/code/client.tsx
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Artifact } from '@/components/create-artifact';
|
| 2 |
+
import { CodeEditor } from '@/components/code-editor';
|
| 3 |
+
import {
|
| 4 |
+
CopyIcon,
|
| 5 |
+
LogsIcon,
|
| 6 |
+
MessageIcon,
|
| 7 |
+
PlayIcon,
|
| 8 |
+
RedoIcon,
|
| 9 |
+
UndoIcon,
|
| 10 |
+
} from '@/components/icons';
|
| 11 |
+
import { toast } from 'sonner';
|
| 12 |
+
import { generateUUID } from '@/lib/utils';
|
| 13 |
+
import {
|
| 14 |
+
Console,
|
| 15 |
+
type ConsoleOutput,
|
| 16 |
+
type ConsoleOutputContent,
|
| 17 |
+
} from '@/components/console';
|
| 18 |
+
|
| 19 |
+
const OUTPUT_HANDLERS = {
|
| 20 |
+
matplotlib: `
|
| 21 |
+
import io
|
| 22 |
+
import base64
|
| 23 |
+
from matplotlib import pyplot as plt
|
| 24 |
+
|
| 25 |
+
# Clear any existing plots
|
| 26 |
+
plt.clf()
|
| 27 |
+
plt.close('all')
|
| 28 |
+
|
| 29 |
+
# Switch to agg backend
|
| 30 |
+
plt.switch_backend('agg')
|
| 31 |
+
|
| 32 |
+
def setup_matplotlib_output():
|
| 33 |
+
def custom_show():
|
| 34 |
+
if plt.gcf().get_size_inches().prod() * plt.gcf().dpi ** 2 > 25_000_000:
|
| 35 |
+
print("Warning: Plot size too large, reducing quality")
|
| 36 |
+
plt.gcf().set_dpi(100)
|
| 37 |
+
|
| 38 |
+
png_buf = io.BytesIO()
|
| 39 |
+
plt.savefig(png_buf, format='png')
|
| 40 |
+
png_buf.seek(0)
|
| 41 |
+
png_base64 = base64.b64encode(png_buf.read()).decode('utf-8')
|
| 42 |
+
print(f'data:image/png;base64,{png_base64}')
|
| 43 |
+
png_buf.close()
|
| 44 |
+
|
| 45 |
+
plt.clf()
|
| 46 |
+
plt.close('all')
|
| 47 |
+
|
| 48 |
+
plt.show = custom_show
|
| 49 |
+
`,
|
| 50 |
+
basic: `
|
| 51 |
+
# Basic output capture setup
|
| 52 |
+
`,
|
| 53 |
+
};
|
| 54 |
+
|
| 55 |
+
function detectRequiredHandlers(code: string): string[] {
|
| 56 |
+
const handlers: string[] = ['basic'];
|
| 57 |
+
|
| 58 |
+
if (code.includes('matplotlib') || code.includes('plt.')) {
|
| 59 |
+
handlers.push('matplotlib');
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
return handlers;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
interface Metadata {
|
| 66 |
+
outputs: Array<ConsoleOutput>;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
export const codeArtifact = new Artifact<'code', Metadata>({
|
| 70 |
+
kind: 'code',
|
| 71 |
+
description:
|
| 72 |
+
'Useful for code generation; Code execution is only available for python code.',
|
| 73 |
+
initialize: async ({ setMetadata }) => {
|
| 74 |
+
setMetadata({
|
| 75 |
+
outputs: [],
|
| 76 |
+
});
|
| 77 |
+
},
|
| 78 |
+
onStreamPart: ({ streamPart, setArtifact }) => {
|
| 79 |
+
if (streamPart.type === 'data-codeDelta') {
|
| 80 |
+
setArtifact((draftArtifact) => ({
|
| 81 |
+
...draftArtifact,
|
| 82 |
+
content: streamPart.data,
|
| 83 |
+
isVisible:
|
| 84 |
+
draftArtifact.status === 'streaming' &&
|
| 85 |
+
draftArtifact.content.length > 300 &&
|
| 86 |
+
draftArtifact.content.length < 310
|
| 87 |
+
? true
|
| 88 |
+
: draftArtifact.isVisible,
|
| 89 |
+
status: 'streaming',
|
| 90 |
+
}));
|
| 91 |
+
}
|
| 92 |
+
},
|
| 93 |
+
content: ({ metadata, setMetadata, ...props }) => {
|
| 94 |
+
return (
|
| 95 |
+
<>
|
| 96 |
+
<div className="px-1">
|
| 97 |
+
<CodeEditor {...props} />
|
| 98 |
+
</div>
|
| 99 |
+
|
| 100 |
+
{metadata?.outputs && (
|
| 101 |
+
<Console
|
| 102 |
+
consoleOutputs={metadata.outputs}
|
| 103 |
+
setConsoleOutputs={() => {
|
| 104 |
+
setMetadata({
|
| 105 |
+
...metadata,
|
| 106 |
+
outputs: [],
|
| 107 |
+
});
|
| 108 |
+
}}
|
| 109 |
+
/>
|
| 110 |
+
)}
|
| 111 |
+
</>
|
| 112 |
+
);
|
| 113 |
+
},
|
| 114 |
+
actions: [
|
| 115 |
+
{
|
| 116 |
+
icon: <PlayIcon size={18} />,
|
| 117 |
+
label: 'Run',
|
| 118 |
+
description: 'Execute code',
|
| 119 |
+
onClick: async ({ content, setMetadata }) => {
|
| 120 |
+
const runId = generateUUID();
|
| 121 |
+
const outputContent: Array<ConsoleOutputContent> = [];
|
| 122 |
+
|
| 123 |
+
setMetadata((metadata) => ({
|
| 124 |
+
...metadata,
|
| 125 |
+
outputs: [
|
| 126 |
+
...metadata.outputs,
|
| 127 |
+
{
|
| 128 |
+
id: runId,
|
| 129 |
+
contents: [],
|
| 130 |
+
status: 'in_progress',
|
| 131 |
+
},
|
| 132 |
+
],
|
| 133 |
+
}));
|
| 134 |
+
|
| 135 |
+
try {
|
| 136 |
+
// @ts-expect-error - loadPyodide is not defined
|
| 137 |
+
const currentPyodideInstance = await globalThis.loadPyodide({
|
| 138 |
+
indexURL: 'https://cdn.jsdelivr.net/pyodide/v0.23.4/full/',
|
| 139 |
+
});
|
| 140 |
+
|
| 141 |
+
currentPyodideInstance.setStdout({
|
| 142 |
+
batched: (output: string) => {
|
| 143 |
+
outputContent.push({
|
| 144 |
+
type: output.startsWith('data:image/png;base64')
|
| 145 |
+
? 'image'
|
| 146 |
+
: 'text',
|
| 147 |
+
value: output,
|
| 148 |
+
});
|
| 149 |
+
},
|
| 150 |
+
});
|
| 151 |
+
|
| 152 |
+
await currentPyodideInstance.loadPackagesFromImports(content, {
|
| 153 |
+
messageCallback: (message: string) => {
|
| 154 |
+
setMetadata((metadata) => ({
|
| 155 |
+
...metadata,
|
| 156 |
+
outputs: [
|
| 157 |
+
...metadata.outputs.filter((output) => output.id !== runId),
|
| 158 |
+
{
|
| 159 |
+
id: runId,
|
| 160 |
+
contents: [{ type: 'text', value: message }],
|
| 161 |
+
status: 'loading_packages',
|
| 162 |
+
},
|
| 163 |
+
],
|
| 164 |
+
}));
|
| 165 |
+
},
|
| 166 |
+
});
|
| 167 |
+
|
| 168 |
+
const requiredHandlers = detectRequiredHandlers(content);
|
| 169 |
+
for (const handler of requiredHandlers) {
|
| 170 |
+
if (OUTPUT_HANDLERS[handler as keyof typeof OUTPUT_HANDLERS]) {
|
| 171 |
+
await currentPyodideInstance.runPythonAsync(
|
| 172 |
+
OUTPUT_HANDLERS[handler as keyof typeof OUTPUT_HANDLERS],
|
| 173 |
+
);
|
| 174 |
+
|
| 175 |
+
if (handler === 'matplotlib') {
|
| 176 |
+
await currentPyodideInstance.runPythonAsync(
|
| 177 |
+
'setup_matplotlib_output()',
|
| 178 |
+
);
|
| 179 |
+
}
|
| 180 |
+
}
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
await currentPyodideInstance.runPythonAsync(content);
|
| 184 |
+
|
| 185 |
+
setMetadata((metadata) => ({
|
| 186 |
+
...metadata,
|
| 187 |
+
outputs: [
|
| 188 |
+
...metadata.outputs.filter((output) => output.id !== runId),
|
| 189 |
+
{
|
| 190 |
+
id: runId,
|
| 191 |
+
contents: outputContent,
|
| 192 |
+
status: 'completed',
|
| 193 |
+
},
|
| 194 |
+
],
|
| 195 |
+
}));
|
| 196 |
+
} catch (error: any) {
|
| 197 |
+
setMetadata((metadata) => ({
|
| 198 |
+
...metadata,
|
| 199 |
+
outputs: [
|
| 200 |
+
...metadata.outputs.filter((output) => output.id !== runId),
|
| 201 |
+
{
|
| 202 |
+
id: runId,
|
| 203 |
+
contents: [{ type: 'text', value: error.message }],
|
| 204 |
+
status: 'failed',
|
| 205 |
+
},
|
| 206 |
+
],
|
| 207 |
+
}));
|
| 208 |
+
}
|
| 209 |
+
},
|
| 210 |
+
},
|
| 211 |
+
{
|
| 212 |
+
icon: <UndoIcon size={18} />,
|
| 213 |
+
description: 'View Previous version',
|
| 214 |
+
onClick: ({ handleVersionChange }) => {
|
| 215 |
+
handleVersionChange('prev');
|
| 216 |
+
},
|
| 217 |
+
isDisabled: ({ currentVersionIndex }) => {
|
| 218 |
+
if (currentVersionIndex === 0) {
|
| 219 |
+
return true;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
return false;
|
| 223 |
+
},
|
| 224 |
+
},
|
| 225 |
+
{
|
| 226 |
+
icon: <RedoIcon size={18} />,
|
| 227 |
+
description: 'View Next version',
|
| 228 |
+
onClick: ({ handleVersionChange }) => {
|
| 229 |
+
handleVersionChange('next');
|
| 230 |
+
},
|
| 231 |
+
isDisabled: ({ isCurrentVersion }) => {
|
| 232 |
+
if (isCurrentVersion) {
|
| 233 |
+
return true;
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
return false;
|
| 237 |
+
},
|
| 238 |
+
},
|
| 239 |
+
{
|
| 240 |
+
icon: <CopyIcon size={18} />,
|
| 241 |
+
description: 'Copy code to clipboard',
|
| 242 |
+
onClick: ({ content }) => {
|
| 243 |
+
navigator.clipboard.writeText(content);
|
| 244 |
+
toast.success('Copied to clipboard!');
|
| 245 |
+
},
|
| 246 |
+
},
|
| 247 |
+
],
|
| 248 |
+
toolbar: [
|
| 249 |
+
{
|
| 250 |
+
icon: <MessageIcon />,
|
| 251 |
+
description: 'Add comments',
|
| 252 |
+
onClick: ({ sendMessage }) => {
|
| 253 |
+
sendMessage({
|
| 254 |
+
role: 'user',
|
| 255 |
+
parts: [
|
| 256 |
+
{
|
| 257 |
+
type: 'text',
|
| 258 |
+
text: 'Add comments to the code snippet for understanding',
|
| 259 |
+
},
|
| 260 |
+
],
|
| 261 |
+
});
|
| 262 |
+
},
|
| 263 |
+
},
|
| 264 |
+
{
|
| 265 |
+
icon: <LogsIcon />,
|
| 266 |
+
description: 'Add logs',
|
| 267 |
+
onClick: ({ sendMessage }) => {
|
| 268 |
+
sendMessage({
|
| 269 |
+
role: 'user',
|
| 270 |
+
parts: [
|
| 271 |
+
{
|
| 272 |
+
type: 'text',
|
| 273 |
+
text: 'Add logs to the code snippet for debugging',
|
| 274 |
+
},
|
| 275 |
+
],
|
| 276 |
+
});
|
| 277 |
+
},
|
| 278 |
+
},
|
| 279 |
+
],
|
| 280 |
+
});
|
artifacts/code/server.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { z } from 'zod';
|
| 2 |
+
import { streamObject } from 'ai';
|
| 3 |
+
import { myProvider } from '@/lib/ai/providers';
|
| 4 |
+
import { codePrompt, updateDocumentPrompt } from '@/lib/ai/prompts';
|
| 5 |
+
import { createDocumentHandler } from '@/lib/artifacts/server';
|
| 6 |
+
|
| 7 |
+
export const codeDocumentHandler = createDocumentHandler<'code'>({
|
| 8 |
+
kind: 'code',
|
| 9 |
+
onCreateDocument: async ({ title, dataStream }) => {
|
| 10 |
+
let draftContent = '';
|
| 11 |
+
|
| 12 |
+
const { fullStream } = streamObject({
|
| 13 |
+
model: myProvider.languageModel('artifact-model'),
|
| 14 |
+
system: codePrompt,
|
| 15 |
+
prompt: title,
|
| 16 |
+
schema: z.object({
|
| 17 |
+
code: z.string(),
|
| 18 |
+
}),
|
| 19 |
+
});
|
| 20 |
+
|
| 21 |
+
for await (const delta of fullStream) {
|
| 22 |
+
const { type } = delta;
|
| 23 |
+
|
| 24 |
+
if (type === 'object') {
|
| 25 |
+
const { object } = delta;
|
| 26 |
+
const { code } = object;
|
| 27 |
+
|
| 28 |
+
if (code) {
|
| 29 |
+
dataStream.write({
|
| 30 |
+
type: 'data-codeDelta',
|
| 31 |
+
data: code ?? '',
|
| 32 |
+
transient: true,
|
| 33 |
+
});
|
| 34 |
+
|
| 35 |
+
draftContent = code;
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
return draftContent;
|
| 41 |
+
},
|
| 42 |
+
onUpdateDocument: async ({ document, description, dataStream }) => {
|
| 43 |
+
let draftContent = '';
|
| 44 |
+
|
| 45 |
+
const { fullStream } = streamObject({
|
| 46 |
+
model: myProvider.languageModel('artifact-model'),
|
| 47 |
+
system: updateDocumentPrompt(document.content, 'code'),
|
| 48 |
+
prompt: description,
|
| 49 |
+
schema: z.object({
|
| 50 |
+
code: z.string(),
|
| 51 |
+
}),
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
+
for await (const delta of fullStream) {
|
| 55 |
+
const { type } = delta;
|
| 56 |
+
|
| 57 |
+
if (type === 'object') {
|
| 58 |
+
const { object } = delta;
|
| 59 |
+
const { code } = object;
|
| 60 |
+
|
| 61 |
+
if (code) {
|
| 62 |
+
dataStream.write({
|
| 63 |
+
type: 'data-codeDelta',
|
| 64 |
+
data: code ?? '',
|
| 65 |
+
transient: true,
|
| 66 |
+
});
|
| 67 |
+
|
| 68 |
+
draftContent = code;
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
return draftContent;
|
| 74 |
+
},
|
| 75 |
+
});
|
artifacts/image/client.tsx
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Artifact } from '@/components/create-artifact';
|
| 2 |
+
import { CopyIcon, RedoIcon, UndoIcon } from '@/components/icons';
|
| 3 |
+
import { ImageEditor } from '@/components/image-editor';
|
| 4 |
+
import { toast } from 'sonner';
|
| 5 |
+
|
| 6 |
+
export const imageArtifact = new Artifact({
|
| 7 |
+
kind: 'image',
|
| 8 |
+
description: 'Useful for image generation',
|
| 9 |
+
onStreamPart: ({ streamPart, setArtifact }) => {
|
| 10 |
+
if (streamPart.type === 'data-imageDelta') {
|
| 11 |
+
setArtifact((draftArtifact) => ({
|
| 12 |
+
...draftArtifact,
|
| 13 |
+
content: streamPart.data,
|
| 14 |
+
isVisible: true,
|
| 15 |
+
status: 'streaming',
|
| 16 |
+
}));
|
| 17 |
+
}
|
| 18 |
+
},
|
| 19 |
+
content: ImageEditor,
|
| 20 |
+
actions: [
|
| 21 |
+
{
|
| 22 |
+
icon: <UndoIcon size={18} />,
|
| 23 |
+
description: 'View Previous version',
|
| 24 |
+
onClick: ({ handleVersionChange }) => {
|
| 25 |
+
handleVersionChange('prev');
|
| 26 |
+
},
|
| 27 |
+
isDisabled: ({ currentVersionIndex }) => {
|
| 28 |
+
if (currentVersionIndex === 0) {
|
| 29 |
+
return true;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
return false;
|
| 33 |
+
},
|
| 34 |
+
},
|
| 35 |
+
{
|
| 36 |
+
icon: <RedoIcon size={18} />,
|
| 37 |
+
description: 'View Next version',
|
| 38 |
+
onClick: ({ handleVersionChange }) => {
|
| 39 |
+
handleVersionChange('next');
|
| 40 |
+
},
|
| 41 |
+
isDisabled: ({ isCurrentVersion }) => {
|
| 42 |
+
if (isCurrentVersion) {
|
| 43 |
+
return true;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
return false;
|
| 47 |
+
},
|
| 48 |
+
},
|
| 49 |
+
{
|
| 50 |
+
icon: <CopyIcon size={18} />,
|
| 51 |
+
description: 'Copy image to clipboard',
|
| 52 |
+
onClick: ({ content }) => {
|
| 53 |
+
const img = new Image();
|
| 54 |
+
img.src = `data:image/png;base64,${content}`;
|
| 55 |
+
|
| 56 |
+
img.onload = () => {
|
| 57 |
+
const canvas = document.createElement('canvas');
|
| 58 |
+
canvas.width = img.width;
|
| 59 |
+
canvas.height = img.height;
|
| 60 |
+
const ctx = canvas.getContext('2d');
|
| 61 |
+
ctx?.drawImage(img, 0, 0);
|
| 62 |
+
canvas.toBlob((blob) => {
|
| 63 |
+
if (blob) {
|
| 64 |
+
navigator.clipboard.write([
|
| 65 |
+
new ClipboardItem({ 'image/png': blob }),
|
| 66 |
+
]);
|
| 67 |
+
}
|
| 68 |
+
}, 'image/png');
|
| 69 |
+
};
|
| 70 |
+
|
| 71 |
+
toast.success('Copied image to clipboard!');
|
| 72 |
+
},
|
| 73 |
+
},
|
| 74 |
+
],
|
| 75 |
+
toolbar: [],
|
| 76 |
+
});
|
artifacts/image/server.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { myProvider } from '@/lib/ai/providers';
|
| 2 |
+
import { createDocumentHandler } from '@/lib/artifacts/server';
|
| 3 |
+
import { experimental_generateImage } from 'ai';
|
| 4 |
+
|
| 5 |
+
export const imageDocumentHandler = createDocumentHandler<'image'>({
|
| 6 |
+
kind: 'image',
|
| 7 |
+
onCreateDocument: async ({ title, dataStream }) => {
|
| 8 |
+
let draftContent = '';
|
| 9 |
+
|
| 10 |
+
const { image } = await experimental_generateImage({
|
| 11 |
+
model: myProvider.imageModel('small-model'),
|
| 12 |
+
prompt: title,
|
| 13 |
+
n: 1,
|
| 14 |
+
});
|
| 15 |
+
|
| 16 |
+
draftContent = image.base64;
|
| 17 |
+
|
| 18 |
+
dataStream.write({
|
| 19 |
+
type: 'data-imageDelta',
|
| 20 |
+
data: image.base64,
|
| 21 |
+
transient: true,
|
| 22 |
+
});
|
| 23 |
+
|
| 24 |
+
return draftContent;
|
| 25 |
+
},
|
| 26 |
+
onUpdateDocument: async ({ description, dataStream }) => {
|
| 27 |
+
let draftContent = '';
|
| 28 |
+
|
| 29 |
+
const { image } = await experimental_generateImage({
|
| 30 |
+
model: myProvider.imageModel('small-model'),
|
| 31 |
+
prompt: description,
|
| 32 |
+
n: 1,
|
| 33 |
+
});
|
| 34 |
+
|
| 35 |
+
draftContent = image.base64;
|
| 36 |
+
|
| 37 |
+
dataStream.write({
|
| 38 |
+
type: 'data-imageDelta',
|
| 39 |
+
data: image.base64,
|
| 40 |
+
transient: true,
|
| 41 |
+
});
|
| 42 |
+
|
| 43 |
+
return draftContent;
|
| 44 |
+
},
|
| 45 |
+
});
|
artifacts/sheet/client.tsx
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Artifact } from '@/components/create-artifact';
|
| 2 |
+
import {
|
| 3 |
+
CopyIcon,
|
| 4 |
+
LineChartIcon,
|
| 5 |
+
RedoIcon,
|
| 6 |
+
SparklesIcon,
|
| 7 |
+
UndoIcon,
|
| 8 |
+
} from '@/components/icons';
|
| 9 |
+
import { SpreadsheetEditor } from '@/components/sheet-editor';
|
| 10 |
+
import { parse, unparse } from 'papaparse';
|
| 11 |
+
import { toast } from 'sonner';
|
| 12 |
+
|
| 13 |
+
type Metadata = any;
|
| 14 |
+
|
| 15 |
+
export const sheetArtifact = new Artifact<'sheet', Metadata>({
|
| 16 |
+
kind: 'sheet',
|
| 17 |
+
description: 'Useful for working with spreadsheets',
|
| 18 |
+
initialize: async () => {},
|
| 19 |
+
onStreamPart: ({ setArtifact, streamPart }) => {
|
| 20 |
+
if (streamPart.type === 'data-sheetDelta') {
|
| 21 |
+
setArtifact((draftArtifact) => ({
|
| 22 |
+
...draftArtifact,
|
| 23 |
+
content: streamPart.data,
|
| 24 |
+
isVisible: true,
|
| 25 |
+
status: 'streaming',
|
| 26 |
+
}));
|
| 27 |
+
}
|
| 28 |
+
},
|
| 29 |
+
content: ({
|
| 30 |
+
content,
|
| 31 |
+
currentVersionIndex,
|
| 32 |
+
isCurrentVersion,
|
| 33 |
+
onSaveContent,
|
| 34 |
+
status,
|
| 35 |
+
}) => {
|
| 36 |
+
return (
|
| 37 |
+
<SpreadsheetEditor
|
| 38 |
+
content={content}
|
| 39 |
+
currentVersionIndex={currentVersionIndex}
|
| 40 |
+
isCurrentVersion={isCurrentVersion}
|
| 41 |
+
saveContent={onSaveContent}
|
| 42 |
+
status={status}
|
| 43 |
+
/>
|
| 44 |
+
);
|
| 45 |
+
},
|
| 46 |
+
actions: [
|
| 47 |
+
{
|
| 48 |
+
icon: <UndoIcon size={18} />,
|
| 49 |
+
description: 'View Previous version',
|
| 50 |
+
onClick: ({ handleVersionChange }) => {
|
| 51 |
+
handleVersionChange('prev');
|
| 52 |
+
},
|
| 53 |
+
isDisabled: ({ currentVersionIndex }) => {
|
| 54 |
+
if (currentVersionIndex === 0) {
|
| 55 |
+
return true;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
return false;
|
| 59 |
+
},
|
| 60 |
+
},
|
| 61 |
+
{
|
| 62 |
+
icon: <RedoIcon size={18} />,
|
| 63 |
+
description: 'View Next version',
|
| 64 |
+
onClick: ({ handleVersionChange }) => {
|
| 65 |
+
handleVersionChange('next');
|
| 66 |
+
},
|
| 67 |
+
isDisabled: ({ isCurrentVersion }) => {
|
| 68 |
+
if (isCurrentVersion) {
|
| 69 |
+
return true;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
return false;
|
| 73 |
+
},
|
| 74 |
+
},
|
| 75 |
+
{
|
| 76 |
+
icon: <CopyIcon />,
|
| 77 |
+
description: 'Copy as .csv',
|
| 78 |
+
onClick: ({ content }) => {
|
| 79 |
+
const parsed = parse<string[]>(content, { skipEmptyLines: true });
|
| 80 |
+
|
| 81 |
+
const nonEmptyRows = parsed.data.filter((row) =>
|
| 82 |
+
row.some((cell) => cell.trim() !== ''),
|
| 83 |
+
);
|
| 84 |
+
|
| 85 |
+
const cleanedCsv = unparse(nonEmptyRows);
|
| 86 |
+
|
| 87 |
+
navigator.clipboard.writeText(cleanedCsv);
|
| 88 |
+
toast.success('Copied csv to clipboard!');
|
| 89 |
+
},
|
| 90 |
+
},
|
| 91 |
+
],
|
| 92 |
+
toolbar: [
|
| 93 |
+
{
|
| 94 |
+
description: 'Format and clean data',
|
| 95 |
+
icon: <SparklesIcon />,
|
| 96 |
+
onClick: ({ sendMessage }) => {
|
| 97 |
+
sendMessage({
|
| 98 |
+
role: 'user',
|
| 99 |
+
parts: [
|
| 100 |
+
{ type: 'text', text: 'Can you please format and clean the data?' },
|
| 101 |
+
],
|
| 102 |
+
});
|
| 103 |
+
},
|
| 104 |
+
},
|
| 105 |
+
{
|
| 106 |
+
description: 'Analyze and visualize data',
|
| 107 |
+
icon: <LineChartIcon />,
|
| 108 |
+
onClick: ({ sendMessage }) => {
|
| 109 |
+
sendMessage({
|
| 110 |
+
role: 'user',
|
| 111 |
+
parts: [
|
| 112 |
+
{
|
| 113 |
+
type: 'text',
|
| 114 |
+
text: 'Can you please analyze and visualize the data by creating a new code artifact in python?',
|
| 115 |
+
},
|
| 116 |
+
],
|
| 117 |
+
});
|
| 118 |
+
},
|
| 119 |
+
},
|
| 120 |
+
],
|
| 121 |
+
});
|
artifacts/sheet/server.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { myProvider } from '@/lib/ai/providers';
|
| 2 |
+
import { sheetPrompt, updateDocumentPrompt } from '@/lib/ai/prompts';
|
| 3 |
+
import { createDocumentHandler } from '@/lib/artifacts/server';
|
| 4 |
+
import { streamObject } from 'ai';
|
| 5 |
+
import { z } from 'zod';
|
| 6 |
+
|
| 7 |
+
export const sheetDocumentHandler = createDocumentHandler<'sheet'>({
|
| 8 |
+
kind: 'sheet',
|
| 9 |
+
onCreateDocument: async ({ title, dataStream }) => {
|
| 10 |
+
let draftContent = '';
|
| 11 |
+
|
| 12 |
+
const { fullStream } = streamObject({
|
| 13 |
+
model: myProvider.languageModel('artifact-model'),
|
| 14 |
+
system: sheetPrompt,
|
| 15 |
+
prompt: title,
|
| 16 |
+
schema: z.object({
|
| 17 |
+
csv: z.string().describe('CSV data'),
|
| 18 |
+
}),
|
| 19 |
+
});
|
| 20 |
+
|
| 21 |
+
for await (const delta of fullStream) {
|
| 22 |
+
const { type } = delta;
|
| 23 |
+
|
| 24 |
+
if (type === 'object') {
|
| 25 |
+
const { object } = delta;
|
| 26 |
+
const { csv } = object;
|
| 27 |
+
|
| 28 |
+
if (csv) {
|
| 29 |
+
dataStream.write({
|
| 30 |
+
type: 'data-sheetDelta',
|
| 31 |
+
data: csv,
|
| 32 |
+
transient: true,
|
| 33 |
+
});
|
| 34 |
+
|
| 35 |
+
draftContent = csv;
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
dataStream.write({
|
| 41 |
+
type: 'data-sheetDelta',
|
| 42 |
+
data: draftContent,
|
| 43 |
+
transient: true,
|
| 44 |
+
});
|
| 45 |
+
|
| 46 |
+
return draftContent;
|
| 47 |
+
},
|
| 48 |
+
onUpdateDocument: async ({ document, description, dataStream }) => {
|
| 49 |
+
let draftContent = '';
|
| 50 |
+
|
| 51 |
+
const { fullStream } = streamObject({
|
| 52 |
+
model: myProvider.languageModel('artifact-model'),
|
| 53 |
+
system: updateDocumentPrompt(document.content, 'sheet'),
|
| 54 |
+
prompt: description,
|
| 55 |
+
schema: z.object({
|
| 56 |
+
csv: z.string(),
|
| 57 |
+
}),
|
| 58 |
+
});
|
| 59 |
+
|
| 60 |
+
for await (const delta of fullStream) {
|
| 61 |
+
const { type } = delta;
|
| 62 |
+
|
| 63 |
+
if (type === 'object') {
|
| 64 |
+
const { object } = delta;
|
| 65 |
+
const { csv } = object;
|
| 66 |
+
|
| 67 |
+
if (csv) {
|
| 68 |
+
dataStream.write({
|
| 69 |
+
type: 'data-sheetDelta',
|
| 70 |
+
data: csv,
|
| 71 |
+
transient: true,
|
| 72 |
+
});
|
| 73 |
+
|
| 74 |
+
draftContent = csv;
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
return draftContent;
|
| 80 |
+
},
|
| 81 |
+
});
|
artifacts/text/client.tsx
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Artifact } from '@/components/create-artifact';
|
| 2 |
+
import { DiffView } from '@/components/diffview';
|
| 3 |
+
import { DocumentSkeleton } from '@/components/document-skeleton';
|
| 4 |
+
import { Editor } from '@/components/text-editor';
|
| 5 |
+
import {
|
| 6 |
+
ClockRewind,
|
| 7 |
+
CopyIcon,
|
| 8 |
+
MessageIcon,
|
| 9 |
+
PenIcon,
|
| 10 |
+
RedoIcon,
|
| 11 |
+
UndoIcon,
|
| 12 |
+
} from '@/components/icons';
|
| 13 |
+
import type { Suggestion } from '@/lib/db/schema';
|
| 14 |
+
import { toast } from 'sonner';
|
| 15 |
+
import { getSuggestions } from '../actions';
|
| 16 |
+
|
| 17 |
+
interface TextArtifactMetadata {
|
| 18 |
+
suggestions: Array<Suggestion>;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export const textArtifact = new Artifact<'text', TextArtifactMetadata>({
|
| 22 |
+
kind: 'text',
|
| 23 |
+
description: 'Useful for text content, like drafting essays and emails.',
|
| 24 |
+
initialize: async ({ documentId, setMetadata }) => {
|
| 25 |
+
const suggestions = await getSuggestions({ documentId });
|
| 26 |
+
|
| 27 |
+
setMetadata({
|
| 28 |
+
suggestions,
|
| 29 |
+
});
|
| 30 |
+
},
|
| 31 |
+
onStreamPart: ({ streamPart, setMetadata, setArtifact }) => {
|
| 32 |
+
if (streamPart.type === 'data-suggestion') {
|
| 33 |
+
setMetadata((metadata) => {
|
| 34 |
+
return {
|
| 35 |
+
suggestions: [...metadata.suggestions, streamPart.data],
|
| 36 |
+
};
|
| 37 |
+
});
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
if (streamPart.type === 'data-textDelta') {
|
| 41 |
+
setArtifact((draftArtifact) => {
|
| 42 |
+
return {
|
| 43 |
+
...draftArtifact,
|
| 44 |
+
content: draftArtifact.content + streamPart.data,
|
| 45 |
+
isVisible:
|
| 46 |
+
draftArtifact.status === 'streaming' &&
|
| 47 |
+
draftArtifact.content.length > 400 &&
|
| 48 |
+
draftArtifact.content.length < 450
|
| 49 |
+
? true
|
| 50 |
+
: draftArtifact.isVisible,
|
| 51 |
+
status: 'streaming',
|
| 52 |
+
};
|
| 53 |
+
});
|
| 54 |
+
}
|
| 55 |
+
},
|
| 56 |
+
content: ({
|
| 57 |
+
mode,
|
| 58 |
+
status,
|
| 59 |
+
content,
|
| 60 |
+
isCurrentVersion,
|
| 61 |
+
currentVersionIndex,
|
| 62 |
+
onSaveContent,
|
| 63 |
+
getDocumentContentById,
|
| 64 |
+
isLoading,
|
| 65 |
+
metadata,
|
| 66 |
+
}) => {
|
| 67 |
+
if (isLoading) {
|
| 68 |
+
return <DocumentSkeleton artifactKind="text" />;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
if (mode === 'diff') {
|
| 72 |
+
const oldContent = getDocumentContentById(currentVersionIndex - 1);
|
| 73 |
+
const newContent = getDocumentContentById(currentVersionIndex);
|
| 74 |
+
|
| 75 |
+
return <DiffView oldContent={oldContent} newContent={newContent} />;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
return (
|
| 79 |
+
<>
|
| 80 |
+
<div className="flex flex-row py-8 md:p-20 px-4">
|
| 81 |
+
<Editor
|
| 82 |
+
content={content}
|
| 83 |
+
suggestions={metadata ? metadata.suggestions : []}
|
| 84 |
+
isCurrentVersion={isCurrentVersion}
|
| 85 |
+
currentVersionIndex={currentVersionIndex}
|
| 86 |
+
status={status}
|
| 87 |
+
onSaveContent={onSaveContent}
|
| 88 |
+
/>
|
| 89 |
+
|
| 90 |
+
{metadata?.suggestions && metadata.suggestions.length > 0 ? (
|
| 91 |
+
<div className="md:hidden h-dvh w-12 shrink-0" />
|
| 92 |
+
) : null}
|
| 93 |
+
</div>
|
| 94 |
+
</>
|
| 95 |
+
);
|
| 96 |
+
},
|
| 97 |
+
actions: [
|
| 98 |
+
{
|
| 99 |
+
icon: <ClockRewind size={18} />,
|
| 100 |
+
description: 'View changes',
|
| 101 |
+
onClick: ({ handleVersionChange }) => {
|
| 102 |
+
handleVersionChange('toggle');
|
| 103 |
+
},
|
| 104 |
+
isDisabled: ({ currentVersionIndex, setMetadata }) => {
|
| 105 |
+
if (currentVersionIndex === 0) {
|
| 106 |
+
return true;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
return false;
|
| 110 |
+
},
|
| 111 |
+
},
|
| 112 |
+
{
|
| 113 |
+
icon: <UndoIcon size={18} />,
|
| 114 |
+
description: 'View Previous version',
|
| 115 |
+
onClick: ({ handleVersionChange }) => {
|
| 116 |
+
handleVersionChange('prev');
|
| 117 |
+
},
|
| 118 |
+
isDisabled: ({ currentVersionIndex }) => {
|
| 119 |
+
if (currentVersionIndex === 0) {
|
| 120 |
+
return true;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
return false;
|
| 124 |
+
},
|
| 125 |
+
},
|
| 126 |
+
{
|
| 127 |
+
icon: <RedoIcon size={18} />,
|
| 128 |
+
description: 'View Next version',
|
| 129 |
+
onClick: ({ handleVersionChange }) => {
|
| 130 |
+
handleVersionChange('next');
|
| 131 |
+
},
|
| 132 |
+
isDisabled: ({ isCurrentVersion }) => {
|
| 133 |
+
if (isCurrentVersion) {
|
| 134 |
+
return true;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
return false;
|
| 138 |
+
},
|
| 139 |
+
},
|
| 140 |
+
{
|
| 141 |
+
icon: <CopyIcon size={18} />,
|
| 142 |
+
description: 'Copy to clipboard',
|
| 143 |
+
onClick: ({ content }) => {
|
| 144 |
+
navigator.clipboard.writeText(content);
|
| 145 |
+
toast.success('Copied to clipboard!');
|
| 146 |
+
},
|
| 147 |
+
},
|
| 148 |
+
],
|
| 149 |
+
toolbar: [
|
| 150 |
+
{
|
| 151 |
+
icon: <PenIcon />,
|
| 152 |
+
description: 'Add final polish',
|
| 153 |
+
onClick: ({ sendMessage }) => {
|
| 154 |
+
sendMessage({
|
| 155 |
+
role: 'user',
|
| 156 |
+
parts: [
|
| 157 |
+
{
|
| 158 |
+
type: 'text',
|
| 159 |
+
text: 'Please add final polish and check for grammar, add section titles for better structure, and ensure everything reads smoothly.',
|
| 160 |
+
},
|
| 161 |
+
],
|
| 162 |
+
});
|
| 163 |
+
},
|
| 164 |
+
},
|
| 165 |
+
{
|
| 166 |
+
icon: <MessageIcon />,
|
| 167 |
+
description: 'Request suggestions',
|
| 168 |
+
onClick: ({ sendMessage }) => {
|
| 169 |
+
sendMessage({
|
| 170 |
+
role: 'user',
|
| 171 |
+
parts: [
|
| 172 |
+
{
|
| 173 |
+
type: 'text',
|
| 174 |
+
text: 'Please add suggestions you have that could improve the writing.',
|
| 175 |
+
},
|
| 176 |
+
],
|
| 177 |
+
});
|
| 178 |
+
},
|
| 179 |
+
},
|
| 180 |
+
],
|
| 181 |
+
});
|
artifacts/text/server.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { smoothStream, streamText } from 'ai';
|
| 2 |
+
import { myProvider } from '@/lib/ai/providers';
|
| 3 |
+
import { createDocumentHandler } from '@/lib/artifacts/server';
|
| 4 |
+
import { updateDocumentPrompt } from '@/lib/ai/prompts';
|
| 5 |
+
|
| 6 |
+
export const textDocumentHandler = createDocumentHandler<'text'>({
|
| 7 |
+
kind: 'text',
|
| 8 |
+
onCreateDocument: async ({ title, dataStream }) => {
|
| 9 |
+
let draftContent = '';
|
| 10 |
+
|
| 11 |
+
const { fullStream } = streamText({
|
| 12 |
+
model: myProvider.languageModel('artifact-model'),
|
| 13 |
+
system:
|
| 14 |
+
'Write about the given topic. Markdown is supported. Use headings wherever appropriate.',
|
| 15 |
+
experimental_transform: smoothStream({ chunking: 'word' }),
|
| 16 |
+
prompt: title,
|
| 17 |
+
});
|
| 18 |
+
|
| 19 |
+
for await (const delta of fullStream) {
|
| 20 |
+
const { type } = delta;
|
| 21 |
+
|
| 22 |
+
if (type === 'text-delta') {
|
| 23 |
+
const { text } = delta;
|
| 24 |
+
|
| 25 |
+
draftContent += text;
|
| 26 |
+
|
| 27 |
+
dataStream.write({
|
| 28 |
+
type: 'data-textDelta',
|
| 29 |
+
data: text,
|
| 30 |
+
transient: true,
|
| 31 |
+
});
|
| 32 |
+
}
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
return draftContent;
|
| 36 |
+
},
|
| 37 |
+
onUpdateDocument: async ({ document, description, dataStream }) => {
|
| 38 |
+
let draftContent = '';
|
| 39 |
+
|
| 40 |
+
const { fullStream } = streamText({
|
| 41 |
+
model: myProvider.languageModel('artifact-model'),
|
| 42 |
+
system: updateDocumentPrompt(document.content, 'text'),
|
| 43 |
+
experimental_transform: smoothStream({ chunking: 'word' }),
|
| 44 |
+
prompt: description,
|
| 45 |
+
providerOptions: {
|
| 46 |
+
openai: {
|
| 47 |
+
prediction: {
|
| 48 |
+
type: 'content',
|
| 49 |
+
content: document.content,
|
| 50 |
+
},
|
| 51 |
+
},
|
| 52 |
+
},
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
for await (const delta of fullStream) {
|
| 56 |
+
const { type } = delta;
|
| 57 |
+
|
| 58 |
+
if (type === 'text-delta') {
|
| 59 |
+
const { text } = delta;
|
| 60 |
+
|
| 61 |
+
draftContent += text;
|
| 62 |
+
|
| 63 |
+
dataStream.write({
|
| 64 |
+
type: 'data-textDelta',
|
| 65 |
+
data: text,
|
| 66 |
+
transient: true,
|
| 67 |
+
});
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
return draftContent;
|
| 72 |
+
},
|
| 73 |
+
});
|
biome.jsonc
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
| 3 |
+
"files": {
|
| 4 |
+
"ignoreUnknown": false,
|
| 5 |
+
"ignore": [
|
| 6 |
+
"**/pnpm-lock.yaml",
|
| 7 |
+
"lib/db/migrations",
|
| 8 |
+
"lib/editor/react-renderer.tsx",
|
| 9 |
+
"node_modules",
|
| 10 |
+
".next",
|
| 11 |
+
"public",
|
| 12 |
+
".vercel"
|
| 13 |
+
]
|
| 14 |
+
},
|
| 15 |
+
"vcs": {
|
| 16 |
+
"enabled": true,
|
| 17 |
+
"clientKind": "git",
|
| 18 |
+
"defaultBranch": "main",
|
| 19 |
+
"useIgnoreFile": true
|
| 20 |
+
},
|
| 21 |
+
"formatter": {
|
| 22 |
+
"enabled": true,
|
| 23 |
+
"formatWithErrors": false,
|
| 24 |
+
"indentStyle": "space",
|
| 25 |
+
"indentWidth": 2,
|
| 26 |
+
"lineEnding": "lf",
|
| 27 |
+
"lineWidth": 80,
|
| 28 |
+
"attributePosition": "auto"
|
| 29 |
+
},
|
| 30 |
+
"linter": {
|
| 31 |
+
"enabled": true,
|
| 32 |
+
"rules": {
|
| 33 |
+
"recommended": true,
|
| 34 |
+
"a11y": {
|
| 35 |
+
"useHtmlLang": "warn", // Not in recommended ruleset, turning on manually
|
| 36 |
+
"noHeaderScope": "warn", // Not in recommended ruleset, turning on manually
|
| 37 |
+
"useValidAriaRole": {
|
| 38 |
+
"level": "warn",
|
| 39 |
+
"options": {
|
| 40 |
+
"ignoreNonDom": false,
|
| 41 |
+
"allowInvalidRoles": ["none", "text"]
|
| 42 |
+
}
|
| 43 |
+
},
|
| 44 |
+
"useSemanticElements": "off", // Rule is buggy, revisit later
|
| 45 |
+
"noSvgWithoutTitle": "off", // We do not intend to adhere to this rule
|
| 46 |
+
"useMediaCaption": "off", // We would need a cultural change to turn this on
|
| 47 |
+
"noAutofocus": "off", // We're highly intentional about when we use autofocus
|
| 48 |
+
"noBlankTarget": "off", // Covered by Conformance
|
| 49 |
+
"useFocusableInteractive": "off", // Disable focusable interactive element requirement
|
| 50 |
+
"useAriaPropsForRole": "off", // Disable required ARIA attributes check
|
| 51 |
+
"useKeyWithClickEvents": "off" // Disable keyboard event requirement with click events
|
| 52 |
+
},
|
| 53 |
+
"complexity": {
|
| 54 |
+
"noUselessStringConcat": "warn", // Not in recommended ruleset, turning on manually
|
| 55 |
+
"noForEach": "off", // forEach is too familiar to ban
|
| 56 |
+
"noUselessSwitchCase": "off", // Turned off due to developer preferences
|
| 57 |
+
"noUselessThisAlias": "off", // Turned off due to developer preferences
|
| 58 |
+
"noBannedTypes": "off"
|
| 59 |
+
},
|
| 60 |
+
"correctness": {
|
| 61 |
+
"noUnusedImports": "warn", // Not in recommended ruleset, turning on manually
|
| 62 |
+
"useArrayLiterals": "warn", // Not in recommended ruleset, turning on manually
|
| 63 |
+
"noNewSymbol": "warn", // Not in recommended ruleset, turning on manually
|
| 64 |
+
"useJsxKeyInIterable": "off", // Rule is buggy, revisit later
|
| 65 |
+
"useExhaustiveDependencies": "off", // Community feedback on this rule has been poor, we will continue with ESLint
|
| 66 |
+
"noUnnecessaryContinue": "off" // Turned off due to developer preferences
|
| 67 |
+
},
|
| 68 |
+
"security": {
|
| 69 |
+
"noDangerouslySetInnerHtml": "off" // Covered by Conformance
|
| 70 |
+
},
|
| 71 |
+
"style": {
|
| 72 |
+
"useFragmentSyntax": "warn", // Not in recommended ruleset, turning on manually
|
| 73 |
+
"noYodaExpression": "warn", // Not in recommended ruleset, turning on manually
|
| 74 |
+
"useDefaultParameterLast": "warn", // Not in recommended ruleset, turning on manually
|
| 75 |
+
"useExponentiationOperator": "off", // Obscure and arguably not easily readable
|
| 76 |
+
"noUnusedTemplateLiteral": "off", // Stylistic opinion
|
| 77 |
+
"noUselessElse": "off" // Stylistic opinion
|
| 78 |
+
},
|
| 79 |
+
"suspicious": {
|
| 80 |
+
"noExplicitAny": "off" // We trust Vercelians to use any only when necessary
|
| 81 |
+
},
|
| 82 |
+
"nursery": {
|
| 83 |
+
"noStaticElementInteractions": "warn",
|
| 84 |
+
"noHeadImportInDocument": "warn",
|
| 85 |
+
"noDocumentImportInPage": "warn",
|
| 86 |
+
"noDuplicateElseIf": "warn",
|
| 87 |
+
"noIrregularWhitespace": "warn",
|
| 88 |
+
"useValidAutocomplete": "warn"
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
},
|
| 92 |
+
"javascript": {
|
| 93 |
+
"jsxRuntime": "reactClassic",
|
| 94 |
+
"formatter": {
|
| 95 |
+
"jsxQuoteStyle": "double",
|
| 96 |
+
"quoteProperties": "asNeeded",
|
| 97 |
+
"trailingCommas": "all",
|
| 98 |
+
"semicolons": "always",
|
| 99 |
+
"arrowParentheses": "always",
|
| 100 |
+
"bracketSpacing": true,
|
| 101 |
+
"bracketSameLine": false,
|
| 102 |
+
"quoteStyle": "single",
|
| 103 |
+
"attributePosition": "auto"
|
| 104 |
+
}
|
| 105 |
+
},
|
| 106 |
+
"json": {
|
| 107 |
+
"formatter": {
|
| 108 |
+
"enabled": true,
|
| 109 |
+
"trailingCommas": "none"
|
| 110 |
+
},
|
| 111 |
+
"parser": {
|
| 112 |
+
"allowComments": true,
|
| 113 |
+
"allowTrailingCommas": false
|
| 114 |
+
}
|
| 115 |
+
},
|
| 116 |
+
"css": {
|
| 117 |
+
"formatter": { "enabled": false },
|
| 118 |
+
"linter": { "enabled": false }
|
| 119 |
+
},
|
| 120 |
+
"organizeImports": { "enabled": false },
|
| 121 |
+
"overrides": [
|
| 122 |
+
// Playwright requires an object destructure, even if empty
|
| 123 |
+
// https://github.com/microsoft/playwright/issues/30007
|
| 124 |
+
{
|
| 125 |
+
"include": ["playwright/**"],
|
| 126 |
+
"linter": {
|
| 127 |
+
"rules": {
|
| 128 |
+
"correctness": {
|
| 129 |
+
"noEmptyPattern": "off"
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
+
}
|
| 133 |
+
}
|
| 134 |
+
]
|
| 135 |
+
}
|
components.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$schema": "https://ui.shadcn.com/schema.json",
|
| 3 |
+
"style": "default",
|
| 4 |
+
"rsc": true,
|
| 5 |
+
"tsx": true,
|
| 6 |
+
"tailwind": {
|
| 7 |
+
"config": "tailwind.config.ts",
|
| 8 |
+
"css": "app/globals.css",
|
| 9 |
+
"baseColor": "zinc",
|
| 10 |
+
"cssVariables": true,
|
| 11 |
+
"prefix": ""
|
| 12 |
+
},
|
| 13 |
+
"aliases": {
|
| 14 |
+
"components": "@/components",
|
| 15 |
+
"utils": "@/lib/utils",
|
| 16 |
+
"ui": "@/components/ui",
|
| 17 |
+
"lib": "@/lib",
|
| 18 |
+
"hooks": "@/hooks"
|
| 19 |
+
}
|
| 20 |
+
}
|
components/app-sidebar.tsx
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import type { User } from 'next-auth';
|
| 4 |
+
import { useRouter } from 'next/navigation';
|
| 5 |
+
|
| 6 |
+
import { PlusIcon } from '@/components/icons';
|
| 7 |
+
import { SidebarHistory } from '@/components/sidebar-history';
|
| 8 |
+
import { SidebarUserNav } from '@/components/sidebar-user-nav';
|
| 9 |
+
import { Button } from '@/components/ui/button';
|
| 10 |
+
import {
|
| 11 |
+
Sidebar,
|
| 12 |
+
SidebarContent,
|
| 13 |
+
SidebarFooter,
|
| 14 |
+
SidebarHeader,
|
| 15 |
+
SidebarMenu,
|
| 16 |
+
useSidebar,
|
| 17 |
+
} from '@/components/ui/sidebar';
|
| 18 |
+
import Link from 'next/link';
|
| 19 |
+
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
| 20 |
+
|
| 21 |
+
export function AppSidebar({ user }: { user: User | undefined }) {
|
| 22 |
+
const router = useRouter();
|
| 23 |
+
const { setOpenMobile } = useSidebar();
|
| 24 |
+
|
| 25 |
+
return (
|
| 26 |
+
<Sidebar className="group-data-[side=left]:border-r-0">
|
| 27 |
+
<SidebarHeader>
|
| 28 |
+
<SidebarMenu>
|
| 29 |
+
<div className="flex flex-row justify-between items-center">
|
| 30 |
+
<Link
|
| 31 |
+
href="/"
|
| 32 |
+
onClick={() => {
|
| 33 |
+
setOpenMobile(false);
|
| 34 |
+
}}
|
| 35 |
+
className="flex flex-row gap-3 items-center"
|
| 36 |
+
>
|
| 37 |
+
<span className="text-lg font-semibold px-2 hover:bg-muted rounded-md cursor-pointer">
|
| 38 |
+
Chatbot
|
| 39 |
+
</span>
|
| 40 |
+
</Link>
|
| 41 |
+
<Tooltip>
|
| 42 |
+
<TooltipTrigger asChild>
|
| 43 |
+
<Button
|
| 44 |
+
variant="ghost"
|
| 45 |
+
type="button"
|
| 46 |
+
className="p-2 h-fit"
|
| 47 |
+
onClick={() => {
|
| 48 |
+
setOpenMobile(false);
|
| 49 |
+
router.push('/');
|
| 50 |
+
router.refresh();
|
| 51 |
+
}}
|
| 52 |
+
>
|
| 53 |
+
<PlusIcon />
|
| 54 |
+
</Button>
|
| 55 |
+
</TooltipTrigger>
|
| 56 |
+
<TooltipContent align="end">New Chat</TooltipContent>
|
| 57 |
+
</Tooltip>
|
| 58 |
+
</div>
|
| 59 |
+
</SidebarMenu>
|
| 60 |
+
</SidebarHeader>
|
| 61 |
+
<SidebarContent>
|
| 62 |
+
<SidebarHistory user={user} />
|
| 63 |
+
</SidebarContent>
|
| 64 |
+
<SidebarFooter>{user && <SidebarUserNav user={user} />}</SidebarFooter>
|
| 65 |
+
</Sidebar>
|
| 66 |
+
);
|
| 67 |
+
}
|
components/artifact-actions.tsx
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Button } from './ui/button';
|
| 2 |
+
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
| 3 |
+
import { artifactDefinitions, UIArtifact } from './artifact';
|
| 4 |
+
import { Dispatch, memo, SetStateAction, useState } from 'react';
|
| 5 |
+
import { ArtifactActionContext } from './create-artifact';
|
| 6 |
+
import { cn } from '@/lib/utils';
|
| 7 |
+
import { toast } from 'sonner';
|
| 8 |
+
|
| 9 |
+
interface ArtifactActionsProps {
|
| 10 |
+
artifact: UIArtifact;
|
| 11 |
+
handleVersionChange: (type: 'next' | 'prev' | 'toggle' | 'latest') => void;
|
| 12 |
+
currentVersionIndex: number;
|
| 13 |
+
isCurrentVersion: boolean;
|
| 14 |
+
mode: 'edit' | 'diff';
|
| 15 |
+
metadata: any;
|
| 16 |
+
setMetadata: Dispatch<SetStateAction<any>>;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
function PureArtifactActions({
|
| 20 |
+
artifact,
|
| 21 |
+
handleVersionChange,
|
| 22 |
+
currentVersionIndex,
|
| 23 |
+
isCurrentVersion,
|
| 24 |
+
mode,
|
| 25 |
+
metadata,
|
| 26 |
+
setMetadata,
|
| 27 |
+
}: ArtifactActionsProps) {
|
| 28 |
+
const [isLoading, setIsLoading] = useState(false);
|
| 29 |
+
|
| 30 |
+
const artifactDefinition = artifactDefinitions.find(
|
| 31 |
+
(definition) => definition.kind === artifact.kind,
|
| 32 |
+
);
|
| 33 |
+
|
| 34 |
+
if (!artifactDefinition) {
|
| 35 |
+
throw new Error('Artifact definition not found!');
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
const actionContext: ArtifactActionContext = {
|
| 39 |
+
content: artifact.content,
|
| 40 |
+
handleVersionChange,
|
| 41 |
+
currentVersionIndex,
|
| 42 |
+
isCurrentVersion,
|
| 43 |
+
mode,
|
| 44 |
+
metadata,
|
| 45 |
+
setMetadata,
|
| 46 |
+
};
|
| 47 |
+
|
| 48 |
+
return (
|
| 49 |
+
<div className="flex flex-row gap-1">
|
| 50 |
+
{artifactDefinition.actions.map((action) => (
|
| 51 |
+
<Tooltip key={action.description}>
|
| 52 |
+
<TooltipTrigger asChild>
|
| 53 |
+
<Button
|
| 54 |
+
variant="outline"
|
| 55 |
+
className={cn('h-fit dark:hover:bg-zinc-700', {
|
| 56 |
+
'p-2': !action.label,
|
| 57 |
+
'py-1.5 px-2': action.label,
|
| 58 |
+
})}
|
| 59 |
+
onClick={async () => {
|
| 60 |
+
setIsLoading(true);
|
| 61 |
+
|
| 62 |
+
try {
|
| 63 |
+
await Promise.resolve(action.onClick(actionContext));
|
| 64 |
+
} catch (error) {
|
| 65 |
+
toast.error('Failed to execute action');
|
| 66 |
+
} finally {
|
| 67 |
+
setIsLoading(false);
|
| 68 |
+
}
|
| 69 |
+
}}
|
| 70 |
+
disabled={
|
| 71 |
+
isLoading || artifact.status === 'streaming'
|
| 72 |
+
? true
|
| 73 |
+
: action.isDisabled
|
| 74 |
+
? action.isDisabled(actionContext)
|
| 75 |
+
: false
|
| 76 |
+
}
|
| 77 |
+
>
|
| 78 |
+
{action.icon}
|
| 79 |
+
{action.label}
|
| 80 |
+
</Button>
|
| 81 |
+
</TooltipTrigger>
|
| 82 |
+
<TooltipContent>{action.description}</TooltipContent>
|
| 83 |
+
</Tooltip>
|
| 84 |
+
))}
|
| 85 |
+
</div>
|
| 86 |
+
);
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
export const ArtifactActions = memo(
|
| 90 |
+
PureArtifactActions,
|
| 91 |
+
(prevProps, nextProps) => {
|
| 92 |
+
if (prevProps.artifact.status !== nextProps.artifact.status) return false;
|
| 93 |
+
if (prevProps.currentVersionIndex !== nextProps.currentVersionIndex)
|
| 94 |
+
return false;
|
| 95 |
+
if (prevProps.isCurrentVersion !== nextProps.isCurrentVersion) return false;
|
| 96 |
+
if (prevProps.artifact.content !== nextProps.artifact.content) return false;
|
| 97 |
+
|
| 98 |
+
return true;
|
| 99 |
+
},
|
| 100 |
+
);
|
components/artifact-close-button.tsx
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { memo } from 'react';
|
| 2 |
+
import { CrossIcon } from './icons';
|
| 3 |
+
import { Button } from './ui/button';
|
| 4 |
+
import { initialArtifactData, useArtifact } from '@/hooks/use-artifact';
|
| 5 |
+
|
| 6 |
+
function PureArtifactCloseButton() {
|
| 7 |
+
const { setArtifact } = useArtifact();
|
| 8 |
+
|
| 9 |
+
return (
|
| 10 |
+
<Button
|
| 11 |
+
data-testid="artifact-close-button"
|
| 12 |
+
variant="outline"
|
| 13 |
+
className="h-fit p-2 dark:hover:bg-zinc-700"
|
| 14 |
+
onClick={() => {
|
| 15 |
+
setArtifact((currentArtifact) =>
|
| 16 |
+
currentArtifact.status === 'streaming'
|
| 17 |
+
? {
|
| 18 |
+
...currentArtifact,
|
| 19 |
+
isVisible: false,
|
| 20 |
+
}
|
| 21 |
+
: { ...initialArtifactData, status: 'idle' },
|
| 22 |
+
);
|
| 23 |
+
}}
|
| 24 |
+
>
|
| 25 |
+
<CrossIcon size={18} />
|
| 26 |
+
</Button>
|
| 27 |
+
);
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
export const ArtifactCloseButton = memo(PureArtifactCloseButton, () => true);
|
components/artifact-messages.tsx
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { PreviewMessage, ThinkingMessage } from './message';
|
| 2 |
+
import type { Vote } from '@/lib/db/schema';
|
| 3 |
+
import { memo } from 'react';
|
| 4 |
+
import equal from 'fast-deep-equal';
|
| 5 |
+
import type { UIArtifact } from './artifact';
|
| 6 |
+
import type { UseChatHelpers } from '@ai-sdk/react';
|
| 7 |
+
import { motion } from 'framer-motion';
|
| 8 |
+
import { useMessages } from '@/hooks/use-messages';
|
| 9 |
+
import type { ChatMessage } from '@/lib/types';
|
| 10 |
+
|
| 11 |
+
interface ArtifactMessagesProps {
|
| 12 |
+
chatId: string;
|
| 13 |
+
status: UseChatHelpers<ChatMessage>['status'];
|
| 14 |
+
votes: Array<Vote> | undefined;
|
| 15 |
+
messages: ChatMessage[];
|
| 16 |
+
setMessages: UseChatHelpers<ChatMessage>['setMessages'];
|
| 17 |
+
regenerate: UseChatHelpers<ChatMessage>['regenerate'];
|
| 18 |
+
isReadonly: boolean;
|
| 19 |
+
artifactStatus: UIArtifact['status'];
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
function PureArtifactMessages({
|
| 23 |
+
chatId,
|
| 24 |
+
status,
|
| 25 |
+
votes,
|
| 26 |
+
messages,
|
| 27 |
+
setMessages,
|
| 28 |
+
regenerate,
|
| 29 |
+
isReadonly,
|
| 30 |
+
}: ArtifactMessagesProps) {
|
| 31 |
+
const {
|
| 32 |
+
containerRef: messagesContainerRef,
|
| 33 |
+
endRef: messagesEndRef,
|
| 34 |
+
onViewportEnter,
|
| 35 |
+
onViewportLeave,
|
| 36 |
+
hasSentMessage,
|
| 37 |
+
} = useMessages({
|
| 38 |
+
chatId,
|
| 39 |
+
status,
|
| 40 |
+
});
|
| 41 |
+
|
| 42 |
+
return (
|
| 43 |
+
<div
|
| 44 |
+
ref={messagesContainerRef}
|
| 45 |
+
className="flex flex-col gap-4 h-full items-center overflow-y-scroll px-4 pt-20"
|
| 46 |
+
>
|
| 47 |
+
{messages.map((message, index) => (
|
| 48 |
+
<PreviewMessage
|
| 49 |
+
chatId={chatId}
|
| 50 |
+
key={message.id}
|
| 51 |
+
message={message}
|
| 52 |
+
isLoading={status === 'streaming' && index === messages.length - 1}
|
| 53 |
+
vote={
|
| 54 |
+
votes
|
| 55 |
+
? votes.find((vote) => vote.messageId === message.id)
|
| 56 |
+
: undefined
|
| 57 |
+
}
|
| 58 |
+
setMessages={setMessages}
|
| 59 |
+
regenerate={regenerate}
|
| 60 |
+
isReadonly={isReadonly}
|
| 61 |
+
requiresScrollPadding={
|
| 62 |
+
hasSentMessage && index === messages.length - 1
|
| 63 |
+
}
|
| 64 |
+
/>
|
| 65 |
+
))}
|
| 66 |
+
|
| 67 |
+
{status === 'submitted' &&
|
| 68 |
+
messages.length > 0 &&
|
| 69 |
+
messages[messages.length - 1].role === 'user' && <ThinkingMessage />}
|
| 70 |
+
|
| 71 |
+
<motion.div
|
| 72 |
+
ref={messagesEndRef}
|
| 73 |
+
className="shrink-0 min-w-[24px] min-h-[24px]"
|
| 74 |
+
onViewportLeave={onViewportLeave}
|
| 75 |
+
onViewportEnter={onViewportEnter}
|
| 76 |
+
/>
|
| 77 |
+
</div>
|
| 78 |
+
);
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
function areEqual(
|
| 82 |
+
prevProps: ArtifactMessagesProps,
|
| 83 |
+
nextProps: ArtifactMessagesProps,
|
| 84 |
+
) {
|
| 85 |
+
if (
|
| 86 |
+
prevProps.artifactStatus === 'streaming' &&
|
| 87 |
+
nextProps.artifactStatus === 'streaming'
|
| 88 |
+
)
|
| 89 |
+
return true;
|
| 90 |
+
|
| 91 |
+
if (prevProps.status !== nextProps.status) return false;
|
| 92 |
+
if (prevProps.status && nextProps.status) return false;
|
| 93 |
+
if (prevProps.messages.length !== nextProps.messages.length) return false;
|
| 94 |
+
if (!equal(prevProps.votes, nextProps.votes)) return false;
|
| 95 |
+
|
| 96 |
+
return true;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
export const ArtifactMessages = memo(PureArtifactMessages, areEqual);
|
components/artifact.tsx
ADDED
|
@@ -0,0 +1,511 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { formatDistance } from 'date-fns';
|
| 2 |
+
import { AnimatePresence, motion } from 'framer-motion';
|
| 3 |
+
import {
|
| 4 |
+
type Dispatch,
|
| 5 |
+
memo,
|
| 6 |
+
type SetStateAction,
|
| 7 |
+
useCallback,
|
| 8 |
+
useEffect,
|
| 9 |
+
useState,
|
| 10 |
+
} from 'react';
|
| 11 |
+
import useSWR, { useSWRConfig } from 'swr';
|
| 12 |
+
import { useDebounceCallback, useWindowSize } from 'usehooks-ts';
|
| 13 |
+
import type { Document, Vote } from '@/lib/db/schema';
|
| 14 |
+
import { fetcher } from '@/lib/utils';
|
| 15 |
+
import { MultimodalInput } from './multimodal-input';
|
| 16 |
+
import { Toolbar } from './toolbar';
|
| 17 |
+
import { VersionFooter } from './version-footer';
|
| 18 |
+
import { ArtifactActions } from './artifact-actions';
|
| 19 |
+
import { ArtifactCloseButton } from './artifact-close-button';
|
| 20 |
+
import { ArtifactMessages } from './artifact-messages';
|
| 21 |
+
import { useSidebar } from './ui/sidebar';
|
| 22 |
+
import { useArtifact } from '@/hooks/use-artifact';
|
| 23 |
+
import { imageArtifact } from '@/artifacts/image/client';
|
| 24 |
+
import { codeArtifact } from '@/artifacts/code/client';
|
| 25 |
+
import { sheetArtifact } from '@/artifacts/sheet/client';
|
| 26 |
+
import { textArtifact } from '@/artifacts/text/client';
|
| 27 |
+
import equal from 'fast-deep-equal';
|
| 28 |
+
import type { UseChatHelpers } from '@ai-sdk/react';
|
| 29 |
+
import type { VisibilityType } from './visibility-selector';
|
| 30 |
+
import type { Attachment, ChatMessage } from '@/lib/types';
|
| 31 |
+
|
| 32 |
+
export const artifactDefinitions = [
|
| 33 |
+
textArtifact,
|
| 34 |
+
codeArtifact,
|
| 35 |
+
imageArtifact,
|
| 36 |
+
sheetArtifact,
|
| 37 |
+
];
|
| 38 |
+
export type ArtifactKind = (typeof artifactDefinitions)[number]['kind'];
|
| 39 |
+
|
| 40 |
+
export interface UIArtifact {
|
| 41 |
+
title: string;
|
| 42 |
+
documentId: string;
|
| 43 |
+
kind: ArtifactKind;
|
| 44 |
+
content: string;
|
| 45 |
+
isVisible: boolean;
|
| 46 |
+
status: 'streaming' | 'idle';
|
| 47 |
+
boundingBox: {
|
| 48 |
+
top: number;
|
| 49 |
+
left: number;
|
| 50 |
+
width: number;
|
| 51 |
+
height: number;
|
| 52 |
+
};
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
function PureArtifact({
|
| 56 |
+
chatId,
|
| 57 |
+
input,
|
| 58 |
+
setInput,
|
| 59 |
+
status,
|
| 60 |
+
stop,
|
| 61 |
+
attachments,
|
| 62 |
+
setAttachments,
|
| 63 |
+
sendMessage,
|
| 64 |
+
messages,
|
| 65 |
+
setMessages,
|
| 66 |
+
regenerate,
|
| 67 |
+
votes,
|
| 68 |
+
isReadonly,
|
| 69 |
+
selectedVisibilityType,
|
| 70 |
+
}: {
|
| 71 |
+
chatId: string;
|
| 72 |
+
input: string;
|
| 73 |
+
setInput: Dispatch<SetStateAction<string>>;
|
| 74 |
+
status: UseChatHelpers<ChatMessage>['status'];
|
| 75 |
+
stop: UseChatHelpers<ChatMessage>['stop'];
|
| 76 |
+
attachments: Attachment[];
|
| 77 |
+
setAttachments: Dispatch<SetStateAction<Attachment[]>>;
|
| 78 |
+
messages: ChatMessage[];
|
| 79 |
+
setMessages: UseChatHelpers<ChatMessage>['setMessages'];
|
| 80 |
+
votes: Array<Vote> | undefined;
|
| 81 |
+
sendMessage: UseChatHelpers<ChatMessage>['sendMessage'];
|
| 82 |
+
regenerate: UseChatHelpers<ChatMessage>['regenerate'];
|
| 83 |
+
isReadonly: boolean;
|
| 84 |
+
selectedVisibilityType: VisibilityType;
|
| 85 |
+
}) {
|
| 86 |
+
const { artifact, setArtifact, metadata, setMetadata } = useArtifact();
|
| 87 |
+
|
| 88 |
+
const {
|
| 89 |
+
data: documents,
|
| 90 |
+
isLoading: isDocumentsFetching,
|
| 91 |
+
mutate: mutateDocuments,
|
| 92 |
+
} = useSWR<Array<Document>>(
|
| 93 |
+
artifact.documentId !== 'init' && artifact.status !== 'streaming'
|
| 94 |
+
? `/api/document?id=${artifact.documentId}`
|
| 95 |
+
: null,
|
| 96 |
+
fetcher,
|
| 97 |
+
);
|
| 98 |
+
|
| 99 |
+
const [mode, setMode] = useState<'edit' | 'diff'>('edit');
|
| 100 |
+
const [document, setDocument] = useState<Document | null>(null);
|
| 101 |
+
const [currentVersionIndex, setCurrentVersionIndex] = useState(-1);
|
| 102 |
+
|
| 103 |
+
const { open: isSidebarOpen } = useSidebar();
|
| 104 |
+
|
| 105 |
+
useEffect(() => {
|
| 106 |
+
if (documents && documents.length > 0) {
|
| 107 |
+
const mostRecentDocument = documents.at(-1);
|
| 108 |
+
|
| 109 |
+
if (mostRecentDocument) {
|
| 110 |
+
setDocument(mostRecentDocument);
|
| 111 |
+
setCurrentVersionIndex(documents.length - 1);
|
| 112 |
+
setArtifact((currentArtifact) => ({
|
| 113 |
+
...currentArtifact,
|
| 114 |
+
content: mostRecentDocument.content ?? '',
|
| 115 |
+
}));
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
+
}, [documents, setArtifact]);
|
| 119 |
+
|
| 120 |
+
useEffect(() => {
|
| 121 |
+
mutateDocuments();
|
| 122 |
+
}, [artifact.status, mutateDocuments]);
|
| 123 |
+
|
| 124 |
+
const { mutate } = useSWRConfig();
|
| 125 |
+
const [isContentDirty, setIsContentDirty] = useState(false);
|
| 126 |
+
|
| 127 |
+
const handleContentChange = useCallback(
|
| 128 |
+
(updatedContent: string) => {
|
| 129 |
+
if (!artifact) return;
|
| 130 |
+
|
| 131 |
+
mutate<Array<Document>>(
|
| 132 |
+
`/api/document?id=${artifact.documentId}`,
|
| 133 |
+
async (currentDocuments) => {
|
| 134 |
+
if (!currentDocuments) return undefined;
|
| 135 |
+
|
| 136 |
+
const currentDocument = currentDocuments.at(-1);
|
| 137 |
+
|
| 138 |
+
if (!currentDocument || !currentDocument.content) {
|
| 139 |
+
setIsContentDirty(false);
|
| 140 |
+
return currentDocuments;
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
if (currentDocument.content !== updatedContent) {
|
| 144 |
+
await fetch(`/api/document?id=${artifact.documentId}`, {
|
| 145 |
+
method: 'POST',
|
| 146 |
+
body: JSON.stringify({
|
| 147 |
+
title: artifact.title,
|
| 148 |
+
content: updatedContent,
|
| 149 |
+
kind: artifact.kind,
|
| 150 |
+
}),
|
| 151 |
+
});
|
| 152 |
+
|
| 153 |
+
setIsContentDirty(false);
|
| 154 |
+
|
| 155 |
+
const newDocument = {
|
| 156 |
+
...currentDocument,
|
| 157 |
+
content: updatedContent,
|
| 158 |
+
createdAt: new Date(),
|
| 159 |
+
};
|
| 160 |
+
|
| 161 |
+
return [...currentDocuments, newDocument];
|
| 162 |
+
}
|
| 163 |
+
return currentDocuments;
|
| 164 |
+
},
|
| 165 |
+
{ revalidate: false },
|
| 166 |
+
);
|
| 167 |
+
},
|
| 168 |
+
[artifact, mutate],
|
| 169 |
+
);
|
| 170 |
+
|
| 171 |
+
const debouncedHandleContentChange = useDebounceCallback(
|
| 172 |
+
handleContentChange,
|
| 173 |
+
2000,
|
| 174 |
+
);
|
| 175 |
+
|
| 176 |
+
const saveContent = useCallback(
|
| 177 |
+
(updatedContent: string, debounce: boolean) => {
|
| 178 |
+
if (document && updatedContent !== document.content) {
|
| 179 |
+
setIsContentDirty(true);
|
| 180 |
+
|
| 181 |
+
if (debounce) {
|
| 182 |
+
debouncedHandleContentChange(updatedContent);
|
| 183 |
+
} else {
|
| 184 |
+
handleContentChange(updatedContent);
|
| 185 |
+
}
|
| 186 |
+
}
|
| 187 |
+
},
|
| 188 |
+
[document, debouncedHandleContentChange, handleContentChange],
|
| 189 |
+
);
|
| 190 |
+
|
| 191 |
+
function getDocumentContentById(index: number) {
|
| 192 |
+
if (!documents) return '';
|
| 193 |
+
if (!documents[index]) return '';
|
| 194 |
+
return documents[index].content ?? '';
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
const handleVersionChange = (type: 'next' | 'prev' | 'toggle' | 'latest') => {
|
| 198 |
+
if (!documents) return;
|
| 199 |
+
|
| 200 |
+
if (type === 'latest') {
|
| 201 |
+
setCurrentVersionIndex(documents.length - 1);
|
| 202 |
+
setMode('edit');
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
if (type === 'toggle') {
|
| 206 |
+
setMode((mode) => (mode === 'edit' ? 'diff' : 'edit'));
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
if (type === 'prev') {
|
| 210 |
+
if (currentVersionIndex > 0) {
|
| 211 |
+
setCurrentVersionIndex((index) => index - 1);
|
| 212 |
+
}
|
| 213 |
+
} else if (type === 'next') {
|
| 214 |
+
if (currentVersionIndex < documents.length - 1) {
|
| 215 |
+
setCurrentVersionIndex((index) => index + 1);
|
| 216 |
+
}
|
| 217 |
+
}
|
| 218 |
+
};
|
| 219 |
+
|
| 220 |
+
const [isToolbarVisible, setIsToolbarVisible] = useState(false);
|
| 221 |
+
|
| 222 |
+
/*
|
| 223 |
+
* NOTE: if there are no documents, or if
|
| 224 |
+
* the documents are being fetched, then
|
| 225 |
+
* we mark it as the current version.
|
| 226 |
+
*/
|
| 227 |
+
|
| 228 |
+
const isCurrentVersion =
|
| 229 |
+
documents && documents.length > 0
|
| 230 |
+
? currentVersionIndex === documents.length - 1
|
| 231 |
+
: true;
|
| 232 |
+
|
| 233 |
+
const { width: windowWidth, height: windowHeight } = useWindowSize();
|
| 234 |
+
const isMobile = windowWidth ? windowWidth < 768 : false;
|
| 235 |
+
|
| 236 |
+
const artifactDefinition = artifactDefinitions.find(
|
| 237 |
+
(definition) => definition.kind === artifact.kind,
|
| 238 |
+
);
|
| 239 |
+
|
| 240 |
+
if (!artifactDefinition) {
|
| 241 |
+
throw new Error('Artifact definition not found!');
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
useEffect(() => {
|
| 245 |
+
if (artifact.documentId !== 'init') {
|
| 246 |
+
if (artifactDefinition.initialize) {
|
| 247 |
+
artifactDefinition.initialize({
|
| 248 |
+
documentId: artifact.documentId,
|
| 249 |
+
setMetadata,
|
| 250 |
+
});
|
| 251 |
+
}
|
| 252 |
+
}
|
| 253 |
+
}, [artifact.documentId, artifactDefinition, setMetadata]);
|
| 254 |
+
|
| 255 |
+
return (
|
| 256 |
+
<AnimatePresence>
|
| 257 |
+
{artifact.isVisible && (
|
| 258 |
+
<motion.div
|
| 259 |
+
data-testid="artifact"
|
| 260 |
+
className="flex flex-row h-dvh w-dvw fixed top-0 left-0 z-50 bg-transparent"
|
| 261 |
+
initial={{ opacity: 1 }}
|
| 262 |
+
animate={{ opacity: 1 }}
|
| 263 |
+
exit={{ opacity: 0, transition: { delay: 0.4 } }}
|
| 264 |
+
>
|
| 265 |
+
{!isMobile && (
|
| 266 |
+
<motion.div
|
| 267 |
+
className="fixed bg-background h-dvh"
|
| 268 |
+
initial={{
|
| 269 |
+
width: isSidebarOpen ? windowWidth - 256 : windowWidth,
|
| 270 |
+
right: 0,
|
| 271 |
+
}}
|
| 272 |
+
animate={{ width: windowWidth, right: 0 }}
|
| 273 |
+
exit={{
|
| 274 |
+
width: isSidebarOpen ? windowWidth - 256 : windowWidth,
|
| 275 |
+
right: 0,
|
| 276 |
+
}}
|
| 277 |
+
/>
|
| 278 |
+
)}
|
| 279 |
+
|
| 280 |
+
{!isMobile && (
|
| 281 |
+
<motion.div
|
| 282 |
+
className="relative w-[400px] bg-muted dark:bg-background h-dvh shrink-0"
|
| 283 |
+
initial={{ opacity: 0, x: 10, scale: 1 }}
|
| 284 |
+
animate={{
|
| 285 |
+
opacity: 1,
|
| 286 |
+
x: 0,
|
| 287 |
+
scale: 1,
|
| 288 |
+
transition: {
|
| 289 |
+
delay: 0.2,
|
| 290 |
+
type: 'spring',
|
| 291 |
+
stiffness: 200,
|
| 292 |
+
damping: 30,
|
| 293 |
+
},
|
| 294 |
+
}}
|
| 295 |
+
exit={{
|
| 296 |
+
opacity: 0,
|
| 297 |
+
x: 0,
|
| 298 |
+
scale: 1,
|
| 299 |
+
transition: { duration: 0 },
|
| 300 |
+
}}
|
| 301 |
+
>
|
| 302 |
+
<AnimatePresence>
|
| 303 |
+
{!isCurrentVersion && (
|
| 304 |
+
<motion.div
|
| 305 |
+
className="left-0 absolute h-dvh w-[400px] top-0 bg-zinc-900/50 z-50"
|
| 306 |
+
initial={{ opacity: 0 }}
|
| 307 |
+
animate={{ opacity: 1 }}
|
| 308 |
+
exit={{ opacity: 0 }}
|
| 309 |
+
/>
|
| 310 |
+
)}
|
| 311 |
+
</AnimatePresence>
|
| 312 |
+
|
| 313 |
+
<div className="flex flex-col h-full justify-between items-center">
|
| 314 |
+
<ArtifactMessages
|
| 315 |
+
chatId={chatId}
|
| 316 |
+
status={status}
|
| 317 |
+
votes={votes}
|
| 318 |
+
messages={messages}
|
| 319 |
+
setMessages={setMessages}
|
| 320 |
+
regenerate={regenerate}
|
| 321 |
+
isReadonly={isReadonly}
|
| 322 |
+
artifactStatus={artifact.status}
|
| 323 |
+
/>
|
| 324 |
+
|
| 325 |
+
<div className="flex flex-row gap-2 relative items-end w-full px-4 pb-4">
|
| 326 |
+
<MultimodalInput
|
| 327 |
+
chatId={chatId}
|
| 328 |
+
input={input}
|
| 329 |
+
setInput={setInput}
|
| 330 |
+
status={status}
|
| 331 |
+
stop={stop}
|
| 332 |
+
attachments={attachments}
|
| 333 |
+
setAttachments={setAttachments}
|
| 334 |
+
messages={messages}
|
| 335 |
+
sendMessage={sendMessage}
|
| 336 |
+
className="bg-background dark:bg-muted"
|
| 337 |
+
setMessages={setMessages}
|
| 338 |
+
selectedVisibilityType={selectedVisibilityType}
|
| 339 |
+
/>
|
| 340 |
+
</div>
|
| 341 |
+
</div>
|
| 342 |
+
</motion.div>
|
| 343 |
+
)}
|
| 344 |
+
|
| 345 |
+
<motion.div
|
| 346 |
+
className="fixed dark:bg-muted bg-background h-dvh flex flex-col overflow-y-scroll md:border-l dark:border-zinc-700 border-zinc-200"
|
| 347 |
+
initial={
|
| 348 |
+
isMobile
|
| 349 |
+
? {
|
| 350 |
+
opacity: 1,
|
| 351 |
+
x: artifact.boundingBox.left,
|
| 352 |
+
y: artifact.boundingBox.top,
|
| 353 |
+
height: artifact.boundingBox.height,
|
| 354 |
+
width: artifact.boundingBox.width,
|
| 355 |
+
borderRadius: 50,
|
| 356 |
+
}
|
| 357 |
+
: {
|
| 358 |
+
opacity: 1,
|
| 359 |
+
x: artifact.boundingBox.left,
|
| 360 |
+
y: artifact.boundingBox.top,
|
| 361 |
+
height: artifact.boundingBox.height,
|
| 362 |
+
width: artifact.boundingBox.width,
|
| 363 |
+
borderRadius: 50,
|
| 364 |
+
}
|
| 365 |
+
}
|
| 366 |
+
animate={
|
| 367 |
+
isMobile
|
| 368 |
+
? {
|
| 369 |
+
opacity: 1,
|
| 370 |
+
x: 0,
|
| 371 |
+
y: 0,
|
| 372 |
+
height: windowHeight,
|
| 373 |
+
width: windowWidth ? windowWidth : 'calc(100dvw)',
|
| 374 |
+
borderRadius: 0,
|
| 375 |
+
transition: {
|
| 376 |
+
delay: 0,
|
| 377 |
+
type: 'spring',
|
| 378 |
+
stiffness: 200,
|
| 379 |
+
damping: 30,
|
| 380 |
+
duration: 5000,
|
| 381 |
+
},
|
| 382 |
+
}
|
| 383 |
+
: {
|
| 384 |
+
opacity: 1,
|
| 385 |
+
x: 400,
|
| 386 |
+
y: 0,
|
| 387 |
+
height: windowHeight,
|
| 388 |
+
width: windowWidth
|
| 389 |
+
? windowWidth - 400
|
| 390 |
+
: 'calc(100dvw-400px)',
|
| 391 |
+
borderRadius: 0,
|
| 392 |
+
transition: {
|
| 393 |
+
delay: 0,
|
| 394 |
+
type: 'spring',
|
| 395 |
+
stiffness: 200,
|
| 396 |
+
damping: 30,
|
| 397 |
+
duration: 5000,
|
| 398 |
+
},
|
| 399 |
+
}
|
| 400 |
+
}
|
| 401 |
+
exit={{
|
| 402 |
+
opacity: 0,
|
| 403 |
+
scale: 0.5,
|
| 404 |
+
transition: {
|
| 405 |
+
delay: 0.1,
|
| 406 |
+
type: 'spring',
|
| 407 |
+
stiffness: 600,
|
| 408 |
+
damping: 30,
|
| 409 |
+
},
|
| 410 |
+
}}
|
| 411 |
+
>
|
| 412 |
+
<div className="p-2 flex flex-row justify-between items-start">
|
| 413 |
+
<div className="flex flex-row gap-4 items-start">
|
| 414 |
+
<ArtifactCloseButton />
|
| 415 |
+
|
| 416 |
+
<div className="flex flex-col">
|
| 417 |
+
<div className="font-medium">{artifact.title}</div>
|
| 418 |
+
|
| 419 |
+
{isContentDirty ? (
|
| 420 |
+
<div className="text-sm text-muted-foreground">
|
| 421 |
+
Saving changes...
|
| 422 |
+
</div>
|
| 423 |
+
) : document ? (
|
| 424 |
+
<div className="text-sm text-muted-foreground">
|
| 425 |
+
{`Updated ${formatDistance(
|
| 426 |
+
new Date(document.createdAt),
|
| 427 |
+
new Date(),
|
| 428 |
+
{
|
| 429 |
+
addSuffix: true,
|
| 430 |
+
},
|
| 431 |
+
)}`}
|
| 432 |
+
</div>
|
| 433 |
+
) : (
|
| 434 |
+
<div className="w-32 h-3 mt-2 bg-muted-foreground/20 rounded-md animate-pulse" />
|
| 435 |
+
)}
|
| 436 |
+
</div>
|
| 437 |
+
</div>
|
| 438 |
+
|
| 439 |
+
<ArtifactActions
|
| 440 |
+
artifact={artifact}
|
| 441 |
+
currentVersionIndex={currentVersionIndex}
|
| 442 |
+
handleVersionChange={handleVersionChange}
|
| 443 |
+
isCurrentVersion={isCurrentVersion}
|
| 444 |
+
mode={mode}
|
| 445 |
+
metadata={metadata}
|
| 446 |
+
setMetadata={setMetadata}
|
| 447 |
+
/>
|
| 448 |
+
</div>
|
| 449 |
+
|
| 450 |
+
<div className="dark:bg-muted bg-background h-full overflow-y-scroll !max-w-full items-center">
|
| 451 |
+
<artifactDefinition.content
|
| 452 |
+
title={artifact.title}
|
| 453 |
+
content={
|
| 454 |
+
isCurrentVersion
|
| 455 |
+
? artifact.content
|
| 456 |
+
: getDocumentContentById(currentVersionIndex)
|
| 457 |
+
}
|
| 458 |
+
mode={mode}
|
| 459 |
+
status={artifact.status}
|
| 460 |
+
currentVersionIndex={currentVersionIndex}
|
| 461 |
+
suggestions={[]}
|
| 462 |
+
onSaveContent={saveContent}
|
| 463 |
+
isInline={false}
|
| 464 |
+
isCurrentVersion={isCurrentVersion}
|
| 465 |
+
getDocumentContentById={getDocumentContentById}
|
| 466 |
+
isLoading={isDocumentsFetching && !artifact.content}
|
| 467 |
+
metadata={metadata}
|
| 468 |
+
setMetadata={setMetadata}
|
| 469 |
+
/>
|
| 470 |
+
|
| 471 |
+
<AnimatePresence>
|
| 472 |
+
{isCurrentVersion && (
|
| 473 |
+
<Toolbar
|
| 474 |
+
isToolbarVisible={isToolbarVisible}
|
| 475 |
+
setIsToolbarVisible={setIsToolbarVisible}
|
| 476 |
+
sendMessage={sendMessage}
|
| 477 |
+
status={status}
|
| 478 |
+
stop={stop}
|
| 479 |
+
setMessages={setMessages}
|
| 480 |
+
artifactKind={artifact.kind}
|
| 481 |
+
/>
|
| 482 |
+
)}
|
| 483 |
+
</AnimatePresence>
|
| 484 |
+
</div>
|
| 485 |
+
|
| 486 |
+
<AnimatePresence>
|
| 487 |
+
{!isCurrentVersion && (
|
| 488 |
+
<VersionFooter
|
| 489 |
+
currentVersionIndex={currentVersionIndex}
|
| 490 |
+
documents={documents}
|
| 491 |
+
handleVersionChange={handleVersionChange}
|
| 492 |
+
/>
|
| 493 |
+
)}
|
| 494 |
+
</AnimatePresence>
|
| 495 |
+
</motion.div>
|
| 496 |
+
</motion.div>
|
| 497 |
+
)}
|
| 498 |
+
</AnimatePresence>
|
| 499 |
+
);
|
| 500 |
+
}
|
| 501 |
+
|
| 502 |
+
export const Artifact = memo(PureArtifact, (prevProps, nextProps) => {
|
| 503 |
+
if (prevProps.status !== nextProps.status) return false;
|
| 504 |
+
if (!equal(prevProps.votes, nextProps.votes)) return false;
|
| 505 |
+
if (prevProps.input !== nextProps.input) return false;
|
| 506 |
+
if (!equal(prevProps.messages, nextProps.messages.length)) return false;
|
| 507 |
+
if (prevProps.selectedVisibilityType !== nextProps.selectedVisibilityType)
|
| 508 |
+
return false;
|
| 509 |
+
|
| 510 |
+
return true;
|
| 511 |
+
});
|
components/auth-form.tsx
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import Form from 'next/form';
|
| 2 |
+
|
| 3 |
+
import { Input } from './ui/input';
|
| 4 |
+
import { Label } from './ui/label';
|
| 5 |
+
|
| 6 |
+
export function AuthForm({
|
| 7 |
+
action,
|
| 8 |
+
children,
|
| 9 |
+
defaultEmail = '',
|
| 10 |
+
}: {
|
| 11 |
+
action: NonNullable<
|
| 12 |
+
string | ((formData: FormData) => void | Promise<void>) | undefined
|
| 13 |
+
>;
|
| 14 |
+
children: React.ReactNode;
|
| 15 |
+
defaultEmail?: string;
|
| 16 |
+
}) {
|
| 17 |
+
return (
|
| 18 |
+
<Form action={action} className="flex flex-col gap-4 px-4 sm:px-16">
|
| 19 |
+
<div className="flex flex-col gap-2">
|
| 20 |
+
<Label
|
| 21 |
+
htmlFor="email"
|
| 22 |
+
className="text-zinc-600 font-normal dark:text-zinc-400"
|
| 23 |
+
>
|
| 24 |
+
Email Address
|
| 25 |
+
</Label>
|
| 26 |
+
|
| 27 |
+
<Input
|
| 28 |
+
id="email"
|
| 29 |
+
name="email"
|
| 30 |
+
className="bg-muted text-md md:text-sm"
|
| 31 |
+
type="email"
|
| 32 |
+
placeholder="user@acme.com"
|
| 33 |
+
autoComplete="email"
|
| 34 |
+
required
|
| 35 |
+
autoFocus
|
| 36 |
+
defaultValue={defaultEmail}
|
| 37 |
+
/>
|
| 38 |
+
</div>
|
| 39 |
+
|
| 40 |
+
<div className="flex flex-col gap-2">
|
| 41 |
+
<Label
|
| 42 |
+
htmlFor="password"
|
| 43 |
+
className="text-zinc-600 font-normal dark:text-zinc-400"
|
| 44 |
+
>
|
| 45 |
+
Password
|
| 46 |
+
</Label>
|
| 47 |
+
|
| 48 |
+
<Input
|
| 49 |
+
id="password"
|
| 50 |
+
name="password"
|
| 51 |
+
className="bg-muted text-md md:text-sm"
|
| 52 |
+
type="password"
|
| 53 |
+
required
|
| 54 |
+
/>
|
| 55 |
+
</div>
|
| 56 |
+
|
| 57 |
+
{children}
|
| 58 |
+
</Form>
|
| 59 |
+
);
|
| 60 |
+
}
|