mirror of
https://github.com/linsa-io/linsa.git
synced 2026-04-10 18:57:02 +02:00
.
This commit is contained in:
282
packages/web/src/components/chat/ChatInput.tsx
Normal file
282
packages/web/src/components/chat/ChatInput.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
import { useState, useRef, useEffect, useMemo } from "react"
|
||||
import { ChevronDown } from "lucide-react"
|
||||
|
||||
const AVAILABLE_MODELS = [
|
||||
{
|
||||
id: "deepseek/deepseek-chat-v3-0324",
|
||||
name: "DeepSeek V3",
|
||||
provider: "DeepSeek",
|
||||
},
|
||||
{
|
||||
id: "google/gemini-2.0-flash-001",
|
||||
name: "Gemini 2.0 Flash",
|
||||
provider: "Google",
|
||||
},
|
||||
{
|
||||
id: "anthropic/claude-sonnet-4",
|
||||
name: "Claude Sonnet 4",
|
||||
provider: "Anthropic",
|
||||
},
|
||||
{ id: "openai/gpt-4o", name: "GPT-4o", provider: "OpenAI" },
|
||||
] as const
|
||||
|
||||
export type ModelId = (typeof AVAILABLE_MODELS)[number]["id"]
|
||||
|
||||
function ModelSparkle() {
|
||||
return (
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clipPath="url(#clip0_1212_3035)">
|
||||
<path
|
||||
d="M16 8.016C13.9242 8.14339 11.9666 9.02545 10.496 10.496C9.02545 11.9666 8.14339 13.9242 8.016 16H7.984C7.85682 13.9241 6.97483 11.9664 5.5042 10.4958C4.03358 9.02518 2.07588 8.14318 0 8.016L0 7.984C2.07588 7.85682 4.03358 6.97483 5.5042 5.5042C6.97483 4.03358 7.85682 2.07588 7.984 0L8.016 0C8.14339 2.07581 9.02545 4.03339 10.496 5.50397C11.9666 6.97455 13.9242 7.85661 16 7.984V8.016Z"
|
||||
fill="url(#paint0_radial_1212_3035)"
|
||||
style={{ stopColor: "#9168C0", stopOpacity: 1 }}
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<radialGradient
|
||||
id="paint0_radial_1212_3035"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="1"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(1.588 6.503) rotate(18.6832) scale(17.03 136.421)"
|
||||
>
|
||||
<stop
|
||||
offset="0.067"
|
||||
stopColor="#9168C0"
|
||||
style={{ stopColor: "#9168C0", stopOpacity: 1 }}
|
||||
/>
|
||||
<stop
|
||||
offset="0.343"
|
||||
stopColor="#5684D1"
|
||||
style={{ stopColor: "#5684D1", stopOpacity: 1 }}
|
||||
/>
|
||||
<stop
|
||||
offset="0.672"
|
||||
stopColor="#1BA1E3"
|
||||
style={{ stopColor: "#1BA1E3", stopOpacity: 1 }}
|
||||
/>
|
||||
</radialGradient>
|
||||
<clipPath id="clip0_1212_3035">
|
||||
<rect
|
||||
width="16"
|
||||
height="16"
|
||||
fill="white"
|
||||
style={{ fill: "white", fillOpacity: 1 }}
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
interface ModelSelectProps {
|
||||
selectedModel: ModelId
|
||||
onChange: (model: ModelId) => void
|
||||
}
|
||||
|
||||
function ModelSelect({ selectedModel, onChange }: ModelSelectProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const selected = useMemo(
|
||||
() => AVAILABLE_MODELS.find((m) => m.id === selectedModel),
|
||||
[selectedModel],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false)
|
||||
}
|
||||
window.addEventListener("mousedown", handleClickOutside)
|
||||
window.addEventListener("keydown", handleEscape)
|
||||
return () => {
|
||||
window.removeEventListener("mousedown", handleClickOutside)
|
||||
window.removeEventListener("keydown", handleEscape)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative select-none">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
className="flex items-center gap-2 rounded-xl border border-white/8 bg-linear-to-b from-[#2d2e39] via-[#1e1f28] to-[#1a1b24] px-3 py-1.5 text-left shadow-inner shadow-black/40 hover:border-white/14 transition-colors min-w-[170px]"
|
||||
>
|
||||
<ModelSparkle />
|
||||
<div className="flex flex-col leading-tight">
|
||||
<span className="text-sm font-semibold text-white">
|
||||
{selected?.name ?? "Choose model"}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={`ml-auto h-4 w-4 text-neutral-500 transition-transform ${open ? "rotate-180 text-neutral-300" : ""}`}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute left-0 bottom-full z-20 my-1 max-w-52 overflow-hidden rounded-2xl border border-white/5 bg-[#0b0c11]/95 backdrop-blur-lg box-shadow-xl">
|
||||
<div className="px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.12em] text-neutral-500">
|
||||
Models
|
||||
</div>
|
||||
<div className="flex flex-col py-1">
|
||||
{AVAILABLE_MODELS.map((model) => {
|
||||
const isActive = model.id === selectedModel
|
||||
return (
|
||||
<button
|
||||
key={model.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(model.id)
|
||||
setOpen(false)
|
||||
}}
|
||||
className={`flex items-center hover:bg-white/2 rounded-lg gap-3 px-3 py-1 text-sm transition-colors ${isActive ? "text-white" : "text-white/65 hover:text-white"}`}
|
||||
>
|
||||
<ModelSparkle />
|
||||
<div className="flex flex-col cursor-pointer items-start">
|
||||
<span className="text-[13px] font-semibold leading-tight">
|
||||
{model.name}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ChatInputProps {
|
||||
onSubmit: (message: string) => void
|
||||
isLoading: boolean
|
||||
selectedModel: ModelId
|
||||
onModelChange: (model: ModelId) => void
|
||||
limitReached?: boolean
|
||||
remainingRequests?: number
|
||||
}
|
||||
|
||||
export function ChatInput({
|
||||
onSubmit,
|
||||
isLoading,
|
||||
selectedModel,
|
||||
onModelChange,
|
||||
limitReached = false,
|
||||
remainingRequests,
|
||||
}: ChatInputProps) {
|
||||
const [message, setMessage] = useState("")
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (message.trim() && !isLoading && !limitReached) {
|
||||
onSubmit(message)
|
||||
setMessage("")
|
||||
textareaRef.current?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSubmit(e)
|
||||
}
|
||||
}
|
||||
|
||||
const isDisabled = isLoading || limitReached
|
||||
const sendDisabled = !message.trim() || isDisabled
|
||||
|
||||
return (
|
||||
<div className="px-4 pb-4">
|
||||
{limitReached && (
|
||||
<div className="max-w-4xl mx-auto mb-3 overflow-hidden rounded-[26px] border border-white/8 bg-linear-to-b from-[#0c1c27] via-[#0a1923] to-[#08141d] shadow-[0_18px_60px_rgba(0,0,0,0.4)]">
|
||||
<div className="flex items-center gap-4 px-6 py-4">
|
||||
<div className="flex-1">
|
||||
<div className="text-lg font-semibold text-white">
|
||||
Sign in to continue chatting
|
||||
</div>
|
||||
<div className="text-sm text-neutral-300">
|
||||
Get more requests with a free account
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href="/login"
|
||||
className="shrink-0 rounded-2xl px-4 py-2.5 text-sm font-semibold text-white bg-linear-to-b from-[#1ab8b0] via-[#0a8f8b] to-[#0ba58a] shadow-[0_16px_48px_rgba(0,0,0,0.45)] transition hover:brightness-110"
|
||||
>
|
||||
Sign in
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div
|
||||
className={`relative min-h-[6.5em] max-w-4xl mx-auto rounded-2xl border border-neutral-700/30 bg-[#181921d9]/90 px-3 p-4 backdrop-blur-lg transition-all hover:border-neutral-600/40 ${limitReached ? "opacity-80" : ""}`}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
autoFocus
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask anything..."
|
||||
className="w-full max-h-32 min-h-[24px] resize-none overflow-y-auto bg-transparent text-[15px] text-neutral-100 placeholder-neutral-500 focus:outline-none disabled:opacity-60 scrollbar-hide [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]"
|
||||
rows={3}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
|
||||
{limitReached && (
|
||||
<div className="pointer-events-none absolute inset-0 rounded-2xl bg-linear-to-b from-black/25 via-transparent to-black/30" />
|
||||
)}
|
||||
|
||||
<div className="absolute bottom-0 left-0 p-2 w-full flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ModelSelect
|
||||
selectedModel={selectedModel}
|
||||
onChange={onModelChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm text-neutral-400">
|
||||
{typeof remainingRequests === "number" && (
|
||||
<span className="text-white/70">
|
||||
{remainingRequests} requests remaining
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={sendDisabled}
|
||||
className="flex py-2 px-3 cursor-pointer items-center justify-center text-white rounded-[10px] bg-linear-to-b from-[#5b9fbf] via-[#0d817f] to-[#069d7f] transition-colors duration-300 hover:bg-cyan-700 hover:text-neutral-100 disabled:opacity-40 disabled:cursor-not-allowed shadow-lg box-shadow-xl"
|
||||
aria-label="Send message"
|
||||
>
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M0.184054 0.112806C0.258701 0.0518157 0.349387 0.0137035 0.445193 0.00305845C0.540999 -0.00758662 0.637839 0.00968929 0.724054 0.0528059L15.7241 7.55281C15.8073 7.59427 15.8773 7.65812 15.9262 7.73717C15.9751 7.81622 16.001 7.90734 16.001 8.00031C16.001 8.09327 15.9751 8.18439 15.9262 8.26344C15.8773 8.34249 15.8073 8.40634 15.7241 8.44781L0.724054 15.9478C0.637926 15.9909 0.541171 16.0083 0.445423 15.9977C0.349675 15.9872 0.25901 15.9492 0.184331 15.8884C0.109651 15.8275 0.0541361 15.7464 0.0244608 15.6548C-0.00521444 15.5631 -0.0077866 15.4649 0.0170539 15.3718L1.98305 8.00081L0.0170539 0.629806C-0.00790602 0.536702 -0.0054222 0.438369 0.0242064 0.346644C0.053835 0.25492 0.109345 0.173715 0.184054 0.112806ZM2.88405 8.50081L1.27005 14.5568L14.3821 8.00081L1.26905 1.44481L2.88405 7.50081H9.50005C9.63266 7.50081 9.75984 7.55348 9.85361 7.64725C9.94738 7.74102 10.0001 7.8682 10.0001 8.00081C10.0001 8.13341 9.94738 8.26059 9.85361 8.35436C9.75984 8.44813 9.63266 8.50081 9.50005 8.50081H2.88405Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { AVAILABLE_MODELS }
|
||||
45
packages/web/src/components/chat/ChatMessages.tsx
Normal file
45
packages/web/src/components/chat/ChatMessages.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Sparkles } from "lucide-react"
|
||||
import {
|
||||
MessageBubble,
|
||||
TypingIndicator,
|
||||
StreamingMessage,
|
||||
type Message,
|
||||
} from "./MessageBubble"
|
||||
|
||||
export function EmptyChatState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center">
|
||||
<div className="w-16 h-16 rounded-3xl bg-gradient-to-br from-teal-500/20 to-teal-500/5 flex items-center justify-center mb-6 shadow-lg">
|
||||
<Sparkles className="w-8 h-8 text-teal-400" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold mb-3 text-white">How can I help?</h2>
|
||||
<p className="text-neutral-400 text-sm max-w-sm">
|
||||
Start a conversation below.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface MessageListProps {
|
||||
messages: Message[]
|
||||
streamingContent: string
|
||||
isStreaming: boolean
|
||||
}
|
||||
|
||||
export function MessageList({
|
||||
messages,
|
||||
streamingContent,
|
||||
isStreaming,
|
||||
}: MessageListProps) {
|
||||
return (
|
||||
<div className="space-y-6 pb-4">
|
||||
{messages.map((message) => (
|
||||
<MessageBubble key={message.id} message={message} />
|
||||
))}
|
||||
{isStreaming && streamingContent && (
|
||||
<StreamingMessage content={streamingContent} />
|
||||
)}
|
||||
{isStreaming && !streamingContent && <TypingIndicator />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
618
packages/web/src/components/chat/ChatPage.tsx
Normal file
618
packages/web/src/components/chat/ChatPage.tsx
Normal file
@@ -0,0 +1,618 @@
|
||||
import { useEffect, useMemo, useState, useRef } from "react"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { useLiveQuery, eq } from "@tanstack/react-db"
|
||||
import { LogIn, Menu, X, LogOut } from "lucide-react"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
import {
|
||||
getChatThreadsCollection,
|
||||
getChatMessagesCollection,
|
||||
} from "@/lib/collections"
|
||||
import ContextPanel from "@/components/Context-panel"
|
||||
import { ChatInput, AVAILABLE_MODELS, type ModelId } from "./ChatInput"
|
||||
import { EmptyChatState, MessageList } from "./ChatMessages"
|
||||
import type { Message } from "./MessageBubble"
|
||||
|
||||
const MODEL_STORAGE_KEY = "gen_chat_model"
|
||||
const FREE_REQUEST_LIMIT = 2
|
||||
|
||||
function getStoredModel(): ModelId {
|
||||
if (typeof window === "undefined") return AVAILABLE_MODELS[0].id
|
||||
const stored = localStorage.getItem(MODEL_STORAGE_KEY)
|
||||
if (stored && AVAILABLE_MODELS.some((m) => m.id === stored)) {
|
||||
return stored as ModelId
|
||||
}
|
||||
return AVAILABLE_MODELS[0].id
|
||||
}
|
||||
|
||||
function setStoredModel(model: ModelId) {
|
||||
localStorage.setItem(MODEL_STORAGE_KEY, model)
|
||||
}
|
||||
|
||||
async function createThread(title = "New chat") {
|
||||
const res = await fetch("/api/chat/mutations", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ action: "createThread", title }),
|
||||
})
|
||||
if (!res.ok) throw new Error("Failed to create chat")
|
||||
const json = (await res.json()) as {
|
||||
thread: { id: number; title: string; created_at?: string }
|
||||
}
|
||||
return {
|
||||
...json.thread,
|
||||
created_at: json.thread.created_at
|
||||
? new Date(json.thread.created_at)
|
||||
: new Date(),
|
||||
}
|
||||
}
|
||||
|
||||
async function addMessage({
|
||||
threadId,
|
||||
role,
|
||||
content,
|
||||
}: {
|
||||
threadId: number
|
||||
role: "user" | "assistant"
|
||||
content: string
|
||||
}) {
|
||||
const res = await fetch("/api/chat/mutations", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
action: "addMessage",
|
||||
threadId,
|
||||
role,
|
||||
content,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) throw new Error("Failed to add message")
|
||||
const json = (await res.json()) as {
|
||||
message: { id: number; thread_id: number; role: string; content: string; created_at?: string }
|
||||
}
|
||||
return {
|
||||
...json.message,
|
||||
created_at: json.message.created_at
|
||||
? new Date(json.message.created_at)
|
||||
: new Date(),
|
||||
}
|
||||
}
|
||||
|
||||
type DBMessage = {
|
||||
id: number
|
||||
thread_id: number
|
||||
role: string
|
||||
content: string
|
||||
created_at: Date
|
||||
}
|
||||
|
||||
type GuestMessage = {
|
||||
id: number
|
||||
role: "user" | "assistant"
|
||||
content: string
|
||||
}
|
||||
|
||||
// Guest chat component - saves to database with null user_id
|
||||
function GuestChat() {
|
||||
const [isStreaming, setIsStreaming] = useState(false)
|
||||
const [streamingContent, setStreamingContent] = useState("")
|
||||
const [guestMessages, setGuestMessages] = useState<GuestMessage[]>([])
|
||||
const [pendingUserMessage, setPendingUserMessage] = useState<string | null>(null)
|
||||
const [selectedModel, setSelectedModel] = useState<ModelId>(AVAILABLE_MODELS[0].id)
|
||||
const [threadId, setThreadId] = useState<number | null>(null)
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedModel(getStoredModel())
|
||||
}, [])
|
||||
|
||||
const messages: Message[] = useMemo(() => {
|
||||
const msgs = guestMessages.map((m) => ({
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
}))
|
||||
// Add pending user message if streaming and not yet in guestMessages
|
||||
if (pendingUserMessage && !msgs.some((m) => m.role === "user" && m.content === pendingUserMessage)) {
|
||||
msgs.push({
|
||||
id: Date.now(),
|
||||
role: "user",
|
||||
content: pendingUserMessage,
|
||||
})
|
||||
}
|
||||
return msgs
|
||||
}, [guestMessages, pendingUserMessage])
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||
}, [messages, streamingContent])
|
||||
|
||||
const handleModelChange = (model: ModelId) => {
|
||||
setSelectedModel(model)
|
||||
setStoredModel(model)
|
||||
}
|
||||
|
||||
const userMessagesSent = guestMessages.filter((m) => m.role === "user").length
|
||||
const limitReached = userMessagesSent >= FREE_REQUEST_LIMIT
|
||||
|
||||
const handleSubmit = async (userContent: string) => {
|
||||
if (!userContent.trim() || isStreaming || limitReached) return
|
||||
|
||||
// Set pending message immediately so it shows while streaming
|
||||
setPendingUserMessage(userContent)
|
||||
setIsStreaming(true)
|
||||
setStreamingContent("")
|
||||
|
||||
try {
|
||||
const newUserMsg: GuestMessage = {
|
||||
id: Date.now(),
|
||||
role: "user",
|
||||
content: userContent,
|
||||
}
|
||||
setGuestMessages((prev) => [...prev, newUserMsg])
|
||||
setPendingUserMessage(null) // Clear pending once added to guestMessages
|
||||
|
||||
const apiMessages = [
|
||||
...guestMessages.map((m) => ({ role: m.role, content: m.content })),
|
||||
{ role: "user" as const, content: userContent },
|
||||
]
|
||||
|
||||
const res = await fetch("/api/chat/guest", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ messages: apiMessages, model: selectedModel, threadId }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`AI request failed: ${res.status}`)
|
||||
}
|
||||
|
||||
// Get thread ID from response header
|
||||
const responseThreadId = res.headers.get("X-Thread-Id")
|
||||
if (responseThreadId && !threadId) {
|
||||
setThreadId(Number(responseThreadId))
|
||||
}
|
||||
|
||||
const reader = res.body?.getReader()
|
||||
if (!reader) {
|
||||
throw new Error("No response body")
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
let accumulated = ""
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true })
|
||||
accumulated += chunk
|
||||
setStreamingContent(accumulated)
|
||||
}
|
||||
|
||||
const newAssistantMsg: GuestMessage = {
|
||||
id: Date.now() + 1,
|
||||
role: "assistant",
|
||||
content: accumulated,
|
||||
}
|
||||
setGuestMessages((prev) => [...prev, newAssistantMsg])
|
||||
setStreamingContent("")
|
||||
|
||||
} catch (error) {
|
||||
console.error("Chat error:", error)
|
||||
setStreamingContent("")
|
||||
setPendingUserMessage(null)
|
||||
} finally {
|
||||
setIsStreaming(false)
|
||||
}
|
||||
}
|
||||
|
||||
const remainingRequests = Math.max(0, FREE_REQUEST_LIMIT - userMessagesSent)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile header - only visible on small screens */}
|
||||
<header className="md:hidden fixed top-0 left-0 right-0 z-40 flex items-center justify-between px-4 py-3 bg-[#07080f]/95 backdrop-blur-sm border-b border-white/5">
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
className="p-2 -ml-2 text-white/70 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<Menu size={22} />
|
||||
</button>
|
||||
<Link
|
||||
to="/auth"
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm font-medium bg-white text-black rounded-lg hover:bg-white/90 transition-colors"
|
||||
>
|
||||
<LogIn size={16} />
|
||||
<span>Sign in</span>
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
{/* Mobile slide-out menu */}
|
||||
{mobileMenuOpen && (
|
||||
<div className="md:hidden fixed inset-0 z-50">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
<aside className="absolute top-0 left-0 h-full w-72 bg-[#0a0b10] border-r border-white/5 flex flex-col">
|
||||
<div className="flex items-center justify-between p-4 border-b border-white/5">
|
||||
<span className="text-white font-medium">Menu</span>
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="p-2 -mr-2 text-white/70 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 p-4">
|
||||
<ContextPanel
|
||||
chats={[]}
|
||||
activeChatId={null}
|
||||
isAuthenticated={false}
|
||||
profile={null}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4 border-t border-white/5">
|
||||
<Link
|
||||
to="/auth"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 text-sm font-medium bg-white text-black rounded-lg hover:bg-white/90 transition-colors"
|
||||
>
|
||||
<LogIn size={18} />
|
||||
<span>Sign in</span>
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-h-screen max-w-[1700px] mx-auto md:grid md:grid-cols-[280px_1fr] bg-inherit">
|
||||
<aside className="hidden md:flex border-r flex-col w-full h-screen border-none">
|
||||
<ContextPanel
|
||||
chats={[]}
|
||||
activeChatId={null}
|
||||
isAuthenticated={false}
|
||||
profile={null}
|
||||
/>
|
||||
</aside>
|
||||
<main className="flex flex-col h-screen bg-[#07080f] pt-14 md:pt-0">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-3xl mx-auto py-8 px-4 sm:px-6">
|
||||
{messages.length === 0 && !isStreaming ? (
|
||||
<EmptyChatState />
|
||||
) : (
|
||||
<>
|
||||
<MessageList
|
||||
messages={messages}
|
||||
streamingContent={streamingContent}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
<div ref={messagesEndRef} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<ChatInput
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={isStreaming}
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={handleModelChange}
|
||||
remainingRequests={remainingRequests}
|
||||
limitReached={limitReached}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Authenticated chat component - uses Electric SQL
|
||||
function AuthenticatedChat({ user }: { user: { name?: string | null; email: string; image?: string | null } }) {
|
||||
const [isStreaming, setIsStreaming] = useState(false)
|
||||
const [streamingContent, setStreamingContent] = useState("")
|
||||
const [activeThreadId, setActiveThreadId] = useState<number | null>(null)
|
||||
const [pendingMessages, setPendingMessages] = useState<Message[]>([])
|
||||
const [selectedModel, setSelectedModel] = useState<ModelId>(AVAILABLE_MODELS[0].id)
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await authClient.signOut()
|
||||
window.location.href = "/"
|
||||
}
|
||||
|
||||
const chatThreadsCollection = getChatThreadsCollection()
|
||||
const chatMessagesCollection = getChatMessagesCollection()
|
||||
|
||||
const { data: threads = [] } = useLiveQuery((q) =>
|
||||
q
|
||||
.from({ chatThreads: chatThreadsCollection })
|
||||
.orderBy(({ chatThreads }) => chatThreads.created_at),
|
||||
)
|
||||
|
||||
const sortedThreads = useMemo(
|
||||
() => [...threads].sort((a, b) => b.id - a.id),
|
||||
[threads],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (activeThreadId === null && sortedThreads.length > 0) {
|
||||
setActiveThreadId(sortedThreads[0].id)
|
||||
}
|
||||
}, [sortedThreads, activeThreadId])
|
||||
|
||||
const { data: dbMessages = [] } = useLiveQuery((q) => {
|
||||
const base = q
|
||||
.from({ chatMessages: chatMessagesCollection })
|
||||
.orderBy(({ chatMessages }) => chatMessages.created_at)
|
||||
if (activeThreadId === null) {
|
||||
return base.where(({ chatMessages }) => eq(chatMessages.thread_id, -1))
|
||||
}
|
||||
return base.where(({ chatMessages }) =>
|
||||
eq(chatMessages.thread_id, activeThreadId),
|
||||
)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingMessages.length === 0) return
|
||||
|
||||
const stillPending = pendingMessages.filter((pending) => {
|
||||
const isSynced = dbMessages.some(
|
||||
(m: DBMessage) =>
|
||||
m.role === pending.role &&
|
||||
m.content === pending.content,
|
||||
)
|
||||
return !isSynced
|
||||
})
|
||||
|
||||
if (stillPending.length !== pendingMessages.length) {
|
||||
setPendingMessages(stillPending)
|
||||
}
|
||||
}, [dbMessages, pendingMessages])
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedModel(getStoredModel())
|
||||
}, [])
|
||||
|
||||
const messages: Message[] = useMemo(() => {
|
||||
const baseMessages: Message[] = dbMessages.map((m: DBMessage) => ({
|
||||
id: m.id,
|
||||
role: m.role as "user" | "assistant",
|
||||
content: m.content,
|
||||
createdAt: m.created_at,
|
||||
}))
|
||||
|
||||
const msgs = [...baseMessages]
|
||||
for (const pending of pendingMessages) {
|
||||
const alreadyExists = msgs.some(
|
||||
(m) => m.role === pending.role && m.content === pending.content,
|
||||
)
|
||||
if (!alreadyExists) {
|
||||
msgs.push(pending)
|
||||
}
|
||||
}
|
||||
|
||||
return msgs
|
||||
}, [dbMessages, pendingMessages])
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||
}, [messages, streamingContent])
|
||||
|
||||
const handleModelChange = (model: ModelId) => {
|
||||
setSelectedModel(model)
|
||||
setStoredModel(model)
|
||||
}
|
||||
|
||||
const handleSubmit = async (userContent: string) => {
|
||||
if (!userContent.trim() || isStreaming) return
|
||||
|
||||
setIsStreaming(true)
|
||||
setStreamingContent("")
|
||||
|
||||
try {
|
||||
let threadId = activeThreadId
|
||||
if (!threadId) {
|
||||
const thread = await createThread(userContent.slice(0, 40) || "New chat")
|
||||
threadId = thread.id
|
||||
setActiveThreadId(thread.id)
|
||||
}
|
||||
|
||||
const pendingUserMsg: Message = {
|
||||
id: Date.now(),
|
||||
role: "user",
|
||||
content: userContent,
|
||||
createdAt: new Date(),
|
||||
}
|
||||
setPendingMessages((prev) => [...prev, pendingUserMsg])
|
||||
|
||||
await addMessage({ threadId, role: "user", content: userContent })
|
||||
|
||||
const threadMessages = dbMessages.filter((m: DBMessage) => m.thread_id === threadId)
|
||||
const apiMessages = [
|
||||
...threadMessages.map((m: DBMessage) => ({
|
||||
role: m.role as "user" | "assistant",
|
||||
content: m.content,
|
||||
})),
|
||||
{ role: "user" as const, content: userContent },
|
||||
]
|
||||
|
||||
const res = await fetch("/api/chat/ai", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ threadId, messages: apiMessages, model: selectedModel }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`AI request failed: ${res.status}`)
|
||||
}
|
||||
|
||||
const reader = res.body?.getReader()
|
||||
if (!reader) {
|
||||
throw new Error("No response body")
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
let accumulated = ""
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true })
|
||||
accumulated += chunk
|
||||
setStreamingContent(accumulated)
|
||||
}
|
||||
|
||||
if (accumulated) {
|
||||
const pendingAssistantMsg: Message = {
|
||||
id: Date.now() + 1,
|
||||
role: "assistant",
|
||||
content: accumulated,
|
||||
createdAt: new Date(),
|
||||
}
|
||||
setPendingMessages((prev) => [...prev, pendingAssistantMsg])
|
||||
}
|
||||
setStreamingContent("")
|
||||
} catch (error) {
|
||||
console.error("Chat error:", error)
|
||||
setStreamingContent("")
|
||||
} finally {
|
||||
setIsStreaming(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile header - only visible on small screens */}
|
||||
<header className="md:hidden fixed top-0 left-0 right-0 z-40 flex items-center justify-between px-4 py-3 bg-[#07080f]/95 backdrop-blur-sm border-b border-white/5">
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
className="p-2 -ml-2 text-white/70 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<Menu size={22} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
className="flex items-center gap-2 p-2 text-white/70 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
|
||||
aria-label="Sign out"
|
||||
>
|
||||
<div className="w-7 h-7 rounded-full bg-cyan-600 flex items-center justify-center">
|
||||
<span className="text-xs font-medium text-white">
|
||||
{user.email?.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Mobile slide-out menu */}
|
||||
{mobileMenuOpen && (
|
||||
<div className="md:hidden fixed inset-0 z-50">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
<aside className="absolute top-0 left-0 h-full w-72 bg-[#0a0b10] border-r border-white/5 flex flex-col">
|
||||
<div className="flex items-center justify-between p-4 border-b border-white/5">
|
||||
<span className="text-white font-medium">Menu</span>
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="p-2 -mr-2 text-white/70 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<ContextPanel
|
||||
chats={sortedThreads}
|
||||
activeChatId={activeThreadId ? activeThreadId.toString() : null}
|
||||
isAuthenticated={true}
|
||||
profile={user}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4 border-t border-white/5">
|
||||
<div className="flex items-center gap-3 mb-3 px-1">
|
||||
<div className="w-8 h-8 rounded-full bg-cyan-600 flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-sm font-medium text-white">
|
||||
{user.email?.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-white/70 text-sm truncate">{user.email}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setMobileMenuOpen(false)
|
||||
handleSignOut()
|
||||
}}
|
||||
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 text-sm font-medium text-red-400 hover:text-red-300 hover:bg-white/5 rounded-lg transition-colors"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
<span>Sign out</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-h-screen max-w-[1700px] mx-auto md:grid md:grid-cols-[280px_1fr] bg-inherit">
|
||||
<aside className="hidden md:flex border-r flex-col w-full h-screen border-none">
|
||||
<ContextPanel
|
||||
chats={sortedThreads}
|
||||
activeChatId={activeThreadId ? activeThreadId.toString() : null}
|
||||
isAuthenticated={true}
|
||||
profile={user}
|
||||
/>
|
||||
</aside>
|
||||
<main className="flex flex-col h-screen bg-[#07080f] pt-14 md:pt-0">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-3xl mx-auto py-8 px-4 sm:px-6">
|
||||
{messages.length === 0 && !isStreaming ? (
|
||||
<EmptyChatState />
|
||||
) : (
|
||||
<>
|
||||
<MessageList
|
||||
messages={messages}
|
||||
streamingContent={streamingContent}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
<div ref={messagesEndRef} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<ChatInput
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={isStreaming}
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={handleModelChange}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChatPage() {
|
||||
const { data: session, isPending } = authClient.useSession()
|
||||
const isAuthenticated = !!session?.user
|
||||
|
||||
if (isPending) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Render different components based on auth state
|
||||
// This prevents Electric SQL collections from being initialized for guests
|
||||
return isAuthenticated ? <AuthenticatedChat user={session.user} /> : <GuestChat />
|
||||
}
|
||||
215
packages/web/src/components/chat/MessageBubble.tsx
Normal file
215
packages/web/src/components/chat/MessageBubble.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import ReactMarkdown from "react-markdown"
|
||||
import remarkGfm from "remark-gfm"
|
||||
|
||||
export type Message = {
|
||||
id: string | number
|
||||
role: "user" | "assistant"
|
||||
content: string
|
||||
createdAt?: Date
|
||||
}
|
||||
|
||||
export function MessageBubble({ message }: { message: Message }) {
|
||||
const isUser = message.role === "user"
|
||||
|
||||
if (isUser) {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<div className="w-fit max-w-2xl rounded-xl px-4 py-2 bg-[#16171f] inner-shadow-xl outline-1 outline-neutral-100/12 text-white">
|
||||
<p className="whitespace-pre-wrap text-sm">{message.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Assistant message with Markdown rendering
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
<div className="prose prose-sm prose-invert max-w-none text-white">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ ...props }) => (
|
||||
<h1
|
||||
className="text-2xl font-bold mt-6 mb-4 text-white"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
h2: ({ ...props }) => (
|
||||
<h2
|
||||
className="text-xl font-bold mt-5 mb-3 text-white"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
h3: ({ ...props }) => (
|
||||
<h3
|
||||
className="text-lg font-semibold mt-4 mb-2 text-white"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
p: ({ ...props }) => (
|
||||
<p className="mb-4 leading-relaxed text-neutral-200" {...props} />
|
||||
),
|
||||
ul: ({ ...props }) => (
|
||||
<ul
|
||||
className="list-disc list-inside mb-4 space-y-1 text-neutral-200"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
ol: ({ ...props }) => (
|
||||
<ol
|
||||
className="list-decimal list-inside mb-4 space-y-1 text-neutral-200"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
li: ({ ...props }) => (
|
||||
<li className="ml-2 text-neutral-200" {...props} />
|
||||
),
|
||||
code: ({ className, children, ...props }: any) => {
|
||||
const isInline = !className
|
||||
return isInline ? (
|
||||
<code
|
||||
className="bg-[#1e1f28] px-1.5 py-0.5 rounded text-sm font-mono text-teal-300"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
) : (
|
||||
<code
|
||||
className="block bg-[#1e1f28] p-3 rounded-lg overflow-x-auto text-sm font-mono my-4 text-neutral-200"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
},
|
||||
pre: ({ ...props }) => <pre className="my-4" {...props} />,
|
||||
blockquote: ({ ...props }) => (
|
||||
<blockquote
|
||||
className="border-l-4 border-teal-500/50 pl-4 italic my-4 text-neutral-400"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
a: ({ ...props }) => (
|
||||
<a
|
||||
className="text-teal-400 hover:text-teal-300 underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
strong: ({ ...props }) => (
|
||||
<strong className="font-semibold text-white" {...props} />
|
||||
),
|
||||
}}
|
||||
>
|
||||
{message.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TypingIndicator() {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 py-2">
|
||||
<div className="w-2 h-2 bg-teal-500 rounded-full animate-pulse" />
|
||||
<div
|
||||
className="w-2 h-2 bg-teal-500 rounded-full animate-pulse"
|
||||
style={{ animationDelay: "0.2s" }}
|
||||
/>
|
||||
<div
|
||||
className="w-2 h-2 bg-teal-500 rounded-full animate-pulse"
|
||||
style={{ animationDelay: "0.4s" }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function StreamingMessage({ content }: { content: string }) {
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
<div className="prose prose-sm prose-invert max-w-none text-white">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ ...props }) => (
|
||||
<h1
|
||||
className="text-2xl font-bold mt-6 mb-4 text-white"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
h2: ({ ...props }) => (
|
||||
<h2
|
||||
className="text-xl font-bold mt-5 mb-3 text-white"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
h3: ({ ...props }) => (
|
||||
<h3
|
||||
className="text-lg font-semibold mt-4 mb-2 text-white"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
p: ({ ...props }) => (
|
||||
<p className="mb-4 leading-relaxed text-neutral-200" {...props} />
|
||||
),
|
||||
ul: ({ ...props }) => (
|
||||
<ul
|
||||
className="list-disc list-inside mb-4 space-y-1 text-neutral-200"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
ol: ({ ...props }) => (
|
||||
<ol
|
||||
className="list-decimal list-inside mb-4 space-y-1 text-neutral-200"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
li: ({ ...props }) => (
|
||||
<li className="ml-2 text-neutral-200" {...props} />
|
||||
),
|
||||
code: ({ className, children, ...props }: any) => {
|
||||
const isInline = !className
|
||||
return isInline ? (
|
||||
<code
|
||||
className="bg-[#1e1f28] px-1.5 py-0.5 rounded text-sm font-mono text-teal-300"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
) : (
|
||||
<code
|
||||
className="block bg-[#1e1f28] p-3 rounded-lg overflow-x-auto text-sm font-mono my-4 text-neutral-200"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
},
|
||||
pre: ({ ...props }) => <pre className="my-4" {...props} />,
|
||||
blockquote: ({ ...props }) => (
|
||||
<blockquote
|
||||
className="border-l-4 border-teal-500/50 pl-4 italic my-4 text-neutral-400"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
a: ({ ...props }) => (
|
||||
<a
|
||||
className="text-teal-400 hover:text-teal-300 underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
strong: ({ ...props }) => (
|
||||
<strong className="font-semibold text-white" {...props} />
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
<span className="inline-block w-1.5 h-4 ml-0.5 bg-teal-500/70 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user