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

This commit is contained in:
upstream-mirror
2026-09-23 16:12:52 +05:00
commit a774ac673a
4541 changed files with 770499 additions and 0 deletions

36
templates/chat/.gitignore vendored Normal file
View 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

View 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
View 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).

View File

@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
serverExternalPackages: ['@tldraw/tldraw'],
}
export default nextConfig

View 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"
}
}

View 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()
}

View 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 })
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View 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>
)
}

View File

@@ -0,0 +1,10 @@
'use client'
import { Chat } from '../components/Chat'
export default function Home() {
return (
<main className="tl-theme__light">
<Chat />
</main>
)
}

View 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;
}
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
})

View 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>
)
}

View 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>
)
})

View 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
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
// )
}

View 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>
)
}

View 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)
}

View 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]
}

View 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
}

View 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
}

View 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"
}
]
}

View 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"
}