docs-chat.tsx 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. "use client";
  2. import { useRef, useEffect, useState } from "react";
  3. import { useChat } from "@ai-sdk/react";
  4. import { DefaultChatTransport } from "ai";
  5. import { Streamdown } from "streamdown";
  6. const STORAGE_KEY = "docs-chat-messages";
  7. const transport = new DefaultChatTransport({ api: "/api/docs-chat" });
  8. export function DocsChat() {
  9. const [open, setOpen] = useState(false);
  10. const [input, setInput] = useState("");
  11. const messagesEndRef = useRef<HTMLDivElement>(null);
  12. const inputRef = useRef<HTMLTextAreaElement>(null);
  13. const containerRef = useRef<HTMLDivElement>(null);
  14. const restoredRef = useRef(false);
  15. const { messages, sendMessage, status, setMessages } = useChat({ transport });
  16. const isLoading = status === "streaming" || status === "submitted";
  17. // Restore messages from sessionStorage on mount
  18. useEffect(() => {
  19. if (restoredRef.current) return;
  20. restoredRef.current = true;
  21. try {
  22. const stored = sessionStorage.getItem(STORAGE_KEY);
  23. if (stored) {
  24. const parsed = JSON.parse(stored);
  25. if (Array.isArray(parsed) && parsed.length > 0) {
  26. setMessages(parsed);
  27. }
  28. }
  29. } catch {
  30. // ignore parse errors
  31. }
  32. }, [setMessages]);
  33. // Save completed messages to sessionStorage
  34. useEffect(() => {
  35. if (!restoredRef.current) return;
  36. if (isLoading) return;
  37. if (messages.length === 0) {
  38. sessionStorage.removeItem(STORAGE_KEY);
  39. return;
  40. }
  41. try {
  42. sessionStorage.setItem(STORAGE_KEY, JSON.stringify(messages));
  43. } catch {
  44. // ignore quota errors
  45. }
  46. }, [messages, isLoading]);
  47. // Auto-open when new messages arrive
  48. const prevMessageCount = useRef(0);
  49. useEffect(() => {
  50. if (messages.length > prevMessageCount.current) {
  51. setOpen(true);
  52. }
  53. prevMessageCount.current = messages.length;
  54. }, [messages.length]);
  55. // Scroll to bottom when messages change
  56. useEffect(() => {
  57. messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
  58. }, [messages]);
  59. // Close message area when clicking outside
  60. useEffect(() => {
  61. if (!open) return;
  62. const handleClickOutside = (e: MouseEvent) => {
  63. if (
  64. containerRef.current &&
  65. !containerRef.current.contains(e.target as Node)
  66. ) {
  67. setOpen(false);
  68. }
  69. };
  70. document.addEventListener("mousedown", handleClickOutside);
  71. return () => document.removeEventListener("mousedown", handleClickOutside);
  72. }, [open]);
  73. const handleSubmit = (e: React.FormEvent) => {
  74. e.preventDefault();
  75. if (!input.trim() || isLoading) return;
  76. sendMessage({ text: input });
  77. setInput("");
  78. };
  79. const handleClear = () => {
  80. setMessages([]);
  81. sessionStorage.removeItem(STORAGE_KEY);
  82. setOpen(false);
  83. inputRef.current?.focus();
  84. };
  85. const getTextFromParts = (
  86. parts: (typeof messages)[number]["parts"],
  87. ): string => {
  88. return parts
  89. .filter(
  90. (p): p is Extract<typeof p, { type: "text" }> => p.type === "text",
  91. )
  92. .map((p) => p.text)
  93. .join("");
  94. };
  95. return (
  96. <div className="fixed bottom-0 left-0 right-0 z-50 pointer-events-none">
  97. <div
  98. ref={containerRef}
  99. className="max-w-xl mx-auto px-4 pb-4 [&>*]:pointer-events-auto"
  100. >
  101. {/* Messages panel */}
  102. {open && messages.length > 0 && (
  103. <div className="mb-2 bg-background border border-border rounded-lg shadow-lg max-h-[60vh] flex flex-col">
  104. <div className="flex items-center justify-between px-4 py-2 border-b border-border shrink-0">
  105. <span className="text-xs font-medium text-muted-foreground">
  106. json-render Docs
  107. </span>
  108. <button
  109. onClick={handleClear}
  110. className="text-xs text-muted-foreground hover:text-foreground transition-colors"
  111. aria-label="Clear conversation"
  112. >
  113. Clear
  114. </button>
  115. </div>
  116. <div className="p-4 space-y-4 overflow-y-auto">
  117. {messages.map((message) => {
  118. const text = getTextFromParts(message.parts);
  119. if (!text) return null;
  120. return (
  121. <div key={message.id}>
  122. {message.role === "assistant" ? (
  123. <div className="text-sm text-foreground/90 leading-relaxed prose prose-sm dark:prose-invert max-w-none">
  124. <Streamdown>{text}</Streamdown>
  125. </div>
  126. ) : (
  127. <div className="text-sm text-muted-foreground whitespace-pre-wrap leading-relaxed">
  128. {text}
  129. </div>
  130. )}
  131. </div>
  132. );
  133. })}
  134. <div ref={messagesEndRef} />
  135. </div>
  136. </div>
  137. )}
  138. {/* Input bar */}
  139. <form
  140. onSubmit={handleSubmit}
  141. onClick={() => inputRef.current?.focus()}
  142. className="flex items-end gap-2 bg-background border border-border rounded-lg shadow-lg px-4 py-3 cursor-text"
  143. >
  144. <textarea
  145. ref={inputRef}
  146. value={input}
  147. onChange={(e) => {
  148. setInput(e.target.value);
  149. e.target.style.height = "auto";
  150. e.target.style.height = `${e.target.scrollHeight}px`;
  151. }}
  152. placeholder="Ask about the docs..."
  153. rows={1}
  154. onFocus={() => {
  155. if (messages.length > 0) setOpen(true);
  156. }}
  157. onKeyDown={(e) => {
  158. if (e.key === "Escape") {
  159. setOpen(false);
  160. inputRef.current?.blur();
  161. }
  162. if (e.key === "Enter" && !e.shiftKey) {
  163. e.preventDefault();
  164. handleSubmit(e);
  165. }
  166. }}
  167. 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"
  168. />
  169. <button
  170. type="submit"
  171. disabled={isLoading || !input.trim()}
  172. className="bg-primary text-primary-foreground rounded-md p-1 hover:bg-primary/90 transition-colors disabled:opacity-30"
  173. aria-label="Send message"
  174. >
  175. <svg
  176. width="16"
  177. height="16"
  178. viewBox="0 0 24 24"
  179. fill="none"
  180. stroke="currentColor"
  181. strokeWidth="2"
  182. strokeLinecap="round"
  183. strokeLinejoin="round"
  184. >
  185. <line x1="12" y1="19" x2="12" y2="5" />
  186. <polyline points="5 12 12 5 19 12" />
  187. </svg>
  188. </button>
  189. </form>
  190. </div>
  191. </div>
  192. );
  193. }