Selaa lähdekoodia

better chat (#92)

* better chat

* fixes

* fix lint
Chris Tate 5 kuukautta sitten
vanhempi
commit
2b3f3a723f

+ 4 - 0
apps/web/.env.example

@@ -12,3 +12,7 @@ AI_GATEWAY_MODEL=anthropic/claude-haiku-4.5
 # Automatically populated when you add Vercel KV to your project
 KV_REST_API_URL=
 KV_REST_API_TOKEN=
+
+# Rate Limiting
+# RATE_LIMIT_PER_MINUTE=10
+# RATE_LIMIT_PER_DAY=100

+ 0 - 2
apps/web/app/(main)/docs/layout.tsx

@@ -1,7 +1,6 @@
 import { DocsMobileNav } from "@/components/docs-mobile-nav";
 import { DocsSidebar } from "@/components/docs-sidebar";
 import { CopyPageButton } from "@/components/copy-page-button";
-import { DocsChat } from "@/components/docs-chat";
 
 export default function DocsLayout({
   children,
@@ -25,7 +24,6 @@ export default function DocsLayout({
           <article>{children}</article>
         </div>
       </div>
-      <DocsChat />
     </>
   );
 }

+ 3 - 3
apps/web/app/api/docs-chat/route.ts

@@ -18,11 +18,11 @@ GitHub repository: https://github.com/vercel-labs/json-render
 Documentation: https://json-render.dev/docs
 npm packages: @json-render/core, @json-render/react, @json-render/remotion, @json-render/codegen
 
-You have access to the full json-render documentation via the bash and readFile tools. The docs are available as markdown files in the /docs/ directory.
+You have access to the full json-render documentation via the bash and readFile tools. The docs are available as markdown files in the /workspace/docs/ directory.
 
 When answering questions:
-- Use the bash tool to list files (ls /docs/) or search for content (grep -r "keyword" /docs/)
-- Use the readFile tool to read specific documentation pages
+- Use the bash tool to list files (ls /workspace/docs/) or search for content (grep -r "keyword" /workspace/docs/)
+- Use the readFile tool to read specific documentation pages (e.g. readFile with path "/workspace/docs/index.md")
 - Always base your answers on the actual documentation content
 - Be concise and accurate
 - If the docs don't cover a topic, say so honestly

+ 37 - 2
apps/web/app/globals.css

@@ -28,6 +28,7 @@
   --border: oklch(0.85 0 0);
   --input: oklch(0.85 0 0);
   --ring: oklch(0.6 0 0);
+  --chat-bg: oklch(0.95 0 0);
 }
 
 .dark {
@@ -52,6 +53,7 @@
   --border: oklch(0.25 0 0);
   --input: oklch(0.25 0 0);
   --ring: oklch(0.4 0 0);
+  --chat-bg: oklch(0.25 0 0);
 }
 
 @theme inline {
@@ -116,11 +118,11 @@
   }
 
   ::-webkit-scrollbar-track {
-    @apply bg-background;
+    @apply bg-border;
   }
 
   ::-webkit-scrollbar-thumb {
-    @apply bg-border rounded;
+    @apply bg-background rounded;
   }
 
   ::-webkit-scrollbar-thumb:hover {
@@ -132,6 +134,39 @@ button {
   cursor: pointer;
 }
 
+/* Tool call shimmer animation */
+@keyframes tool-shimmer {
+  0% { opacity: 0.5; }
+  50% { opacity: 1; }
+  100% { opacity: 0.5; }
+}
+
+.animate-tool-shimmer {
+  animation: tool-shimmer 1.5s ease-in-out infinite;
+}
+
+/* Dark mode: use page bg color for borders in chat message content */
+.dark .docs-chat-content * {
+  border-color: var(--background);
+}
+
+/* Fix list rendering in chat content */
+.docs-chat-content ul,
+.docs-chat-content ol {
+  list-style-position: outside;
+  padding-left: 1.25em;
+}
+
+.docs-chat-content li > p {
+  display: inline;
+  margin: 0;
+}
+
+.docs-chat-content li {
+  margin-top: 0.5em;
+  margin-bottom: 0.5em;
+}
+
 /* Shiki dual theme support */
 .shiki,
 .shiki span {

+ 5 - 1
apps/web/app/layout.tsx

@@ -2,6 +2,7 @@ import type { Metadata } from "next";
 import localFont from "next/font/local";
 import "./globals.css";
 import { ThemeProvider } from "@/components/theme-provider";
+import { DocsChat } from "@/components/docs-chat";
 import { Analytics } from "@vercel/analytics/next";
 import { SpeedInsights } from "@vercel/speed-insights/next";
 import { PAGE_TITLES } from "@/lib/page-titles";
@@ -79,7 +80,10 @@ export default function RootLayout({
   return (
     <html lang="en" suppressHydrationWarning>
       <body className={`${geistSans.variable} ${geistMono.variable}`}>
-        <ThemeProvider>{children}</ThemeProvider>
+        <ThemeProvider>
+          {children}
+          <DocsChat />
+        </ThemeProvider>
         <Analytics />
         <SpeedInsights />
       </body>

+ 331 - 100
apps/web/components/docs-chat.tsx

@@ -4,19 +4,124 @@ import { useRef, useEffect, useState } from "react";
 import { useChat } from "@ai-sdk/react";
 import { DefaultChatTransport } from "ai";
 import { Streamdown } from "streamdown";
+import Link from "next/link";
 
 const STORAGE_KEY = "docs-chat-messages";
 const transport = new DefaultChatTransport({ api: "/api/docs-chat" });
 
+const TOOL_LABELS: Record<
+  string,
+  { label: string; pastLabel: string; argKey?: string }
+> = {
+  readFile: { label: "Reading", pastLabel: "Read", argKey: "path" },
+  bash: { label: "Running", pastLabel: "Ran", argKey: "command" },
+};
+
+function isToolPart(part: { type: string }): part is {
+  type: string;
+  toolCallId: string;
+  toolName?: string;
+  state: string;
+  input?: Record<string, unknown>;
+  output?: unknown;
+  errorText?: string;
+} {
+  return part.type.startsWith("tool-") || part.type === "dynamic-tool";
+}
+
+function getToolName(part: { type: string; toolName?: string }): string {
+  if (part.type === "dynamic-tool") return part.toolName ?? "tool";
+  return part.type.replace(/^tool-/, "");
+}
+
+function ToolCallDisplay({
+  part,
+}: {
+  part: {
+    type: string;
+    toolCallId: string;
+    toolName?: string;
+    state: string;
+    input?: Record<string, unknown>;
+    output?: unknown;
+    errorText?: string;
+  };
+}) {
+  const toolName = getToolName(part);
+  const config = TOOL_LABELS[toolName] ?? {
+    label: toolName,
+    pastLabel: toolName,
+  };
+  const isDone = part.state === "output-available";
+  const isError = part.state === "output-error";
+  const isRunning = !isDone && !isError;
+  const displayLabel = isRunning ? config.label : config.pastLabel;
+
+  const args = (part.input ?? {}) as Record<string, unknown>;
+  const argValue = config.argKey ? args[config.argKey] : undefined;
+  const argPreview =
+    argValue != null
+      ? String(argValue)
+          .replace(/^\/workspace\//, "/")
+          .replace(/\.md$/, "")
+          .replace(/\/index$/, "")
+      : "";
+
+  // Link to the docs page if it's a /docs/ path from readFile
+  const docsLink =
+    toolName === "readFile" &&
+    (argPreview === "/docs" || argPreview.startsWith("/docs/"))
+      ? argPreview
+      : null;
+
+  const argEl = argPreview ? (
+    docsLink ? (
+      <Link href={docsLink} className="truncate underline underline-offset-2">
+        {argPreview}
+      </Link>
+    ) : (
+      <span className="truncate">{argPreview}</span>
+    )
+  ) : null;
+
+  return (
+    <div className="text-xs py-0.5 min-w-0">
+      {isRunning ? (
+        <span className="inline-flex items-center gap-1 font-mono text-muted-foreground animate-tool-shimmer min-w-0">
+          <span className="shrink-0">{displayLabel}</span>
+          {argEl}
+        </span>
+      ) : (
+        <span className="inline-flex items-center gap-1 font-mono text-muted-foreground/60 min-w-0">
+          <span className="shrink-0">{displayLabel}</span>
+          {argEl}
+          {isError && <span className="text-destructive">failed</span>}
+        </span>
+      )}
+    </div>
+  );
+}
+
+const SUGGESTIONS = [
+  "What is json-render?",
+  "How do I install it?",
+  "How does streaming work?",
+  "What components are available?",
+  "How do I create a custom schema?",
+];
+
 export function DocsChat() {
   const [open, setOpen] = useState(false);
   const [input, setInput] = useState("");
+  const [focused, setFocused] = useState(false);
   const messagesEndRef = useRef<HTMLDivElement>(null);
   const inputRef = useRef<HTMLTextAreaElement>(null);
   const containerRef = useRef<HTMLDivElement>(null);
   const restoredRef = useRef(false);
 
-  const { messages, sendMessage, status, setMessages } = useChat({ transport });
+  const { messages, sendMessage, status, setMessages, error } = useChat({
+    transport,
+  });
 
   const isLoading = status === "streaming" || status === "submitted";
 
@@ -52,19 +157,48 @@ export function DocsChat() {
     }
   }, [messages, isLoading]);
 
-  // Auto-open when new messages arrive
-  const prevMessageCount = useRef(0);
+  // Auto-open when new messages arrive (but not on initial restore)
+  const prevMessageCount = useRef<number | null>(null);
+  const initializedRef = useRef(false);
   useEffect(() => {
-    if (messages.length > prevMessageCount.current) {
+    // Skip until after the first sessionStorage restore cycle
+    if (!initializedRef.current) {
+      // Wait one tick after mount to let restore settle
+      const id = requestAnimationFrame(() => {
+        prevMessageCount.current = messages.length;
+        initializedRef.current = true;
+      });
+      return () => cancelAnimationFrame(id);
+    }
+    if (
+      prevMessageCount.current !== null &&
+      messages.length > prevMessageCount.current
+    ) {
       setOpen(true);
     }
     prevMessageCount.current = messages.length;
   }, [messages.length]);
 
-  // Scroll to bottom when messages change
+  // Scroll to bottom when messages change or error occurs
   useEffect(() => {
     messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
-  }, [messages]);
+  }, [messages, error]);
+
+  // Cmd+K to focus prompt, Esc to close
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
+        e.preventDefault();
+        inputRef.current?.focus();
+      }
+      if (e.key === "Escape" && open) {
+        setOpen(false);
+        inputRef.current?.blur();
+      }
+    };
+    document.addEventListener("keydown", handleKeyDown);
+    return () => document.removeEventListener("keydown", handleKeyDown);
+  }, [open]);
 
   // Close message area when clicking outside
   useEffect(() => {
@@ -95,113 +229,210 @@ export function DocsChat() {
     inputRef.current?.focus();
   };
 
-  const getTextFromParts = (
+  const hasVisibleContent = (
     parts: (typeof messages)[number]["parts"],
-  ): string => {
-    return parts
-      .filter(
-        (p): p is Extract<typeof p, { type: "text" }> => p.type === "text",
-      )
-      .map((p) => p.text)
-      .join("");
+  ): boolean => {
+    return parts.some(
+      (p) => (p.type === "text" && p.text.length > 0) || isToolPart(p),
+    );
   };
 
+  // Auto-open when error occurs
+  useEffect(() => {
+    if (error) setOpen(true);
+  }, [error]);
+
+  const showMessages = open && (messages.length > 0 || !!error);
+  const showSuggestions = focused && messages.length === 0 && !isLoading;
+
   return (
     <div className="fixed bottom-0 left-0 right-0 z-50 pointer-events-none">
       <div
         ref={containerRef}
-        className="max-w-xl mx-auto px-4 pb-4 [&>*]:pointer-events-auto"
+        className={`mx-auto px-4 pb-4 [&>*]:pointer-events-auto transition-all duration-300 ${focused || showMessages ? "max-w-xl" : "max-w-56"}`}
       >
-        {/* Messages panel */}
-        {open && messages.length > 0 && (
-          <div className="mb-2 bg-background border border-border rounded-lg shadow-lg max-h-[60vh] flex flex-col">
-            <div className="flex items-center justify-between px-4 py-2 border-b border-border shrink-0">
-              <span className="text-xs font-medium text-muted-foreground">
-                json-render Docs
-              </span>
-              <button
-                onClick={handleClear}
-                className="text-xs text-muted-foreground hover:text-foreground transition-colors"
-                aria-label="Clear conversation"
-              >
-                Clear
-              </button>
+        <div
+          className="border border-background rounded-lg overflow-hidden"
+          style={{ backgroundColor: "var(--chat-bg)" }}
+        >
+          {/* Suggestions panel */}
+          {showSuggestions && (
+            <div>
+              <div className="flex items-center px-4 py-2 border-b border-background shrink-0">
+                <span className="text-xs font-medium text-muted-foreground">
+                  json-render Docs
+                </span>
+              </div>
+              <div className="flex flex-wrap gap-2 p-3">
+                {SUGGESTIONS.map((s) => (
+                  <button
+                    key={s}
+                    type="button"
+                    onMouseDown={(e) => {
+                      e.preventDefault();
+                      sendMessage({ text: s });
+                    }}
+                    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"
+                  >
+                    {s}
+                  </button>
+                ))}
+              </div>
             </div>
-            <div className="p-4 space-y-4 overflow-y-auto">
-              {messages.map((message) => {
-                const text = getTextFromParts(message.parts);
-                if (!text) return null;
-                return (
-                  <div key={message.id}>
-                    {message.role === "assistant" ? (
-                      <div className="text-sm text-foreground/90 leading-relaxed prose prose-sm dark:prose-invert max-w-none">
-                        <Streamdown>{text}</Streamdown>
-                      </div>
-                    ) : (
-                      <div className="text-sm text-muted-foreground whitespace-pre-wrap leading-relaxed">
-                        {text}
-                      </div>
-                    )}
+          )}
+          {/* Messages panel */}
+          {showMessages && (
+            <div className="max-h-[60vh] flex flex-col">
+              <div className="flex items-center justify-between px-4 py-2 border-b border-background shrink-0">
+                <span className="text-xs font-medium text-muted-foreground">
+                  json-render Docs
+                </span>
+                <button
+                  onClick={handleClear}
+                  className="text-xs text-muted-foreground hover:text-foreground transition-colors"
+                  aria-label="Clear conversation"
+                >
+                  Clear
+                </button>
+              </div>
+              <div
+                className="p-4 space-y-4 overflow-y-auto"
+                onClick={(e) => {
+                  if ((e.target as HTMLElement).closest("a")) {
+                    setOpen(false);
+                  }
+                }}
+              >
+                {messages.map((message) => {
+                  if (!hasVisibleContent(message.parts)) return null;
+                  return (
+                    <div key={message.id}>
+                      {message.role === "user" ? (
+                        <div className="text-sm text-muted-foreground whitespace-pre-wrap leading-relaxed">
+                          {message.parts
+                            .filter(
+                              (p): p is Extract<typeof p, { type: "text" }> =>
+                                p.type === "text",
+                            )
+                            .map((p) => p.text)
+                            .join("")}
+                        </div>
+                      ) : (
+                        <div className="space-y-2">
+                          {message.parts.map((part, i) => {
+                            if (part.type === "text" && part.text) {
+                              return (
+                                <div
+                                  key={i}
+                                  className="docs-chat-content text-sm text-foreground/90 leading-relaxed prose prose-sm dark:prose-invert max-w-none"
+                                >
+                                  <Streamdown>{part.text}</Streamdown>
+                                </div>
+                              );
+                            }
+                            if (isToolPart(part)) {
+                              return (
+                                <ToolCallDisplay
+                                  key={part.toolCallId}
+                                  part={part}
+                                />
+                              );
+                            }
+                            return null;
+                          })}
+                        </div>
+                      )}
+                    </div>
+                  );
+                })}
+                {error && (
+                  <div className="text-sm text-destructive/80 bg-destructive/10 rounded-md px-3 py-2">
+                    {(() => {
+                      try {
+                        const parsed = JSON.parse(error.message);
+                        return parsed.message || parsed.error || error.message;
+                      } catch {
+                        return (
+                          error.message ||
+                          "Something went wrong. Please try again."
+                        );
+                      }
+                    })()}
                   </div>
-                );
-              })}
-              <div ref={messagesEndRef} />
+                )}
+                <div ref={messagesEndRef} />
+              </div>
             </div>
-          </div>
-        )}
-
-        {/* Input bar */}
-        <form
-          onSubmit={handleSubmit}
-          onClick={() => inputRef.current?.focus()}
-          className="flex items-end gap-2 bg-background border border-border rounded-lg shadow-lg px-4 py-3 cursor-text"
-        >
-          <textarea
-            ref={inputRef}
-            value={input}
-            onChange={(e) => {
-              setInput(e.target.value);
-              e.target.style.height = "auto";
-              e.target.style.height = `${e.target.scrollHeight}px`;
-            }}
-            placeholder="Ask about the docs..."
-            rows={1}
-            onFocus={() => {
-              if (messages.length > 0) setOpen(true);
-            }}
-            onKeyDown={(e) => {
-              if (e.key === "Escape") {
-                setOpen(false);
-                inputRef.current?.blur();
-              }
-              if (e.key === "Enter" && !e.shiftKey) {
-                e.preventDefault();
-                handleSubmit(e);
-              }
-            }}
-            className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50 resize-none max-h-32 leading-relaxed"
-          />
-          <button
-            type="submit"
-            disabled={isLoading || !input.trim()}
-            className="bg-primary text-primary-foreground rounded-md p-1 hover:bg-primary/90 transition-colors disabled:opacity-30"
-            aria-label="Send message"
+          )}
+
+          {/* Input bar */}
+          <form
+            onSubmit={handleSubmit}
+            onClick={() => inputRef.current?.focus()}
+            className={`relative flex items-end gap-2 px-3 py-2 cursor-text${showMessages ? " border-t border-background" : ""}`}
           >
-            <svg
-              width="16"
-              height="16"
-              viewBox="0 0 24 24"
-              fill="none"
-              stroke="currentColor"
-              strokeWidth="2"
-              strokeLinecap="round"
-              strokeLinejoin="round"
+            {!input && (
+              <div className="absolute inset-0 flex items-center px-3 pointer-events-none">
+                <span className="text-sm text-muted-foreground truncate flex-1">
+                  Ask a question...
+                </span>
+                {!focused && !showMessages && (
+                  <span className="text-muted-foreground/40 font-mono text-xs shrink-0">
+                    &#8984;K
+                  </span>
+                )}
+              </div>
+            )}
+            <textarea
+              ref={inputRef}
+              value={input}
+              onChange={(e) => {
+                setInput(e.target.value);
+                e.target.style.height = "auto";
+                e.target.style.height = `${e.target.scrollHeight}px`;
+              }}
+              rows={1}
+              onFocus={() => {
+                setFocused(true);
+                if (messages.length > 0) setOpen(true);
+              }}
+              onBlur={() => {
+                setFocused(false);
+              }}
+              onKeyDown={(e) => {
+                if (e.key === "Escape") {
+                  setOpen(false);
+                  inputRef.current?.blur();
+                }
+                if (e.key === "Enter" && !e.shiftKey) {
+                  e.preventDefault();
+                  handleSubmit(e);
+                }
+              }}
+              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"
+            />
+            <button
+              type="submit"
+              disabled={isLoading || !input.trim()}
+              className={`bg-primary text-primary-foreground rounded-md p-1 hover:bg-primary/90 transition-colors disabled:opacity-30${!focused && !showMessages ? " hidden" : ""}`}
+              aria-label="Send message"
             >
-              <line x1="12" y1="19" x2="12" y2="5" />
-              <polyline points="5 12 12 5 19 12" />
-            </svg>
-          </button>
-        </form>
+              <svg
+                width="16"
+                height="16"
+                viewBox="0 0 24 24"
+                fill="none"
+                stroke="currentColor"
+                strokeWidth="2"
+                strokeLinecap="round"
+                strokeLinejoin="round"
+              >
+                <line x1="12" y1="19" x2="12" y2="5" />
+                <polyline points="5 12 12 5 19 12" />
+              </svg>
+            </button>
+          </form>
+        </div>
       </div>
     </div>
   );

+ 7 - 4
apps/web/lib/rate-limit.ts

@@ -21,7 +21,10 @@ const noopRateLimiter = {
   limit: async () => ({ success: true, limit: 0, remaining: 0, reset: 0 }),
 };
 
-// 10 requests per minute (sliding window)
+const MINUTE_LIMIT = Number(process.env.RATE_LIMIT_PER_MINUTE) || 10;
+const DAILY_LIMIT = Number(process.env.RATE_LIMIT_PER_DAY) || 100;
+
+// Requests per minute (sliding window)
 export const minuteRateLimit = {
   limit: async (identifier: string) => {
     if (!_minuteRateLimit) {
@@ -29,7 +32,7 @@ export const minuteRateLimit = {
       if (!redis) return noopRateLimiter.limit();
       _minuteRateLimit = new Ratelimit({
         redis,
-        limiter: Ratelimit.slidingWindow(10, "1 m"),
+        limiter: Ratelimit.slidingWindow(MINUTE_LIMIT, "1 m"),
         prefix: "ratelimit:minute",
       });
     }
@@ -37,7 +40,7 @@ export const minuteRateLimit = {
   },
 };
 
-// 100 requests per day (fixed window)
+// Requests per day (fixed window)
 export const dailyRateLimit = {
   limit: async (identifier: string) => {
     if (!_dailyRateLimit) {
@@ -45,7 +48,7 @@ export const dailyRateLimit = {
       if (!redis) return noopRateLimiter.limit();
       _dailyRateLimit = new Ratelimit({
         redis,
-        limiter: Ratelimit.fixedWindow(100, "1 d"),
+        limiter: Ratelimit.fixedWindow(DAILY_LIMIT, "1 d"),
         prefix: "ratelimit:daily",
       });
     }

+ 1 - 1
turbo.json

@@ -1,7 +1,7 @@
 {
   "$schema": "https://turborepo.dev/schema.json",
   "ui": "tui",
-  "globalEnv": ["AI_GATEWAY_MODEL", "KV_REST_API_URL", "KV_REST_API_TOKEN"],
+  "globalEnv": ["AI_GATEWAY_MODEL", "KV_REST_API_URL", "KV_REST_API_TOKEN", "RATE_LIMIT_PER_MINUTE", "RATE_LIMIT_PER_DAY"],
   "tasks": {
     "build": {
       "dependsOn": ["^build"],