docs-chat.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440
  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. import Link from "next/link";
  7. const STORAGE_KEY = "docs-chat-messages";
  8. const transport = new DefaultChatTransport({ api: "/api/docs-chat" });
  9. const TOOL_LABELS: Record<
  10. string,
  11. { label: string; pastLabel: string; argKey?: string }
  12. > = {
  13. readFile: { label: "Reading", pastLabel: "Read", argKey: "path" },
  14. bash: { label: "Running", pastLabel: "Ran", argKey: "command" },
  15. };
  16. function isToolPart(part: { type: string }): part is {
  17. type: string;
  18. toolCallId: string;
  19. toolName?: string;
  20. state: string;
  21. input?: Record<string, unknown>;
  22. output?: unknown;
  23. errorText?: string;
  24. } {
  25. return part.type.startsWith("tool-") || part.type === "dynamic-tool";
  26. }
  27. function getToolName(part: { type: string; toolName?: string }): string {
  28. if (part.type === "dynamic-tool") return part.toolName ?? "tool";
  29. return part.type.replace(/^tool-/, "");
  30. }
  31. function ToolCallDisplay({
  32. part,
  33. }: {
  34. part: {
  35. type: string;
  36. toolCallId: string;
  37. toolName?: string;
  38. state: string;
  39. input?: Record<string, unknown>;
  40. output?: unknown;
  41. errorText?: string;
  42. };
  43. }) {
  44. const toolName = getToolName(part);
  45. const config = TOOL_LABELS[toolName] ?? {
  46. label: toolName,
  47. pastLabel: toolName,
  48. };
  49. const isDone = part.state === "output-available";
  50. const isError = part.state === "output-error";
  51. const isRunning = !isDone && !isError;
  52. const displayLabel = isRunning ? config.label : config.pastLabel;
  53. const args = (part.input ?? {}) as Record<string, unknown>;
  54. const argValue = config.argKey ? args[config.argKey] : undefined;
  55. const argPreview =
  56. argValue != null
  57. ? String(argValue)
  58. .replace(/^\/workspace\//, "/")
  59. .replace(/\.md$/, "")
  60. .replace(/\/index$/, "")
  61. : "";
  62. // Link to the docs page if it's a /docs/ path from readFile
  63. const docsLink =
  64. toolName === "readFile" &&
  65. (argPreview === "/docs" || argPreview.startsWith("/docs/"))
  66. ? argPreview
  67. : null;
  68. const argEl = argPreview ? (
  69. docsLink ? (
  70. <Link href={docsLink} className="truncate underline underline-offset-2">
  71. {argPreview}
  72. </Link>
  73. ) : (
  74. <span className="truncate">{argPreview}</span>
  75. )
  76. ) : null;
  77. return (
  78. <div className="text-xs py-0.5 min-w-0">
  79. {isRunning ? (
  80. <span className="inline-flex items-center gap-1 font-mono text-muted-foreground animate-tool-shimmer min-w-0">
  81. <span className="shrink-0">{displayLabel}</span>
  82. {argEl}
  83. </span>
  84. ) : (
  85. <span className="inline-flex items-center gap-1 font-mono text-muted-foreground/60 min-w-0">
  86. <span className="shrink-0">{displayLabel}</span>
  87. {argEl}
  88. {isError && <span className="text-destructive">failed</span>}
  89. </span>
  90. )}
  91. </div>
  92. );
  93. }
  94. const SUGGESTIONS = [
  95. "What is json-render?",
  96. "How do I install it?",
  97. "How does streaming work?",
  98. "What components are available?",
  99. "How do I create a custom schema?",
  100. ];
  101. export function DocsChat() {
  102. const [open, setOpen] = useState(false);
  103. const [input, setInput] = useState("");
  104. const [focused, setFocused] = useState(false);
  105. const messagesEndRef = useRef<HTMLDivElement>(null);
  106. const inputRef = useRef<HTMLTextAreaElement>(null);
  107. const containerRef = useRef<HTMLDivElement>(null);
  108. const restoredRef = useRef(false);
  109. const { messages, sendMessage, status, setMessages, error } = useChat({
  110. transport,
  111. });
  112. const isLoading = status === "streaming" || status === "submitted";
  113. // Restore messages from sessionStorage on mount
  114. useEffect(() => {
  115. if (restoredRef.current) return;
  116. restoredRef.current = true;
  117. try {
  118. const stored = sessionStorage.getItem(STORAGE_KEY);
  119. if (stored) {
  120. const parsed = JSON.parse(stored);
  121. if (Array.isArray(parsed) && parsed.length > 0) {
  122. setMessages(parsed);
  123. }
  124. }
  125. } catch {
  126. // ignore parse errors
  127. }
  128. }, [setMessages]);
  129. // Save completed messages to sessionStorage
  130. useEffect(() => {
  131. if (!restoredRef.current) return;
  132. if (isLoading) return;
  133. if (messages.length === 0) {
  134. sessionStorage.removeItem(STORAGE_KEY);
  135. return;
  136. }
  137. try {
  138. sessionStorage.setItem(STORAGE_KEY, JSON.stringify(messages));
  139. } catch {
  140. // ignore quota errors
  141. }
  142. }, [messages, isLoading]);
  143. // Auto-open when new messages arrive (but not on initial restore)
  144. const prevMessageCount = useRef<number | null>(null);
  145. const initializedRef = useRef(false);
  146. useEffect(() => {
  147. // Skip until after the first sessionStorage restore cycle
  148. if (!initializedRef.current) {
  149. // Wait one tick after mount to let restore settle
  150. const id = requestAnimationFrame(() => {
  151. prevMessageCount.current = messages.length;
  152. initializedRef.current = true;
  153. });
  154. return () => cancelAnimationFrame(id);
  155. }
  156. if (
  157. prevMessageCount.current !== null &&
  158. messages.length > prevMessageCount.current
  159. ) {
  160. setOpen(true);
  161. }
  162. prevMessageCount.current = messages.length;
  163. }, [messages.length]);
  164. // Scroll to bottom when messages change or error occurs
  165. useEffect(() => {
  166. messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
  167. }, [messages, error]);
  168. // Cmd+K to focus prompt, Esc to close
  169. useEffect(() => {
  170. const handleKeyDown = (e: KeyboardEvent) => {
  171. if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
  172. e.preventDefault();
  173. inputRef.current?.focus();
  174. }
  175. if (e.key === "Escape" && open) {
  176. setOpen(false);
  177. inputRef.current?.blur();
  178. }
  179. };
  180. document.addEventListener("keydown", handleKeyDown);
  181. return () => document.removeEventListener("keydown", handleKeyDown);
  182. }, [open]);
  183. // Close message area when clicking outside
  184. useEffect(() => {
  185. if (!open) return;
  186. const handleClickOutside = (e: MouseEvent) => {
  187. if (
  188. containerRef.current &&
  189. !containerRef.current.contains(e.target as Node)
  190. ) {
  191. setOpen(false);
  192. }
  193. };
  194. document.addEventListener("mousedown", handleClickOutside);
  195. return () => document.removeEventListener("mousedown", handleClickOutside);
  196. }, [open]);
  197. const handleSubmit = (e: React.FormEvent) => {
  198. e.preventDefault();
  199. if (!input.trim() || isLoading) return;
  200. sendMessage({ text: input });
  201. setInput("");
  202. };
  203. const handleClear = () => {
  204. setMessages([]);
  205. sessionStorage.removeItem(STORAGE_KEY);
  206. setOpen(false);
  207. inputRef.current?.focus();
  208. };
  209. const hasVisibleContent = (
  210. parts: (typeof messages)[number]["parts"],
  211. ): boolean => {
  212. return parts.some(
  213. (p) => (p.type === "text" && p.text.length > 0) || isToolPart(p),
  214. );
  215. };
  216. // Auto-open when error occurs
  217. useEffect(() => {
  218. if (error) setOpen(true);
  219. }, [error]);
  220. const showMessages = open && (messages.length > 0 || !!error || isLoading);
  221. const showSuggestions = focused && messages.length === 0 && !isLoading;
  222. return (
  223. <div className="fixed bottom-0 left-0 right-0 z-50 pointer-events-none">
  224. <div
  225. ref={containerRef}
  226. className={`mx-auto px-4 pb-4 [&>*]:pointer-events-auto transition-all duration-300 ${focused || showMessages ? "max-w-xl" : "max-w-56"}`}
  227. >
  228. <div
  229. className={`border rounded-lg overflow-hidden ${focused || showMessages ? "border-background" : "border-[var(--chat-bg)]"}`}
  230. style={{ backgroundColor: "var(--chat-bg)" }}
  231. >
  232. {/* Suggestions panel */}
  233. {showSuggestions && (
  234. <div>
  235. <div className="flex items-center px-4 py-2 border-b border-background shrink-0">
  236. <span className="text-xs font-medium text-muted-foreground">
  237. json-render Docs
  238. </span>
  239. </div>
  240. <div className="flex flex-wrap gap-2 p-3">
  241. {SUGGESTIONS.map((s) => (
  242. <button
  243. key={s}
  244. type="button"
  245. onMouseDown={(e) => {
  246. e.preventDefault();
  247. setOpen(true);
  248. sendMessage({ text: s });
  249. }}
  250. className="text-xs px-3 py-1.5 rounded-full border border-background bg-background font-medium text-muted-foreground hover:text-foreground transition-colors"
  251. >
  252. {s}
  253. </button>
  254. ))}
  255. </div>
  256. </div>
  257. )}
  258. {/* Messages panel */}
  259. {showMessages && (
  260. <div className="max-h-[60vh] flex flex-col">
  261. <div className="flex items-center justify-between px-4 py-2 border-b border-background shrink-0">
  262. <span className="text-xs font-medium text-muted-foreground">
  263. json-render Docs
  264. </span>
  265. <button
  266. onClick={handleClear}
  267. className="text-xs text-muted-foreground hover:text-foreground transition-colors"
  268. aria-label="Clear conversation"
  269. >
  270. Clear
  271. </button>
  272. </div>
  273. <div
  274. className="p-4 space-y-4 overflow-y-auto"
  275. onClick={(e) => {
  276. if ((e.target as HTMLElement).closest("a")) {
  277. setOpen(false);
  278. }
  279. }}
  280. >
  281. {messages.map((message) => {
  282. if (!hasVisibleContent(message.parts)) return null;
  283. return (
  284. <div key={message.id}>
  285. {message.role === "user" ? (
  286. <div className="text-sm text-muted-foreground whitespace-pre-wrap leading-relaxed">
  287. {message.parts
  288. .filter(
  289. (p): p is Extract<typeof p, { type: "text" }> =>
  290. p.type === "text",
  291. )
  292. .map((p) => p.text)
  293. .join("")}
  294. </div>
  295. ) : (
  296. <div className="space-y-2">
  297. {message.parts.map((part, i) => {
  298. if (part.type === "text" && part.text) {
  299. return (
  300. <div
  301. key={i}
  302. className="docs-chat-content text-sm text-foreground/90 leading-relaxed prose prose-sm dark:prose-invert max-w-none"
  303. >
  304. <Streamdown>{part.text}</Streamdown>
  305. </div>
  306. );
  307. }
  308. if (isToolPart(part)) {
  309. return (
  310. <ToolCallDisplay
  311. key={part.toolCallId}
  312. part={part}
  313. />
  314. );
  315. }
  316. return null;
  317. })}
  318. </div>
  319. )}
  320. </div>
  321. );
  322. })}
  323. {error && (
  324. <div className="text-sm text-destructive/80 bg-destructive/10 rounded-md px-3 py-2">
  325. {(() => {
  326. try {
  327. const parsed = JSON.parse(error.message);
  328. return parsed.message || parsed.error || error.message;
  329. } catch {
  330. return (
  331. error.message ||
  332. "Something went wrong. Please try again."
  333. );
  334. }
  335. })()}
  336. </div>
  337. )}
  338. <div ref={messagesEndRef} />
  339. </div>
  340. </div>
  341. )}
  342. {/* Input bar */}
  343. <form
  344. onSubmit={handleSubmit}
  345. onClick={() => inputRef.current?.focus()}
  346. className={`relative flex items-end gap-2 px-3 py-2 cursor-text${showMessages ? " border-t border-background" : ""}`}
  347. >
  348. {!input && (
  349. <div className="absolute inset-0 flex items-center px-3 pointer-events-none">
  350. <span className="text-sm text-muted-foreground truncate flex-1">
  351. Ask a question...
  352. </span>
  353. {!focused && !showMessages && (
  354. <span className="text-muted-foreground/40 font-mono text-xs shrink-0">
  355. &#8984;K
  356. </span>
  357. )}
  358. </div>
  359. )}
  360. <textarea
  361. ref={inputRef}
  362. value={input}
  363. onChange={(e) => {
  364. setInput(e.target.value);
  365. e.target.style.height = "auto";
  366. e.target.style.height = `${e.target.scrollHeight}px`;
  367. }}
  368. rows={1}
  369. onFocus={() => {
  370. setFocused(true);
  371. if (messages.length > 0) setOpen(true);
  372. }}
  373. onBlur={() => {
  374. setFocused(false);
  375. }}
  376. onKeyDown={(e) => {
  377. if (e.key === "Escape") {
  378. setOpen(false);
  379. inputRef.current?.blur();
  380. }
  381. if (e.key === "Enter" && !e.shiftKey) {
  382. e.preventDefault();
  383. handleSubmit(e);
  384. }
  385. }}
  386. className="flex-1 bg-transparent text-base sm:text-sm text-foreground outline-none disabled:opacity-50 resize-none max-h-32 leading-relaxed relative z-10"
  387. />
  388. <button
  389. type="submit"
  390. disabled={isLoading || !input.trim()}
  391. className={`bg-primary text-primary-foreground rounded-md p-1 hover:bg-primary/90 transition-colors disabled:opacity-30${!focused && !showMessages ? " hidden" : ""}`}
  392. aria-label="Send message"
  393. >
  394. <svg
  395. width="16"
  396. height="16"
  397. viewBox="0 0 24 24"
  398. fill="none"
  399. stroke="currentColor"
  400. strokeWidth="2"
  401. strokeLinecap="round"
  402. strokeLinejoin="round"
  403. >
  404. <line x1="12" y1="19" x2="12" y2="5" />
  405. <polyline points="5 12 12 5 19 12" />
  406. </svg>
  407. </button>
  408. </form>
  409. </div>
  410. </div>
  411. </div>
  412. );
  413. }