feat(home): add AI chat interface with OpenCode integration

This commit is contained in:
2026-03-14 21:53:52 +08:00
parent 88d42b37a6
commit cbc1af7348
17 changed files with 1175 additions and 3 deletions

View File

@@ -56,6 +56,13 @@ async function getStaticModules(): Promise<ApiModule[]> {
console.warn('[ModuleLoader] Failed to load remote module:', e)
}
try {
const { createOpencodeModule } = await import('./opencode/index.js')
modules.push(createOpencodeModule())
} catch (e) {
console.warn('[ModuleLoader] Failed to load opencode module:', e)
}
modules.sort((a, b) => {
const orderA = a.metadata.order ?? 0
const orderB = b.metadata.order ?? 0
@@ -74,3 +81,4 @@ export * from './pydemos/index.js'
export * from './document-parser/index.js'
export * from './ai/index.js'
export * from './remote/index.js'
export * from './opencode/index.js'

View File

@@ -0,0 +1,20 @@
import type { Router } from 'express'
import type { ServiceContainer } from '../../infra/container.js'
import { createApiModule } from '../../infra/createModule.js'
import { OPENCODE_MODULE } from '../../../shared/modules/opencode/index.js'
import { createOpencodeRoutes } from './routes.js'
export * from './routes.js'
export const createOpencodeModule = () => {
return createApiModule({
...OPENCODE_MODULE,
version: OPENCODE_MODULE.version || '1.0.0',
}, {
routes: (_container: ServiceContainer): Router => {
return createOpencodeRoutes()
},
})
}
export default createOpencodeModule

View File

@@ -0,0 +1,223 @@
import { Router } from 'express'
import { createOpencodeClient } from '@opencode-ai/sdk'
const OPENCODE_URL = 'http://localhost:4096'
async function getClient() {
console.log('[OpenCode] Creating client for URL:', OPENCODE_URL)
return createOpencodeClient({
baseUrl: OPENCODE_URL,
})
}
export function createOpencodeRoutes(): Router {
const router = Router()
router.get('/', async (req, res) => {
const action = req.query.action as string | undefined
try {
const client = await getClient()
if (action === 'list-sessions') {
const response = await client.session.list()
const sessions = (response.data || []).map((session: { id: string; title?: string; createdAt?: string }) => ({
id: session.id,
title: session.title || 'New Chat',
createdAt: session.createdAt ? new Date(session.createdAt).getTime() : Date.now(),
}))
res.json({ sessions })
return
}
res.status(400).json({ error: 'Unknown action' })
} catch (error: unknown) {
console.error('API error:', error)
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
res.status(500).json({ error: errorMessage })
}
})
router.post('/', async (req, res) => {
try {
const body = req.body
const { action, sessionId, title, text, attachments } = body
console.log('[OpenCode] Received action:', action, 'sessionId:', sessionId)
const client = await getClient()
console.log('[OpenCode] Client created successfully')
if (action === 'create-session') {
try {
const response = await client.session.create({
body: { title: title || 'New Chat' },
})
if (response.error) {
res.status(500).json({ error: 'OpenCode error', details: response.error })
return
}
const sessionData = response.data as { id: string; title?: string; createdAt?: string } | undefined
if (sessionData) {
res.json({
session: {
id: sessionData.id,
title: sessionData.title,
createdAt: sessionData.createdAt ? new Date(sessionData.createdAt).getTime() : Date.now(),
},
})
return
}
res.status(500).json({ error: 'Failed to create session - no data' })
return
} catch (e: unknown) {
console.error('Create session exception:', e)
const errorMessage = e instanceof Error ? e.message : 'Unknown error'
res.status(500).json({ error: errorMessage, exception: true })
return
}
}
if (action === 'delete-session') {
const response = await client.session.delete({
path: { id: sessionId },
})
res.json({ success: response.data })
return
}
if (action === 'get-session') {
const response = await client.session.get({
path: { id: sessionId },
})
if (response.data) {
res.json({
title: response.data.title || 'New Chat',
})
return
}
res.status(404).json({ error: 'Session not found' })
return
}
if (action === 'get-messages') {
const limit = body.limit || 20
const response = await client.session.messages({
path: { id: sessionId },
query: { limit }
})
const messages = (response.data || []).map((item: { info?: { id?: string; role?: string; content?: string; createdAt?: string }; content?: string; parts?: unknown[]; id?: string; role?: string }) => {
let content = item.info?.content || item.content || ''
const parts = item.parts as Array<{ type: string; text?: string }> | undefined
if (parts && parts.length > 0) {
const textParts = parts
.filter((p) => p.type === 'text')
.map((p) => p.text)
.join('')
if (textParts) {
content = textParts
}
}
return {
id: item.info?.id || item.id,
role: item.info?.role || item.role,
content: content,
parts: item.parts || [],
createdAt: item.info?.createdAt,
}
})
const hasMore = messages.length >= limit
res.json({ messages, hasMore })
return
}
if (action === 'prompt') {
const parts: Array<{ type: 'text'; text: string } | { type: 'file'; url: string; name: string; mime: string }> = []
if (attachments && attachments.length > 0) {
for (const att of attachments as Array<{ url: string; name: string; mediaType: string }>) {
parts.push({
type: 'file',
url: att.url,
name: att.name,
mime: att.mediaType,
})
}
}
parts.push({ type: 'text', text })
try {
const response = await client.session.prompt({
path: { id: sessionId },
body: { parts },
})
if (response.data) {
res.json({
message: {
id: (response.data.info as { id?: string })?.id || crypto.randomUUID(),
role: (response.data.info as { role?: string })?.role || 'assistant',
content: '',
parts: response.data.parts || [],
createdAt: new Date().toISOString(),
},
})
return
}
res.status(500).json({ error: 'Failed to get response', details: response.error })
return
} catch (e: unknown) {
console.error('Prompt exception:', e)
const errorMessage = e instanceof Error ? e.message : 'Unknown error'
res.status(500).json({ error: errorMessage })
return
}
}
if (action === 'summarize-session') {
try {
await client.session.summarize({
path: { id: sessionId },
})
const response = await client.session.get({
path: { id: sessionId },
})
if (response.data) {
res.json({
title: response.data.title || 'New Chat',
})
return
}
res.status(500).json({ error: 'Failed to get session' })
return
} catch (e: unknown) {
console.error('Summarize error:', e)
const errorMessage = e instanceof Error ? e.message : 'Unknown error'
res.status(500).json({ error: errorMessage })
return
}
}
res.status(400).json({ error: 'Unknown action' })
} catch (error: unknown) {
console.error('API error:', error)
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
res.status(500).json({ error: errorMessage })
}
})
return router
}

View File

@@ -7,6 +7,6 @@ export const OPENCODE_MODULE = defineModule({
order: 15,
version: '1.0.0',
backend: {
enabled: false,
enabled: true,
},
})

View File

@@ -0,0 +1,243 @@
import { useRef, useState, useCallback, useEffect } from 'react'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from './ui/AlertDialog'
import { Messages } from './Messages'
import { MultimodalInput } from './MultimodalInput'
import { generateUUID } from '@/lib/utils'
import { Eraser } from 'lucide-react'
type Status = 'submitted' | 'streaming' | 'ready' | 'error'
interface Attachment {
url: string
name: string
contentType: string
}
interface ChatMessage {
id: string
role: 'user' | 'assistant' | 'system'
content: string
parts: Array<{ type: string; text?: string }>
createdAt?: Date
}
const OPENCODE_API = '/api/opencode'
const INITIAL_MESSAGE_LIMIT = 20
export function Chat({
id,
initialMessages,
onClear,
}: {
id: string
initialMessages: ChatMessage[]
onClear?: () => void
}) {
const messagesEndRef = useRef<HTMLDivElement>(null)
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages)
const [hasMoreMessages, setHasMoreMessages] = useState(false)
const [input, setInput] = useState<string>('')
const [showCreditCardAlert, setShowCreditCardAlert] = useState(false)
const [status, setStatus] = useState<Status>('ready')
const [attachments, setAttachments] = useState<Attachment[]>([])
const [showInputAtBottom, setShowInputAtBottom] = useState(false)
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'auto' })
}, [])
const loadMessages = useCallback(async () => {
try {
const res = await fetch(OPENCODE_API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'get-messages',
sessionId: id,
limit: INITIAL_MESSAGE_LIMIT
}),
})
if (res.ok) {
const data = await res.json()
const newMessages = data.messages || []
setMessages(newMessages)
setHasMoreMessages(data.hasMore ?? newMessages.length >= INITIAL_MESSAGE_LIMIT)
setTimeout(scrollToBottom, 100)
}
} catch (error) {
console.error('Failed to load messages:', error)
}
}, [id, scrollToBottom])
const loadMoreMessages = useCallback(async () => {
if (messages.length === 0) return
const currentCount = messages.length
const newLimit = currentCount + INITIAL_MESSAGE_LIMIT
try {
const res = await fetch(OPENCODE_API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'get-messages',
sessionId: id,
limit: newLimit
}),
})
if (res.ok) {
const data = await res.json()
const newMessages = data.messages || []
setMessages(prev => {
const existingIds = new Set(prev.map(m => m.id))
const uniqueNew = newMessages.filter((m: ChatMessage) => !existingIds.has(m.id))
return [...uniqueNew, ...prev]
})
setHasMoreMessages(data.hasMore ?? newMessages.length >= newLimit)
}
} catch (error) {
console.error('Failed to load more messages:', error)
}
}, [id, messages])
useEffect(() => {
if (id) {
loadMessages()
}
}, [id, loadMessages])
const handleSendMessage = useCallback(async (text: string, atts: Attachment[]) => {
const userMessage: ChatMessage = {
id: generateUUID(),
role: 'user',
content: text,
parts: [],
createdAt: new Date(),
}
setMessages(prev => [...prev, userMessage])
setStatus('submitted')
try {
const res = await fetch(OPENCODE_API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'prompt',
sessionId: id,
text,
attachments: atts.map(a => ({
url: a.url,
name: a.name,
mediaType: a.contentType,
})),
}),
})
if (!res.ok) {
throw new Error(await res.text())
}
const data = await res.json()
if (data.message) {
setMessages(prev => [...prev, data.message])
setShowInputAtBottom(true)
}
setStatus('ready')
} catch (error: unknown) {
console.error('Send message error:', error)
setStatus('error')
const errorMessage = error instanceof Error ? error.message : 'Failed to send message'
if (errorMessage.includes('credit card')) {
setShowCreditCardAlert(true)
}
}
}, [id])
const handleStop = useCallback(() => {
setStatus('ready')
}, [])
return (
<>
<div className="overscroll-behavior-contain flex h-full min-w-0 touch-pan-y flex-col bg-background">
{onClear && messages.length > 0 && (
<div className="absolute top-4 right-4 z-10">
<button
onClick={onClear}
className="flex items-center rounded-md bg-primary p-2 text-primary-foreground hover:bg-primary/90"
>
<Eraser className="h-4 w-4" />
</button>
</div>
)}
<Messages
messages={messages}
status={status}
hasMoreMessages={hasMoreMessages}
loadMoreMessages={loadMoreMessages}
/>
<div ref={messagesEndRef} />
<div
className={`mx-auto flex w-full max-w-4xl gap-2 px-2 pb-3 transition-all duration-300 md:px-4 md:pb-4 ${
showInputAtBottom || messages.length > 0
? 'sticky bottom-0'
: 'absolute bottom-[40%] left-0 right-0'
}`}
>
<MultimodalInput
attachments={attachments}
chatId={id}
input={input}
messages={messages}
sendMessage={handleSendMessage}
setAttachments={setAttachments}
setInput={setInput}
status={status}
stop={handleStop}
/>
</div>
</div>
<AlertDialog
onOpenChange={setShowCreditCardAlert}
open={showCreditCardAlert}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>API </AlertDialogTitle>
<AlertDialogDescription>
AI API
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setShowCreditCardAlert(false)
}}
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -0,0 +1,9 @@
export function Greeting() {
return (
<div className="mx-auto mt-64 max-w-2xl px-4 text-center">
<h1 className="text-3xl font-bold tracking-tight">
</h1>
</div>
)
}

View File

@@ -0,0 +1,88 @@
import { memo } from 'react'
import { cn } from '@/lib/utils'
import { ArrowDownIcon, SparklesIcon } from 'lucide-react'
interface MessageProps {
message: {
id: string
role: 'user' | 'assistant' | 'system'
content: string
parts?: Array<{ type: string; text?: string }>
}
isLoading?: boolean
}
function PureMessage({ message, isLoading }: MessageProps) {
const isUser = message.role === 'user'
return (
<div
className={cn(
'flex w-full gap-4 px-4',
isUser ? 'justify-end' : 'justify-start'
)}
>
<div className={cn(
'flex max-w-[80%] flex-col gap-1 rounded-lg p-4',
isUser ? 'bg-primary text-primary-foreground' : 'bg-muted/50'
)}>
{message.parts?.map((part: { type: string; text?: string }, i: number) => {
const safeString = (val: unknown): string => {
if (val === null || val === undefined) return ''
if (typeof val === 'string') return val
if (typeof val === 'object') return JSON.stringify(val, null, 2)
return String(val)
}
if (part.type === 'text') {
return (
<div key={i} className={cn('whitespace-pre-wrap text-sm', isUser && 'text-primary-foreground')}>
{safeString(part.text)}
</div>
)
}
if (part.type === 'reasoning') {
return (
<div key={i} className="rounded border border-gray-500/30 bg-gray-500/10 p-2 text-xs text-gray-600 dark:text-gray-400">
<div className="flex items-center gap-1 font-semibold">
<SparklesIcon className="size-3" />
Thinking
</div>
<div className="mt-1 whitespace-pre-wrap">{safeString(part.text)}</div>
</div>
)
}
return null
})}
{(!message.parts || message.parts.length === 0) && message.content && (
<div className={cn('whitespace-pre-wrap text-sm', isUser && 'text-primary-foreground')}>{message.content}</div>
)}
{isLoading && (
<div className={cn('flex items-center gap-1 text-xs', isUser ? 'text-primary-foreground/70' : 'text-muted-foreground')}>
<span className="animate-pulse">Thinking</span>
<ArrowDownIcon className="size-3 animate-bounce" />
</div>
)}
</div>
</div>
)
}
export const PreviewMessage = memo(PureMessage)
export function ThinkingMessage() {
return (
<div className="flex w-full gap-4 px-4">
<div className="flex flex-col gap-1 rounded-lg bg-muted/50 p-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="animate-pulse">Thinking</span>
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground" />
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:0.2s]" />
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:0.4s]" />
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,103 @@
import { ArrowDownIcon, Loader2Icon } from 'lucide-react'
import { useState, useEffect } from 'react'
import { useMessages } from '@/hooks/useMessages'
import { Greeting } from './Greeting'
import { PreviewMessage, ThinkingMessage } from './Message'
type Status = 'submitted' | 'streaming' | 'ready' | 'error'
interface ChatMessage {
id: string
role: 'user' | 'assistant' | 'system'
content: string
parts: Array<{ type: string; text?: string }>
createdAt?: Date
}
type MessagesProps = {
messages: ChatMessage[]
status: Status
hasMoreMessages?: boolean
loadMoreMessages?: () => void
}
export function Messages({ messages, status, hasMoreMessages, loadMoreMessages }: MessagesProps) {
const [isLoadingMore, setIsLoadingMore] = useState(false)
const {
containerRef: messagesContainerRef,
endRef: messagesEndRef,
isAtBottom,
scrollToBottom,
} = useMessages({ status })
useEffect(() => {
if (messages.length > 0) {
const container = messagesContainerRef.current
if (container) {
container.scrollTop = container.scrollHeight
}
}
}, [messages, messagesContainerRef])
const handleLoadMore = async () => {
if (!loadMoreMessages || isLoadingMore) return
setIsLoadingMore(true)
await loadMoreMessages()
setIsLoadingMore(false)
}
return (
<div className="relative flex-1 bg-background">
<div
className="absolute inset-0 touch-pan-y overflow-y-auto bg-background"
ref={messagesContainerRef}
>
<div className="mx-auto flex min-w-0 max-w-4xl flex-col gap-4 px-2 py-4 md:gap-6 md:px-4">
{hasMoreMessages && (
<div className="flex justify-center py-2">
<button
onClick={handleLoadMore}
disabled={isLoadingMore}
className="flex items-center gap-2 rounded-full border bg-background px-4 py-2 text-sm text-muted-foreground hover:bg-muted disabled:opacity-50"
>
{isLoadingMore && <Loader2Icon className="size-4 animate-spin" />}
{isLoadingMore ? 'Loading...' : 'Load more messages'}
</button>
</div>
)}
{messages.length === 0 && <Greeting />}
{messages.map((message, index) => (
<PreviewMessage
key={message.id}
message={message}
isLoading={status === 'streaming' && messages.length - 1 === index}
/>
))}
{status === 'submitted' && <ThinkingMessage />}
<div
className="min-h-[24px] min-w-[24px] shrink-0"
ref={messagesEndRef}
/>
</div>
</div>
<button
aria-label="Scroll to bottom"
className={`absolute bottom-4 left-1/2 z-10 -translate-x-1/2 rounded-full border bg-background p-2 shadow-lg transition-all hover:bg-muted ${
isAtBottom
? 'pointer-events-none scale-0 opacity-0'
: 'pointer-events-auto scale-100 opacity-100'
}`}
onClick={() => scrollToBottom('smooth')}
type="button"
>
<ArrowDownIcon className="size-4" />
</button>
</div>
)
}

View File

@@ -0,0 +1,165 @@
import { useCallback, useEffect, useRef } from 'react'
import { cn } from '@/lib/utils'
import { ArrowUpIcon, StopIcon } from './icons'
import { Button } from './ui/Button'
type Status = 'submitted' | 'streaming' | 'ready' | 'error'
interface Attachment {
url: string
name: string
contentType: string
}
interface ChatMessage {
id: string
role: 'user' | 'assistant' | 'system'
content: string
parts: unknown[]
createdAt?: Date
}
interface MultimodalInputProps {
chatId: string
input: string
setInput: (value: string) => void
status: Status
stop: () => void
attachments: Attachment[]
setAttachments: (attachments: Attachment[]) => void
messages: ChatMessage[]
sendMessage: (text: string, attachments: Attachment[]) => Promise<void>
className?: string
}
export function MultimodalInput({
chatId,
input,
setInput,
status,
stop,
attachments,
setAttachments,
sendMessage,
className,
}: MultimodalInputProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null)
const adjustHeight = useCallback(() => {
if (textareaRef.current) {
textareaRef.current.style.height = '44px'
}
}, [])
useEffect(() => {
if (textareaRef.current) {
adjustHeight()
}
}, [adjustHeight])
const resetHeight = useCallback(() => {
if (textareaRef.current) {
textareaRef.current.style.height = '44px'
}
}, [])
const handleInput = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
setInput(event.target.value)
}
const submitForm = useCallback(() => {
if (!input.trim() && attachments.length === 0) return
sendMessage(input, attachments)
setAttachments([])
resetHeight()
setInput('')
textareaRef.current?.focus()
}, [input, setInput, attachments, sendMessage, setAttachments, resetHeight])
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
if (!input.trim() && attachments.length === 0) {
return
}
if (status !== 'ready') {
console.warn('Please wait for the model to finish its response!')
} else {
submitForm()
}
}
}
useEffect(() => {
if (textareaRef.current) {
const target = textareaRef.current
target.style.height = '44px'
const scrollHeight = target.scrollHeight
if (scrollHeight > 44) {
target.style.height = `${Math.min(scrollHeight, 200)}px`
}
}
}, [input])
return (
<div className={cn('relative flex w-full flex-col gap-4', className)}>
<div className="relative flex w-full items-end gap-2 rounded-xl border border-border bg-background p-3 shadow-xs transition-all duration-200 focus-within:border-border hover:border-muted-foreground/50">
{attachments.length > 0 && (
<div className="flex flex-row items-end gap-2 overflow-x-scroll">
{attachments.map((attachment) => (
<div
key={attachment.url}
className="relative flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs"
>
<span className="max-w-[100px] truncate">{attachment.name}</span>
<button
className="text-muted-foreground hover:text-foreground"
onClick={() => {
setAttachments(attachments.filter((a) => a.url !== attachment.url))
}}
>
×
</button>
</div>
))}
</div>
)}
<textarea
ref={textareaRef}
className="max-h-[200px] min-h-[44px] w-full resize-none border-0 bg-transparent p-2 text-base outline-none placeholder:text-muted-foreground [-ms-overflow-style:none] [scrollbar-width:none]"
style={{ height: '44px' }}
value={input}
onChange={handleInput}
onKeyDown={handleKeyDown}
placeholder="发送消息..."
rows={1}
/>
<div className="flex items-center gap-1">
{status === 'submitted' ? (
<Button
className="size-8 rounded-full bg-foreground p-1 text-background transition-colors duration-200 hover:bg-foreground/90"
onClick={() => {
stop()
}}
>
<StopIcon size={14} />
</Button>
) : (
<Button
className="size-8 rounded-full bg-primary text-primary-foreground transition-colors duration-200 hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground"
disabled={!input.trim() && attachments.length === 0}
onClick={submitForm}
>
<ArrowUpIcon size={14} />
</Button>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,25 @@
import {
ArrowUp,
ArrowDown,
Paperclip,
StopCircle,
ChevronDown,
Check,
Plus,
Trash,
PanelLeftClose,
PanelLeft,
} from 'lucide-react'
export {
ArrowUp as ArrowUpIcon,
ArrowDown as ArrowDownIcon,
Paperclip as PaperclipIcon,
StopCircle as StopIcon,
ChevronDown as ChevronDownIcon,
Check as CheckIcon,
Plus as PlusIcon,
Trash as TrashIcon,
PanelLeftClose as PanelLeftCloseIcon,
PanelLeft as PanelLeftOpenIcon,
}

View File

@@ -0,0 +1,132 @@
import * as React from 'react'
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
import { cn } from '@/lib/utils'
import { buttonVariants } from './Button'
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className
)}
ref={ref}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col space-y-2 text-center sm:text-left', className)}
{...props}
/>
)
AlertDialogHeader.displayName = 'AlertDialogHeader'
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = 'AlertDialogFooter'
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
className={cn('text-lg font-semibold', className)}
ref={ref}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
className={cn('text-sm text-muted-foreground', className)}
ref={ref}
{...props}
/>
))
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
ref={ref}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
className={cn(
buttonVariants({ variant: 'outline' }),
'mt-2 sm:mt-0',
className
)}
ref={ref}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@@ -0,0 +1,52 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = 'Button'
export { Button, buttonVariants }

54
src/hooks/useMessages.ts Normal file
View File

@@ -0,0 +1,54 @@
import { useRef, useEffect, useState, useCallback } from 'react'
type Status = 'submitted' | 'streaming' | 'ready' | 'error'
interface UseMessagesOptions {
status: Status
}
export function useMessages({ status }: UseMessagesOptions) {
const [hasSentMessage, setHasSentMessage] = useState(false)
const [isAtBottom, setIsAtBottom] = useState(true)
const messagesContainerRef = useRef<HTMLDivElement>(null)
const messagesEndRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (status === 'submitted' || status === 'streaming') {
setHasSentMessage(true)
}
}, [status])
useEffect(() => {
const container = messagesContainerRef.current
if (!container) return
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = container
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
setIsAtBottom(distanceFromBottom <= 100)
}
handleScroll()
container.addEventListener('scroll', handleScroll)
return () => container.removeEventListener('scroll', handleScroll)
}, [])
const scrollToBottom = useCallback((behavior: 'smooth' | 'auto' = 'smooth') => {
messagesEndRef.current?.scrollIntoView({ behavior })
}, [])
useEffect(() => {
if (status === 'ready' || status === 'submitted') {
scrollToBottom('auto')
}
}, [status, scrollToBottom])
return {
containerRef: messagesContainerRef,
endRef: messagesEndRef,
isAtBottom,
scrollToBottom,
hasSentMessage,
}
}

6
src/lib/utils/cn.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -3,3 +3,5 @@ export { resolveImagePath } from './images'
export { stripMarkdown, generateHeadingId, extractLocalImagePathsFromMarkdown } from './markdown'
export type { TOCItem } from './markdown'
export { generatePrintHtml } from './print'
export { cn } from './cn'
export { generateUUID } from './uuid'

7
src/lib/utils/uuid.ts Normal file
View File

@@ -0,0 +1,7 @@
export function generateUUID(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0
const v = c === 'x' ? r : (r & 0x3) | 0x8
return v.toString(16)
})
}

View File

@@ -1,7 +1,42 @@
import React from 'react'
import React, { useState, useEffect } from 'react'
import { Chat } from '@/components/chat/Chat'
const OPENCODE_API = '/api/opencode'
export const HomePage = () => {
const [sessionId, setSessionId] = useState<string | null>(null)
const createNewSession = async () => {
try {
const res = await fetch(OPENCODE_API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'create-session', title: 'New Chat' }),
})
const data = await res.json()
if (data.session?.id) {
setSessionId(data.session.id)
}
} catch (e) {
console.error('Failed to create session:', e)
}
}
useEffect(() => {
createNewSession()
}, [])
if (!sessionId) {
return (
<div className="h-full w-full flex items-center justify-center">
<div className="text-muted-foreground">Loading...</div>
</div>
)
}
return (
<div className="h-full w-full" />
<div className="h-full w-full">
<Chat key={sessionId} id={sessionId} initialMessages={[]} onClear={createNewSession} />
</div>
)
}