docs-chat.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439
  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);
  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 border-background rounded-lg overflow-hidden"
  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. sendMessage({ text: s });
  248. }}
  249. 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"
  250. >
  251. {s}
  252. </button>
  253. ))}
  254. </div>
  255. </div>
  256. )}
  257. {/* Messages panel */}
  258. {showMessages && (
  259. <div className="max-h-[60vh] flex flex-col">
  260. <div className="flex items-center justify-between px-4 py-2 border-b border-background shrink-0">
  261. <span className="text-xs font-medium text-muted-foreground">
  262. json-render Docs
  263. </span>
  264. <button
  265. onClick={handleClear}
  266. className="text-xs text-muted-foreground hover:text-foreground transition-colors"
  267. aria-label="Clear conversation"
  268. >
  269. Clear
  270. </button>
  271. </div>
  272. <div
  273. className="p-4 space-y-4 overflow-y-auto"
  274. onClick={(e) => {
  275. if ((e.target as HTMLElement).closest("a")) {
  276. setOpen(false);
  277. }
  278. }}
  279. >
  280. {messages.map((message) => {
  281. if (!hasVisibleContent(message.parts)) return null;
  282. return (
  283. <div key={message.id}>
  284. {message.role === "user" ? (
  285. <div className="text-sm text-muted-foreground whitespace-pre-wrap leading-relaxed">
  286. {message.parts
  287. .filter(
  288. (p): p is Extract<typeof p, { type: "text" }> =>
  289. p.type === "text",
  290. )
  291. .map((p) => p.text)
  292. .join("")}
  293. </div>
  294. ) : (
  295. <div className="space-y-2">
  296. {message.parts.map((part, i) => {
  297. if (part.type === "text" && part.text) {
  298. return (
  299. <div
  300. key={i}
  301. className="docs-chat-content text-sm text-foreground/90 leading-relaxed prose prose-sm dark:prose-invert max-w-none"
  302. >
  303. <Streamdown>{part.text}</Streamdown>
  304. </div>
  305. );
  306. }
  307. if (isToolPart(part)) {
  308. return (
  309. <ToolCallDisplay
  310. key={part.toolCallId}
  311. part={part}
  312. />
  313. );
  314. }
  315. return null;
  316. })}
  317. </div>
  318. )}
  319. </div>
  320. );
  321. })}
  322. {error && (
  323. <div className="text-sm text-destructive/80 bg-destructive/10 rounded-md px-3 py-2">
  324. {(() => {
  325. try {
  326. const parsed = JSON.parse(error.message);
  327. return parsed.message || parsed.error || error.message;
  328. } catch {
  329. return (
  330. error.message ||
  331. "Something went wrong. Please try again."
  332. );
  333. }
  334. })()}
  335. </div>
  336. )}
  337. <div ref={messagesEndRef} />
  338. </div>
  339. </div>
  340. )}
  341. {/* Input bar */}
  342. <form
  343. onSubmit={handleSubmit}
  344. onClick={() => inputRef.current?.focus()}
  345. className={`relative flex items-end gap-2 px-3 py-2 cursor-text${showMessages ? " border-t border-background" : ""}`}
  346. >
  347. {!input && (
  348. <div className="absolute inset-0 flex items-center px-3 pointer-events-none">
  349. <span className="text-sm text-muted-foreground truncate flex-1">
  350. Ask a question...
  351. </span>
  352. {!focused && !showMessages && (
  353. <span className="text-muted-foreground/40 font-mono text-xs shrink-0">
  354. &#8984;K
  355. </span>
  356. )}
  357. </div>
  358. )}
  359. <textarea
  360. ref={inputRef}
  361. value={input}
  362. onChange={(e) => {
  363. setInput(e.target.value);
  364. e.target.style.height = "auto";
  365. e.target.style.height = `${e.target.scrollHeight}px`;
  366. }}
  367. rows={1}
  368. onFocus={() => {
  369. setFocused(true);
  370. if (messages.length > 0) setOpen(true);
  371. }}
  372. onBlur={() => {
  373. setFocused(false);
  374. }}
  375. onKeyDown={(e) => {
  376. if (e.key === "Escape") {
  377. setOpen(false);
  378. inputRef.current?.blur();
  379. }
  380. if (e.key === "Enter" && !e.shiftKey) {
  381. e.preventDefault();
  382. handleSubmit(e);
  383. }
  384. }}
  385. 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"
  386. />
  387. <button
  388. type="submit"
  389. disabled={isLoading || !input.trim()}
  390. className={`bg-primary text-primary-foreground rounded-md p-1 hover:bg-primary/90 transition-colors disabled:opacity-30${!focused && !showMessages ? " hidden" : ""}`}
  391. aria-label="Send message"
  392. >
  393. <svg
  394. width="16"
  395. height="16"
  396. viewBox="0 0 24 24"
  397. fill="none"
  398. stroke="currentColor"
  399. strokeWidth="2"
  400. strokeLinecap="round"
  401. strokeLinejoin="round"
  402. >
  403. <line x1="12" y1="19" x2="12" y2="5" />
  404. <polyline points="5 12 12 5 19 12" />
  405. </svg>
  406. </button>
  407. </form>
  408. </div>
  409. </div>
  410. </div>
  411. );
  412. }