Feature/ai (#57)

* ai setup

* working ai theme with rich editor

* update action bar ui

* style chnages

* submit on enter

* remove imports

* fix: Merge ai theme with default

* refactor

* put AI behind auth and fix post login action

* feat: Add ratelimiting using kv and use groq

* fix build errors

* lazy load editor
This commit is contained in:
Sahaj Jain
2025-05-03 17:38:35 +05:30
committed by GitHub
parent 9bc00c926a
commit c88440e703
17 changed files with 2034 additions and 39 deletions
+70
View File
@@ -0,0 +1,70 @@
import { createGroq } from "@ai-sdk/groq";
import { generateObject } from "ai";
import { z } from "zod";
import { themeStylePropsSchema } from "@/types/theme";
import kv from "@vercel/kv";
import { Ratelimit } from "@upstash/ratelimit";
import { NextRequest } from "next/server";
const requestSchema = z.object({
prompt: z.string().min(1),
});
// Create a new schema based on themeStylePropsSchema excluding 'spacing'
const themeStylePropsWithoutSpacing = themeStylePropsSchema.omit({
spacing: true,
});
// Define the main theme schema using the modified props schema
const themeSchemaWithoutSpacing = z.object({
light: themeStylePropsWithoutSpacing,
dark: themeStylePropsWithoutSpacing,
});
// Create Rate limit - 5 requests per 60 seconds
const ratelimit = new Ratelimit({
redis: kv,
limiter: Ratelimit.fixedWindow(5, "60s"),
});
export async function POST(req: NextRequest) {
try {
// Apply rate limiting based on the request IP
const ip = req.headers.get("x-forwarded-for") ?? "anonymous";
const { success } = await ratelimit.limit(ip);
// Block the request if rate limit exceeded
if (!success) {
return new Response("Rate limit exceeded. Please try again later.", {
status: 429,
});
}
const body = await req.json();
const { prompt } = requestSchema.parse(body);
const groq = createGroq({
apiKey: process.env.GROQ_API_KEY,
});
const model = groq("llama-3.3-70b-versatile");
const { object: theme } = await generateObject({
model,
schema: themeSchemaWithoutSpacing,
system: `You are an AI generating Shadcn UI color themes.
Input: User description or existing theme tokens.
Format: Use Hex values (#000000) exclusively for colors.
Requirement: Ensure light/dark mode cohesion. If asked to change the theme's main color (e.g., "make it green"), adjust related colors (--accent, --secondary, --ring, --border) along with --primary to create a cohesive new palette.`,
prompt: `Generate Shadcn theme. Input: ${prompt}`,
});
return new Response(JSON.stringify(theme), {
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error(error);
// Consider more specific error handling based on AI SDK errors if needed
return new Response("Error generating theme", { status: 500 });
}
}
+18
View File
@@ -114,3 +114,21 @@ body {
/* Apply the reveal animation */
animation: reveal 0.4s ease-in-out forwards;
}
/* Mention styles */
.mention {
@apply bg-primary/10 text-primary px-1 rounded-md;
}
@keyframes text {
from {
background-position: 0% center;
}
to {
background-position: -200% center;
}
}
.animate-text {
animation: text 3s linear infinite;
}
+1
View File
@@ -32,6 +32,7 @@ export function AuthDialogWrapper() {
}
if (session && postLoginAction) {
// Execute action immediately - the system will now handle waiting for handlers
executePostLoginAction(postLoginAction);
clearPostLoginAction();
}
@@ -13,21 +13,52 @@ import { usePostLoginAction } from "@/hooks/use-post-login-action";
import { useThemeActions } from "@/hooks/use-theme-actions";
import { ActionBarButtons } from "@/components/editor/action-bar/components/action-bar-buttons";
import { usePostHog } from "posthog-js/react";
import { AIGenerateDialog } from "@/components/editor/action-bar/components/ai-generate-dialog";
import { useAIThemeGeneration } from "@/hooks/use-ai-theme-generation";
export function ActionBar() {
const { themeState, setThemeState, applyThemePreset } = useEditorStore();
const [cssImportOpen, setCssImportOpen] = useState(false);
const [codePanelOpen, setCodePanelOpen] = useState(false);
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const [aiGenerateOpen, setAiGenerateOpen] = useState(false);
const [dialogKey, setDialogKey] = useState(0);
const { data: session } = authClient.useSession();
const { openAuthDialog } = useAuthStore();
const { createTheme, isCreatingTheme } = useThemeActions();
const posthog = usePostHog();
const { generateTheme, loading: aiGenerateLoading } = useAIThemeGeneration({
onSuccess: () => {
setAiGenerateOpen(false);
setDialogKey((prev) => prev + 1);
},
});
usePostLoginAction("SAVE_THEME", () => {
setSaveDialogOpen(true);
});
usePostLoginAction("AI_GENERATE", () => {
setAiGenerateOpen(true);
});
const handleGenerateTheme = async (
promptText: string,
jsonPromptText: string
) => {
if (!promptText.trim()) return;
await generateTheme(promptText, jsonPromptText);
};
const handleAiGenerateClick = () => {
if (!session) {
openAuthDialog("signin", "AI_GENERATE");
return;
}
setAiGenerateOpen(true);
};
const handleCssImport = (css: string) => {
const { lightColors, darkColors } = parseCssInput(css);
const styles = {
@@ -88,6 +119,7 @@ export function ActionBar() {
onImportClick={() => setCssImportOpen(true)}
onCodeClick={() => setCodePanelOpen(true)}
onSaveClick={handleSaveClick}
onAiGenerateClick={handleAiGenerateClick}
isSaving={isCreatingTheme}
/>
</div>
@@ -108,6 +140,13 @@ export function ActionBar() {
onSave={saveTheme}
isSaving={isCreatingTheme}
/>
<AIGenerateDialog
key={dialogKey}
open={aiGenerateOpen}
onOpenChange={setAiGenerateOpen}
loading={aiGenerateLoading}
onGenerate={handleGenerateTheme}
/>
</div>
);
}
@@ -4,16 +4,17 @@ import { ImportButton } from "./import-button";
import { ResetButton } from "./reset-button";
import { SaveButton } from "./save-button";
import { CodeButton } from "./code-button";
import ContrastChecker from "@/components/editor/contrast-checker";
import { useEditorStore } from "@/store/editor-store";
import { useThemePresetStore } from "@/store/theme-preset-store";
import { EditButton } from "./edit-button";
import { MoreOptions } from "./more-options";
import { AIGenerateButton } from "./ai-generate-button";
interface ActionBarButtonsProps {
onImportClick: () => void;
onCodeClick: () => void;
onSaveClick: () => void;
onAiGenerateClick: () => void;
isSaving: boolean;
}
@@ -21,6 +22,7 @@ export function ActionBarButtons({
onImportClick,
onCodeClick,
onSaveClick,
onAiGenerateClick,
isSaving,
}: ActionBarButtonsProps) {
const { themeState, restoreThemeCheckpoint, hasThemeChangedFromCheckpoint } =
@@ -36,14 +38,12 @@ export function ActionBarButtons({
<Separator orientation="vertical" className="h-8 mx-1" />
<ThemeToggle />
<Separator orientation="vertical" className="h-8 mx-1" />
<ContrastChecker
currentStyles={themeState.styles[themeState.currentMode]}
/>
<ImportButton onImportClick={onImportClick} />
<ResetButton
onReset={restoreThemeCheckpoint}
isDisabled={!hasThemeChangedFromCheckpoint()}
/>
<ImportButton onImportClick={onImportClick} />
<AIGenerateButton onClick={onAiGenerateClick} />
<Separator orientation="vertical" className="h-8 mx-1" />
{showEditButton && <EditButton themeId={themeState.preset as string} />}
<SaveButton onSaveClick={onSaveClick} isSaving={isSaving} />
@@ -0,0 +1,32 @@
import { Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface AIGenerateButtonProps {
onClick: () => void;
}
export function AIGenerateButton({ onClick }: AIGenerateButtonProps) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={onClick}
className="h-8 px-2 gap-1.5 text-muted-foreground hover:text-foreground hover:bg-accent/50"
>
<Sparkles className="size-3.5" />
<span className="text-sm hidden md:block animate-text bg-gradient-to-r from-muted-foreground via-foreground to-muted-foreground bg-[200%_auto] bg-clip-text text-transparent">
Generate
</span>
</Button>
</TooltipTrigger>
<TooltipContent>Generate theme with AI</TooltipContent>
</Tooltip>
);
}
@@ -0,0 +1,111 @@
"use client";
import { lazy, Suspense, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
} from "@/components/ui/dialog";
import { Sparkles, Loader2 } from "lucide-react";
import type { JSONContent } from "@tiptap/react";
import { Loading } from "@/components/loading";
const CustomTextarea = lazy(
() => import("@/components/editor/custom-textarea")
);
interface AIGenerateDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
loading: boolean;
onGenerate: (textPrompt: string, jsonPrompt: string) => void;
}
export function AIGenerateDialog({
open,
onOpenChange,
loading,
onGenerate,
}: AIGenerateDialogProps) {
const [prompt, setPrompt] = useState("");
const [jsonPrompt, setJsonPrompt] = useState("");
const handleContentChange = (
textContent: string,
jsonContent: JSONContent
) => {
setJsonPrompt(JSON.stringify(jsonContent));
setPrompt(textContent);
};
const handleGenerate = () => {
onGenerate(prompt, jsonPrompt);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[550px] p-0 overflow-hidden rounded-lg border shadow-lg">
<DialogHeader className="px-6 pt-6 mb-1 w-full">
<div className="text-center text-2xl font-bold">
How can I help you theme?
</div>
</DialogHeader>
<div className="px-6 pb-6">
<div className="bg-muted/40 rounded-lg p-1">
<Suspense fallback={<Loading className="min-h-[80px]" />}>
<CustomTextarea
onContentChange={handleContentChange}
onGenerate={handleGenerate}
/>
</Suspense>
</div>
<div className="mt-2 text-xs text-muted-foreground">
Try{" "}
<em className="text-foreground font-medium">
@Modern Minimal but in red
</em>{" "}
or{" "}
<em className="text-foreground font-medium">
Make the @Current Theme high contrast
</em>
</div>
</div>
<DialogFooter className="bg-muted/30 px-6 py-4 border-t">
<div className="flex items-center justify-end w-full gap-2">
<Button
onClick={() => onOpenChange(false)}
variant="ghost"
disabled={loading}
size="sm"
>
Cancel
</Button>
<Button
onClick={handleGenerate}
disabled={!prompt.trim() || loading}
className="gap-1"
size="sm"
>
{loading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Generating...
</>
) : (
<>
<Sparkles className="h-4 w-4" />
Create Theme
</>
)}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -9,9 +9,12 @@ import { Button } from "@/components/ui/button";
import { MoreVertical } from "lucide-react";
import { MCPDialog } from "./mcp-dialog";
import McpIcon from "@/assets/mcp.svg";
import ContrastChecker from "@/components/editor/contrast-checker";
import { useEditorStore } from "@/store/editor-store";
export function MoreOptions() {
const [mcpDialogOpen, setMcpDialogOpen] = useState(false);
const { themeState } = useEditorStore();
return (
<>
@@ -26,6 +29,11 @@ export function MoreOptions() {
<McpIcon className="h-4 w-4" />
MCP
</DropdownMenuItem>
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<ContrastChecker
currentStyles={themeState.styles[themeState.currentMode]}
/>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
+5 -14
View File
@@ -188,20 +188,11 @@ const ContrastChecker = ({ currentStyles }: ContrastCheckerProps) => {
return (
<Dialog>
<DialogTrigger>
<Tooltip>
<TooltipTrigger>
<Button
variant="ghost"
size="sm"
className="relative h-8 px-2 gap-1.5 text-muted-foreground hover:text-foreground hover:bg-accent/50"
>
<Contrast className="h-4 w-4" />
<span className="text-sm hidden md:block">Contrast</span>
</Button>
<TooltipContent>Check contrast accessibility</TooltipContent>
</TooltipTrigger>
</Tooltip>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="px-2 w-full justify-start">
<Contrast className="h-4 w-4" />
<span className="text-sm hidden md:block">Contrast</span>
</Button>
</DialogTrigger>
<DialogContent className="max-w-screen-lg max-h-[90vh]">
<DialogHeader className="mb-4">
+79
View File
@@ -0,0 +1,79 @@
"use client";
import React from "react";
import { useEditor, EditorContent, JSONContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Mention from "@tiptap/extension-mention";
import Placeholder from "@tiptap/extension-placeholder";
import { suggestion } from "@/components/editor/mention-suggestion"; // We'll create this next
interface CustomTextareaProps {
onContentChange: (textContent: string, jsonContent: JSONContent) => void;
onGenerate?: () => void;
}
const CustomTextarea: React.FC<CustomTextareaProps> = ({
onContentChange,
onGenerate,
}) => {
const editor = useEditor({
extensions: [
StarterKit,
Mention.configure({
HTMLAttributes: {
class: "mention",
},
suggestion: suggestion,
}),
Placeholder.configure({
placeholder: "Describe your theme...",
emptyEditorClass:
"cursor-text before:content-[attr(data-placeholder)] before:absolute before:top-2 before:left-3 before:text-mauve-11 before:opacity-50 before-pointer-events-none",
}),
],
autofocus: true,
editorProps: {
attributes: {
class:
"min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50",
},
handleKeyDown: (view, event) => {
if (event.key === "Enter" && !event.shiftKey) {
const { state } = view;
const mentionPluginKey = Mention.options.suggestion.pluginKey;
// Ensure the plugin key exists before trying to get state
if (!mentionPluginKey) {
console.error("Mention plugin key not found.");
// Fallback: allow default Enter behavior if key is missing
return false;
}
const mentionState = mentionPluginKey.getState(state);
if (mentionState?.active) {
// Mention list is active, let the mention extension handle Enter.
return false;
} else {
// Mention list is not active, submit the prompt.
event.preventDefault();
onGenerate?.();
return true;
}
}
return false;
},
},
onUpdate: ({ editor }) => {
onContentChange(editor.getText(), editor.getJSON());
},
});
if (!editor) {
return null;
}
return <EditorContent editor={editor} />;
};
export default CustomTextarea;
+118
View File
@@ -0,0 +1,118 @@
"use client";
import React, {
forwardRef,
useEffect,
useImperativeHandle,
useState,
} from "react";
import { cn } from "@/lib/utils";
// Define the structure of the theme item object
interface ThemeItem {
id: string;
label: string;
}
interface MentionListProps {
items: ThemeItem[]; // Update items type to ThemeItem[]
command: (item: { id: string; label: string }) => void; // Update command type if needed, here passing the whole object
}
// Use a type for the ref handle if needed, e.g., { onKeyDown: ... }
// Using `any` for now as in the original code
export interface MentionListRef {
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
}
export const MentionList = forwardRef<MentionListRef, MentionListProps>(
(props, ref) => {
const [selectedIndex, setSelectedIndex] = useState(0);
// Function to select item (adapted from reference)
const selectItem = (index: number) => {
const item = props.items[index];
if (item) {
// Pass the whole item object to the command function
props.command(item);
}
};
// Arrow key handlers using modulo (adapted from reference)
const upHandler = () => {
setSelectedIndex(
(prevIndex) => (prevIndex + props.items.length - 1) % props.items.length
);
};
const downHandler = () => {
setSelectedIndex((prevIndex) => (prevIndex + 1) % props.items.length);
};
const enterHandler = () => {
selectItem(selectedIndex);
};
useEffect(() => setSelectedIndex(0), [props.items]);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }) => {
if (event.key === "ArrowUp") {
// Use modulo handlers
upHandler();
return true;
}
if (event.key === "ArrowDown") {
// Use modulo handlers
downHandler();
return true;
}
if (event.key === "Enter") {
// Use enter handler
enterHandler();
return true;
}
return false;
},
}));
return (
<div
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95"
)}
>
{
props.items.length ? (
props.items.map((item, index) => (
<button
// Use Tailwind classes mimicking shadcn/ui DropdownMenuItem with cn utility
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm p-1.5 text-xs outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
index === selectedIndex && "bg-accent text-accent-foreground"
)}
key={item.id} // Use item.id as the key
onClick={() => selectItem(index)}
>
{item.label} {/* Display the item's label */}
</button>
))
) : (
<div
className={cn(
"relative flex cursor-default select-none items-center rounded-sm p-1.5 text-xs text-muted-foreground"
)}
>
No result
</div>
) // Use Tailwind classes for the empty state, styled like an item, using cn
}
</div>
);
}
);
MentionList.displayName = "MentionList";
+87
View File
@@ -0,0 +1,87 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { ReactRenderer } from "@tiptap/react";
import tippy from "tippy.js";
import { MentionList } from "@/components/editor/mention-list"; // We'll create this component next
import { useThemePresetStore } from "@/store/theme-preset-store"; // Import the theme store
export const suggestion = {
items: ({ query }: { query: string }) => {
// Get all presets from the store
const allPresets = useThemePresetStore.getState().getAllPresets();
// Convert presets object to the required array format { id: string, label: string }
const themeItems = Object.entries(allPresets).map(([id, preset]) => ({
id: id, // Use the preset key as the id
label: preset.label, // Use the preset label
}));
// Filter based on the query
return themeItems
.filter((item) => {
const labelWithoutSpaces =
item.label?.replace(/\s+/g, "").toLowerCase() || "";
const queryWithoutSpaces = query.replace(/\s+/g, "").toLowerCase();
return labelWithoutSpaces.includes(queryWithoutSpaces);
})
.slice(0, 7)
.concat({ id: "editor:current-changes", label: "Current Theme" }); // Limit to 5 suggestions
},
render: () => {
let component: ReactRenderer | null = null;
let popup: any | null = null;
return {
onStart: (props: any) => {
component = new ReactRenderer(MentionList, {
props,
editor: props.editor,
});
if (!props.clientRect) {
return;
}
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
},
onUpdate(props: any) {
component?.updateProps(props);
if (!props.clientRect) {
return;
}
popup?.[0]?.setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown(props: any) {
if (props.event.key === "Escape") {
popup?.[0]?.hide();
return true;
}
// @ts-expect-error - This is a valid way to access the component's methods
return component?.ref?.onKeyDown(props);
},
onExit() {
popup?.[0]?.destroy();
component?.destroy();
popup = null;
component = null;
},
};
},
};
+55
View File
@@ -0,0 +1,55 @@
import { useState } from "react";
import { useToast } from "@/components/ui/use-toast";
import { generateThemeWithReferences } from "@/lib/ai-theme-generator";
interface UseAIThemeGenerationProps {
onSuccess?: () => void;
onError?: (error: Error) => void;
}
export function useAIThemeGeneration(props?: UseAIThemeGenerationProps) {
const { onSuccess, onError } = props || {};
const [loading, setLoading] = useState(false);
const { toast } = useToast();
const generateTheme = async (prompt: string, jsonPrompt: string) => {
if (!prompt.trim()) return;
setLoading(true);
try {
const themeStyles = await generateThemeWithReferences(
prompt,
jsonPrompt,
{
onSuccess: () => {
toast({
title: "Theme generated",
description: "Your AI-generated theme has been applied",
});
onSuccess?.();
},
onError: (error) => {
toast({
title: "Error",
description: "Failed to generate theme. Please try again.",
variant: "destructive",
});
onError?.(error);
},
}
);
return themeStyles;
} catch (error) {
// Error is already handled by the utility function
console.error(error);
} finally {
setLoading(false);
}
};
return {
generateTheme,
loading,
};
}
+35 -6
View File
@@ -1,9 +1,11 @@
import { useEffect } from "react";
export type PostLoginAction = "SAVE_THEME" | null;
export type PostLoginAction = "SAVE_THEME" | "AI_GENERATE" | null;
type PostLoginHandler = () => void | Promise<void>;
const handlers: Map<PostLoginAction, PostLoginHandler[]> = new Map();
const readyActions: Set<PostLoginAction> = new Set();
let pendingAction: PostLoginAction = null;
export function usePostLoginAction(
action: PostLoginAction,
@@ -17,6 +19,15 @@ export function usePostLoginAction(
}
handlers.get(action)!.push(handler);
// Signal this action type is ready to be executed if needed
readyActions.add(action);
// If there's a pending action that matches this one, execute it
if (pendingAction === action) {
executePostLoginActionInternal(action);
pendingAction = null;
}
return () => {
const actionHandlers = handlers.get(action);
if (actionHandlers) {
@@ -24,19 +35,37 @@ export function usePostLoginAction(
if (index > -1) {
actionHandlers.splice(index, 1);
}
// If no more handlers for this action, remove from ready set
if (actionHandlers.length === 0) {
readyActions.delete(action);
}
}
};
}, [action, handler]);
}
// Internal function to actually execute handlers
async function executePostLoginActionInternal(action: PostLoginAction) {
if (!action) return;
const actionHandlers = handlers.get(action);
if (actionHandlers && actionHandlers.length > 0) {
for (const handler of actionHandlers) {
await handler();
}
}
}
// This function should be called when a user successfully logs in
export async function executePostLoginAction(action: PostLoginAction) {
if (!action) return;
const actionHandlers = handlers.get(action);
if (actionHandlers) {
for (const handler of actionHandlers) {
await handler();
}
// If handlers for this action type are ready, execute immediately
if (readyActions.has(action)) {
await executePostLoginActionInternal(action);
} else {
// Otherwise, set as pending to be executed when handlers are registered
pendingAction = action;
}
}
+121
View File
@@ -0,0 +1,121 @@
import { useEditorStore } from "@/store/editor-store";
import { useThemePresetStore } from "@/store/theme-preset-store";
import { defaultThemeState } from "@/config/theme";
import { JSONContent } from "@tiptap/react";
import { Theme } from "@/types/theme";
interface GenerateThemeOptions {
onSuccess?: (themeStyles: Theme["styles"]) => void;
onError?: (error: Error) => void;
}
/**
* Generate a theme with AI using a text prompt
*/
export async function generateThemeWithAI(
prompt: string,
options?: GenerateThemeOptions
) {
if (!prompt.trim()) return null;
try {
const response = await fetch("/api/generate-theme", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ prompt }),
});
if (!response.ok) {
throw new Error("Failed to generate theme");
}
const themeStyles = await response.json();
applyGeneratedTheme(themeStyles);
options?.onSuccess?.(themeStyles);
return themeStyles;
} catch (error) {
console.error("AI theme generation error:", error);
if (error instanceof Error) {
options?.onError?.(error);
} else {
options?.onError?.(new Error("Unknown error occurred"));
}
throw error;
}
}
/**
* Generate a theme with AI using a structured prompt
* with references to existing themes/presets
*/
export async function generateThemeWithReferences(
textPrompt: string,
jsonPrompt: string,
options?: GenerateThemeOptions
) {
if (!textPrompt.trim()) return null;
const transformedPrompt = transformPrompt(textPrompt, jsonPrompt);
return generateThemeWithAI(transformedPrompt, options);
}
/**
* Apply a generated theme to the editor state
*/
export function applyGeneratedTheme(themeStyles: Theme["styles"]) {
const { themeState, setThemeState } = useEditorStore.getState();
if (!document.startViewTransition) {
setThemeState({
...themeState,
styles: {
...themeState.styles,
light: { ...defaultThemeState.styles.light, ...themeStyles.light },
dark: { ...defaultThemeState.styles.dark, ...themeStyles.dark },
},
});
} else {
document.startViewTransition(() => {
setThemeState({
...themeState,
styles: {
...themeState.styles,
light: {
...defaultThemeState.styles.light,
...themeStyles.light,
},
dark: { ...defaultThemeState.styles.dark, ...themeStyles.dark },
},
});
});
}
}
/**
* Transform a prompt to include references to other themes
*/
function transformPrompt(prompt: string, jsonPrompt: string) {
const parsedJsonPrompt = JSON.parse(jsonPrompt) as JSONContent;
const mentions = parsedJsonPrompt.content?.[0]?.content?.filter(
(item) => item.type === "mention"
);
const getMentionContent = (id: string) => {
if (id === "editor:current-changes") {
return useEditorStore.getState().themeState.styles;
}
return useThemePresetStore.getState().getPreset(id)?.styles;
};
const mentionReferences = mentions?.map(
(mention) => `@${mention.attrs?.label} =
${JSON.stringify(getMentionContent(mention.attrs?.id))}`
);
return prompt + "\n\n" + (mentionReferences?.join("\n") || "");
}
+15 -1
View File
@@ -10,11 +10,14 @@
"generate-theme-registry": "tsx scripts/generate-theme-registry.ts && tsx scripts/generate-registry.ts"
},
"dependencies": {
"@ai-sdk/groq": "^1.2.8",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@google/generative-ai": "^0.24.1",
"@hookform/resolvers": "^5.0.1",
"@lexical/react": "^0.31.0",
"@neondatabase/serverless": "^1.0.0",
"@radix-ui/react-accordion": "^1.2.7",
"@radix-ui/react-alert-dialog": "^1.1.10",
@@ -44,6 +47,14 @@
"@radix-ui/react-toggle-group": "^1.1.6",
"@radix-ui/react-tooltip": "^1.2.3",
"@tanstack/react-table": "^8.21.3",
"@tiptap/extension-mention": "^2.11.9",
"@tiptap/extension-placeholder": "^2.11.9",
"@tiptap/react": "^2.11.9",
"@tiptap/starter-kit": "^2.11.9",
"@tiptap/suggestion": "^2.11.9",
"@upstash/ratelimit": "^2.0.5",
"@vercel/kv": "^3.0.0",
"ai": "^4.3.13",
"better-auth": "^1.2.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -56,11 +67,13 @@
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"isbot": "^5.1.26",
"lexical": "^0.31.0",
"lucide-react": "^0.488.0",
"motion": "^12.7.3",
"next": "15.3.0",
"next-themes": "^0.4.6",
"nuqs": "^2.4.3",
"openai": "^4.96.2",
"posthog-js": "^1.236.1",
"react": "^19.0.0",
"react-day-picker": "^9.6.7",
@@ -73,7 +86,7 @@
"sonner": "^2.0.3",
"swr": "^2.3.3",
"tailwind-merge": "^3.2.0",
"tw-animate-css": "^1.2.5",
"tippy.js": "^6.3.7",
"vaul": "^1.1.2",
"zod": "^3.24.2",
"zustand": "^5.0.3"
@@ -95,6 +108,7 @@
"raw-loader": "^4.0.2",
"tailwindcss": "^4",
"tsx": "^4.19.3",
"tw-animate-css": "^1.2.5",
"typescript": "^5"
}
}
+1235 -13
View File
File diff suppressed because it is too large Load Diff