1 line
10 KiB
JSON
1 line
10 KiB
JSON
{"repo": "agribeacon/agribeacon-landingpage", "files": {"src/components/ChatBot.tsx": {"3fcd14a9beaf030cc7694b3504ee4112e9f530f7": {"t": 1785846946.0062146, "n": 9883, "h": "f74d330b203c01a3", "c": "import { FormEvent, KeyboardEvent, useEffect, useRef, useState } from \"react\";\nimport { Bot, Loader2, MessageCircle, Send, Sprout, User, X } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { cn } from \"@/lib/utils\";\n\ntype ChatMessage = {\n id: string;\n role: \"user\" | \"assistant\";\n content: string;\n};\n\nconst starterMessages: ChatMessage[] = [\n {\n id: \"welcome\",\n role: \"assistant\",\n content:\n \"Xin chào, mình là Tiểu Thần Nông AI của AgriBeacon. Bạn có thể hỏi về canh tác, cảm biến IoT, UAV, robot, bản đồ nông trại, dự báo năng suất hoặc giải pháp AgriBeacon.\",\n },\n];\n\nconst quickPrompts = [\n \"Vườn sầu riêng nên đặt cảm biến gì?\",\n \"Robot phun thuốc phù hợp diện tích nào?\",\n \"Làm sao dự báo năng suất tốt hơn?\",\n];\n\nconst createId = () => `${Date.now()}-${Math.random().toString(36).slice(2)}`;\n\nconst renderInlineMarkdown = (text: string) => {\n const parts = text.split(/(\\*\\*[^*\\n]+\\*\\*)/g);\n\n return parts.map((part, index) => {\n if (part.startsWith(\"**\") && part.endsWith(\"**\") && part.length > 4) {\n return (\n <strong key={`${part}-${index}`} className=\"font-semibold\">\n {part.slice(2, -2)}\n </strong>\n );\n }\n\n return <span key={`${part}-${index}`}>{part}</span>;\n });\n};\n\nconst renderAssistantMarkdown = (content: string) => {\n const elements: JSX.Element[] = [];\n let listType: \"ul\" | \"ol\" | null = null;\n let listItems: string[] = [];\n\n const flushList = () => {\n if (!listType || listItems.length === 0) return;\n\n const ListTag = listType;\n elements.push(\n <ListTag key={`list-${elements.length}`} className=\"ml-4 list-outside space-y-1 pl-1\">\n {listItems.map((item, index) => (\n <li key={`${item}-${index}`}>{renderInlineMarkdown(item)}</li>\n ))}\n </ListTag>,\n );\n listType = null;\n listItems = [];\n };\n\n content.split(/\\r?\\n/).forEach((line) => {\n const trimmed = line.trim();\n if (!trimmed) {\n flushList();\n return;\n }\n\n const bullet = trimmed.match(/^[-*]\\s+(.+)$/);\n const numbered = trimmed.match(/^\\d+[.)]\\s+(.+)$/);\n\n if (bullet) {\n if (listType && listType !== \"ul\") flushList();\n listType = \"ul\";\n listItems.push(bullet[1]);\n return;\n }\n\n if (numbered) {\n if (listType && listType !== \"ol\") flushList();\n listType = \"ol\";\n listItems.push(numbered[1]);\n return;\n }\n\n flushList();\n elements.push(\n <p key={`p-${elements.length}`}>\n {renderInlineMarkdown(trimmed)}\n </p>,\n );\n });\n\n flushList();\n\n return <div className=\"space-y-2\">{elements}</div>;\n};\n\nconst ChatBot = () => {\n const [open, setOpen] = useState(false);\n const [messages, setMessages] = useState<ChatMessage[]>(starterMessages);\n const [input, setInput] = useState(\"\");\n const [loading, setLoading] = useState(false);\n const scrollRef = useRef<HTMLDivElement>(null);\n\n useEffect(() => {\n if (!open) return;\n scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: \"smooth\" });\n }, [messages, open]);\n\n const sendMessage = async (content: string) => {\n const trimmed = content.trim();\n if (!trimmed || loading) return;\n\n const nextMessages = [...messages, { id: createId(), role: \"user\" as const, content: trimmed }];\n setMessages(nextMessages);\n setInput(\"\");\n setLoading(true);\n\n try {\n const response = await fetch(\"/api/chat\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n messages: nextMessages.map(({ role, content }) => ({ role, content })),\n }),\n });\n\n const data = await response.json().catch(() => ({}));\n if (!response.ok) {\n throw new Error(data?.error || \"Không gọi được AI\");\n }\n\n setMessages((current) => [\n ...current,\n {\n id: createId(),\n role: \"assistant\",\n content: data.answer || \"Mình chưa có câu trả lời phù hợp lúc này.\",\n },\n ]);\n } catch (error) {\n setMessages((current) => [\n ...current,\n {\n id: createId(),\n role: \"assistant\",\n content:\n error instanceof Error && error.message.includes(\"OPENCODE_GO_API_KEY\")\n ? \"Chatbot chưa được cấu hình OPENCODE_GO_API_KEY trên server. Vui lòng thêm key trong biến môi trường deploy.\"\n : \"Mình đang không kết nối được AI. Bạn thử lại sau ít phút nhé.\",\n },\n ]);\n } finally {\n setLoading(false);\n }\n };\n\n const handleSubmit = (event: FormEvent) => {\n event.preventDefault();\n void sendMessage(input);\n };\n\n const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {\n if (event.key === \"Enter\" && !event.shiftKey) {\n event.preventDefault();\n void sendMessage(input);\n }\n };\n\n return (\n <div className=\"fixed bottom-4 right-4 z-50 sm:bottom-6 sm:right-6\">\n {open && (\n <section className=\"mb-3 flex h-[min(620px,calc(100vh-12rem))] w-[calc(100vw-2rem)] flex-col overflow-hidden rounded-2xl border border-border bg-background shadow-2xl sm:w-[420px]\">\n <header className=\"flex items-center justify-between border-b border-border bg-card px-4 py-3\">\n <div className=\"flex items-center gap-3 min-w-0\">\n <div className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground\">\n <Sprout className=\"h-5 w-5\" />\n </div>\n <div className=\"min-w-0\">\n <h2 className=\"truncate text-sm font-semibold text-foreground\">Tiểu Thần Nông AI</h2>\n <p className=\"truncate text-xs text-muted-foreground\">Trợ lý AgriBeacon</p>\n </div>\n </div>\n <Button variant=\"ghost\" size=\"icon\" onClick={() => setOpen(false)} aria-label=\"Đóng chat\">\n <X className=\"h-4 w-4\" />\n </Button>\n </header>\n\n <div ref={scrollRef} className=\"flex-1 space-y-4 overflow-y-auto px-4 py-4\">\n {messages.map((message) => {\n const isUser = message.role === \"user\";\n const Icon = isUser ? User : Bot;\n\n return (\n <div key={message.id} className={cn(\"flex gap-2\", isUser && \"justify-end\")}>\n {!isUser && (\n <div className=\"mt-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-primary\">\n <Icon className=\"h-4 w-4\" />\n </div>\n )}\n <div\n className={cn(\n \"max-w-[78%] rounded-2xl px-3 py-2 text-sm leading-relaxed\",\n isUser\n ? \"whitespace-pre-wrap bg-primary text-primary-foreground\"\n : \"border border-border bg-card text-card-foreground\",\n )}\n >\n {isUser ? message.content : renderAssistantMarkdown(message.content)}\n </div>\n {isUser && (\n <div className=\"mt-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary\">\n <Icon className=\"h-4 w-4\" />\n </div>\n )}\n </div>\n );\n })}\n {loading && (\n <div className=\"flex gap-2\">\n <div className=\"mt-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-primary\">\n <Bot className=\"h-4 w-4\" />\n </div>\n <div className=\"flex items-center gap-2 rounded-2xl border border-border bg-card px-3 py-2 text-sm text-muted-foreground\">\n <Loader2 className=\"h-4 w-4 animate-spin\" /> Đang trả lời...\n </div>\n </div>\n )}\n </div>\n\n <div className=\"border-t border-border bg-card/70 px-4 py-3\">\n {messages.length === 1 && (\n <div className=\"mb-3 flex gap-2 overflow-x-auto pb-1\">\n {quickPrompts.map((prompt) => (\n <button\n key={prompt}\n type=\"button\"\n onClick={() => void sendMessage(prompt)}\n className=\"shrink-0 rounded-full border border-border bg-background px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:border-primary/50 hover:text-primary\"\n >\n {prompt}\n </button>\n ))}\n </div>\n )}\n <form onSubmit={handleSubmit} className=\"flex items-end gap-2\">\n <Textarea\n value={input}\n onChange={(event) => setInput(event.target.value)}\n onKeyDown={handleKeyDown}\n placeholder=\"Hỏi Tiểu Thần Nông AI...\"\n rows={1}\n className=\"max-h-28 min-h-11 resize-none rounded-xl\"\n disabled={loading}\n />\n <Button type=\"submit\" size=\"icon\" disabled={loading || !input.trim()} aria-label=\"Gửi tin nhắn\">\n {loading ? <Loader2 className=\"h-4 w-4 animate-spin\" /> : <Send className=\"h-4 w-4\" />}\n </Button>\n </form>\n </div>\n </section>\n )}\n\n <Button\n size=\"lg\"\n onClick={() => setOpen((current) => !current)}\n className=\"h-14 rounded-full bg-gradient-to-r from-primary to-secondary px-5 shadow-glow\"\n aria-label={open ? \"Đóng chat\" : \"Mở chat\"}\n >\n {open ? <X className=\"mr-2 h-5 w-5\" /> : <MessageCircle className=\"mr-2 h-5 w-5\" />}\n Tiểu Thần Nông AI\n </Button>\n </div>\n );\n};\n\nexport default ChatBot;\n"}}}} |