Compare commits

...
Author SHA1 Message Date
Chris Tate cc4493f9af better registry docs 2026-02-10 13:43:56 -06:00
Chris Tate 5b4fcaa349 chat improvements (#93)
* better chat

* fixes

* fix lint

* chat fixes
2026-02-09 11:15:57 -06:00
Chris Tate 2b3f3a723f better chat (#92)
* better chat

* fixes

* fix lint
2026-02-09 09:44:29 -06:00
12 changed files with 467 additions and 133 deletions
+4
View File
@@ -12,3 +12,7 @@ AI_GATEWAY_MODEL=anthropic/claude-haiku-4.5
# Automatically populated when you add Vercel KV to your project # Automatically populated when you add Vercel KV to your project
KV_REST_API_URL= KV_REST_API_URL=
KV_REST_API_TOKEN= KV_REST_API_TOKEN=
# Rate Limiting
# RATE_LIMIT_PER_MINUTE=10
# RATE_LIMIT_PER_DAY=100
@@ -121,4 +121,4 @@ AI can reference data paths in component props:
## Next ## Next
Learn about [actions](/docs/actions) for user interactions. Learn about [action handlers](/docs/registry#action-handlers) for user interactions.
-2
View File
@@ -1,7 +1,6 @@
import { DocsMobileNav } from "@/components/docs-mobile-nav"; import { DocsMobileNav } from "@/components/docs-mobile-nav";
import { DocsSidebar } from "@/components/docs-sidebar"; import { DocsSidebar } from "@/components/docs-sidebar";
import { CopyPageButton } from "@/components/copy-page-button"; import { CopyPageButton } from "@/components/copy-page-button";
import { DocsChat } from "@/components/docs-chat";
export default function DocsLayout({ export default function DocsLayout({
children, children,
@@ -25,7 +24,6 @@ export default function DocsLayout({
<article>{children}</article> <article>{children}</article>
</div> </div>
</div> </div>
<DocsChat />
</> </>
); );
} }
@@ -165,5 +165,5 @@ export default function Page() {
- Learn about [catalogs](/docs/catalog) in depth - Learn about [catalogs](/docs/catalog) in depth
- Explore [data binding](/docs/data-binding) for dynamic values - Explore [data binding](/docs/data-binding) for dynamic values
- Add [actions](/docs/actions) for interactivity - Add [action handlers](/docs/registry#action-handlers) for interactivity
- Implement [conditional visibility](/docs/visibility) - Implement [conditional visibility](/docs/visibility)
+69 -16
View File
@@ -2,11 +2,19 @@ export const metadata = { title: "Registry" }
# Registry # Registry
Register React components and action handlers to bring your catalog to life. A registry maps your [catalog](/docs/catalog) definitions to platform-specific implementations. The catalog defines *what* AI can generate — the registry provides the *how*.
## defineRegistry What a registry contains depends on the schema you use. Each package defines its own schema, which determines the shape of both the catalog and the registry.
Use `defineRegistry` to create a type-safe registry from your catalog. Pass your components, actions, or both in a single call: - **`@json-render/react`** — Components (React elements) and action handlers
- **`@json-render/react-native`** — Components (React Native elements) and action handlers
- **`@json-render/remotion`** — Clip components, transitions, and effects
## @json-render/react
### defineRegistry
Use `defineRegistry` to create a type-safe registry from your catalog. Pass your components, actions, or both:
```tsx ```tsx
import { defineRegistry } from '@json-render/react'; import { defineRegistry } from '@json-render/react';
@@ -49,13 +57,13 @@ export const { registry, handlers, executeAction } = defineRegistry(myCatalog, {
The returned object contains: The returned object contains:
- `registry` - component registry for `<Renderer />` - `registry` — component registry for `<Renderer />`
- `handlers` - factory for ActionProvider-compatible handlers - `handlers` — factory for ActionProvider-compatible handlers
- `executeAction` - imperative action dispatch (for use outside the React tree) - `executeAction` — imperative action dispatch (for use outside the React tree)
## Component Props ### Component Props
Each component in the registry receives a `ComponentContext` object: Each component receives a `ComponentContext` object:
```typescript ```typescript
interface ComponentContext { interface ComponentContext {
@@ -68,13 +76,11 @@ interface ComponentContext {
Props are automatically inferred from your catalog, so `props.title` is typed as `string` if your catalog defines it that way. Props are automatically inferred from your catalog, so `props.title` is typed as `string` if your catalog defines it that way.
## Action Handlers ### Action Handlers
Instead of AI generating arbitrary code, it declares *intent* by name. Your application provides the implementation. This is a core guardrail. Instead of AI generating arbitrary code, it declares *intent* by name. Your application provides the implementation. This is a core guardrail.
### Defining Actions Actions are declared in your [catalog](/docs/catalog). The `@json-render/react` schema supports an `actions` key where you define what operations AI can trigger:
Define available actions in your catalog:
```typescript ```typescript
import { defineCatalog } from '@json-render/core'; import { defineCatalog } from '@json-render/core';
@@ -104,8 +110,6 @@ const catalog = defineCatalog(schema, {
}); });
``` ```
### Implementing Action Handlers
Action handlers receive `(params, setState, data)` and are defined inside `defineRegistry`: Action handlers receive `(params, setState, data)` and are defined inside `defineRegistry`:
```tsx ```tsx
@@ -132,7 +136,7 @@ export const { handlers, executeAction } = defineRegistry(catalog, {
}); });
``` ```
## Using Data Binding ### Data Binding
Use hooks inside your registry components to read and write data: Use hooks inside your registry components to read and write data:
@@ -168,7 +172,7 @@ TextField: ({ props }) => {
}, },
``` ```
## Using the Renderer ### Using the Renderer
Wire everything together with providers and the `<Renderer />` component: Wire everything together with providers and the `<Renderer />` component:
@@ -205,6 +209,55 @@ function App({ spec, data, setState }) {
} }
``` ```
## @json-render/react-native
`@json-render/react-native` uses the same `defineRegistry` API. The only difference is that components return React Native elements instead of HTML:
```tsx
import { defineRegistry } from '@json-render/react-native';
import { View, Text, Pressable } from 'react-native';
export const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) => (
<View style={styles.card}>
<Text style={styles.title}>{props.title}</Text>
{children}
</View>
),
Button: ({ props, emit }) => (
<Pressable onPress={() => emit?.("press")}>
<Text>{props.label}</Text>
</Pressable>
),
},
});
```
See the [@json-render/react-native API reference](/docs/api/react-native) for the full API.
## @json-render/remotion
`@json-render/remotion` takes a different approach. Instead of `defineRegistry`, it uses a plain component registry with built-in standard components for video production:
```tsx
import { Renderer, standardComponents } from '@json-render/remotion';
// Use the standard components directly
<Renderer spec={timelineSpec} components={standardComponents} />
// Or extend with your own
const components = {
...standardComponents,
CustomSlide: ({ clip }) => <AbsoluteFill>{/* ... */}</AbsoluteFill>,
};
```
The Remotion schema also supports `transitions` and `effects` in the catalog rather than actions.
See the [@json-render/remotion API reference](/docs/api/remotion) for the full API.
## Next ## Next
Learn about [data binding](/docs/data-binding) for dynamic values. Learn about [data binding](/docs/data-binding) for dynamic values.
+3 -3
View File
@@ -18,11 +18,11 @@ GitHub repository: https://github.com/vercel-labs/json-render
Documentation: https://json-render.dev/docs Documentation: https://json-render.dev/docs
npm packages: @json-render/core, @json-render/react, @json-render/remotion, @json-render/codegen 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: When answering questions:
- Use the bash tool to list files (ls /docs/) or search for content (grep -r "keyword" /docs/) - 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 - 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 - Always base your answers on the actual documentation content
- Be concise and accurate - Be concise and accurate
- If the docs don't cover a topic, say so honestly - If the docs don't cover a topic, say so honestly
+37 -2
View File
@@ -28,6 +28,7 @@
--border: oklch(0.85 0 0); --border: oklch(0.85 0 0);
--input: oklch(0.85 0 0); --input: oklch(0.85 0 0);
--ring: oklch(0.6 0 0); --ring: oklch(0.6 0 0);
--chat-bg: oklch(0.95 0 0);
} }
.dark { .dark {
@@ -52,6 +53,7 @@
--border: oklch(0.25 0 0); --border: oklch(0.25 0 0);
--input: oklch(0.25 0 0); --input: oklch(0.25 0 0);
--ring: oklch(0.4 0 0); --ring: oklch(0.4 0 0);
--chat-bg: oklch(0.25 0 0);
} }
@theme inline { @theme inline {
@@ -116,11 +118,11 @@
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
@apply bg-background; @apply bg-border;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
@apply bg-border rounded; @apply bg-background rounded;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
@@ -132,6 +134,39 @@ button {
cursor: pointer; 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 dual theme support */
.shiki, .shiki,
.shiki span { .shiki span {
+5 -1
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import localFont from "next/font/local"; import localFont from "next/font/local";
import "./globals.css"; import "./globals.css";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme-provider";
import { DocsChat } from "@/components/docs-chat";
import { Analytics } from "@vercel/analytics/next"; import { Analytics } from "@vercel/analytics/next";
import { SpeedInsights } from "@vercel/speed-insights/next"; import { SpeedInsights } from "@vercel/speed-insights/next";
import { PAGE_TITLES } from "@/lib/page-titles"; import { PAGE_TITLES } from "@/lib/page-titles";
@@ -79,7 +80,10 @@ export default function RootLayout({
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<body className={`${geistSans.variable} ${geistMono.variable}`}> <body className={`${geistSans.variable} ${geistMono.variable}`}>
<ThemeProvider>{children}</ThemeProvider> <ThemeProvider>
{children}
<DocsChat />
</ThemeProvider>
<Analytics /> <Analytics />
<SpeedInsights /> <SpeedInsights />
</body> </body>
+334 -102
View File
@@ -4,19 +4,124 @@ import { useRef, useEffect, useState } from "react";
import { useChat } from "@ai-sdk/react"; import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai"; import { DefaultChatTransport } from "ai";
import { Streamdown } from "streamdown"; import { Streamdown } from "streamdown";
import Link from "next/link";
const STORAGE_KEY = "docs-chat-messages"; const STORAGE_KEY = "docs-chat-messages";
const transport = new DefaultChatTransport({ api: "/api/docs-chat" }); 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() { export function DocsChat() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [focused, setFocused] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null); const inputRef = useRef<HTMLTextAreaElement>(null);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const restoredRef = useRef(false); 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"; const isLoading = status === "streaming" || status === "submitted";
@@ -52,19 +157,48 @@ export function DocsChat() {
} }
}, [messages, isLoading]); }, [messages, isLoading]);
// Auto-open when new messages arrive // Auto-open when new messages arrive (but not on initial restore)
const prevMessageCount = useRef(0); const prevMessageCount = useRef<number | null>(null);
const initializedRef = useRef(false);
useEffect(() => { 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); setOpen(true);
} }
prevMessageCount.current = messages.length; prevMessageCount.current = messages.length;
}, [messages.length]); }, [messages.length]);
// Scroll to bottom when messages change // Scroll to bottom when messages change or error occurs
useEffect(() => { useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); 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 // Close message area when clicking outside
useEffect(() => { useEffect(() => {
@@ -95,113 +229,211 @@ export function DocsChat() {
inputRef.current?.focus(); inputRef.current?.focus();
}; };
const getTextFromParts = ( const hasVisibleContent = (
parts: (typeof messages)[number]["parts"], parts: (typeof messages)[number]["parts"],
): string => { ): boolean => {
return parts return parts.some(
.filter( (p) => (p.type === "text" && p.text.length > 0) || isToolPart(p),
(p): p is Extract<typeof p, { type: "text" }> => p.type === "text", );
)
.map((p) => p.text)
.join("");
}; };
// Auto-open when error occurs
useEffect(() => {
if (error) setOpen(true);
}, [error]);
const showMessages = open && (messages.length > 0 || !!error || isLoading);
const showSuggestions = focused && messages.length === 0 && !isLoading;
return ( return (
<div className="fixed bottom-0 left-0 right-0 z-50 pointer-events-none"> <div className="fixed bottom-0 left-0 right-0 z-50 pointer-events-none">
<div <div
ref={containerRef} 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 */} <div
{open && messages.length > 0 && ( className={`border rounded-lg overflow-hidden ${focused || showMessages ? "border-background" : "border-[var(--chat-bg)]"}`}
<div className="mb-2 bg-background border border-border rounded-lg shadow-lg max-h-[60vh] flex flex-col"> style={{ backgroundColor: "var(--chat-bg)" }}
<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>
<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>
)}
</div>
);
})}
<div ref={messagesEndRef} />
</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 {/* Suggestions panel */}
ref={inputRef} {showSuggestions && (
value={input} <div>
onChange={(e) => { <div className="flex items-center px-4 py-2 border-b border-background shrink-0">
setInput(e.target.value); <span className="text-xs font-medium text-muted-foreground">
e.target.style.height = "auto"; json-render Docs
e.target.style.height = `${e.target.scrollHeight}px`; </span>
}} </div>
placeholder="Ask about the docs..." <div className="flex flex-wrap gap-2 p-3">
rows={1} {SUGGESTIONS.map((s) => (
onFocus={() => { <button
if (messages.length > 0) setOpen(true); key={s}
}} type="button"
onKeyDown={(e) => { onMouseDown={(e) => {
if (e.key === "Escape") { e.preventDefault();
setOpen(false); setOpen(true);
inputRef.current?.blur(); sendMessage({ text: s });
} }}
if (e.key === "Enter" && !e.shiftKey) { 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"
e.preventDefault(); >
handleSubmit(e); {s}
} </button>
}} ))}
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" </div>
/> </div>
<button )}
type="submit" {/* Messages panel */}
disabled={isLoading || !input.trim()} {showMessages && (
className="bg-primary text-primary-foreground rounded-md p-1 hover:bg-primary/90 transition-colors disabled:opacity-30" <div className="max-h-[60vh] flex flex-col">
aria-label="Send message" <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>
</div>
)}
{/* 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 {!input && (
width="16" <div className="absolute inset-0 flex items-center px-3 pointer-events-none">
height="16" <span className="text-sm text-muted-foreground truncate flex-1">
viewBox="0 0 24 24" Ask a question...
fill="none" </span>
stroke="currentColor" {!focused && !showMessages && (
strokeWidth="2" <span className="text-muted-foreground/40 font-mono text-xs shrink-0">
strokeLinecap="round" &#8984;K
strokeLinejoin="round" </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" /> <svg
<polyline points="5 12 12 5 19 12" /> width="16"
</svg> height="16"
</button> viewBox="0 0 24 24"
</form> 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>
</div> </div>
); );
+7 -4
View File
@@ -21,7 +21,10 @@ const noopRateLimiter = {
limit: async () => ({ success: true, limit: 0, remaining: 0, reset: 0 }), 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 = { export const minuteRateLimit = {
limit: async (identifier: string) => { limit: async (identifier: string) => {
if (!_minuteRateLimit) { if (!_minuteRateLimit) {
@@ -29,7 +32,7 @@ export const minuteRateLimit = {
if (!redis) return noopRateLimiter.limit(); if (!redis) return noopRateLimiter.limit();
_minuteRateLimit = new Ratelimit({ _minuteRateLimit = new Ratelimit({
redis, redis,
limiter: Ratelimit.slidingWindow(10, "1 m"), limiter: Ratelimit.slidingWindow(MINUTE_LIMIT, "1 m"),
prefix: "ratelimit:minute", prefix: "ratelimit:minute",
}); });
} }
@@ -37,7 +40,7 @@ export const minuteRateLimit = {
}, },
}; };
// 100 requests per day (fixed window) // Requests per day (fixed window)
export const dailyRateLimit = { export const dailyRateLimit = {
limit: async (identifier: string) => { limit: async (identifier: string) => {
if (!_dailyRateLimit) { if (!_dailyRateLimit) {
@@ -45,7 +48,7 @@ export const dailyRateLimit = {
if (!redis) return noopRateLimiter.limit(); if (!redis) return noopRateLimiter.limit();
_dailyRateLimit = new Ratelimit({ _dailyRateLimit = new Ratelimit({
redis, redis,
limiter: Ratelimit.fixedWindow(100, "1 d"), limiter: Ratelimit.fixedWindow(DAILY_LIMIT, "1 d"),
prefix: "ratelimit:daily", prefix: "ratelimit:daily",
}); });
} }
+5
View File
@@ -11,6 +11,11 @@ const nextConfig = {
destination: "/docs/registry", destination: "/docs/registry",
permanent: true, permanent: true,
}, },
{
source: "/docs/actions",
destination: "/docs/registry#action-handlers",
permanent: true,
},
]; ];
}, },
}; };
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://turborepo.dev/schema.json", "$schema": "https://turborepo.dev/schema.json",
"ui": "tui", "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": { "tasks": {
"build": { "build": {
"dependsOn": ["^build"], "dependsOn": ["^build"],