feat(home): add AI chat interface with OpenCode integration
This commit is contained in:
@@ -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'
|
||||
|
||||
20
api/modules/opencode/index.ts
Normal file
20
api/modules/opencode/index.ts
Normal 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
|
||||
223
api/modules/opencode/routes.ts
Normal file
223
api/modules/opencode/routes.ts
Normal 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
|
||||
}
|
||||
@@ -7,6 +7,6 @@ export const OPENCODE_MODULE = defineModule({
|
||||
order: 15,
|
||||
version: '1.0.0',
|
||||
backend: {
|
||||
enabled: false,
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
|
||||
243
src/components/chat/Chat.tsx
Normal file
243
src/components/chat/Chat.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
9
src/components/chat/Greeting.tsx
Normal file
9
src/components/chat/Greeting.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
88
src/components/chat/Message.tsx
Normal file
88
src/components/chat/Message.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
103
src/components/chat/Messages.tsx
Normal file
103
src/components/chat/Messages.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
165
src/components/chat/MultimodalInput.tsx
Normal file
165
src/components/chat/MultimodalInput.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
25
src/components/chat/icons.tsx
Normal file
25
src/components/chat/icons.tsx
Normal 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,
|
||||
}
|
||||
132
src/components/chat/ui/AlertDialog.tsx
Normal file
132
src/components/chat/ui/AlertDialog.tsx
Normal 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,
|
||||
}
|
||||
52
src/components/chat/ui/Button.tsx
Normal file
52
src/components/chat/ui/Button.tsx
Normal 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
54
src/hooks/useMessages.ts
Normal 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
6
src/lib/utils/cn.ts
Normal 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))
|
||||
}
|
||||
@@ -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
7
src/lib/utils/uuid.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user