| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208 |
- "use client";
- import { useRef, useEffect, useState } from "react";
- import { useChat } from "@ai-sdk/react";
- import { DefaultChatTransport } from "ai";
- import { Streamdown } from "streamdown";
- const STORAGE_KEY = "docs-chat-messages";
- const transport = new DefaultChatTransport({ api: "/api/docs-chat" });
- export function DocsChat() {
- const [open, setOpen] = useState(false);
- const [input, setInput] = useState("");
- const messagesEndRef = useRef<HTMLDivElement>(null);
- const inputRef = useRef<HTMLTextAreaElement>(null);
- const containerRef = useRef<HTMLDivElement>(null);
- const restoredRef = useRef(false);
- const { messages, sendMessage, status, setMessages } = useChat({ transport });
- const isLoading = status === "streaming" || status === "submitted";
- // Restore messages from sessionStorage on mount
- useEffect(() => {
- if (restoredRef.current) return;
- restoredRef.current = true;
- try {
- const stored = sessionStorage.getItem(STORAGE_KEY);
- if (stored) {
- const parsed = JSON.parse(stored);
- if (Array.isArray(parsed) && parsed.length > 0) {
- setMessages(parsed);
- }
- }
- } catch {
- // ignore parse errors
- }
- }, [setMessages]);
- // Save completed messages to sessionStorage
- useEffect(() => {
- if (!restoredRef.current) return;
- if (isLoading) return;
- if (messages.length === 0) {
- sessionStorage.removeItem(STORAGE_KEY);
- return;
- }
- try {
- sessionStorage.setItem(STORAGE_KEY, JSON.stringify(messages));
- } catch {
- // ignore quota errors
- }
- }, [messages, isLoading]);
- // Auto-open when new messages arrive
- const prevMessageCount = useRef(0);
- useEffect(() => {
- if (messages.length > prevMessageCount.current) {
- setOpen(true);
- }
- prevMessageCount.current = messages.length;
- }, [messages.length]);
- // Scroll to bottom when messages change
- useEffect(() => {
- messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
- }, [messages]);
- // Close message area when clicking outside
- useEffect(() => {
- if (!open) return;
- const handleClickOutside = (e: MouseEvent) => {
- if (
- containerRef.current &&
- !containerRef.current.contains(e.target as Node)
- ) {
- setOpen(false);
- }
- };
- document.addEventListener("mousedown", handleClickOutside);
- return () => document.removeEventListener("mousedown", handleClickOutside);
- }, [open]);
- const handleSubmit = (e: React.FormEvent) => {
- e.preventDefault();
- if (!input.trim() || isLoading) return;
- sendMessage({ text: input });
- setInput("");
- };
- const handleClear = () => {
- setMessages([]);
- sessionStorage.removeItem(STORAGE_KEY);
- setOpen(false);
- inputRef.current?.focus();
- };
- const getTextFromParts = (
- parts: (typeof messages)[number]["parts"],
- ): string => {
- return parts
- .filter(
- (p): p is Extract<typeof p, { type: "text" }> => p.type === "text",
- )
- .map((p) => p.text)
- .join("");
- };
- return (
- <div className="fixed bottom-0 left-0 right-0 z-50 pointer-events-none">
- <div
- ref={containerRef}
- className="max-w-xl mx-auto px-4 pb-4 [&>*]:pointer-events-auto"
- >
- {/* Messages panel */}
- {open && messages.length > 0 && (
- <div className="mb-2 bg-background border border-border rounded-lg shadow-lg max-h-[60vh] flex flex-col">
- <div className="flex items-center justify-between px-4 py-2 border-b border-border shrink-0">
- <span className="text-xs font-medium text-muted-foreground">
- json-render Docs
- </span>
- <button
- onClick={handleClear}
- className="text-xs text-muted-foreground hover:text-foreground transition-colors"
- aria-label="Clear conversation"
- >
- Clear
- </button>
- </div>
- <div className="p-4 space-y-4 overflow-y-auto">
- {messages.map((message) => {
- const text = getTextFromParts(message.parts);
- if (!text) return null;
- return (
- <div key={message.id}>
- {message.role === "assistant" ? (
- <div className="text-sm text-foreground/90 leading-relaxed prose prose-sm dark:prose-invert max-w-none">
- <Streamdown>{text}</Streamdown>
- </div>
- ) : (
- <div className="text-sm text-muted-foreground whitespace-pre-wrap leading-relaxed">
- {text}
- </div>
- )}
- </div>
- );
- })}
- <div ref={messagesEndRef} />
- </div>
- </div>
- )}
- {/* Input bar */}
- <form
- onSubmit={handleSubmit}
- onClick={() => inputRef.current?.focus()}
- className="flex items-end gap-2 bg-background border border-border rounded-lg shadow-lg px-4 py-3 cursor-text"
- >
- <textarea
- ref={inputRef}
- value={input}
- onChange={(e) => {
- setInput(e.target.value);
- e.target.style.height = "auto";
- e.target.style.height = `${e.target.scrollHeight}px`;
- }}
- placeholder="Ask about the docs..."
- rows={1}
- onFocus={() => {
- if (messages.length > 0) setOpen(true);
- }}
- onKeyDown={(e) => {
- if (e.key === "Escape") {
- setOpen(false);
- inputRef.current?.blur();
- }
- if (e.key === "Enter" && !e.shiftKey) {
- e.preventDefault();
- handleSubmit(e);
- }
- }}
- className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50 resize-none max-h-32 leading-relaxed"
- />
- <button
- type="submit"
- disabled={isLoading || !input.trim()}
- className="bg-primary text-primary-foreground rounded-md p-1 hover:bg-primary/90 transition-colors disabled:opacity-30"
- aria-label="Send message"
- >
- <svg
- width="16"
- height="16"
- viewBox="0 0 24 24"
- fill="none"
- stroke="currentColor"
- strokeWidth="2"
- strokeLinecap="round"
- strokeLinejoin="round"
- >
- <line x1="12" y1="19" x2="12" y2="5" />
- <polyline points="5 12 12 5 19 12" />
- </svg>
- </button>
- </form>
- </div>
- </div>
- );
- }
|