snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
This commit is contained in:
36
templates/chat/.gitignore
vendored
Normal file
36
templates/chat/.gitignore
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
package-lock.json
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
9
templates/chat/LICENSE.md
Normal file
9
templates/chat/LICENSE.md
Normal file
@@ -0,0 +1,9 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 tldraw Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
75
templates/chat/README.md
Normal file
75
templates/chat/README.md
Normal file
@@ -0,0 +1,75 @@
|
||||
<div alt style="text-align: center; transform: scale(.5);">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/tldraw/tldraw/main/assets/github-hero-dark.png" />
|
||||
<img alt="tldraw" src="https://raw.githubusercontent.com/tldraw/tldraw/main/assets/github-hero-light.png" />
|
||||
</picture>
|
||||
</div>
|
||||
|
||||
This repo contains a starter-kit for making an AI chat application using [tldraw](https://github.com/tldraw/tldraw).
|
||||
|
||||
## Local development
|
||||
|
||||
Install dependencies with `yarn` or `npm install`.
|
||||
|
||||
Run the development server with `yarn dev` or `npm run dev`.
|
||||
|
||||
Open `http://localhost:3000/` in your browser to see the app.
|
||||
|
||||
This starter kit demonstrates how to build an AI chat application that uses tldraw to provide sketches and annotated image to the model. The app features:
|
||||
|
||||
- Integrated whiteboard for providing visual context
|
||||
- Image annotation and markup
|
||||
- Easy switching between text chat and visual canvas input
|
||||
|
||||
Key interactions include:
|
||||
|
||||
- Chat with AI using natural language
|
||||
- Click the whiteboard button to open the tldraw canvas
|
||||
- Draw, sketch, and create diagrams to supplement conversations
|
||||
- Annotate images and visual content directly on the canvas
|
||||
|
||||
## Environment setup
|
||||
|
||||
Create a `.env.local` file in the root directory and add your Google Generative API key:
|
||||
|
||||
```
|
||||
GOOGLE_GENERATIVE_AI_API_KEY=your_gemini_api_key_here
|
||||
```
|
||||
|
||||
Get your API key from [Google AI Studio](https://aistudio.google.com/apikey).
|
||||
You can also switch to a different provider using the [Vercel AI SDK](https://ai-sdk.dev/providers/ai-sdk-providers).
|
||||
|
||||
## File structure
|
||||
|
||||
- **`src/app/page.tsx`:** The main entry point that renders the chat interface
|
||||
- **`src/components/Chat.tsx`:** The main chat container using the Vercel AI SDK's useChat hook
|
||||
- **`src/components/MessageList.tsx`:** Scrollable message history with loading states
|
||||
- **`src/components/ChatMessage.tsx`:** Individual message display component
|
||||
- **`src/components/ChatInput.tsx`:** Input field with send functionality
|
||||
- **`src/components/WhiteboardModal.tsx`:** Modal component that integrates tldraw for drawing and sketching
|
||||
- **`src/app/api/chat/route.ts`:** Next.js API route using Vercel AI SDK for OpenAI integration
|
||||
- **`src/app/styles.css`:** CSS with responsive design for all components
|
||||
|
||||
## License
|
||||
|
||||
This project is provided under the MIT license found [here](https://github.com/tldraw/char-template/blob/main/LICENSE.md). The tldraw SDK is provided under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md).
|
||||
|
||||
## Trademarks
|
||||
|
||||
Copyright (c) 2024-present tldraw Inc. The tldraw name and logo are trademarks of tldraw. Please see our [trademark guidelines](https://github.com/tldraw/tldraw/blob/main/TRADEMARKS.md) for info on acceptable usage.
|
||||
|
||||
## Distributions
|
||||
|
||||
You can find tldraw on npm [here](https://www.npmjs.com/package/@tldraw/tldraw?activeTab=versions).
|
||||
|
||||
## Contribution
|
||||
|
||||
Found a bug? Please [submit an issue](https://github.com/tldraw/tldraw/issues/new).
|
||||
|
||||
## Community
|
||||
|
||||
Have questions, comments or feedback? [Join our discord](https://discord.tldraw.com/?utm_source=github&utm_medium=readme&utm_campaign=sociallink). For the latest news and release notes, visit [tldraw.dev](https://tldraw.dev).
|
||||
|
||||
## Contact
|
||||
|
||||
Find us on Twitter/X at [@tldraw](https://twitter.com/tldraw).
|
||||
6
templates/chat/next.config.mjs
Normal file
6
templates/chat/next.config.mjs
Normal file
@@ -0,0 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
serverExternalPackages: ['@tldraw/tldraw'],
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
45
templates/chat/package.json
Normal file
45
templates/chat/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "tldraw-chat-app-example",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"author": {
|
||||
"name": "tldraw GB Ltd.",
|
||||
"email": "hello@tldraw.com"
|
||||
},
|
||||
"homepage": "https://tldraw.dev",
|
||||
"license": "MIT",
|
||||
"tldraw_template": {
|
||||
"repo": "tldraw/chat-template",
|
||||
"cli": {
|
||||
"name": "Chat",
|
||||
"description": "Create an AI chat that uses tldraw for sketches and annotations.",
|
||||
"shortDescription": "A chat UI with sketches and images as context for AI.",
|
||||
"order": 5
|
||||
},
|
||||
"scripts": {
|
||||
"lint": null
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "next dev -H 0.0.0.0",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "yarn run -T tsx ../../internal/scripts/lint.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/google": "^2.0.74",
|
||||
"@ai-sdk/react": "^2.0.15",
|
||||
"@google/genai": "^1.17.0",
|
||||
"@types/node": "^22.15.31",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"ai": "^5.0.194",
|
||||
"next": "^16.3.0",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"tldraw": "workspace:*",
|
||||
"typescript": "^5.8.3",
|
||||
"zod": "^4.1.8"
|
||||
}
|
||||
}
|
||||
26
templates/chat/src/app/api/chat/route.ts
Normal file
26
templates/chat/src/app/api/chat/route.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { google } from '@ai-sdk/google'
|
||||
import { convertToModelMessages, streamText, UIMessage } from 'ai'
|
||||
|
||||
// Allow streaming responses up to 60 seconds
|
||||
export const maxDuration = 60
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { messages }: { messages: UIMessage[] } = await req.json()
|
||||
|
||||
const result = streamText({
|
||||
model: google('gemini-3.8-flash'),
|
||||
system: [
|
||||
"You're a friendly AI chatbot.",
|
||||
'The user can send you images, sketches and diagrams using your built-in tldraw whiteboard.',
|
||||
'You cannot create or edit whiteboards yourself.',
|
||||
'You gently steer every conversation towards how tldraw is a very good whiteboard.',
|
||||
'You try to be subtle about tldraw and not bring it up unless it becomes relevant, but you are very excited about tldraw.',
|
||||
'Do not offer to help with tldraw.',
|
||||
'You can use markdown in your responses.',
|
||||
'Keep your responses short and concise.',
|
||||
].join(' '),
|
||||
messages: convertToModelMessages(messages),
|
||||
})
|
||||
|
||||
return result.toUIMessageStreamResponse()
|
||||
}
|
||||
27
templates/chat/src/app/api/upload/route.ts
Normal file
27
templates/chat/src/app/api/upload/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { GoogleGenAI } from '@google/genai'
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const apiKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY
|
||||
if (!apiKey) {
|
||||
return new Response('GOOGLE_GENERATIVE_AI_API_KEY is not set', { status: 500 })
|
||||
}
|
||||
|
||||
const contentType = req.headers.get('content-type')
|
||||
if (!contentType) {
|
||||
return new Response('content-type is not set', { status: 400 })
|
||||
}
|
||||
|
||||
const displayName = req.headers.get('x-file-name')
|
||||
if (!displayName) {
|
||||
return new Response('x-file-name is not set', { status: 400 })
|
||||
}
|
||||
|
||||
const ai = new GoogleGenAI({ apiKey })
|
||||
|
||||
const file = await ai.files.upload({
|
||||
file: await req.blob(),
|
||||
config: { mimeType: contentType, displayName },
|
||||
})
|
||||
|
||||
return Response.json({ uploadedUrl: file.uri, expiresAt: file.expirationTime })
|
||||
}
|
||||
BIN
templates/chat/src/app/favicon.ico
Normal file
BIN
templates/chat/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
14
templates/chat/src/app/layout.tsx
Normal file
14
templates/chat/src/app/layout.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import './styles.css'
|
||||
|
||||
export const metadata = {
|
||||
title: 'tldraw chat starter kit',
|
||||
description: 'An example of how to use tldraw to provide visual context in a chat app',
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
10
templates/chat/src/app/page.tsx
Normal file
10
templates/chat/src/app/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
'use client'
|
||||
import { Chat } from '../components/Chat'
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main className="tl-theme__light">
|
||||
<Chat />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
615
templates/chat/src/app/styles.css
Normal file
615
templates/chat/src/app/styles.css
Normal file
@@ -0,0 +1,615 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap');
|
||||
@import 'tldraw/tldraw.css';
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
overscroll-behavior: none;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
/* Chat Container */
|
||||
.chat-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Chat Header */
|
||||
.chat-header {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
padding: 4px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: white;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
/* Empty Chat State */
|
||||
.empty-chat-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.empty-chat-content {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-chat-title {
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
color: #64748b;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.centered-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Message List */
|
||||
.message-list {
|
||||
flex: 1;
|
||||
padding: 16px 24px;
|
||||
padding-bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Thinking state styling */
|
||||
.thinking-message .thinking-text {
|
||||
color: #64748b;
|
||||
font-style: italic;
|
||||
font-size: 14px;
|
||||
/* No background or padding - just plain text */
|
||||
}
|
||||
|
||||
/* Message Groups */
|
||||
.message-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px; /* Very small gap between grouped bubbles */
|
||||
width: 100%;
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
.message-group.user-message {
|
||||
align-self: flex-end;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.message-group.assistant-message {
|
||||
align-self: flex-start;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.message {
|
||||
border-radius: 16px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.user-message .message-text {
|
||||
background: #f1f5f9;
|
||||
color: #1e293b;
|
||||
padding: 6px 16px;
|
||||
}
|
||||
|
||||
/* Middle bubbles have small radius on both connecting sides */
|
||||
.user-message .message-content:not(:first-child):not(:last-child),
|
||||
.user-message .message-image-container:not(:first-child):not(:last-child) {
|
||||
border-top-right-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.message-image {
|
||||
overflow: clip;
|
||||
border: none;
|
||||
background: none;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid #e2e8f0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.message-image-content {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
max-width: 100%;
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
.user-message .message {
|
||||
border-bottom-right-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
|
||||
.user-message .message:first-child {
|
||||
border-top-right-radius: 16px;
|
||||
}
|
||||
|
||||
.user-message .message:last-child {
|
||||
border-bottom-right-radius: 16px;
|
||||
}
|
||||
|
||||
/* Markdown styling in messages */
|
||||
.message-text h1,
|
||||
.message-text h2,
|
||||
.message-text h3,
|
||||
.message-text h4,
|
||||
.message-text h5,
|
||||
.message-text h6 {
|
||||
margin: 8px 0 4px 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.message-text p {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.message-text ul,
|
||||
.message-text ol {
|
||||
margin: 4px 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.message-text li {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.message-text pre {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
margin: 8px 0;
|
||||
overflow-x: auto;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.message-text code {
|
||||
background: #f1f5f9;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 13px;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
}
|
||||
|
||||
.message-text pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.message-text blockquote {
|
||||
border-left: 3px solid #e2e8f0;
|
||||
padding-left: 12px;
|
||||
margin: 8px 0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* Chat Footer */
|
||||
.chat-footer {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
padding: 16px 4px;
|
||||
padding-top: 32px;
|
||||
}
|
||||
|
||||
.chat-footer > * {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Chat Input Form */
|
||||
.chat-input-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f1f5f9;
|
||||
border-radius: 28px;
|
||||
padding: 12px 16px;
|
||||
overflow: clip;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.drag-drop-indicator {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: #f1f5f9;
|
||||
color: #878e99;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.drag-drop-indicator .outline {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.drag-drop-indicator .outline rect {
|
||||
x: 8px;
|
||||
y: 8px;
|
||||
width: calc(100% - 16px);
|
||||
height: calc(100% - 16px);
|
||||
stroke: #adb3bd;
|
||||
stroke-width: 3px;
|
||||
stroke-dasharray: 3%;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
fill: none;
|
||||
rx: 22px;
|
||||
}
|
||||
|
||||
.drag-drop-indicator .icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.input-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Input Images */
|
||||
.input-images {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.input-image {
|
||||
position: relative;
|
||||
height: 128px;
|
||||
min-width: 60px;
|
||||
max-width: 256px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--tl-color-background);
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.input-image-preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.input-image-edit {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.input-image-edit:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.input-image-remove {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
padding: 4px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.input-image-remove::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -4px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.input-image-remove:hover {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.input-image-remove svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* Hide remove button by default on hover-capable devices */
|
||||
@media (hover: hover) {
|
||||
.input-image-remove {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.input-image:hover .input-image-remove {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-input-bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin: 0 -6px;
|
||||
margin-bottom: -4px;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 12px;
|
||||
margin: 0 -2px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #374151;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.icon-button svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.icon-button[type='submit'] {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.icon-button[type='submit']:not(:disabled) {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.icon-button[type='submit']:not(:disabled):hover {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.icon-button[type='submit'] svg {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.icon-button:hover:not(:disabled) {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.icon-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
padding: 0 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
resize: none;
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
min-height: 24px;
|
||||
max-height: 5lh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.chat-input:disabled {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.input-spinner {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 4px;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
container-type: size;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.modal-overlay .tl-container {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.modal-overlay .tl-container__focused {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.modal-overlay .tlui-layout__mobile .tlui-main-toolbar--horizontal .tlui-button__tool {
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
.modal-overlay .tlui-main-toolbar__lock-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modal-overlay .tl-canvas {
|
||||
background: var(--tl-color-background);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.modal-overlay .tl-loading {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.modal-overlay .whiteboard-actions {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.whiteboard-actions .tlui-button {
|
||||
font-size: 14px;
|
||||
padding: 0 20px;
|
||||
margin-right: -4px;
|
||||
}
|
||||
.whiteboard-actions .tlui-button::before {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
box-shadow:
|
||||
0px 0px 2px hsl(0, 0%, 0%, 16%),
|
||||
0px 2px 3px hsl(0, 0%, 0%, 24%),
|
||||
0px 2px 6px hsl(0, 0%, 0%, 0.1);
|
||||
}
|
||||
.whiteboard-actions .tlui-button::before,
|
||||
.whiteboard-actions .tlui-button::after {
|
||||
border-radius: var(--tl-radius-4);
|
||||
top: 2px;
|
||||
bottom: 2px;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
.whiteboard-actions .tlui-button__primary {
|
||||
color: var(--tl-color-panel) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.whiteboard-actions .tlui-button__primary::before {
|
||||
background: var(--tl-color-primary);
|
||||
}
|
||||
|
||||
.whiteboard-actions .tlui-button__normal::before {
|
||||
background: var(--tl-color-panel);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.modal-overlay {
|
||||
--padding-x: 24px;
|
||||
--padding-y: 8px;
|
||||
--canvas-inset-bottom: 64px;
|
||||
--canvas-inset-top: 56px;
|
||||
--canvas-aspect-ratio: 0.7;
|
||||
}
|
||||
|
||||
.modal-overlay .tl-container {
|
||||
overflow: visible;
|
||||
width: 100%;
|
||||
max-height: calc(
|
||||
(100cqw - var(--padding-x) * 2) * var(--canvas-aspect-ratio) + var(--canvas-inset-bottom) +
|
||||
var(--canvas-inset-top) - (var(--padding-y) * 2)
|
||||
);
|
||||
}
|
||||
|
||||
.modal-overlay .tlui-layout {
|
||||
overflow: visible;
|
||||
padding: var(--padding-y) var(--padding-x);
|
||||
}
|
||||
|
||||
.modal-overlay .tl-canvas,
|
||||
.modal-overlay .tl-canvas__in-front {
|
||||
border-radius: var(--tl-radius-4);
|
||||
box-shadow: var(--tl-shadow-2);
|
||||
bottom: calc(var(--canvas-inset-bottom) + var(--padding-y));
|
||||
top: calc(var(--canvas-inset-top) + var(--padding-y));
|
||||
left: var(--padding-x);
|
||||
right: var(--padding-x);
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.modal-overlay .tlui-menu-zone {
|
||||
background: var(--tl-color-panel);
|
||||
box-shadow: var(--tl-shadow-2);
|
||||
border-radius: var(--tl-radius-4);
|
||||
margin: 0;
|
||||
border: none;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.modal-overlay .tlui-helper-buttons {
|
||||
top: 12px;
|
||||
}
|
||||
|
||||
.modal-overlay .whiteboard-actions {
|
||||
margin-top: 8px;
|
||||
margin-right: -6px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.chat-container {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.message-group {
|
||||
max-width: 85%;
|
||||
}
|
||||
|
||||
.chat-footer {
|
||||
padding: 8px;
|
||||
padding-top: 24px;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
font-size: 100%;
|
||||
}
|
||||
|
||||
.message-list {
|
||||
padding: 12px 16px;
|
||||
padding-bottom: 12px; /* Just visual spacing */
|
||||
}
|
||||
|
||||
.modal-overlay .tlui-layout__mobile .tlui-main-toolbar--horizontal .tlui-button__tool {
|
||||
width: 43px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 800px) {
|
||||
.chat-header {
|
||||
padding: 8px;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 64px;
|
||||
background: transparent;
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
201
templates/chat/src/components/Chat.tsx
Normal file
201
templates/chat/src/components/Chat.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
'use client'
|
||||
|
||||
import { useChat } from '@ai-sdk/react'
|
||||
import { DefaultChatTransport, FileUIPart, TextUIPart, UIMessage } from 'ai'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useChatMessageStorage } from '@/hooks/useChatMessageStorage'
|
||||
import { uploadMessageContents } from '@/utils/uploadMessageContents'
|
||||
import { useChatInputState } from '../hooks/useChatInputState'
|
||||
import { useScrollToBottom } from '../hooks/useScrollToBottom'
|
||||
import { ChatInput } from './ChatInput'
|
||||
import { ImageClickTarget } from './ChatMessage'
|
||||
import { ClearChatIcon } from './ClearChatIcon'
|
||||
import { MessageList } from './MessageList'
|
||||
import { TldrawProviderMetadata, WhiteboardImage } from './WhiteboardModal'
|
||||
|
||||
export function Chat() {
|
||||
// store chat messages locally in the browser
|
||||
const [initialMessages, saveMessages] = useChatMessageStorage()
|
||||
|
||||
if (!initialMessages) return null
|
||||
|
||||
return <ChatInner initialMessages={initialMessages} saveMessages={saveMessages} />
|
||||
}
|
||||
|
||||
function ChatInner({
|
||||
initialMessages,
|
||||
saveMessages,
|
||||
}: {
|
||||
initialMessages: UIMessage[]
|
||||
saveMessages: (messages: UIMessage[]) => void
|
||||
}) {
|
||||
// All state relating to the chat input and the tldraw modal is managed in this hook
|
||||
const [chatInputState, chatInputDispatch] = useChatInputState()
|
||||
|
||||
// We use the Vercel AI SDK's useChat hook to send messages to the server and manage the chat
|
||||
// history. You could replace this with your own chat implementation.
|
||||
const chat = useChat({
|
||||
transport: new DefaultChatTransport({
|
||||
api: '/api/chat',
|
||||
prepareSendMessagesRequest: async (options) => {
|
||||
const { messagesToSend, messagesToSave } = await uploadMessageContents(options.messages)
|
||||
chat.setMessages(messagesToSave)
|
||||
return {
|
||||
body: {
|
||||
...options.body,
|
||||
id: options.id,
|
||||
messages: messagesToSend,
|
||||
trigger: options.trigger,
|
||||
messageId: options.messageId,
|
||||
},
|
||||
}
|
||||
},
|
||||
}),
|
||||
messages: initialMessages,
|
||||
})
|
||||
|
||||
const { sendMessage, status, error, clearError, setMessages } = chat
|
||||
|
||||
// save the chat messages to local storage when the chat finishes
|
||||
useEffect(() => {
|
||||
if (chat.status === 'ready') {
|
||||
saveMessages(chat.messages)
|
||||
}
|
||||
}, [chat.status, chat.messages, saveMessages])
|
||||
|
||||
// If the chat encounters an error, we alert the user and clear the error.
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
alert(error.message)
|
||||
clearError()
|
||||
}
|
||||
}, [error, clearError])
|
||||
|
||||
// when the user send a message, we take the text they've written and any images / sketches
|
||||
// they've attached and send them to the model.
|
||||
const handleSendMessage = useCallback(
|
||||
(text: string, images: WhiteboardImage[]) => {
|
||||
chatInputDispatch({ type: 'clear' })
|
||||
|
||||
const parts: (TextUIPart | FileUIPart)[] = images.map((image): FileUIPart => {
|
||||
const tldrawMetadata: TldrawProviderMetadata = {
|
||||
snapshot: image.snapshot,
|
||||
imageName: image.name,
|
||||
}
|
||||
return {
|
||||
type: 'file',
|
||||
url: image.url,
|
||||
filename: image.name,
|
||||
mediaType: image.type,
|
||||
providerMetadata: { tldraw: tldrawMetadata } as any,
|
||||
}
|
||||
})
|
||||
|
||||
if (text.trim()) {
|
||||
parts.push({ type: 'text', text })
|
||||
}
|
||||
|
||||
sendMessage({ parts })
|
||||
},
|
||||
[sendMessage, chatInputDispatch]
|
||||
)
|
||||
|
||||
// keep the chat scrolled to the bottom as messages are added or changed
|
||||
const scrollToBottom = useScrollToBottom()
|
||||
useEffect(() => {
|
||||
scrollToBottom()
|
||||
}, [chat.messages, scrollToBottom])
|
||||
|
||||
// when the user clicks on an image from chat history, we open the tldraw modal. here they can
|
||||
// see a larger version of the image, but also annotate it and re-add it to the chat.
|
||||
const handleImageClick = useCallback(
|
||||
(opts: ImageClickTarget) => chatInputDispatch({ type: 'openWhiteboard', ...opts }),
|
||||
[chatInputDispatch]
|
||||
)
|
||||
|
||||
const handleClearChat = useCallback(() => {
|
||||
setMessages([])
|
||||
saveMessages([])
|
||||
}, [setMessages, saveMessages])
|
||||
|
||||
// users can drag and drop images to the chat input area to add them to their message. when
|
||||
// they're dragging we keep track of a special isDragging state.
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
if (
|
||||
e.dataTransfer.types.includes('Files') &&
|
||||
!chatInputState.openWhiteboard &&
|
||||
!chatInputState.isDragging
|
||||
) {
|
||||
chatInputDispatch({ type: 'dragEnter' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||
chatInputDispatch({ type: 'dragLeave' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file && file.type.startsWith('image/') && !chatInputState.openWhiteboard) {
|
||||
chatInputDispatch({ type: 'drop', file })
|
||||
} else {
|
||||
chatInputDispatch({ type: 'dragLeave' })
|
||||
}
|
||||
}
|
||||
|
||||
// if the chat is empty, we put the input area right in the middle of the page
|
||||
if (chat.messages.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className="empty-chat-container"
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="empty-chat-content">
|
||||
<h1 className="empty-chat-title">How can I help?</h1>
|
||||
<div className="centered-input">
|
||||
<ChatInput
|
||||
onSendMessage={handleSendMessage}
|
||||
waitingForResponse={status !== 'ready'}
|
||||
scrollToBottom={scrollToBottom}
|
||||
state={chatInputState}
|
||||
dispatch={chatInputDispatch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// otherwise, we show the chat history and the input area at the bottom.
|
||||
return (
|
||||
<div
|
||||
className="chat-container"
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="chat-header">
|
||||
<button className="icon-button" onClick={handleClearChat} title="Clear chat">
|
||||
<ClearChatIcon />
|
||||
</button>
|
||||
</div>
|
||||
<MessageList messages={chat.messages} onImageClick={handleImageClick} />
|
||||
<div className="chat-footer">
|
||||
<ChatInput
|
||||
onSendMessage={handleSendMessage}
|
||||
waitingForResponse={status !== 'ready'}
|
||||
scrollToBottom={scrollToBottom}
|
||||
state={chatInputState}
|
||||
dispatch={chatInputDispatch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
212
templates/chat/src/components/ChatInput.tsx
Normal file
212
templates/chat/src/components/ChatInput.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import { FormEvent, useCallback, useEffect, useLayoutEffect, useRef } from 'react'
|
||||
import { DefaultSpinner } from 'tldraw'
|
||||
import { useChatInputState } from '../hooks/useChatInputState'
|
||||
import { ChatInputImage } from './ChatInputImage'
|
||||
import { ImageIcon } from './icons/ImageIcon'
|
||||
import { SendIcon } from './icons/SendIcon'
|
||||
import { UploadIcon } from './icons/UploadIcon'
|
||||
import { WhiteboardIcon } from './icons/WhiteboardIcon'
|
||||
import { WhiteboardImage, WhiteboardModal } from './WhiteboardModal'
|
||||
|
||||
interface ChatInputProps {
|
||||
onSendMessage: (message: string, images: WhiteboardImage[]) => void
|
||||
waitingForResponse: boolean
|
||||
scrollToBottom: (behavior?: ScrollBehavior) => void
|
||||
state: ReturnType<typeof useChatInputState>[0]
|
||||
dispatch: ReturnType<typeof useChatInputState>[1]
|
||||
}
|
||||
|
||||
export function ChatInput({
|
||||
onSendMessage,
|
||||
waitingForResponse,
|
||||
scrollToBottom,
|
||||
state,
|
||||
dispatch,
|
||||
}: ChatInputProps) {
|
||||
const { input, images, openWhiteboard, isDragging } = state
|
||||
const disabled = waitingForResponse || isDragging
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// focus the textarea when the input is enabled
|
||||
if (!disabled) textareaRef.current?.focus()
|
||||
}, [disabled])
|
||||
|
||||
// Auto-resize textarea and scroll to bottom when content changes.
|
||||
useLayoutEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
// Reset height to auto to get the correct scrollHeight
|
||||
textareaRef.current.style.height = 'auto'
|
||||
// Set height based on scrollHeight, with max height for ~5 lines
|
||||
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`
|
||||
}
|
||||
}, [input])
|
||||
|
||||
// Scroll to bottom when images are added.
|
||||
useLayoutEffect(() => {
|
||||
scrollToBottom('instant')
|
||||
}, [images, scrollToBottom])
|
||||
|
||||
// the user can only send a message if the input is not disabled and there are either images or
|
||||
// text ready to send
|
||||
const canSend = !disabled && (images.length > 0 || input.trim())
|
||||
|
||||
const send = () => {
|
||||
if (canSend) onSendMessage(input, images)
|
||||
}
|
||||
|
||||
// when the user submits the form, we send the message.
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
send()
|
||||
}
|
||||
|
||||
// when the user presses enter, we send the message.
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Shift+Enter: allow default behavior (insert newline)
|
||||
if (e.key !== 'Enter' || e.shiftKey) return
|
||||
// Enter: submit form
|
||||
e.preventDefault()
|
||||
send()
|
||||
}
|
||||
|
||||
// when the user clicks the image upload button, we open a file input to allow them to select an
|
||||
// image from their device.
|
||||
const handleImageUpload = useCallback(() => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = 'image/*'
|
||||
input.onchange = (e: Event) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (!file || !file.type.startsWith('image/')) return
|
||||
|
||||
// Open whiteboard with the uploaded file
|
||||
dispatch({ type: 'openWhiteboard', uploadedFile: file, imageName: file.name })
|
||||
}
|
||||
input.click()
|
||||
}, [dispatch])
|
||||
|
||||
// when the user cancels the whiteboard modal, we close it.
|
||||
const handleCancelWhiteboard = useCallback(() => {
|
||||
dispatch({ type: 'closeWhiteboard' })
|
||||
}, [dispatch])
|
||||
|
||||
// when the user accepts the whiteboard modal, we add the image to the chat input & close it.
|
||||
const handleAcceptWhiteboard = useCallback(
|
||||
(image: WhiteboardImage) => {
|
||||
dispatch({ type: 'closeWhiteboard' })
|
||||
dispatch({ type: 'setImage', image })
|
||||
// Re-focus the input after adding an image
|
||||
textareaRef.current?.focus()
|
||||
},
|
||||
[dispatch]
|
||||
)
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="chat-input-form">
|
||||
{/* if the user is dragging an image over the input area, we show a visual indicator
|
||||
hiding the normal input content. */}
|
||||
{isDragging && (
|
||||
<div className="drag-drop-indicator">
|
||||
<svg className="outline">
|
||||
{/* we use an svg to draw a dashed outline of the input area. svg allows us
|
||||
to control the dash length in a way that for example a normal <div> with a
|
||||
border would not. */}
|
||||
<rect />
|
||||
</svg>
|
||||
<UploadIcon />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* if the user has added images to the chat input, we show them above the input. */}
|
||||
{images.length > 0 && (
|
||||
<div className="input-images">
|
||||
{images.map((image) => (
|
||||
<ChatInputImage
|
||||
key={image.id}
|
||||
image={image}
|
||||
onRemove={() => dispatch({ type: 'removeImage', imageId: image.id })}
|
||||
onEdit={() => {
|
||||
dispatch({
|
||||
type: 'openWhiteboard',
|
||||
id: image.id,
|
||||
snapshot: image.snapshot,
|
||||
imageName: image.name,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* the main input is a text area. we resize it automatically to fit its content. */}
|
||||
<div className="input-container">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(e) => dispatch({ type: 'setInput', input: e.target.value })}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={disabled ? '' : 'Type your message…'}
|
||||
className="chat-input"
|
||||
disabled={disabled}
|
||||
autoFocus={true}
|
||||
rows={1}
|
||||
/>
|
||||
{waitingForResponse && (
|
||||
<div className="input-spinner">
|
||||
<DefaultSpinner />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* below the input we have several controls: */}
|
||||
<div className="chat-input-bottom">
|
||||
{/* a button to upload an image */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Upload an image"
|
||||
title="Upload an image"
|
||||
className="icon-button"
|
||||
disabled={disabled}
|
||||
onClick={handleImageUpload}
|
||||
>
|
||||
<ImageIcon />
|
||||
</button>
|
||||
{/* a button to open the whiteboard modal */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Draw a sketch"
|
||||
title="Draw a sketch"
|
||||
className="icon-button"
|
||||
disabled={disabled}
|
||||
onClick={() => dispatch({ type: 'openWhiteboard' })}
|
||||
>
|
||||
<WhiteboardIcon />
|
||||
</button>
|
||||
{/* a button to send the message */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSend || disabled}
|
||||
className="icon-button"
|
||||
aria-label="Send message"
|
||||
title="Send message"
|
||||
>
|
||||
<SendIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* if the user has opened the whiteboard modal, we show it. */}
|
||||
{openWhiteboard && (
|
||||
<WhiteboardModal
|
||||
imageId={openWhiteboard.id}
|
||||
initialSnapshot={openWhiteboard.snapshot}
|
||||
uploadedFile={openWhiteboard.uploadedFile}
|
||||
imageName={openWhiteboard.imageName}
|
||||
onCancel={handleCancelWhiteboard}
|
||||
onAccept={handleAcceptWhiteboard}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
28
templates/chat/src/components/ChatInputImage.tsx
Normal file
28
templates/chat/src/components/ChatInputImage.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { XIcon } from './icons/XIcon'
|
||||
import { WhiteboardImage } from './WhiteboardModal'
|
||||
|
||||
interface ChatInputImageProps {
|
||||
image: WhiteboardImage
|
||||
onRemove: () => void
|
||||
onEdit: () => void
|
||||
}
|
||||
|
||||
export function ChatInputImage({ image, onRemove, onEdit }: ChatInputImageProps) {
|
||||
return (
|
||||
<div className="input-image">
|
||||
{/* the whole image is a clickable area that opens the whiteboard modal. */}
|
||||
<button type="button" className="input-image-edit" onClick={onEdit} aria-label="Edit image">
|
||||
<img src={image.url} alt="Uploaded image" className="input-image-preview" />
|
||||
</button>
|
||||
{/* a button to remove the image is positioned over the top of it. */}
|
||||
<button
|
||||
type="button"
|
||||
className="input-image-remove"
|
||||
onClick={onRemove}
|
||||
aria-label="Remove image"
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
71
templates/chat/src/components/ChatMessage.tsx
Normal file
71
templates/chat/src/components/ChatMessage.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { type UIMessage } from '@ai-sdk/react'
|
||||
import { memo } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import { FileHelpers } from 'tldraw'
|
||||
import { TldrawProviderMetadata } from './WhiteboardModal'
|
||||
|
||||
export type ImageClickTarget = TldrawProviderMetadata | { uploadedFile: File }
|
||||
|
||||
interface ChatMessageProps {
|
||||
message: UIMessage
|
||||
onImageClick: (opts: ImageClickTarget) => void
|
||||
}
|
||||
|
||||
export const ChatMessage = memo(function ChatMessage({ message, onImageClick }: ChatMessageProps) {
|
||||
// For AI messages with no content, show thinking state
|
||||
if (
|
||||
message.role === 'assistant' &&
|
||||
!message.parts.some((part) => part.type === 'file' || part.type === 'text')
|
||||
) {
|
||||
return (
|
||||
<div className="message assistant-message thinking-message">
|
||||
<div className="thinking-text">Thinking…</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`message-group ${message.role === 'user' ? 'user-message' : 'assistant-message'}`}
|
||||
>
|
||||
{message.parts.map((part, index) => {
|
||||
if (part.type === 'file') {
|
||||
// we stash a snapshot of the tldraw document in the provider metadata:
|
||||
const tldrawMetadata = part.providerMetadata?.tldraw as TldrawProviderMetadata | undefined
|
||||
const handleImageClick = async () => {
|
||||
// if we have a tldraw snapshot, we open the tldraw modal when it's clicked:
|
||||
if (tldrawMetadata) {
|
||||
onImageClick(tldrawMetadata)
|
||||
} else {
|
||||
const blob = await FileHelpers.urlToBlob(part.url)
|
||||
const file = new File([blob], part.filename || 'image.png', { type: blob.type })
|
||||
onImageClick({ uploadedFile: file })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
aria-label="Open image"
|
||||
className="message message-image message-image-clickable"
|
||||
onClick={handleImageClick}
|
||||
type="button"
|
||||
>
|
||||
<img src={part.url} alt="Whiteboard" className="message-image-content" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
if (part.type === 'text') {
|
||||
return (
|
||||
<div key={index} className="message message-text">
|
||||
<ReactMarkdown>{part.text}</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
27
templates/chat/src/components/ClearChatIcon.tsx
Normal file
27
templates/chat/src/components/ClearChatIcon.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
export function ClearChatIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="30"
|
||||
height="30"
|
||||
viewBox="0 0 30 30"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M10.5 3.3V5.1H19.5V3.3H10.5ZM21.3 5.1V2.85C21.3 2.10441 20.6955 1.5 19.95 1.5H10.05C9.30438 1.5 8.69996 2.10442 8.69996 2.85V5.1H3.29996C2.80291 5.1 2.39996 5.50294 2.39996 6C2.39996 6.49706 2.80291 6.9 3.29996 6.9H5.26097C5.26104 6.9509 5.26326 7.00224 5.26769 7.05395L6.96736 26.8539C7.04728 27.785 7.82632 28.5 8.76076 28.5H21.2392C22.1736 28.5 22.9526 27.785 23.0326 26.8539L24.7322 7.05395C24.7367 7.00224 24.7389 6.9509 24.739 6.9H26.7C27.197 6.9 27.6 6.49706 27.6 6C27.6 5.50294 27.197 5.1 26.7 5.1H21.3ZM22.9388 6.9H7.0611L8.76076 26.7H21.2392L22.9388 6.9Z"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M11.3252 11.4031C11.8206 11.3618 12.2556 11.7299 12.2969 12.2253L13.1969 23.0253C13.2381 23.5206 12.87 23.9556 12.3747 23.9969C11.8794 24.0382 11.4444 23.6701 11.4031 23.1747L10.5031 12.3747C10.4618 11.8794 10.8299 11.4444 11.3252 11.4031Z"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M17.6252 23.9969C17.1299 23.9556 16.7618 23.5206 16.8031 23.0253L17.7031 12.2253C17.7444 11.7299 18.1794 11.3618 18.6747 11.4031C19.17 11.4444 19.5381 11.8794 19.4969 12.3747L18.5969 23.1747C18.5556 23.6701 18.1206 24.0382 17.6252 23.9969Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
18
templates/chat/src/components/MessageList.tsx
Normal file
18
templates/chat/src/components/MessageList.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { type UIMessage } from '@ai-sdk/react'
|
||||
import { memo } from 'react'
|
||||
import { ChatMessage, ImageClickTarget } from './ChatMessage'
|
||||
|
||||
interface MessageListProps {
|
||||
messages: UIMessage[]
|
||||
onImageClick: (target: ImageClickTarget) => void
|
||||
}
|
||||
|
||||
export const MessageList = memo(function MessageList({ messages, onImageClick }: MessageListProps) {
|
||||
return (
|
||||
<div className="message-list">
|
||||
{messages.map((message) => (
|
||||
<ChatMessage key={message.id} message={message} onImageClick={onImageClick} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
189
templates/chat/src/components/WhiteboardModal.tsx
Normal file
189
templates/chat/src/components/WhiteboardModal.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
createShapeId,
|
||||
Editor,
|
||||
notifyIfFileNotAllowed,
|
||||
TLComponents,
|
||||
Tldraw,
|
||||
TldrawOptions,
|
||||
TldrawUiButton,
|
||||
TldrawUiRow,
|
||||
TLEditorSnapshot,
|
||||
useEditor,
|
||||
useToasts,
|
||||
useTranslation,
|
||||
} from 'tldraw'
|
||||
|
||||
export interface TldrawProviderMetadata {
|
||||
snapshot: TLEditorSnapshot
|
||||
imageName: string
|
||||
}
|
||||
|
||||
export interface WhiteboardImage {
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
snapshot: TLEditorSnapshot
|
||||
type: string
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
interface WhiteboardModalProps {
|
||||
initialSnapshot?: TLEditorSnapshot
|
||||
onCancel: () => void
|
||||
onAccept: (image: WhiteboardImage) => void
|
||||
imageId?: string
|
||||
uploadedFile?: File
|
||||
imageName?: string
|
||||
}
|
||||
|
||||
const options: Partial<TldrawOptions> = {
|
||||
// disable the ability to create new pages:
|
||||
maxPages: 1,
|
||||
// make sure the action shortcuts are always in the top-right menu area, not on the toolbar:
|
||||
actionShortcutsLocation: 'menu',
|
||||
// disable font pre-loading to avoid the ui popping in after the modal appears:
|
||||
maxFontsToLoadBeforeRender: 0,
|
||||
}
|
||||
|
||||
export function WhiteboardModal({
|
||||
initialSnapshot,
|
||||
onCancel,
|
||||
onAccept,
|
||||
imageId,
|
||||
uploadedFile,
|
||||
imageName,
|
||||
}: WhiteboardModalProps) {
|
||||
const [editor, setEditor] = useState<Editor | null>(null)
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!editor) return
|
||||
|
||||
// if there are no shapes, we don't want to save the image:
|
||||
const shapes = editor.getCurrentPageShapes()
|
||||
if (shapes.length === 0) {
|
||||
onCancel()
|
||||
return
|
||||
}
|
||||
|
||||
// when the user clicks save, we convert the current whiteboard to an image:
|
||||
const image = await editor.toImageDataUrl(shapes, { format: 'png' })
|
||||
|
||||
// we also take a snapshot of the editor state, so we can still edit
|
||||
// it if we open it up again later, and we pass the image data and the
|
||||
// snapshot to the parent component, so it can add it to the chat input:
|
||||
onAccept({
|
||||
id: imageId ?? crypto.randomUUID(),
|
||||
name: imageName ?? 'tldraw whiteboard.png',
|
||||
snapshot: editor.getSnapshot(),
|
||||
type: 'image/png',
|
||||
...image,
|
||||
})
|
||||
}, [onCancel, onAccept, imageId, imageName, editor])
|
||||
|
||||
// components are used to override parts of the tldraw ui. they shouldn't change often, so it's
|
||||
// important that we memoize them or define them outside the tldraw component.
|
||||
const components = useMemo(
|
||||
(): TLComponents => ({
|
||||
// The "SharePanel" is in the top-right of the editor. Here we want it to show our save
|
||||
// and cancel buttons:
|
||||
SharePanel: () => (
|
||||
<TldrawUiRow className="whiteboard-actions">
|
||||
<TldrawUiButton type="normal" onClick={onCancel}>
|
||||
Cancel
|
||||
</TldrawUiButton>
|
||||
<TldrawUiButton type="primary" onClick={handleSave}>
|
||||
{imageId ? 'Save' : 'Add'}
|
||||
</TldrawUiButton>
|
||||
</TldrawUiRow>
|
||||
),
|
||||
}),
|
||||
[onCancel, handleSave, imageId]
|
||||
)
|
||||
|
||||
// when the user clicks outside the modal, we close it. we add their image to the chat input in
|
||||
// case they wanted it - they can easily delete it if not.
|
||||
const handleOverlayClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
handleSave()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={handleOverlayClick}>
|
||||
<Tldraw
|
||||
components={components}
|
||||
forceMobile
|
||||
options={options}
|
||||
snapshot={initialSnapshot}
|
||||
onMount={(editor) => {
|
||||
setEditor(editor)
|
||||
|
||||
editor.user.updateUserPreferences({ colorScheme: 'light' })
|
||||
editor.selectNone()
|
||||
editor.zoomToSelection()
|
||||
}}
|
||||
>
|
||||
{/* if the user uploaded a file, we insert it in a special component. this means we
|
||||
can use hooks that depend on tldraw's ui to do things like show a toast if
|
||||
something goes wrong. */}
|
||||
<InsideOfTldrawContext uploadedFile={uploadedFile} />
|
||||
</Tldraw>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InsideOfTldrawContext({ uploadedFile }: { uploadedFile?: File }) {
|
||||
const toasts = useToasts()
|
||||
const msg = useTranslation()
|
||||
const editor = useEditor()
|
||||
|
||||
useEffect(() => {
|
||||
if (!uploadedFile) return
|
||||
|
||||
// this effect can run multiple times, but we only want the file to be uploaded once:
|
||||
if ((uploadedFile as any).didUpload) return
|
||||
;(uploadedFile as any).didUpload = true
|
||||
;(async () => {
|
||||
// we check if the file is allowed to be uploaded:
|
||||
if (!notifyIfFileNotAllowed(editor, uploadedFile, { toasts, msg })) return
|
||||
|
||||
// we get the asset for the uploaded file:
|
||||
const asset = await editor.getAssetForExternalContent({
|
||||
type: 'file',
|
||||
file: uploadedFile,
|
||||
})
|
||||
if (!asset || asset.type !== 'image') return
|
||||
|
||||
// scale so the max dimension is 1000px:
|
||||
const scale = Math.min(1000 / Math.max(asset.props.w, asset.props.h), 1)
|
||||
const center = editor.getViewportPageBounds().center
|
||||
const width = asset.props.w * scale
|
||||
const height = asset.props.h * scale
|
||||
|
||||
// create an ID for the new shape so we can select it later:
|
||||
const shapeId = createShapeId()
|
||||
|
||||
// create the shape, select it, make it fill the screen, and start cropping it:
|
||||
editor
|
||||
.createAssets([asset])
|
||||
.createShape({
|
||||
id: shapeId,
|
||||
type: 'image',
|
||||
x: center.x - width / 2,
|
||||
y: center.y - height / 2,
|
||||
props: {
|
||||
assetId: asset.id,
|
||||
w: width,
|
||||
h: height,
|
||||
},
|
||||
})
|
||||
.setSelectedShapes([shapeId])
|
||||
.zoomToSelection()
|
||||
.setCurrentTool('select.crop')
|
||||
})()
|
||||
}, [uploadedFile, toasts, msg, editor])
|
||||
|
||||
return null
|
||||
}
|
||||
22
templates/chat/src/components/icons/ImageIcon.tsx
Normal file
22
templates/chat/src/components/icons/ImageIcon.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
export function ImageIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="30"
|
||||
height="30"
|
||||
viewBox="0 0 30 30"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M15 7.39844C13.0109 7.39844 11.3984 9.01091 11.3984 11C11.3984 12.9891 13.0109 14.6016 15 14.6016C16.9891 14.6016 18.6016 12.9891 18.6016 11C18.6016 9.01091 16.9891 7.39844 15 7.39844ZM13.3984 11C13.3984 10.1155 14.1155 9.39844 15 9.39844C15.8845 9.39844 16.6016 10.1155 16.6016 11C16.6016 11.8845 15.8845 12.6016 15 12.6016C14.1155 12.6016 13.3984 11.8845 13.3984 11Z"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M4.5 2H25.5C26.8807 2 28 3.11929 28 4.5V25.5C28 26.8807 26.8807 28 25.5 28H4.5C3.11929 28 2 26.8807 2 25.5V4.5C2 3.11929 3.11929 2 4.5 2ZM26 4.5V18.5858L22.7071 15.2929C22.3166 14.9024 21.6834 14.9024 21.2929 15.2929L15.7496 20.8362L8.731 13.3176C8.54595 13.1194 8.28833 13.0048 8.01719 13.0001C7.74605 12.9955 7.48465 13.1011 7.29289 13.2929L4 16.5858V4.5C4 4.22386 4.22386 4 4.5 4H25.5C25.7761 4 26 4.22386 26 4.5ZM4 25.5V19.4142L7.97526 15.439L14.9886 22.9519L17.7474 26H4.5C4.22386 26 4 25.7761 4 25.5ZM25.5 26H20.4449L17.1047 22.3095L22 17.4142L26 21.4142V25.5C26 25.7761 25.7761 26 25.5 26Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
19
templates/chat/src/components/icons/SendIcon.tsx
Normal file
19
templates/chat/src/components/icons/SendIcon.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
export function SendIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="30"
|
||||
height="30"
|
||||
viewBox="0 0 30 30"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8.50024 12.6388L15.0002 6.5M15.0002 6.5L21.5002 12.6388M15.0002 6.5L15.0002 23.5"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
19
templates/chat/src/components/icons/UploadIcon.tsx
Normal file
19
templates/chat/src/components/icons/UploadIcon.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
export function UploadIcon() {
|
||||
return (
|
||||
<svg
|
||||
className="icon"
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 15 15"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M7.81825 1.18188C7.64251 1.00615 7.35759 1.00615 7.18185 1.18188L4.18185 4.18188C4.00611 4.35762 4.00611 4.64254 4.18185 4.81828C4.35759 4.99401 4.64251 4.99401 4.81825 4.81828L7.05005 2.58648V9.49996C7.05005 9.74849 7.25152 9.94996 7.50005 9.94996C7.74858 9.94996 7.95005 9.74849 7.95005 9.49996V2.58648L10.1819 4.81828C10.3576 4.99401 10.6425 4.99401 10.8182 4.81828C10.994 4.64254 10.994 4.35762 10.8182 4.18188L7.81825 1.18188ZM2.5 9.99997C2.77614 9.99997 3 10.2238 3 10.5V12C3 12.5538 3.44565 13 3.99635 13H11.0012C11.5529 13 12 12.5528 12 12V10.5C12 10.2238 12.2239 9.99997 12.5 9.99997C12.7761 9.99997 13 10.2238 13 10.5V12C13 13.104 12.1062 14 11.0012 14H3.99635C2.89019 14 2 13.103 2 12V10.5C2 10.2238 2.22386 9.99997 2.5 9.99997Z"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
49
templates/chat/src/components/icons/WhiteboardIcon.tsx
Normal file
49
templates/chat/src/components/icons/WhiteboardIcon.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
export function WhiteboardIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="30"
|
||||
height="30"
|
||||
viewBox="0 0 30 30"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8.63636 19.182L10.2348 15.4523C10.2599 15.3938 10.2958 15.3407 10.3408 15.2957L22.6364 3L27 7.36364L14.7045 19.6593C14.6595 19.7043 14.6063 19.7403 14.5479 19.7654L10.8182 21.3638M8.63636 19.182L7 23.0002L10.8182 21.3638M8.63636 19.182L10.8182 21.3638"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M7 23L8.71429 19L11 21.2857L7 23Z" fill="currentColor" />
|
||||
<path
|
||||
d="M17 3H5C3.89543 3 3 3.89543 3 5V25C3 26.1046 3.89543 27 5 27H25C26.1046 27 27 26.1046 27 25V13"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
// return (
|
||||
// <svg
|
||||
// width="30"
|
||||
// height="30"
|
||||
// viewBox="0 0 30 30"
|
||||
// fill="none"
|
||||
// stroke="currentColor"
|
||||
// xmlns="http://www.w3.org/2000/svg"
|
||||
// >
|
||||
// <path
|
||||
// d="M8.63636 23.182L10.2348 19.4523C10.2599 19.3938 10.2958 19.3407 10.3408 19.2957L22.6364 7L27 11.3636L14.7045 23.6593C14.6595 23.7043 14.6063 23.7403 14.5479 23.7654L10.8182 25.3638M8.63636 23.182L7 27.0002L10.8182 25.3638M8.63636 23.182L10.8182 25.3638"
|
||||
// strokeWidth="2"
|
||||
// strokeLinecap="round"
|
||||
// strokeLinejoin="round"
|
||||
// />
|
||||
// <path d="M7 27L8.71429 23L11 25.2857L7 27Z" fill="currentColor" />
|
||||
// <path
|
||||
// d="M13 11H5C3.89543 11 3 11.8954 3 13V29C3 30.1046 3.89543 31 5 31H21C22.1046 31 23 30.1046 23 29V21"
|
||||
// stroke="currentColor"
|
||||
// strokeWidth="2"
|
||||
// strokeLinecap="round"
|
||||
// />
|
||||
// </svg>
|
||||
// )
|
||||
}
|
||||
13
templates/chat/src/components/icons/XIcon.tsx
Normal file
13
templates/chat/src/components/icons/XIcon.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
export function XIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="30"
|
||||
height="30"
|
||||
viewBox="0 0 30 30"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M23.5631 8.68768C24.0123 8.23858 24.0123 7.51044 23.5631 7.06134C23.114 6.61223 22.3859 6.61223 21.9368 7.06134L15 13.9982L8.06315 7.06134C7.61405 6.61223 6.88591 6.61223 6.4368 7.06134C5.9877 7.51044 5.9877 8.23858 6.4368 8.68768L13.3736 15.6245L6.4368 22.5613C5.9877 23.0104 5.9877 23.7386 6.4368 24.1877C6.88591 24.6368 7.61405 24.6368 8.06315 24.1877L15 17.2509L21.9368 24.1877C22.3859 24.6368 23.114 24.6368 23.5631 24.1877C24.0123 23.7386 24.0123 23.0104 23.5631 22.5613L16.6263 15.6245L23.5631 8.68768Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
101
templates/chat/src/hooks/useChatInputState.ts
Normal file
101
templates/chat/src/hooks/useChatInputState.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useReducer } from 'react'
|
||||
import { TLEditorSnapshot } from 'tldraw'
|
||||
import { WhiteboardImage } from '../components/WhiteboardModal'
|
||||
|
||||
interface OpenWhiteboard {
|
||||
snapshot?: TLEditorSnapshot
|
||||
id?: string
|
||||
uploadedFile?: File
|
||||
imageName?: string
|
||||
}
|
||||
|
||||
interface ChatInputState {
|
||||
input: string
|
||||
images: WhiteboardImage[]
|
||||
openWhiteboard: OpenWhiteboard | null
|
||||
isDragging: boolean
|
||||
}
|
||||
|
||||
type ChatInputAction =
|
||||
/** Updates the text input value */
|
||||
| { type: 'setInput'; input: string }
|
||||
/** Adds or updates an image in the chat input */
|
||||
| { type: 'setImage'; image: WhiteboardImage }
|
||||
/** Removes an image from the chat input by its ID */
|
||||
| { type: 'removeImage'; imageId: string }
|
||||
/** Clears all input data (text, images, modals) */
|
||||
| { type: 'clear' }
|
||||
/** Opens the whiteboard modal for drawing/editing */
|
||||
| ({ type: 'openWhiteboard' } & OpenWhiteboard)
|
||||
/** Closes the whiteboard modal */
|
||||
| { type: 'closeWhiteboard' }
|
||||
/** Indicates a file is being dragged over the input area */
|
||||
| { type: 'dragEnter' }
|
||||
/** Indicates drag operation has left the input area */
|
||||
| { type: 'dragLeave' }
|
||||
/** Handles dropping an image file to open in whiteboard */
|
||||
| { type: 'drop'; file: File }
|
||||
|
||||
const initialState: ChatInputState = {
|
||||
input: '',
|
||||
images: [],
|
||||
openWhiteboard: null,
|
||||
isDragging: false,
|
||||
}
|
||||
|
||||
function chatInputReducer(state: ChatInputState, action: ChatInputAction): ChatInputState {
|
||||
switch (action.type) {
|
||||
case 'setInput':
|
||||
return { ...state, input: action.input }
|
||||
case 'setImage': {
|
||||
const exists = state.images.some((img) => img.id === action.image.id)
|
||||
return {
|
||||
...state,
|
||||
images: exists
|
||||
? state.images.map((img) => (img.id === action.image.id ? action.image : img))
|
||||
: [...state.images, action.image],
|
||||
}
|
||||
}
|
||||
case 'removeImage':
|
||||
return { ...state, images: state.images.filter((img) => img.id !== action.imageId) }
|
||||
case 'clear':
|
||||
return initialState
|
||||
case 'openWhiteboard': {
|
||||
const { snapshot, id, uploadedFile, imageName } = action
|
||||
return {
|
||||
...state,
|
||||
openWhiteboard: { snapshot, id, uploadedFile, imageName },
|
||||
isDragging: false,
|
||||
}
|
||||
}
|
||||
case 'closeWhiteboard':
|
||||
return { ...state, openWhiteboard: null }
|
||||
case 'dragEnter':
|
||||
return { ...state, isDragging: true }
|
||||
case 'dragLeave':
|
||||
return { ...state, isDragging: false }
|
||||
case 'drop':
|
||||
return {
|
||||
...state,
|
||||
isDragging: false,
|
||||
openWhiteboard: { uploadedFile: action.file, imageName: action.file.name },
|
||||
}
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing chat input state including text, images, whiteboard modal, and drag/drop interactions.
|
||||
*
|
||||
* Handles:
|
||||
* - Text input value
|
||||
* - Whiteboard images attached to the message
|
||||
* - Whiteboard modal state for drawing/editing
|
||||
* - Drag and drop file upload interactions
|
||||
*
|
||||
* @returns A tuple containing the current state and dispatch function for actions
|
||||
*/
|
||||
export function useChatInputState(): [ChatInputState, React.Dispatch<ChatInputAction>] {
|
||||
return useReducer(chatInputReducer, initialState)
|
||||
}
|
||||
54
templates/chat/src/hooks/useChatMessageStorage.tsx
Normal file
54
templates/chat/src/hooks/useChatMessageStorage.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { UIMessage, validateUIMessages } from 'ai'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { FileHelpers } from 'tldraw'
|
||||
|
||||
const STORAGE_FILE_NAME = 'chat-messages.json'
|
||||
|
||||
/**
|
||||
* Store chat messages locally in the browser. We use the origin private file system API to store
|
||||
* the messages because it has a higher capacity than localStorage, and is easier to use than
|
||||
* IndexedDB.
|
||||
*/
|
||||
export function useChatMessageStorage(): [UIMessage[] | null, (messages: UIMessage[]) => void] {
|
||||
const [initialMessages, setInitialMessages] = useState<UIMessage[] | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const root = await navigator.storage.getDirectory()
|
||||
if (isCancelled) return
|
||||
|
||||
const file = await root.getFileHandle(STORAGE_FILE_NAME)
|
||||
if (isCancelled) return
|
||||
|
||||
const fileContents = await FileHelpers.blobToText(await file.getFile())
|
||||
if (isCancelled) return
|
||||
|
||||
const messages = JSON.parse(fileContents)
|
||||
const validatedMessages = await validateUIMessages({ messages })
|
||||
if (isCancelled) return
|
||||
|
||||
setInitialMessages(validatedMessages)
|
||||
} catch (err) {
|
||||
if (isCancelled) return
|
||||
console.error('Error loading chat messages from storage', err)
|
||||
setInitialMessages([])
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
isCancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const saveMessages = useCallback(async (messages: UIMessage[]) => {
|
||||
const root = await navigator.storage.getDirectory()
|
||||
const file = await root.getFileHandle(STORAGE_FILE_NAME, { create: true })
|
||||
const writable = await file.createWritable({ keepExistingData: false })
|
||||
await writable.write(JSON.stringify(messages))
|
||||
await writable.close()
|
||||
}, [])
|
||||
|
||||
return [initialMessages, saveMessages]
|
||||
}
|
||||
57
templates/chat/src/hooks/useScrollToBottom.ts
Normal file
57
templates/chat/src/hooks/useScrollToBottom.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* Scrolls to the bottom of the page with a smooth animation. Typically you'd use the browser's
|
||||
* `scrollTo({behavior: 'smooth'})` for this, but that doesn't do well if it's called multiple times
|
||||
* in a row before each previous animation has completed (ie in an AI streaming type use-case). This
|
||||
* hook keeps scrolling until it reaches the bottom using a physics-based approach for easing,
|
||||
* rather than multiple distinct animations.
|
||||
*/
|
||||
export function useScrollToBottom() {
|
||||
const currentScrollAnimation = useRef<{ nextFrame: number } | null>(null)
|
||||
|
||||
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
|
||||
const scrollingElement = document.scrollingElement
|
||||
if (!scrollingElement) return
|
||||
|
||||
if (behavior !== 'smooth') {
|
||||
scrollingElement.scrollTo({ top: scrollingElement.scrollHeight, behavior: 'instant' })
|
||||
if (currentScrollAnimation.current) {
|
||||
cancelAnimationFrame(currentScrollAnimation.current.nextFrame)
|
||||
currentScrollAnimation.current = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (currentScrollAnimation.current) {
|
||||
return
|
||||
}
|
||||
|
||||
let scrollTop = scrollingElement.scrollTop
|
||||
let velocity = 0
|
||||
let lastT = performance.now()
|
||||
const acceleration = 0.02
|
||||
const maxVelocity = 5
|
||||
|
||||
const tick = () => {
|
||||
const t = performance.now()
|
||||
const dt = t - lastT
|
||||
lastT = t
|
||||
|
||||
velocity = Math.min(velocity + acceleration * dt, maxVelocity)
|
||||
scrollTop += velocity * dt
|
||||
|
||||
scrollingElement.scrollTop = scrollTop
|
||||
const maxScrollTop = scrollingElement.scrollHeight - scrollingElement.clientHeight
|
||||
if (scrollTop < maxScrollTop) {
|
||||
currentScrollAnimation.current = { nextFrame: requestAnimationFrame(tick) }
|
||||
} else {
|
||||
currentScrollAnimation.current = null
|
||||
}
|
||||
}
|
||||
|
||||
currentScrollAnimation.current = { nextFrame: requestAnimationFrame(tick) }
|
||||
}, [])
|
||||
|
||||
return scrollToBottom
|
||||
}
|
||||
92
templates/chat/src/utils/uploadMessageContents.ts
Normal file
92
templates/chat/src/utils/uploadMessageContents.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { FileUIPart, UIMessage } from 'ai'
|
||||
import { FileHelpers } from 'tldraw'
|
||||
|
||||
interface UploadedMetadata {
|
||||
uploadedUrl: string
|
||||
expiresAt: string
|
||||
}
|
||||
|
||||
const UPLOAD_METADATA_KEY = 'tldraw_uploaded'
|
||||
|
||||
/**
|
||||
* Vercel limits us to 4.5mb uploads. We upload files to Google GenAI to avoid this limitation.
|
||||
* There's two problems with that we need to work around though:
|
||||
* 1. Google will only store files for 24 hours.
|
||||
* 2. Google will host files privately - so we can't use them to display them in the UI.
|
||||
*
|
||||
* To work around these, we process the messages into two versions:
|
||||
* 1. The messages to send to the server - these have the file data URLs replaced with uploaded versions.
|
||||
* 2. The messages we use locally - these store the upload for re-use in `providerMetadata`, but keep the originals for display or when the uploads expire.
|
||||
*/
|
||||
export async function uploadMessageContents(messages: UIMessage[]) {
|
||||
const now = new Date().toISOString()
|
||||
const messagesToSend = []
|
||||
const messagesToSave = []
|
||||
const promises = []
|
||||
|
||||
for (const message of messages) {
|
||||
const partsToSend = []
|
||||
const partsToSave = []
|
||||
|
||||
for (const part of message.parts) {
|
||||
if (part.type === 'file' && part.url.startsWith('data:')) {
|
||||
const metadata = getUploadedMetadata(part)
|
||||
if (metadata && metadata.expiresAt > now) {
|
||||
partsToSend.push({ ...part, url: metadata.uploadedUrl })
|
||||
partsToSave.push(part)
|
||||
} else {
|
||||
const partToSend = { ...part }
|
||||
const partToSave = { ...part }
|
||||
|
||||
const promise = (async () => {
|
||||
const response = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
body: await FileHelpers.urlToBlob(part.url),
|
||||
headers: {
|
||||
'Content-Type': part.mediaType,
|
||||
'x-file-name': part.filename || 'image.png',
|
||||
},
|
||||
})
|
||||
|
||||
const data: UploadedMetadata = await response.json()
|
||||
|
||||
partToSend.url = data.uploadedUrl
|
||||
if (partToSend.providerMetadata) {
|
||||
partToSend.providerMetadata = { ...partToSend.providerMetadata }
|
||||
delete partToSend.providerMetadata[UPLOAD_METADATA_KEY]
|
||||
delete partToSend.providerMetadata.tldraw
|
||||
}
|
||||
partToSave.providerMetadata = {
|
||||
...partToSave.providerMetadata,
|
||||
[UPLOAD_METADATA_KEY]: data as any,
|
||||
}
|
||||
})()
|
||||
|
||||
promises.push(promise)
|
||||
partsToSend.push(partToSend)
|
||||
partsToSave.push(partToSave)
|
||||
}
|
||||
} else {
|
||||
partsToSend.push(part)
|
||||
partsToSave.push(part)
|
||||
}
|
||||
}
|
||||
|
||||
messagesToSend.push({
|
||||
...message,
|
||||
parts: partsToSend,
|
||||
})
|
||||
messagesToSave.push({
|
||||
...message,
|
||||
parts: partsToSave,
|
||||
})
|
||||
}
|
||||
|
||||
await Promise.all(promises)
|
||||
|
||||
return { messagesToSend, messagesToSave }
|
||||
}
|
||||
|
||||
function getUploadedMetadata(part: FileUIPart): UploadedMetadata | undefined {
|
||||
return part.providerMetadata?.[UPLOAD_METADATA_KEY] as UploadedMetadata | undefined
|
||||
}
|
||||
49
templates/chat/tsconfig.json
Normal file
49
templates/chat/tsconfig.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"DOM",
|
||||
"DOM.Iterable",
|
||||
"ESNext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"noUncheckedSideEffectImports": false,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"experimentalDecorators": true,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
},
|
||||
"target": "ES2018"
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
"next.config.mjs"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../packages/tldraw"
|
||||
}
|
||||
]
|
||||
}
|
||||
6
templates/chat/vercel.json
Normal file
6
templates/chat/vercel.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"framework": "nextjs",
|
||||
"installCommand": "yarn",
|
||||
"buildCommand": "yarn run -T lazy build",
|
||||
"ignoreCommand": "bash ../../internal/scripts/vercel/should-build-template.sh chat"
|
||||
}
|
||||
Reference in New Issue
Block a user