page.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493
  1. "use client";
  2. import { useState, useCallback, useRef, useEffect } from "react";
  3. import { useChat } from "@ai-sdk/react";
  4. import { DefaultChatTransport, type UIMessage } from "ai";
  5. import {
  6. SPEC_DATA_PART,
  7. SPEC_DATA_PART_TYPE,
  8. type SpecDataPart,
  9. } from "@json-render/core";
  10. import { useJsonRenderMessage } from "@json-render/react";
  11. import { ExplorerRenderer } from "@/lib/render/renderer";
  12. import { ThemeToggle } from "@/components/theme-toggle";
  13. import {
  14. ArrowDown,
  15. ArrowUp,
  16. ChevronRight,
  17. Loader2,
  18. Sparkles,
  19. } from "lucide-react";
  20. import { Streamdown } from "streamdown";
  21. import { code } from "@streamdown/code";
  22. // =============================================================================
  23. // Types
  24. // =============================================================================
  25. type AppDataParts = { [SPEC_DATA_PART]: SpecDataPart };
  26. type AppMessage = UIMessage<unknown, AppDataParts>;
  27. // =============================================================================
  28. // Transport
  29. // =============================================================================
  30. const transport = new DefaultChatTransport({ api: "/api/generate" });
  31. // =============================================================================
  32. // Suggestions (shown in empty state)
  33. // =============================================================================
  34. const SUGGESTIONS = [
  35. {
  36. label: "Weather comparison",
  37. prompt: "Compare the weather in New York, London, and Tokyo",
  38. },
  39. {
  40. label: "GitHub repo stats",
  41. prompt: "Show me stats for the vercel/next.js and vercel/ai GitHub repos",
  42. },
  43. {
  44. label: "Crypto dashboard",
  45. prompt: "Build a crypto dashboard for Bitcoin, Ethereum, and Solana",
  46. },
  47. {
  48. label: "Hacker News top stories",
  49. prompt: "Show me the top 15 Hacker News stories right now",
  50. },
  51. ];
  52. // =============================================================================
  53. // Tool Call Display
  54. // =============================================================================
  55. /** Readable labels for tool names: [loading, done] */
  56. const TOOL_LABELS: Record<string, [string, string]> = {
  57. getWeather: ["Getting weather data", "Got weather data"],
  58. getGitHubRepo: ["Fetching GitHub repo", "Fetched GitHub repo"],
  59. getGitHubPullRequests: ["Fetching pull requests", "Fetched pull requests"],
  60. getCryptoPrice: ["Looking up crypto price", "Looked up crypto price"],
  61. getCryptoPriceHistory: ["Fetching price history", "Fetched price history"],
  62. getHackerNewsTop: ["Loading Hacker News", "Loaded Hacker News"],
  63. webSearch: ["Searching the web", "Searched the web"],
  64. };
  65. function ToolCallDisplay({
  66. toolName,
  67. state,
  68. result,
  69. }: {
  70. toolName: string;
  71. state: string;
  72. result: unknown;
  73. }) {
  74. const [expanded, setExpanded] = useState(false);
  75. const isLoading =
  76. state !== "output-available" &&
  77. state !== "output-error" &&
  78. state !== "output-denied";
  79. const labels = TOOL_LABELS[toolName];
  80. const label = labels ? (isLoading ? labels[0] : labels[1]) : toolName;
  81. return (
  82. <div className="text-sm group">
  83. <button
  84. type="button"
  85. className="flex items-center gap-1.5"
  86. onClick={() => setExpanded((e) => !e)}
  87. >
  88. <span
  89. className={`text-muted-foreground ${isLoading ? "animate-shimmer" : ""}`}
  90. >
  91. {label}
  92. </span>
  93. {!isLoading && (
  94. <ChevronRight
  95. className={`h-3 w-3 text-muted-foreground/0 group-hover:text-muted-foreground transition-all ${expanded ? "rotate-90" : ""}`}
  96. />
  97. )}
  98. </button>
  99. {expanded && !isLoading && result != null && (
  100. <div className="mt-1 max-h-64 overflow-auto">
  101. <pre className="text-xs text-muted-foreground whitespace-pre-wrap break-all">
  102. {typeof result === "string"
  103. ? result
  104. : JSON.stringify(result, null, 2)}
  105. </pre>
  106. </div>
  107. )}
  108. </div>
  109. );
  110. }
  111. // =============================================================================
  112. // Message Bubble
  113. // =============================================================================
  114. function MessageBubble({
  115. message,
  116. isLast,
  117. isStreaming,
  118. }: {
  119. message: AppMessage;
  120. isLast: boolean;
  121. isStreaming: boolean;
  122. }) {
  123. const isUser = message.role === "user";
  124. const { spec, text, hasSpec } = useJsonRenderMessage(message.parts);
  125. // Build ordered segments from parts, collapsing adjacent text and adjacent tools.
  126. // Spec data parts are tracked so the rendered UI appears inline where the AI
  127. // placed it rather than always at the bottom.
  128. const segments: Array<
  129. | { kind: "text"; text: string }
  130. | {
  131. kind: "tools";
  132. tools: Array<{
  133. toolCallId: string;
  134. toolName: string;
  135. state: string;
  136. output?: unknown;
  137. }>;
  138. }
  139. | { kind: "spec" }
  140. > = [];
  141. let specInserted = false;
  142. for (const part of message.parts) {
  143. if (part.type === "text") {
  144. if (!part.text.trim()) continue;
  145. const last = segments[segments.length - 1];
  146. if (last?.kind === "text") {
  147. last.text += part.text;
  148. } else {
  149. segments.push({ kind: "text", text: part.text });
  150. }
  151. } else if (part.type.startsWith("tool-")) {
  152. const tp = part as {
  153. type: string;
  154. toolCallId: string;
  155. state: string;
  156. output?: unknown;
  157. };
  158. const last = segments[segments.length - 1];
  159. if (last?.kind === "tools") {
  160. last.tools.push({
  161. toolCallId: tp.toolCallId,
  162. toolName: tp.type.replace(/^tool-/, ""),
  163. state: tp.state,
  164. output: tp.output,
  165. });
  166. } else {
  167. segments.push({
  168. kind: "tools",
  169. tools: [
  170. {
  171. toolCallId: tp.toolCallId,
  172. toolName: tp.type.replace(/^tool-/, ""),
  173. state: tp.state,
  174. output: tp.output,
  175. },
  176. ],
  177. });
  178. }
  179. } else if (part.type === SPEC_DATA_PART_TYPE && !specInserted) {
  180. // First spec data part — mark where the rendered UI should appear
  181. segments.push({ kind: "spec" });
  182. specInserted = true;
  183. }
  184. }
  185. const hasAnything = segments.length > 0 || hasSpec;
  186. const showLoader =
  187. isLast && isStreaming && message.role === "assistant" && !hasAnything;
  188. if (isUser) {
  189. return (
  190. <div className="flex justify-end">
  191. {text && (
  192. <div className="max-w-[85%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed whitespace-pre-wrap bg-primary text-primary-foreground rounded-tr-md">
  193. {text}
  194. </div>
  195. )}
  196. </div>
  197. );
  198. }
  199. // If there's a spec but no spec segment was inserted (edge case),
  200. // append it so it still renders.
  201. const specRenderedInline = specInserted;
  202. const showSpecAtEnd = hasSpec && !specRenderedInline;
  203. return (
  204. <div className="w-full flex flex-col gap-3">
  205. {segments.map((seg, i) => {
  206. if (seg.kind === "text") {
  207. const isLastSegment = i === segments.length - 1;
  208. return (
  209. <div
  210. key={`text-${i}`}
  211. className="text-sm leading-relaxed [&_p+p]:mt-3 [&_ul]:mt-2 [&_ol]:mt-2 [&_pre]:mt-2"
  212. >
  213. <Streamdown
  214. plugins={{ code }}
  215. animated={isLast && isStreaming && isLastSegment}
  216. >
  217. {seg.text}
  218. </Streamdown>
  219. </div>
  220. );
  221. }
  222. if (seg.kind === "spec") {
  223. if (!hasSpec) return null;
  224. return (
  225. <div key="spec" className="w-full">
  226. <ExplorerRenderer spec={spec} loading={isLast && isStreaming} />
  227. </div>
  228. );
  229. }
  230. return (
  231. <div key={`tools-${i}`} className="flex flex-col gap-1">
  232. {seg.tools.map((t) => (
  233. <ToolCallDisplay
  234. key={t.toolCallId}
  235. toolName={t.toolName}
  236. state={t.state}
  237. result={t.output}
  238. />
  239. ))}
  240. </div>
  241. );
  242. })}
  243. {/* Loading indicator */}
  244. {showLoader && (
  245. <div className="text-sm text-muted-foreground animate-shimmer">
  246. Thinking...
  247. </div>
  248. )}
  249. {/* Fallback: render spec at end if no inline position was found */}
  250. {showSpecAtEnd && (
  251. <div className="w-full">
  252. <ExplorerRenderer spec={spec} loading={isLast && isStreaming} />
  253. </div>
  254. )}
  255. </div>
  256. );
  257. }
  258. // =============================================================================
  259. // Page
  260. // =============================================================================
  261. export default function ChatPage() {
  262. const [input, setInput] = useState("");
  263. const messagesEndRef = useRef<HTMLDivElement>(null);
  264. const scrollContainerRef = useRef<HTMLElement>(null);
  265. const [showScrollButton, setShowScrollButton] = useState(false);
  266. const isStickToBottom = useRef(true);
  267. const isAutoScrolling = useRef(false);
  268. const inputRef = useRef<HTMLTextAreaElement>(null);
  269. const { messages, sendMessage, setMessages, status, error } =
  270. useChat<AppMessage>({ transport });
  271. const isStreaming = status === "streaming" || status === "submitted";
  272. // Track whether the user has scrolled away from the bottom.
  273. // During programmatic scrolling, suppress button updates until we arrive.
  274. useEffect(() => {
  275. const container = scrollContainerRef.current;
  276. if (!container) return;
  277. const THRESHOLD = 80;
  278. const handleScroll = () => {
  279. const { scrollTop, scrollHeight, clientHeight } = container;
  280. const atBottom = scrollTop + clientHeight >= scrollHeight - THRESHOLD;
  281. if (isAutoScrolling.current) {
  282. // Wait for the programmatic scroll to reach the bottom before
  283. // handing control back to the user-scroll tracker.
  284. if (atBottom) {
  285. isAutoScrolling.current = false;
  286. }
  287. return;
  288. }
  289. isStickToBottom.current = atBottom;
  290. setShowScrollButton(!atBottom);
  291. };
  292. container.addEventListener("scroll", handleScroll, { passive: true });
  293. return () => container.removeEventListener("scroll", handleScroll);
  294. }, []);
  295. // Auto-scroll to bottom on new messages, unless user scrolled up.
  296. // Uses instant scrollTop assignment (no smooth animation) to avoid
  297. // an ongoing animation that fights user scroll input.
  298. useEffect(() => {
  299. const container = scrollContainerRef.current;
  300. if (!container || !isStickToBottom.current) return;
  301. isAutoScrolling.current = true;
  302. container.scrollTop = container.scrollHeight;
  303. requestAnimationFrame(() => {
  304. isAutoScrolling.current = false;
  305. });
  306. }, [messages, isStreaming]);
  307. const scrollToBottom = useCallback(() => {
  308. const container = scrollContainerRef.current;
  309. if (!container) return;
  310. isStickToBottom.current = true;
  311. setShowScrollButton(false);
  312. isAutoScrolling.current = true;
  313. container.scrollTo({ top: container.scrollHeight, behavior: "smooth" });
  314. // isAutoScrolling is cleared by the scroll handler once it reaches bottom
  315. }, []);
  316. const handleSubmit = useCallback(
  317. async (text?: string) => {
  318. const message = text || input;
  319. if (!message.trim() || isStreaming) return;
  320. setInput("");
  321. await sendMessage({ text: message.trim() });
  322. },
  323. [input, isStreaming, sendMessage],
  324. );
  325. const handleKeyDown = useCallback(
  326. (e: React.KeyboardEvent) => {
  327. if (e.key === "Enter" && !e.shiftKey) {
  328. e.preventDefault();
  329. handleSubmit();
  330. }
  331. },
  332. [handleSubmit],
  333. );
  334. const handleClear = useCallback(() => {
  335. setMessages([]);
  336. setInput("");
  337. inputRef.current?.focus();
  338. }, [setMessages]);
  339. const isEmpty = messages.length === 0;
  340. return (
  341. <div className="h-screen flex flex-col overflow-hidden">
  342. {/* Header */}
  343. <header className="border-b px-6 py-3 flex items-center justify-between flex-shrink-0">
  344. <div className="flex items-center gap-3">
  345. <h1 className="text-lg font-semibold">json-render Chat Example</h1>
  346. </div>
  347. <div className="flex items-center gap-2">
  348. {messages.length > 0 && (
  349. <button
  350. onClick={handleClear}
  351. className="px-3 py-1.5 rounded-md text-sm text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
  352. >
  353. Start Over
  354. </button>
  355. )}
  356. <ThemeToggle />
  357. </div>
  358. </header>
  359. {/* Messages area */}
  360. <main ref={scrollContainerRef} className="flex-1 overflow-auto">
  361. {isEmpty ? (
  362. /* Empty state */
  363. <div className="h-full flex flex-col items-center justify-center px-6 py-12">
  364. <div className="max-w-2xl w-full space-y-8">
  365. <div className="text-center space-y-2">
  366. <h2 className="text-2xl font-semibold tracking-tight">
  367. What would you like to explore?
  368. </h2>
  369. <p className="text-muted-foreground">
  370. Ask about weather, GitHub repos, crypto prices, or Hacker News
  371. -- the agent will fetch real data and build a dashboard.
  372. </p>
  373. </div>
  374. {/* Suggestions */}
  375. <div className="flex flex-wrap gap-2 justify-center">
  376. {SUGGESTIONS.map((s) => (
  377. <button
  378. key={s.label}
  379. onClick={() => handleSubmit(s.prompt)}
  380. className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-border text-sm text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
  381. >
  382. <Sparkles className="h-3 w-3" />
  383. {s.label}
  384. </button>
  385. ))}
  386. </div>
  387. </div>
  388. </div>
  389. ) : (
  390. /* Message thread */
  391. <div className="max-w-4xl mx-auto px-10 py-6 space-y-6">
  392. {messages.map((message, index) => (
  393. <MessageBubble
  394. key={message.id}
  395. message={message}
  396. isLast={index === messages.length - 1}
  397. isStreaming={isStreaming}
  398. />
  399. ))}
  400. {/* Error display */}
  401. {error && (
  402. <div className="rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
  403. {error.message}
  404. </div>
  405. )}
  406. <div ref={messagesEndRef} />
  407. </div>
  408. )}
  409. </main>
  410. {/* Input bar - always visible at bottom */}
  411. <div className="px-6 pb-3 flex-shrink-0 bg-background relative">
  412. {/* Scroll to bottom button */}
  413. {showScrollButton && !isEmpty && (
  414. <button
  415. onClick={scrollToBottom}
  416. className="absolute left-1/2 -translate-x-1/2 -top-10 z-10 h-8 w-8 rounded-full border border-border bg-background text-muted-foreground shadow-md flex items-center justify-center hover:text-foreground hover:bg-accent transition-colors"
  417. aria-label="Scroll to bottom"
  418. >
  419. <ArrowDown className="h-4 w-4" />
  420. </button>
  421. )}
  422. <div className="max-w-4xl mx-auto relative">
  423. <textarea
  424. ref={inputRef}
  425. value={input}
  426. onChange={(e) => setInput(e.target.value)}
  427. onKeyDown={handleKeyDown}
  428. placeholder={
  429. isEmpty
  430. ? "e.g., Compare weather in NYC, London, and Tokyo..."
  431. : "Ask a follow-up..."
  432. }
  433. rows={2}
  434. className="w-full resize-none rounded-xl border border-input bg-card px-4 py-3 pr-12 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
  435. autoFocus
  436. />
  437. <button
  438. onClick={() => handleSubmit()}
  439. disabled={!input.trim() || isStreaming}
  440. className="absolute right-3 bottom-3 h-8 w-8 rounded-lg bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
  441. >
  442. {isStreaming ? (
  443. <Loader2 className="h-4 w-4 animate-spin" />
  444. ) : (
  445. <ArrowUp className="h-4 w-4" />
  446. )}
  447. </button>
  448. </div>
  449. </div>
  450. </div>
  451. );
  452. }