page.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. "use client";
  2. import {
  3. useCallback,
  4. useEffect,
  5. useMemo,
  6. useRef,
  7. useState,
  8. type ReactNode,
  9. } from "react";
  10. import { useChat } from "@ai-sdk/react";
  11. import { DefaultChatTransport, type UIMessage } from "ai";
  12. import {
  13. SPEC_DATA_PART,
  14. type SpecDataPart,
  15. type Spec,
  16. } from "@json-render/core";
  17. import {
  18. JSONUIProvider,
  19. Renderer,
  20. buildSpecFromParts,
  21. useJsonRenderMessage,
  22. useStateStore,
  23. } from "@json-render/react";
  24. import { JsonRenderDevtools } from "@json-render/devtools-react";
  25. import { registry } from "@/lib/registry";
  26. import { catalog } from "@/lib/catalog";
  27. // Shared computed helpers the AI can reference with $computed. Keeping these
  28. // minimal avoids surprising the agent: +/-/toggle cover the most common cases.
  29. const computedFunctions = {
  30. inc: (args: Record<string, unknown>) =>
  31. ((args.of as number | undefined) ?? 0) +
  32. ((args.by as number | undefined) ?? 1),
  33. dec: (args: Record<string, unknown>) =>
  34. ((args.of as number | undefined) ?? 0) -
  35. ((args.by as number | undefined) ?? 1),
  36. toggle: (args: Record<string, unknown>) => !(args.of as boolean | undefined),
  37. add: (args: Record<string, unknown>) =>
  38. ((args.a as number | undefined) ?? 0) +
  39. ((args.b as number | undefined) ?? 0),
  40. };
  41. // ---------------------------------------------------------------------------
  42. // Types & transport
  43. // ---------------------------------------------------------------------------
  44. type AppDataParts = { [SPEC_DATA_PART]: SpecDataPart };
  45. type AppMessage = UIMessage<unknown, AppDataParts>;
  46. const transport = new DefaultChatTransport({ api: "/api/chat" });
  47. const SUGGESTIONS = [
  48. {
  49. label: "Counter",
  50. prompt: "Build an interactive counter with +/- and reset buttons",
  51. blurb: "Dispatches setState actions visible in the Actions panel.",
  52. },
  53. {
  54. label: "Todo list",
  55. prompt:
  56. "Make a todo list where I can type a task, add it, mark it done, and remove it",
  57. blurb: "Shows pushState / removeState and two-way bound inputs.",
  58. },
  59. {
  60. label: "Dashboard",
  61. prompt:
  62. "Show me a fitness tracker with three metrics (steps, calories, sleep), progress bars, and a tip callout",
  63. blurb: "Metrics + progress — good State and Stream panel content.",
  64. },
  65. {
  66. label: "Quiz",
  67. prompt:
  68. "Quiz me on three world geography questions with checkboxes and a submit button that reveals my score",
  69. blurb: "Bindings + conditional visibility in the Spec panel.",
  70. },
  71. ];
  72. // ---------------------------------------------------------------------------
  73. // MessageSpecRenderer — one <Renderer /> per assistant message, all wired
  74. // into the same top-level state store so the devtools sees everything.
  75. // ---------------------------------------------------------------------------
  76. function MessageSpecRenderer({ spec }: { spec: Spec }): ReactNode {
  77. const { update, getSnapshot } = useStateStore();
  78. const seeded = useRef<Set<string>>(new Set());
  79. // Seed the shared store with this message's initial state the first time
  80. // each top-level key appears. The AI is prompted to namespace every path
  81. // with the message id, so keys from different messages never collide.
  82. // We only seed keys that aren't already in the store, so live user edits
  83. // aren't clobbered by late-arriving patches.
  84. useEffect(() => {
  85. const branch = spec.state as Record<string, unknown> | undefined;
  86. if (!branch) return;
  87. const current = getSnapshot() as Record<string, unknown>;
  88. const updates: Record<string, unknown> = {};
  89. for (const [key, value] of Object.entries(branch)) {
  90. if (seeded.current.has(key)) continue;
  91. seeded.current.add(key);
  92. if (current[key] === undefined) {
  93. updates[`/${key}`] = value;
  94. }
  95. }
  96. if (Object.keys(updates).length > 0) {
  97. update(updates);
  98. }
  99. }, [spec.state, update, getSnapshot]);
  100. return (
  101. <div className="spec-wrap">
  102. <Renderer spec={spec} registry={registry} />
  103. </div>
  104. );
  105. }
  106. // ---------------------------------------------------------------------------
  107. // Bubbles
  108. // ---------------------------------------------------------------------------
  109. function UserBubble({ text }: { text: string }) {
  110. return (
  111. <div className="row user">
  112. <div className="bubble user">{text}</div>
  113. </div>
  114. );
  115. }
  116. function AssistantBubble({
  117. message,
  118. isLast,
  119. isStreaming,
  120. }: {
  121. message: AppMessage;
  122. isLast: boolean;
  123. isStreaming: boolean;
  124. }) {
  125. const { spec, text, hasSpec } = useJsonRenderMessage(message.parts);
  126. const showThinking = isLast && isStreaming && !text && !hasSpec;
  127. return (
  128. <div className="row assistant">
  129. <div className="assistant-inner">
  130. {showThinking && <div className="thinking jr-shimmer">Thinking…</div>}
  131. {text && <div className="assistant-text">{text}</div>}
  132. {hasSpec && spec && <MessageSpecRenderer spec={spec} />}
  133. </div>
  134. </div>
  135. );
  136. }
  137. // ---------------------------------------------------------------------------
  138. // Empty state
  139. // ---------------------------------------------------------------------------
  140. function EmptyState({ onPick }: { onPick: (prompt: string) => void }) {
  141. return (
  142. <div className="empty">
  143. <div className="empty-inner">
  144. <div className="eyebrow">json-render devtools</div>
  145. <h1>Chat with AI, inspect every renderer.</h1>
  146. <p className="lead">
  147. Each assistant reply streams a fresh <code>Spec</code> rendered inline
  148. below the message. One floating devtools panel captures every streamed
  149. patch, state change, and dispatched action — across every message on
  150. this page.
  151. </p>
  152. <div className="callout-row">
  153. <div className="callout">
  154. <div className="cap">Tip</div>
  155. <div>
  156. The panel is already open. Switch between Spec, State, Actions,
  157. Stream, Catalog and Pick to see what each renderer exposes.
  158. </div>
  159. </div>
  160. <div className="callout">
  161. <div className="cap">Shortcut</div>
  162. <div>
  163. Toggle the panel with <kbd>⌘</kbd> <kbd>⇧</kbd> <kbd>J</kbd> or
  164. click the <code>{"{}"}</code> badge.
  165. </div>
  166. </div>
  167. </div>
  168. <div className="sugg-grid">
  169. {SUGGESTIONS.map((s) => (
  170. <button
  171. key={s.label}
  172. type="button"
  173. className="sugg"
  174. onClick={() => onPick(s.prompt)}
  175. >
  176. <div className="sugg-label">{s.label}</div>
  177. <div className="sugg-prompt">{s.prompt}</div>
  178. <div className="sugg-blurb">{s.blurb}</div>
  179. </button>
  180. ))}
  181. </div>
  182. </div>
  183. </div>
  184. );
  185. }
  186. // ---------------------------------------------------------------------------
  187. // Page
  188. // ---------------------------------------------------------------------------
  189. export default function Page() {
  190. const [input, setInput] = useState("");
  191. const listRef = useRef<HTMLDivElement>(null);
  192. const taRef = useRef<HTMLTextAreaElement>(null);
  193. const { messages, sendMessage, setMessages, status, error } =
  194. useChat<AppMessage>({ transport });
  195. const isStreaming = status === "streaming" || status === "submitted";
  196. // The devtools Spec panel inspects one spec at a time; we show the most
  197. // recent assistant message's spec as a sensible default.
  198. const currentSpec = useMemo<Spec | null>(() => {
  199. for (let i = messages.length - 1; i >= 0; i--) {
  200. const m = messages[i];
  201. if (m.role !== "assistant") continue;
  202. const spec = buildSpecFromParts(m.parts);
  203. if (spec) return spec;
  204. }
  205. return null;
  206. }, [messages]);
  207. const handleSubmit = useCallback(
  208. (preset?: string) => {
  209. const text = (preset ?? input).trim();
  210. if (!text || isStreaming) return;
  211. setInput("");
  212. void sendMessage({ text });
  213. taRef.current?.focus();
  214. },
  215. [input, isStreaming, sendMessage],
  216. );
  217. // Auto-scroll to bottom on new content.
  218. useEffect(() => {
  219. const el = listRef.current;
  220. if (!el) return;
  221. el.scrollTop = el.scrollHeight;
  222. }, [messages, isStreaming]);
  223. return (
  224. <JSONUIProvider
  225. registry={registry}
  226. initialState={{}}
  227. functions={computedFunctions}
  228. >
  229. <div className="app">
  230. <header className="topbar">
  231. <div className="topbar-brand">
  232. <div className="logo-dot" aria-hidden />
  233. <div className="topbar-text">
  234. <div className="topbar-title">json-render devtools</div>
  235. <div className="topbar-sub">
  236. AI chat · shared state · one panel
  237. </div>
  238. </div>
  239. </div>
  240. <div className="topbar-actions">
  241. {messages.length > 0 && (
  242. <button
  243. className="btn-ghost"
  244. onClick={() => setMessages([])}
  245. type="button"
  246. >
  247. Clear chat
  248. </button>
  249. )}
  250. <a
  251. className="btn-link"
  252. href="https://json-render.dev/docs/devtools"
  253. target="_blank"
  254. rel="noreferrer"
  255. >
  256. Docs ↗
  257. </a>
  258. </div>
  259. </header>
  260. <main ref={listRef} className="scroll">
  261. {messages.length === 0 ? (
  262. <EmptyState onPick={(p) => handleSubmit(p)} />
  263. ) : (
  264. <div className="thread">
  265. {messages.map((m, i) => {
  266. const isLast = i === messages.length - 1;
  267. if (m.role === "user") {
  268. const t = m.parts
  269. .filter((p) => p.type === "text")
  270. .map((p) => (p as { text: string }).text)
  271. .join("");
  272. return <UserBubble key={m.id} text={t} />;
  273. }
  274. return (
  275. <AssistantBubble
  276. key={m.id}
  277. message={m}
  278. isLast={isLast}
  279. isStreaming={isStreaming}
  280. />
  281. );
  282. })}
  283. {error && <div className="error">{error.message}</div>}
  284. </div>
  285. )}
  286. </main>
  287. <div className="composer">
  288. <div className="composer-inner">
  289. <textarea
  290. ref={taRef}
  291. value={input}
  292. onChange={(e) => setInput(e.target.value)}
  293. onKeyDown={(e) => {
  294. if (e.key === "Enter" && !e.shiftKey) {
  295. e.preventDefault();
  296. handleSubmit();
  297. }
  298. }}
  299. placeholder={
  300. messages.length === 0
  301. ? "Ask the AI to build a counter, a todo list, a dashboard…"
  302. : "Ask a follow-up…"
  303. }
  304. rows={2}
  305. />
  306. <button
  307. type="button"
  308. onClick={() => handleSubmit()}
  309. disabled={!input.trim() || isStreaming}
  310. className="btn-primary"
  311. >
  312. {isStreaming ? "…" : "Send"}
  313. </button>
  314. </div>
  315. <div className="composer-hint">
  316. Toggle devtools with <kbd>⌘</kbd> <kbd>⇧</kbd> <kbd>J</kbd>.
  317. </div>
  318. </div>
  319. </div>
  320. <JsonRenderDevtools
  321. spec={currentSpec}
  322. catalog={catalog}
  323. messages={messages}
  324. initialOpen
  325. />
  326. </JSONUIProvider>
  327. );
  328. }