672 lines
17 KiB
Markdown
672 lines
17 KiB
Markdown
|
|
# 🚀 ChatJS Best Practices - Second Brain Integration Guide
|
||
|
|
|
||
|
|
## Overview
|
||
|
|
This document contains advanced patterns from [ChatJS](https://github.com/FranciscoMoretti/chatjs) that should be integrated into the Second Brain project to achieve production-grade quality.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 1. 📜 Smart Scroll Management
|
||
|
|
|
||
|
|
**Problem**: Current implementation scrolls even when user is reading previous messages.
|
||
|
|
|
||
|
|
**ChatJS Solution**: `use-scroll-anchor.ts`
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// hooks/use-scroll-anchor.ts
|
||
|
|
import { useEffect, useRef, useState } from 'react'
|
||
|
|
|
||
|
|
export function useScrollAnchor() {
|
||
|
|
const messagesRef = useRef<HTMLDivElement>(null)
|
||
|
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||
|
|
const isAtBottomRef = useRef(true)
|
||
|
|
const [showScrollButton, setShowScrollButton] = useState(false)
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
const messagesEl = messagesRef.current
|
||
|
|
const scrollEl = scrollRef.current
|
||
|
|
|
||
|
|
if (!messagesEl || !scrollEl) return
|
||
|
|
|
||
|
|
const handleScroll = () => {
|
||
|
|
const { scrollTop, scrollHeight, clientHeight } = scrollEl
|
||
|
|
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
|
||
|
|
|
||
|
|
// User is at bottom if within 50px
|
||
|
|
isAtBottomRef.current = distanceFromBottom < 50
|
||
|
|
setShowScrollButton(!isAtBottomRef.current)
|
||
|
|
}
|
||
|
|
|
||
|
|
scrollEl.addEventListener('scroll', handleScroll)
|
||
|
|
return () => scrollEl.removeEventListener('scroll', handleScroll)
|
||
|
|
}, [])
|
||
|
|
|
||
|
|
const scrollToBottom = () => {
|
||
|
|
const messagesEl = messagesRef.current
|
||
|
|
if (messagesEl) {
|
||
|
|
messagesEl.scrollIntoView({ behavior: 'smooth', block: 'end' })
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Auto-scroll ONLY if user is at bottom
|
||
|
|
const autoScroll = () => {
|
||
|
|
if (isAtBottomRef.current) {
|
||
|
|
scrollToBottom()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
messagesRef,
|
||
|
|
scrollRef,
|
||
|
|
showScrollButton,
|
||
|
|
scrollToBottom,
|
||
|
|
autoScroll,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Usage in ChatPage**:
|
||
|
|
```typescript
|
||
|
|
export function ChatPage() {
|
||
|
|
const { messages, isTyping } = useChatStore()
|
||
|
|
const { messagesRef, scrollRef, showScrollButton, scrollToBottom, autoScroll } = useScrollAnchor()
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
autoScroll() // Only scrolls if user is at bottom
|
||
|
|
}, [messages, isTyping])
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="flex flex-col h-full">
|
||
|
|
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
||
|
|
<div className="py-8">
|
||
|
|
{messages.map(msg => <ChatMessage key={msg.id} message={msg} />)}
|
||
|
|
{isTyping && <TypingIndicator />}
|
||
|
|
<div ref={messagesRef} />
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Scroll to Bottom Button */}
|
||
|
|
{showScrollButton && (
|
||
|
|
<button
|
||
|
|
onClick={scrollToBottom}
|
||
|
|
className="absolute bottom-24 right-8 p-3 bg-primary rounded-full shadow-lg"
|
||
|
|
>
|
||
|
|
<ChevronDown className="w-5 h-5 text-white" />
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<ChatInput />
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 2. 🎬 Message Actions
|
||
|
|
|
||
|
|
**ChatJS has excellent message actions on hover.**
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// components/chat/MessageActions.tsx
|
||
|
|
import { Copy, RotateCw, Edit, Share, MoreHorizontal } from 'lucide-react'
|
||
|
|
import { toast } from 'sonner'
|
||
|
|
|
||
|
|
interface MessageActionsProps {
|
||
|
|
message: Message
|
||
|
|
onRegenerate?: () => void
|
||
|
|
onEdit?: () => void
|
||
|
|
}
|
||
|
|
|
||
|
|
export function MessageActions({ message, onRegenerate, onEdit }: MessageActionsProps) {
|
||
|
|
const copyToClipboard = () => {
|
||
|
|
navigator.clipboard.writeText(message.content)
|
||
|
|
toast.success('Copied to clipboard')
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||
|
|
{/* Copy */}
|
||
|
|
<button
|
||
|
|
onClick={copyToClipboard}
|
||
|
|
className="p-1.5 hover:bg-zinc-800 rounded transition-colors"
|
||
|
|
title="Copy"
|
||
|
|
>
|
||
|
|
<Copy className="w-4 h-4 text-zinc-400" />
|
||
|
|
</button>
|
||
|
|
|
||
|
|
{/* Regenerate (AI messages only) */}
|
||
|
|
{message.role === 'assistant' && onRegenerate && (
|
||
|
|
<button
|
||
|
|
onClick={onRegenerate}
|
||
|
|
className="p-1.5 hover:bg-zinc-800 rounded transition-colors"
|
||
|
|
title="Regenerate"
|
||
|
|
>
|
||
|
|
<RotateCw className="w-4 h-4 text-zinc-400" />
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Edit (User messages only) */}
|
||
|
|
{message.role === 'user' && onEdit && (
|
||
|
|
<button
|
||
|
|
onClick={onEdit}
|
||
|
|
className="p-1.5 hover:bg-zinc-800 rounded transition-colors"
|
||
|
|
title="Edit"
|
||
|
|
>
|
||
|
|
<Edit className="w-4 h-4 text-zinc-400" />
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Share */}
|
||
|
|
<button
|
||
|
|
className="p-1.5 hover:bg-zinc-800 rounded transition-colors"
|
||
|
|
title="Share"
|
||
|
|
>
|
||
|
|
<Share className="w-4 h-4 text-zinc-400" />
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Update ChatMessage to include actions**:
|
||
|
|
```typescript
|
||
|
|
export function ChatMessage({ message }: ChatMessageProps) {
|
||
|
|
const { regenerateMessage } = useChatStore()
|
||
|
|
const isUser = message.role === 'user'
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="group flex gap-3 max-w-4xl px-6 py-4 relative">
|
||
|
|
{/* Avatar */}
|
||
|
|
<div className={/* ... */}>
|
||
|
|
{/* ... */}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Content */}
|
||
|
|
<div className="flex-1 flex flex-col gap-2">
|
||
|
|
{/* ... existing content ... */}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Actions - appears on hover */}
|
||
|
|
<MessageActions
|
||
|
|
message={message}
|
||
|
|
onRegenerate={() => regenerateMessage(message.id)}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 3. 📎 Advanced File Upload
|
||
|
|
|
||
|
|
**ChatJS has excellent drag & drop with preview.**
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// components/chat/FileUploadZone.tsx
|
||
|
|
import { useState } from 'react'
|
||
|
|
import { Upload, X, File, Image } from 'lucide-react'
|
||
|
|
|
||
|
|
interface FileUploadZoneProps {
|
||
|
|
onFilesSelect: (files: File[]) => void
|
||
|
|
selectedFiles: File[]
|
||
|
|
onFileRemove: (index: number) => void
|
||
|
|
}
|
||
|
|
|
||
|
|
export function FileUploadZone({ onFilesSelect, selectedFiles, onFileRemove }: FileUploadZoneProps) {
|
||
|
|
const [isDragging, setIsDragging] = useState(false)
|
||
|
|
|
||
|
|
const handleDrop = (e: React.DragEvent) => {
|
||
|
|
e.preventDefault()
|
||
|
|
setIsDragging(false)
|
||
|
|
|
||
|
|
const files = Array.from(e.dataTransfer.files)
|
||
|
|
onFilesSelect(files)
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleDragOver = (e: React.DragEvent) => {
|
||
|
|
e.preventDefault()
|
||
|
|
setIsDragging(true)
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleDragLeave = () => {
|
||
|
|
setIsDragging(false)
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div>
|
||
|
|
{/* Drag & Drop Zone */}
|
||
|
|
<div
|
||
|
|
onDrop={handleDrop}
|
||
|
|
onDragOver={handleDragOver}
|
||
|
|
onDragLeave={handleDragLeave}
|
||
|
|
className={`
|
||
|
|
border-2 border-dashed rounded-lg p-8 transition-all
|
||
|
|
${isDragging
|
||
|
|
? 'border-primary bg-primary/5'
|
||
|
|
: 'border-zinc-700 hover:border-zinc-600'
|
||
|
|
}
|
||
|
|
`}
|
||
|
|
>
|
||
|
|
<div className="flex flex-col items-center gap-2 text-center">
|
||
|
|
<Upload className={`w-8 h-8 ${isDragging ? 'text-primary' : 'text-zinc-500'}`} />
|
||
|
|
<p className="text-sm text-zinc-400">
|
||
|
|
{isDragging ? 'Drop files here' : 'Drag files here or click to browse'}
|
||
|
|
</p>
|
||
|
|
<p className="text-xs text-zinc-600">
|
||
|
|
Supports: Images, PDFs, Documents (max 10MB)
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* File Previews */}
|
||
|
|
{selectedFiles.length > 0 && (
|
||
|
|
<div className="mt-4 space-y-2">
|
||
|
|
{selectedFiles.map((file, index) => (
|
||
|
|
<FilePreviewCard
|
||
|
|
key={index}
|
||
|
|
file={file}
|
||
|
|
onRemove={() => onFileRemove(index)}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
// File Preview Card
|
||
|
|
function FilePreviewCard({ file, onRemove }: { file: File; onRemove: () => void }) {
|
||
|
|
const isImage = file.type.startsWith('image/')
|
||
|
|
const [preview, setPreview] = useState<string | null>(null)
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (isImage) {
|
||
|
|
const reader = new FileReader()
|
||
|
|
reader.onloadend = () => setPreview(reader.result as string)
|
||
|
|
reader.readAsDataURL(file)
|
||
|
|
}
|
||
|
|
}, [file, isImage])
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="flex items-center gap-3 p-3 bg-zinc-900 border border-zinc-800 rounded-lg">
|
||
|
|
{/* Icon or Image Preview */}
|
||
|
|
{isImage && preview ? (
|
||
|
|
<img src={preview} alt={file.name} className="w-12 h-12 object-cover rounded" />
|
||
|
|
) : (
|
||
|
|
<div className="w-12 h-12 bg-zinc-800 rounded flex items-center justify-center">
|
||
|
|
<File className="w-6 h-6 text-zinc-500" />
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* File Info */}
|
||
|
|
<div className="flex-1 min-w-0">
|
||
|
|
<p className="text-sm font-medium text-zinc-200 truncate">{file.name}</p>
|
||
|
|
<p className="text-xs text-zinc-500">{formatFileSize(file.size)}</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Remove Button */}
|
||
|
|
<button
|
||
|
|
onClick={onRemove}
|
||
|
|
className="p-1 hover:bg-zinc-800 rounded transition-colors"
|
||
|
|
>
|
||
|
|
<X className="w-4 h-4 text-zinc-500" />
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatFileSize(bytes: number): string {
|
||
|
|
if (bytes < 1024) return bytes + ' B'
|
||
|
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||
|
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 4. ⌨️ Advanced Keyboard Shortcuts
|
||
|
|
|
||
|
|
**ChatJS has a keyboard shortcuts modal.**
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// components/KeyboardShortcutsModal.tsx
|
||
|
|
import { useEffect, useState } from 'react'
|
||
|
|
|
||
|
|
export function KeyboardShortcutsModal() {
|
||
|
|
const [isOpen, setIsOpen] = useState(false)
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||
|
|
// Cmd+/ or Ctrl+/ to toggle
|
||
|
|
if ((e.metaKey || e.ctrlKey) && e.key === '/') {
|
||
|
|
e.preventDefault()
|
||
|
|
setIsOpen(prev => !prev)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
window.addEventListener('keydown', handleKeyDown)
|
||
|
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||
|
|
}, [])
|
||
|
|
|
||
|
|
if (!isOpen) return null
|
||
|
|
|
||
|
|
const shortcuts = [
|
||
|
|
{ keys: ['Cmd', 'N'], action: 'New chat' },
|
||
|
|
{ keys: ['Cmd', 'K'], action: 'Search conversations' },
|
||
|
|
{ keys: ['Cmd', 'L'], action: 'Go to library' },
|
||
|
|
{ keys: ['Cmd', 'M'], action: 'Go to neural map' },
|
||
|
|
{ keys: ['Cmd', '/'], action: 'Show keyboard shortcuts' },
|
||
|
|
{ keys: ['Escape'], action: 'Close modal' },
|
||
|
|
{ keys: ['Cmd', 'Enter'], action: 'Send message' },
|
||
|
|
]
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||
|
|
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6 max-w-md w-full">
|
||
|
|
<h2 className="text-xl font-bold text-zinc-100 mb-4">
|
||
|
|
Keyboard Shortcuts
|
||
|
|
</h2>
|
||
|
|
|
||
|
|
<div className="space-y-2">
|
||
|
|
{shortcuts.map((shortcut, i) => (
|
||
|
|
<div key={i} className="flex items-center justify-between py-2">
|
||
|
|
<span className="text-sm text-zinc-400">{shortcut.action}</span>
|
||
|
|
<div className="flex gap-1">
|
||
|
|
{shortcut.keys.map((key, j) => (
|
||
|
|
<kbd
|
||
|
|
key={j}
|
||
|
|
className="px-2 py-1 bg-zinc-800 border border-zinc-700 rounded text-xs font-mono text-zinc-300"
|
||
|
|
>
|
||
|
|
{key}
|
||
|
|
</kbd>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<button
|
||
|
|
onClick={() => setIsOpen(false)}
|
||
|
|
className="mt-6 w-full px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
|
||
|
|
>
|
||
|
|
Close
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 5. 🔄 Regenerate Message
|
||
|
|
|
||
|
|
**Add to chatStore.ts**:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
interface ChatState {
|
||
|
|
messages: Message[]
|
||
|
|
isTyping: boolean
|
||
|
|
sendMessage: (params: SendMessageParams) => Promise<void>
|
||
|
|
regenerateMessage: (messageId: string) => Promise<void> // NEW
|
||
|
|
clearMessages: () => void
|
||
|
|
}
|
||
|
|
|
||
|
|
export const useChatStore = create<ChatState>((set, get) => ({
|
||
|
|
// ... existing code ...
|
||
|
|
|
||
|
|
regenerateMessage: async (messageId: string) => {
|
||
|
|
const { messages } = get()
|
||
|
|
const messageIndex = messages.findIndex(m => m.id === messageId)
|
||
|
|
|
||
|
|
if (messageIndex === -1) return
|
||
|
|
|
||
|
|
// Find the last user message before this AI message
|
||
|
|
const userMessage = messages
|
||
|
|
.slice(0, messageIndex)
|
||
|
|
.reverse()
|
||
|
|
.find(m => m.role === 'user')
|
||
|
|
|
||
|
|
if (!userMessage) return
|
||
|
|
|
||
|
|
// Remove the AI message and everything after it
|
||
|
|
set({ messages: messages.slice(0, messageIndex) })
|
||
|
|
|
||
|
|
// Regenerate from user message
|
||
|
|
set({ isTyping: true })
|
||
|
|
|
||
|
|
try {
|
||
|
|
const res = await fetch('/api/chat', {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify({
|
||
|
|
message: userMessage.content,
|
||
|
|
// Use same model/rag settings as original
|
||
|
|
}),
|
||
|
|
})
|
||
|
|
|
||
|
|
const data = await res.json()
|
||
|
|
|
||
|
|
const newMessage: Message = {
|
||
|
|
id: Date.now().toString(),
|
||
|
|
role: 'assistant',
|
||
|
|
content: data.answer,
|
||
|
|
timestamp: new Date(),
|
||
|
|
sources: data.sources,
|
||
|
|
}
|
||
|
|
|
||
|
|
set(state => ({ messages: [...state.messages, newMessage] }))
|
||
|
|
} catch (err) {
|
||
|
|
console.error('Regenerate error:', err)
|
||
|
|
} finally {
|
||
|
|
set({ isTyping: false })
|
||
|
|
}
|
||
|
|
},
|
||
|
|
}))
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 6. 📱 Mobile Improvements
|
||
|
|
|
||
|
|
**ChatJS has excellent mobile UX.**
|
||
|
|
|
||
|
|
### Mobile-Specific Improvements:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// components/chat/MobileOptimizations.tsx
|
||
|
|
|
||
|
|
// 1. Virtual Keyboard Handling
|
||
|
|
useEffect(() => {
|
||
|
|
const handleResize = () => {
|
||
|
|
// Adjust viewport when keyboard opens
|
||
|
|
const vh = window.innerHeight * 0.01
|
||
|
|
document.documentElement.style.setProperty('--vh', `${vh}px`)
|
||
|
|
}
|
||
|
|
|
||
|
|
window.addEventListener('resize', handleResize)
|
||
|
|
handleResize()
|
||
|
|
|
||
|
|
return () => window.removeEventListener('resize', handleResize)
|
||
|
|
}, [])
|
||
|
|
|
||
|
|
// 2. Touch Gestures for Sidebar
|
||
|
|
const handleSwipe = (e: TouchEvent) => {
|
||
|
|
// Swipe from left edge to open sidebar
|
||
|
|
if (e.touches[0].clientX < 20) {
|
||
|
|
openSidebar()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 3. Prevent Zoom on Double-Tap
|
||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
||
|
|
|
||
|
|
// 4. Safe Area Insets (for iPhone notch)
|
||
|
|
<div className="pb-[env(safe-area-inset-bottom)]">
|
||
|
|
<ChatInput />
|
||
|
|
</div>
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 7. 🎨 Loading States
|
||
|
|
|
||
|
|
**ChatJS has skeleton loaders everywhere.**
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// components/ui/MessageSkeleton.tsx
|
||
|
|
export function MessageSkeleton() {
|
||
|
|
return (
|
||
|
|
<div className="flex gap-3 max-w-4xl px-6 py-4 animate-pulse">
|
||
|
|
<div className="w-8 h-8 rounded-full bg-zinc-800" />
|
||
|
|
<div className="flex-1 space-y-2">
|
||
|
|
<div className="h-3 bg-zinc-800 rounded w-1/4" />
|
||
|
|
<div className="h-4 bg-zinc-800 rounded w-3/4" />
|
||
|
|
<div className="h-4 bg-zinc-800 rounded w-2/3" />
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Usage
|
||
|
|
{isLoading ? (
|
||
|
|
<>
|
||
|
|
<MessageSkeleton />
|
||
|
|
<MessageSkeleton />
|
||
|
|
</>
|
||
|
|
) : (
|
||
|
|
messages.map(msg => <ChatMessage key={msg.id} message={msg} />)
|
||
|
|
)}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 8. 🔔 Better Error Handling
|
||
|
|
|
||
|
|
**ChatJS shows user-friendly errors.**
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// lib/error-handler.ts
|
||
|
|
export function handleChatError(error: unknown) {
|
||
|
|
console.error('Chat error:', error)
|
||
|
|
|
||
|
|
if (error instanceof Error) {
|
||
|
|
// Network errors
|
||
|
|
if (error.message.includes('Failed to fetch')) {
|
||
|
|
toast.error('Connection lost. Check your internet.')
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
// API errors
|
||
|
|
if (error.message.includes('429')) {
|
||
|
|
toast.error('Rate limit exceeded. Please wait a moment.')
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if (error.message.includes('401')) {
|
||
|
|
toast.error('Session expired. Please log in again.')
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Generic error
|
||
|
|
toast.error('Something went wrong. Please try again.')
|
||
|
|
}
|
||
|
|
|
||
|
|
// Usage in chatStore
|
||
|
|
catch (err) {
|
||
|
|
handleChatError(err)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 9. 💾 Local Storage Persistence
|
||
|
|
|
||
|
|
**ChatJS saves draft messages.**
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// hooks/useLocalStorage.ts
|
||
|
|
export function useLocalStorage<T>(key: string, initialValue: T) {
|
||
|
|
const [storedValue, setStoredValue] = useState<T>(() => {
|
||
|
|
try {
|
||
|
|
const item = window.localStorage.getItem(key)
|
||
|
|
return item ? JSON.parse(item) : initialValue
|
||
|
|
} catch (error) {
|
||
|
|
console.error(error)
|
||
|
|
return initialValue
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
const setValue = (value: T | ((val: T) => T)) => {
|
||
|
|
try {
|
||
|
|
const valueToStore = value instanceof Function ? value(storedValue) : value
|
||
|
|
setStoredValue(valueToStore)
|
||
|
|
window.localStorage.setItem(key, JSON.stringify(valueToStore))
|
||
|
|
} catch (error) {
|
||
|
|
console.error(error)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return [storedValue, setValue] as const
|
||
|
|
}
|
||
|
|
|
||
|
|
// Usage in ChatInput
|
||
|
|
export function ChatInput() {
|
||
|
|
const [draftInput, setDraftInput] = useLocalStorage('chat-draft', '')
|
||
|
|
|
||
|
|
const handleSend = () => {
|
||
|
|
// ... send logic
|
||
|
|
setDraftInput('') // Clear draft
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<textarea
|
||
|
|
value={draftInput}
|
||
|
|
onChange={(e) => setDraftInput(e.target.value)}
|
||
|
|
/>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 10. 🎯 Implementation Priority
|
||
|
|
|
||
|
|
**For Second Brain, implement in this order:**
|
||
|
|
|
||
|
|
1. ✅ **Smart Scroll** (useScrollAnchor) - Immediate UX improvement
|
||
|
|
2. ✅ **Message Actions** (Copy, Regenerate) - Professional feel
|
||
|
|
3. ✅ **Better File Upload** (Drag & drop with preview) - Core feature
|
||
|
|
4. ✅ **Keyboard Shortcuts Modal** - Power user feature
|
||
|
|
5. ⏳ **Loading Skeletons** - Polish
|
||
|
|
6. ⏳ **Error Handling** - Reliability
|
||
|
|
7. ⏳ **Draft Persistence** - Nice to have
|
||
|
|
8. ⏳ **Mobile Optimizations** - Once desktop is solid
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 📝 Summary
|
||
|
|
|
||
|
|
ChatJS demonstrates production-grade patterns that Second Brain should adopt:
|
||
|
|
|
||
|
|
- **Smart scrolling** prevents annoying auto-scroll
|
||
|
|
- **Message actions** make the chat feel professional
|
||
|
|
- **File previews** improve upload UX dramatically
|
||
|
|
- **Keyboard shortcuts** delight power users
|
||
|
|
- **Error handling** builds trust
|
||
|
|
- **Loading states** feel responsive
|
||
|
|
|
||
|
|
The key insight: **Small details compound into a delightful experience.**
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 🔗 Resources
|
||
|
|
|
||
|
|
- ChatJS Live Demo: https://chatjs.dev
|
||
|
|
- ChatJS Docs: https://chatjs.dev/docs
|
||
|
|
- ChatJS GitHub: https://github.com/FranciscoMoretti/chatjs
|
||
|
|
|
||
|
|
Use this as reference when polishing Second Brain! 🚀
|