| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493 |
- "use client";
- import { useState, useCallback, useRef, useEffect } from "react";
- import { useChat } from "@ai-sdk/react";
- import { DefaultChatTransport, type UIMessage } from "ai";
- import {
- SPEC_DATA_PART,
- SPEC_DATA_PART_TYPE,
- type SpecDataPart,
- } from "@json-render/core";
- import { useJsonRenderMessage } from "@json-render/react";
- import { ExplorerRenderer } from "@/lib/render/renderer";
- import { ThemeToggle } from "@/components/theme-toggle";
- import {
- ArrowDown,
- ArrowUp,
- ChevronRight,
- Loader2,
- Sparkles,
- } from "lucide-react";
- import { Streamdown } from "streamdown";
- import { code } from "@streamdown/code";
- // =============================================================================
- // Types
- // =============================================================================
- type AppDataParts = { [SPEC_DATA_PART]: SpecDataPart };
- type AppMessage = UIMessage<unknown, AppDataParts>;
- // =============================================================================
- // Transport
- // =============================================================================
- const transport = new DefaultChatTransport({ api: "/api/generate" });
- // =============================================================================
- // Suggestions (shown in empty state)
- // =============================================================================
- const SUGGESTIONS = [
- {
- label: "Weather comparison",
- prompt: "Compare the weather in New York, London, and Tokyo",
- },
- {
- label: "GitHub repo stats",
- prompt: "Show me stats for the vercel/next.js and vercel/ai GitHub repos",
- },
- {
- label: "Crypto dashboard",
- prompt: "Build a crypto dashboard for Bitcoin, Ethereum, and Solana",
- },
- {
- label: "Hacker News top stories",
- prompt: "Show me the top 15 Hacker News stories right now",
- },
- ];
- // =============================================================================
- // Tool Call Display
- // =============================================================================
- /** Readable labels for tool names: [loading, done] */
- const TOOL_LABELS: Record<string, [string, string]> = {
- getWeather: ["Getting weather data", "Got weather data"],
- getGitHubRepo: ["Fetching GitHub repo", "Fetched GitHub repo"],
- getGitHubPullRequests: ["Fetching pull requests", "Fetched pull requests"],
- getCryptoPrice: ["Looking up crypto price", "Looked up crypto price"],
- getCryptoPriceHistory: ["Fetching price history", "Fetched price history"],
- getHackerNewsTop: ["Loading Hacker News", "Loaded Hacker News"],
- webSearch: ["Searching the web", "Searched the web"],
- };
- function ToolCallDisplay({
- toolName,
- state,
- result,
- }: {
- toolName: string;
- state: string;
- result: unknown;
- }) {
- const [expanded, setExpanded] = useState(false);
- const isLoading =
- state !== "output-available" &&
- state !== "output-error" &&
- state !== "output-denied";
- const labels = TOOL_LABELS[toolName];
- const label = labels ? (isLoading ? labels[0] : labels[1]) : toolName;
- return (
- <div className="text-sm group">
- <button
- type="button"
- className="flex items-center gap-1.5"
- onClick={() => setExpanded((e) => !e)}
- >
- <span
- className={`text-muted-foreground ${isLoading ? "animate-shimmer" : ""}`}
- >
- {label}
- </span>
- {!isLoading && (
- <ChevronRight
- className={`h-3 w-3 text-muted-foreground/0 group-hover:text-muted-foreground transition-all ${expanded ? "rotate-90" : ""}`}
- />
- )}
- </button>
- {expanded && !isLoading && result != null && (
- <div className="mt-1 max-h-64 overflow-auto">
- <pre className="text-xs text-muted-foreground whitespace-pre-wrap break-all">
- {typeof result === "string"
- ? result
- : JSON.stringify(result, null, 2)}
- </pre>
- </div>
- )}
- </div>
- );
- }
- // =============================================================================
- // Message Bubble
- // =============================================================================
- function MessageBubble({
- message,
- isLast,
- isStreaming,
- }: {
- message: AppMessage;
- isLast: boolean;
- isStreaming: boolean;
- }) {
- const isUser = message.role === "user";
- const { spec, text, hasSpec } = useJsonRenderMessage(message.parts);
- // Build ordered segments from parts, collapsing adjacent text and adjacent tools.
- // Spec data parts are tracked so the rendered UI appears inline where the AI
- // placed it rather than always at the bottom.
- const segments: Array<
- | { kind: "text"; text: string }
- | {
- kind: "tools";
- tools: Array<{
- toolCallId: string;
- toolName: string;
- state: string;
- output?: unknown;
- }>;
- }
- | { kind: "spec" }
- > = [];
- let specInserted = false;
- for (const part of message.parts) {
- if (part.type === "text") {
- if (!part.text.trim()) continue;
- const last = segments[segments.length - 1];
- if (last?.kind === "text") {
- last.text += part.text;
- } else {
- segments.push({ kind: "text", text: part.text });
- }
- } else if (part.type.startsWith("tool-")) {
- const tp = part as {
- type: string;
- toolCallId: string;
- state: string;
- output?: unknown;
- };
- const last = segments[segments.length - 1];
- if (last?.kind === "tools") {
- last.tools.push({
- toolCallId: tp.toolCallId,
- toolName: tp.type.replace(/^tool-/, ""),
- state: tp.state,
- output: tp.output,
- });
- } else {
- segments.push({
- kind: "tools",
- tools: [
- {
- toolCallId: tp.toolCallId,
- toolName: tp.type.replace(/^tool-/, ""),
- state: tp.state,
- output: tp.output,
- },
- ],
- });
- }
- } else if (part.type === SPEC_DATA_PART_TYPE && !specInserted) {
- // First spec data part — mark where the rendered UI should appear
- segments.push({ kind: "spec" });
- specInserted = true;
- }
- }
- const hasAnything = segments.length > 0 || hasSpec;
- const showLoader =
- isLast && isStreaming && message.role === "assistant" && !hasAnything;
- if (isUser) {
- return (
- <div className="flex justify-end">
- {text && (
- <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">
- {text}
- </div>
- )}
- </div>
- );
- }
- // If there's a spec but no spec segment was inserted (edge case),
- // append it so it still renders.
- const specRenderedInline = specInserted;
- const showSpecAtEnd = hasSpec && !specRenderedInline;
- return (
- <div className="w-full flex flex-col gap-3">
- {segments.map((seg, i) => {
- if (seg.kind === "text") {
- const isLastSegment = i === segments.length - 1;
- return (
- <div
- key={`text-${i}`}
- className="text-sm leading-relaxed [&_p+p]:mt-3 [&_ul]:mt-2 [&_ol]:mt-2 [&_pre]:mt-2"
- >
- <Streamdown
- plugins={{ code }}
- animated={isLast && isStreaming && isLastSegment}
- >
- {seg.text}
- </Streamdown>
- </div>
- );
- }
- if (seg.kind === "spec") {
- if (!hasSpec) return null;
- return (
- <div key="spec" className="w-full">
- <ExplorerRenderer spec={spec} loading={isLast && isStreaming} />
- </div>
- );
- }
- return (
- <div key={`tools-${i}`} className="flex flex-col gap-1">
- {seg.tools.map((t) => (
- <ToolCallDisplay
- key={t.toolCallId}
- toolName={t.toolName}
- state={t.state}
- result={t.output}
- />
- ))}
- </div>
- );
- })}
- {/* Loading indicator */}
- {showLoader && (
- <div className="text-sm text-muted-foreground animate-shimmer">
- Thinking...
- </div>
- )}
- {/* Fallback: render spec at end if no inline position was found */}
- {showSpecAtEnd && (
- <div className="w-full">
- <ExplorerRenderer spec={spec} loading={isLast && isStreaming} />
- </div>
- )}
- </div>
- );
- }
- // =============================================================================
- // Page
- // =============================================================================
- export default function ChatPage() {
- const [input, setInput] = useState("");
- const messagesEndRef = useRef<HTMLDivElement>(null);
- const scrollContainerRef = useRef<HTMLElement>(null);
- const [showScrollButton, setShowScrollButton] = useState(false);
- const isStickToBottom = useRef(true);
- const isAutoScrolling = useRef(false);
- const inputRef = useRef<HTMLTextAreaElement>(null);
- const { messages, sendMessage, setMessages, status, error } =
- useChat<AppMessage>({ transport });
- const isStreaming = status === "streaming" || status === "submitted";
- // Track whether the user has scrolled away from the bottom.
- // During programmatic scrolling, suppress button updates until we arrive.
- useEffect(() => {
- const container = scrollContainerRef.current;
- if (!container) return;
- const THRESHOLD = 80;
- const handleScroll = () => {
- const { scrollTop, scrollHeight, clientHeight } = container;
- const atBottom = scrollTop + clientHeight >= scrollHeight - THRESHOLD;
- if (isAutoScrolling.current) {
- // Wait for the programmatic scroll to reach the bottom before
- // handing control back to the user-scroll tracker.
- if (atBottom) {
- isAutoScrolling.current = false;
- }
- return;
- }
- isStickToBottom.current = atBottom;
- setShowScrollButton(!atBottom);
- };
- container.addEventListener("scroll", handleScroll, { passive: true });
- return () => container.removeEventListener("scroll", handleScroll);
- }, []);
- // Auto-scroll to bottom on new messages, unless user scrolled up.
- // Uses instant scrollTop assignment (no smooth animation) to avoid
- // an ongoing animation that fights user scroll input.
- useEffect(() => {
- const container = scrollContainerRef.current;
- if (!container || !isStickToBottom.current) return;
- isAutoScrolling.current = true;
- container.scrollTop = container.scrollHeight;
- requestAnimationFrame(() => {
- isAutoScrolling.current = false;
- });
- }, [messages, isStreaming]);
- const scrollToBottom = useCallback(() => {
- const container = scrollContainerRef.current;
- if (!container) return;
- isStickToBottom.current = true;
- setShowScrollButton(false);
- isAutoScrolling.current = true;
- container.scrollTo({ top: container.scrollHeight, behavior: "smooth" });
- // isAutoScrolling is cleared by the scroll handler once it reaches bottom
- }, []);
- const handleSubmit = useCallback(
- async (text?: string) => {
- const message = text || input;
- if (!message.trim() || isStreaming) return;
- setInput("");
- await sendMessage({ text: message.trim() });
- },
- [input, isStreaming, sendMessage],
- );
- const handleKeyDown = useCallback(
- (e: React.KeyboardEvent) => {
- if (e.key === "Enter" && !e.shiftKey) {
- e.preventDefault();
- handleSubmit();
- }
- },
- [handleSubmit],
- );
- const handleClear = useCallback(() => {
- setMessages([]);
- setInput("");
- inputRef.current?.focus();
- }, [setMessages]);
- const isEmpty = messages.length === 0;
- return (
- <div className="h-screen flex flex-col overflow-hidden">
- {/* Header */}
- <header className="border-b px-6 py-3 flex items-center justify-between flex-shrink-0">
- <div className="flex items-center gap-3">
- <h1 className="text-lg font-semibold">json-render Chat Example</h1>
- </div>
- <div className="flex items-center gap-2">
- {messages.length > 0 && (
- <button
- onClick={handleClear}
- className="px-3 py-1.5 rounded-md text-sm text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
- >
- Start Over
- </button>
- )}
- <ThemeToggle />
- </div>
- </header>
- {/* Messages area */}
- <main ref={scrollContainerRef} className="flex-1 overflow-auto">
- {isEmpty ? (
- /* Empty state */
- <div className="h-full flex flex-col items-center justify-center px-6 py-12">
- <div className="max-w-2xl w-full space-y-8">
- <div className="text-center space-y-2">
- <h2 className="text-2xl font-semibold tracking-tight">
- What would you like to explore?
- </h2>
- <p className="text-muted-foreground">
- Ask about weather, GitHub repos, crypto prices, or Hacker News
- -- the agent will fetch real data and build a dashboard.
- </p>
- </div>
- {/* Suggestions */}
- <div className="flex flex-wrap gap-2 justify-center">
- {SUGGESTIONS.map((s) => (
- <button
- key={s.label}
- onClick={() => handleSubmit(s.prompt)}
- 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"
- >
- <Sparkles className="h-3 w-3" />
- {s.label}
- </button>
- ))}
- </div>
- </div>
- </div>
- ) : (
- /* Message thread */
- <div className="max-w-4xl mx-auto px-10 py-6 space-y-6">
- {messages.map((message, index) => (
- <MessageBubble
- key={message.id}
- message={message}
- isLast={index === messages.length - 1}
- isStreaming={isStreaming}
- />
- ))}
- {/* Error display */}
- {error && (
- <div className="rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
- {error.message}
- </div>
- )}
- <div ref={messagesEndRef} />
- </div>
- )}
- </main>
- {/* Input bar - always visible at bottom */}
- <div className="px-6 pb-3 flex-shrink-0 bg-background relative">
- {/* Scroll to bottom button */}
- {showScrollButton && !isEmpty && (
- <button
- onClick={scrollToBottom}
- 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"
- aria-label="Scroll to bottom"
- >
- <ArrowDown className="h-4 w-4" />
- </button>
- )}
- <div className="max-w-4xl mx-auto relative">
- <textarea
- ref={inputRef}
- value={input}
- onChange={(e) => setInput(e.target.value)}
- onKeyDown={handleKeyDown}
- placeholder={
- isEmpty
- ? "e.g., Compare weather in NYC, London, and Tokyo..."
- : "Ask a follow-up..."
- }
- rows={2}
- 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"
- autoFocus
- />
- <button
- onClick={() => handleSubmit()}
- disabled={!input.trim() || isStreaming}
- 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"
- >
- {isStreaming ? (
- <Loader2 className="h-4 w-4 animate-spin" />
- ) : (
- <ArrowUp className="h-4 w-4" />
- )}
- </button>
- </div>
- </div>
- </div>
- );
- }
|