Unverified 提交 dc9acbd6 authored 作者: Mohamed Aziz Mejri's avatar Mohamed Aziz Mejri 提交者: GitHub

Custom theme generator (#2182)

<!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Prototype custom theme generator that lets users create themes manually or generate prompts from images, manage them from a new Themes page, and apply them in chat. Themes are global and used in streaming and token counting. - **New Features** - Added custom_themes table. - Implemented IPC and hooks to list/create/update/delete themes with query cache invalidation. - New CustomThemeDialog with manual prompt entry and prompt generation from uploaded images and optional keywords; uses the selected model via Dyad Pro and requires Dyad Pro enabled. - New Themes page with CRUD, EditThemeDialog, and a sidebar link. - Updated chat Themes menu to show built-in plus recent custom themes, with “New Theme” and a “More themes” dialog; newly created themes auto-select and selection persists per app. - **Refactors** - Replaced getThemePrompt with async getThemePromptById to support custom theme IDs (custom:<id>); integrated in chat_stream and token_count handlers. - Whitelisted new IPC channels in preload. <sup>Written for commit 37d9e5f0c477e2bb0847df506450b45a25ab4874. Summary will update on new commits.</sup> <!-- End of auto-generated description by cubic. --> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Adds end-to-end custom theme support, including storage, IPC, UI, and chat/system-prompt integration. > > - New `custom_themes` table (+ migration `0022_loving_wendigo`) and Drizzle schema `customThemes` > - IPC: moved/expanded theme handlers to `pro/main/ipc/handlers/themes_handlers.ts` with endpoints for `get/set-app-theme`, `get/create/update/delete` custom themes, image save/cleanup, and `generate-theme-prompt`; whitelisted channels in `preload` > - Hooks and client: `useCustomThemes` CRUD/generation hooks; `IpcClient` methods for custom themes, image handling, and generation > - UI: new `ThemesPage` with cards, `CustomThemeDialog` (AI + manual), `AIGeneratorTab` (image upload, model/mode, Pro-gated), `EditThemeDialog`, improved `DeleteConfirmationDialog`; added `LibraryList` and updated `app-sidebar` default Library route > - Chat: `AuxiliaryActionsMenu` shows built-in and recent custom themes, "New Theme" and "More themes"; auto-select newly created theme > - Prompt resolution: replaced `getThemePrompt` with async `getThemePromptById` (supports `custom:<id>`) in chat stream and token count handlers > - Routing: added `/themes` route; e2e tests for themes CRUD, AI generator flow/limits, and prompt library navigation > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 37d9e5f0c477e2bb0847df506450b45a25ab4874. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
上级 b49e43ec
CREATE TABLE `custom_themes` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`description` text,
`prompt` text NOT NULL,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
);
差异被折叠。
...@@ -155,6 +155,13 @@ ...@@ -155,6 +155,13 @@
"when": 1768167214574, "when": 1768167214574,
"tag": "0021_kind_luckman", "tag": "0021_kind_luckman",
"breakpoints": true "breakpoints": true
},
{
"idx": 22,
"version": "6",
"when": 1768924462154,
"tag": "0022_loving_wendigo",
"breakpoints": true
} }
] ]
} }
\ No newline at end of file
...@@ -4,6 +4,7 @@ import { expect } from "@playwright/test"; ...@@ -4,6 +4,7 @@ import { expect } from "@playwright/test";
test("create and edit prompt", async ({ po }) => { test("create and edit prompt", async ({ po }) => {
await po.setUp(); await po.setUp();
await po.goToLibraryTab(); await po.goToLibraryTab();
await po.page.getByRole("link", { name: "Prompts" }).click();
await po.createPrompt({ await po.createPrompt({
title: "title1", title: "title1",
description: "desc", description: "desc",
...@@ -24,6 +25,7 @@ test("create and edit prompt", async ({ po }) => { ...@@ -24,6 +25,7 @@ test("create and edit prompt", async ({ po }) => {
test("delete prompt", async ({ po }) => { test("delete prompt", async ({ po }) => {
await po.setUp(); await po.setUp();
await po.goToLibraryTab(); await po.goToLibraryTab();
await po.page.getByRole("link", { name: "Prompts" }).click();
await po.createPrompt({ await po.createPrompt({
title: "title1", title: "title1",
description: "desc", description: "desc",
...@@ -39,6 +41,7 @@ test("delete prompt", async ({ po }) => { ...@@ -39,6 +41,7 @@ test("delete prompt", async ({ po }) => {
test("use prompt", async ({ po }) => { test("use prompt", async ({ po }) => {
await po.setUp(); await po.setUp();
await po.goToLibraryTab(); await po.goToLibraryTab();
await po.page.getByRole("link", { name: "Prompts" }).click();
await po.createPrompt({ await po.createPrompt({
title: "title1", title: "title1",
description: "desc", description: "desc",
......
import { test } from "./helpers/test_helper";
import { expect } from "@playwright/test";
test("themes management - CRUD operations", async ({ po }) => {
await po.setUp();
// Navigate to Themes page via Library sidebar
await po.goToLibraryTab();
await po.page.getByRole("link", { name: "Themes" }).click();
await expect(po.page.getByRole("heading", { name: "Themes" })).toBeVisible();
// Verify no themes exist initially
await expect(
po.page.getByText("No custom themes yet. Create one to get started."),
).toBeVisible();
// === CREATE ===
// Click New Theme button
await po.page.getByRole("button", { name: "New Theme" }).click();
// Wait for dialog to open
await expect(
po.page.getByRole("dialog").getByText("Create Custom Theme"),
).toBeVisible();
// Switch to Manual tab
await po.page.getByRole("tab", { name: "Manual Configuration" }).click();
// Fill in manual configuration form
await po.page.getByLabel("Theme Name").fill("My Test Theme");
await po.page
.getByLabel("Description (optional)")
.fill("A test theme description");
await po.page
.getByLabel("Theme Prompt")
.fill("Use blue colors and modern styling");
// Save the theme
await po.page.getByRole("button", { name: "Save Theme" }).click();
// Verify dialog closes and theme card appears
await expect(po.page.getByRole("dialog")).not.toBeVisible();
await expect(po.page.getByTestId("theme-card")).toBeVisible();
await expect(po.page.getByText("My Test Theme")).toBeVisible();
await expect(po.page.getByText("A test theme description")).toBeVisible();
// === UPDATE ===
// Click edit button on the theme card
await po.page.getByTestId("edit-theme-button").click();
// Wait for edit dialog to open
await expect(
po.page.getByRole("dialog").getByText("Edit Theme"),
).toBeVisible();
// Update the theme details
await po.page.getByLabel("Theme Name").clear();
await po.page.getByLabel("Theme Name").fill("Updated Theme");
await po.page
.getByLabel("Description (optional)")
.fill("Updated description");
await po.page.getByLabel("Theme Prompt").clear();
await po.page.getByLabel("Theme Prompt").fill("Updated prompt content");
// Save changes
await po.page.getByRole("button", { name: "Save" }).click();
// Verify dialog closes and updated content appears
await expect(po.page.getByRole("dialog")).not.toBeVisible();
await expect(po.page.getByText("Updated Theme")).toBeVisible();
await expect(po.page.getByText("Updated description")).toBeVisible();
await expect(po.page.getByText("Updated prompt content")).toBeVisible();
// Verify old name is gone
await expect(po.page.getByText("My Test Theme")).not.toBeVisible();
// === DELETE ===
// Click delete button on the theme card
await po.page.getByTestId("delete-prompt-button").click();
// Verify delete confirmation dialog appears
await expect(po.page.getByRole("alertdialog")).toBeVisible();
await expect(po.page.getByText("Delete Theme")).toBeVisible();
await expect(
po.page.getByText('Are you sure you want to delete "Updated Theme"?'),
).toBeVisible();
// Confirm deletion
await po.page.getByRole("button", { name: "Delete" }).click();
// Verify dialog closes and theme is removed
await expect(po.page.getByRole("alertdialog")).not.toBeVisible();
await expect(po.page.getByText("Updated Theme")).not.toBeVisible();
// Verify empty state is shown again
await expect(
po.page.getByText("No custom themes yet. Create one to get started."),
).toBeVisible();
});
test("themes management - create theme from chat input", async ({ po }) => {
await po.setUp();
// Open the auxiliary actions menu
await po
.getHomeChatInputContainer()
.getByTestId("auxiliary-actions-menu")
.click();
// Hover over Themes submenu
await po.page.getByRole("menuitem", { name: "Themes" }).hover();
// Click "New Theme" option
await po.page.getByRole("menuitem", { name: "New Theme" }).click();
// Wait for dialog to open
await expect(
po.page.getByRole("dialog").getByText("Create Custom Theme"),
).toBeVisible();
// Switch to Manual tab (AI tab is now default)
await po.page.getByRole("tab", { name: "Manual Configuration" }).click();
// Fill in manual configuration form
await po.page.getByLabel("Theme Name").fill("Chat Input Theme");
await po.page
.getByLabel("Description (optional)")
.fill("Created from chat input");
await po.page
.getByLabel("Theme Prompt")
.fill("Use dark mode with purple accents");
// Save the theme
await po.page.getByRole("button", { name: "Save Theme" }).click();
// Verify dialog closes
await expect(po.page.getByRole("dialog")).not.toBeVisible();
// Verify the newly created theme is auto-selected
// Re-open the menu to verify
await po
.getHomeChatInputContainer()
.getByTestId("auxiliary-actions-menu")
.click();
await po.page.getByRole("menuitem", { name: "Themes" }).hover();
// The custom theme should be visible and selected (has bg-primary class)
await expect(po.page.getByTestId("theme-option-custom:1")).toHaveClass(
/bg-primary/,
);
});
test("themes management - AI generator image upload limit", async ({ po }) => {
await po.setUpDyadPro();
// Navigate to Themes page via Library sidebar
await po.goToLibraryTab();
await po.page.getByRole("link", { name: "Themes" }).click();
await expect(po.page.getByRole("heading", { name: "Themes" })).toBeVisible();
// Click New Theme button
await po.page.getByRole("button", { name: "New Theme" }).click();
// Wait for dialog to open
await expect(
po.page.getByRole("dialog").getByText("Create Custom Theme"),
).toBeVisible();
// Verify AI-Powered Generator tab is active by default
const aiTab = po.page.getByRole("tab", { name: "AI-Powered Generator" });
await expect(aiTab).toHaveAttribute("data-state", "active");
// Verify upload area is visible
const uploadArea = po.page.getByText("Click to upload images");
await expect(uploadArea).toBeVisible();
// Set up file chooser listener BEFORE clicking the upload area
const fileChooserPromise = po.page.waitForEvent("filechooser");
// Click the upload area to trigger file picker
await uploadArea.click();
// Handle the file chooser dialog - select the same image 7 times (exceeds 5 limit)
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles([
"e2e-tests/fixtures/images/logo.png",
"e2e-tests/fixtures/images/logo.png",
"e2e-tests/fixtures/images/logo.png",
"e2e-tests/fixtures/images/logo.png",
"e2e-tests/fixtures/images/logo.png",
"e2e-tests/fixtures/images/logo.png",
"e2e-tests/fixtures/images/logo.png",
]);
// Verify that only 5 images were uploaded (max limit)
await expect(po.page.getByText("5 / 5 images")).toBeVisible();
await expect(po.page.getByText("Maximum reached")).toBeVisible();
// Verify error toast appeared about skipped images
await expect(po.page.getByText(/files? (was|were) skipped/)).toBeVisible();
});
test("themes management - AI generator flow", async ({ po }) => {
await po.setUp();
// Navigate to Themes page via Library sidebar
await po.goToLibraryTab();
await po.page.getByRole("link", { name: "Themes" }).click();
await expect(po.page.getByRole("heading", { name: "Themes" })).toBeVisible();
// Verify no themes exist initially
await expect(
po.page.getByText("No custom themes yet. Create one to get started."),
).toBeVisible();
// Click New Theme button
await po.page.getByRole("button", { name: "New Theme" }).click();
// Wait for dialog to open
await expect(
po.page.getByRole("dialog").getByText("Create Custom Theme"),
).toBeVisible();
// Verify AI-Powered Generator tab is active by default
const aiTab = po.page.getByRole("tab", { name: "AI-Powered Generator" });
await expect(aiTab).toHaveAttribute("data-state", "active");
// Verify upload area is visible
const uploadArea = po.page.getByText("Click to upload images");
await expect(uploadArea).toBeVisible();
// Verify Generate button is disabled before uploading images
const generateButton = po.page.getByRole("button", {
name: "Generate Theme Prompt",
});
await expect(generateButton).toBeDisabled();
// Fill in theme details
await po.page.getByLabel("Theme Name").fill("AI Generated Theme");
await po.page
.getByLabel("Description (optional)")
.fill("Created via AI generator");
// Upload an image
const fileChooserPromise = po.page.waitForEvent("filechooser");
await uploadArea.click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(["e2e-tests/fixtures/images/logo.png"]);
// Verify image counter shows 1 image
await expect(po.page.getByText("1 / 5 images")).toBeVisible();
// Verify Generate button is now enabled
await expect(generateButton).toBeEnabled();
// Click Generate to get mock theme prompt (test mode returns mock response)
await generateButton.click();
// Wait for generation to complete - the generated prompt textarea should appear
await expect(po.page.locator("#ai-prompt")).toBeVisible({ timeout: 10000 });
// Verify the mock theme content is displayed
await expect(po.page.getByText("Test Mode Theme")).toBeVisible();
// Save the theme
await po.page.getByRole("button", { name: "Save Theme" }).click();
// Verify dialog closes and theme card appears
await expect(po.page.getByRole("dialog")).not.toBeVisible();
await expect(po.page.getByTestId("theme-card")).toBeVisible();
await expect(po.page.getByText("AI Generated Theme")).toBeVisible();
await expect(po.page.getByText("Created via AI generator")).toBeVisible();
});
差异被折叠。
import { useState, useCallback } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Loader2, Sparkles, PenLine } from "lucide-react";
import { useCreateCustomTheme } from "@/hooks/useCustomThemes";
import { showError } from "@/lib/toast";
import { toast } from "sonner";
import { AIGeneratorTab } from "./AIGeneratorTab";
interface CustomThemeDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onThemeCreated?: (themeId: number) => void; // callback when theme is created
}
export function CustomThemeDialog({
open,
onOpenChange,
onThemeCreated,
}: CustomThemeDialogProps) {
const [activeTab, setActiveTab] = useState<"manual" | "ai">("ai");
// Manual tab state
const [manualName, setManualName] = useState("");
const [manualDescription, setManualDescription] = useState("");
const [manualPrompt, setManualPrompt] = useState("");
// AI tab state (shared with AIGeneratorTab)
const [aiName, setAiName] = useState("");
const [aiDescription, setAiDescription] = useState("");
const [aiGeneratedPrompt, setAiGeneratedPrompt] = useState("");
const createThemeMutation = useCreateCustomTheme();
const resetForm = useCallback(() => {
setManualName("");
setManualDescription("");
setManualPrompt("");
setAiName("");
setAiDescription("");
setAiGeneratedPrompt("");
setActiveTab("ai");
}, []);
const handleClose = useCallback(async () => {
resetForm();
onOpenChange(false);
}, [onOpenChange, resetForm]);
const handleSave = useCallback(async () => {
const isManual = activeTab === "manual";
const name = isManual ? manualName : aiName;
const description = isManual ? manualDescription : aiDescription;
const prompt = isManual ? manualPrompt : aiGeneratedPrompt;
if (!name.trim()) {
showError("Please enter a theme name");
return;
}
if (!prompt.trim()) {
showError(
isManual
? "Please enter a theme prompt"
: "Please generate a prompt first",
);
return;
}
try {
const createdTheme = await createThemeMutation.mutateAsync({
name: name.trim(),
description: description.trim() || undefined,
prompt: prompt.trim(),
});
toast.success("Custom theme created successfully");
onThemeCreated?.(createdTheme.id);
await handleClose();
} catch (error) {
showError(
`Failed to create theme: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}, [
activeTab,
manualName,
manualDescription,
manualPrompt,
aiName,
aiDescription,
aiGeneratedPrompt,
createThemeMutation,
onThemeCreated,
handleClose,
]);
const isSaving = createThemeMutation.isPending;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Create Custom Theme</DialogTitle>
<DialogDescription>
Create a custom theme using manual configuration or AI-powered
generation.
</DialogDescription>
</DialogHeader>
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as "manual" | "ai")}
className="mt-4"
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="ai" className="flex items-center gap-2">
<Sparkles className="h-4 w-4" />
AI-Powered Generator
</TabsTrigger>
<TabsTrigger value="manual" className="flex items-center gap-2">
<PenLine className="h-4 w-4" />
Manual Configuration
</TabsTrigger>
</TabsList>
{/* AI-Powered Generator Tab */}
<TabsContent value="ai">
<AIGeneratorTab
aiName={aiName}
setAiName={setAiName}
aiDescription={aiDescription}
setAiDescription={setAiDescription}
aiGeneratedPrompt={aiGeneratedPrompt}
setAiGeneratedPrompt={setAiGeneratedPrompt}
onSave={handleSave}
isSaving={isSaving}
isDialogOpen={open}
/>
</TabsContent>
{/* Manual Configuration Tab */}
<TabsContent value="manual" className="space-y-4 mt-4">
<div className="space-y-2">
<Label htmlFor="manual-name">Theme Name</Label>
<Input
id="manual-name"
placeholder="My Custom Theme"
value={manualName}
onChange={(e) => setManualName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="manual-description">Description (optional)</Label>
<Input
id="manual-description"
placeholder="A brief description of your theme"
value={manualDescription}
onChange={(e) => setManualDescription(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="manual-prompt">Theme Prompt</Label>
<Textarea
id="manual-prompt"
placeholder="Enter your theme system prompt..."
className="min-h-[200px] font-mono text-sm"
value={manualPrompt}
onChange={(e) => setManualPrompt(e.target.value)}
/>
</div>
<Button
onClick={handleSave}
disabled={isSaving || !manualName.trim() || !manualPrompt.trim()}
className="w-full"
>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
"Save Theme"
)}
</Button>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
}
import React from "react"; import React from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Trash2 } from "lucide-react"; import { Trash2, Loader2 } from "lucide-react";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
...@@ -23,6 +23,7 @@ interface DeleteConfirmationDialogProps { ...@@ -23,6 +23,7 @@ interface DeleteConfirmationDialogProps {
itemType?: string; itemType?: string;
onDelete: () => void | Promise<void>; onDelete: () => void | Promise<void>;
trigger?: React.ReactNode; trigger?: React.ReactNode;
isDeleting?: boolean;
} }
export function DeleteConfirmationDialog({ export function DeleteConfirmationDialog({
...@@ -30,6 +31,7 @@ export function DeleteConfirmationDialog({ ...@@ -30,6 +31,7 @@ export function DeleteConfirmationDialog({
itemType = "item", itemType = "item",
onDelete, onDelete,
trigger, trigger,
isDeleting = false,
}: DeleteConfirmationDialogProps) { }: DeleteConfirmationDialogProps) {
return ( return (
<AlertDialog> <AlertDialog>
...@@ -43,6 +45,7 @@ export function DeleteConfirmationDialog({ ...@@ -43,6 +45,7 @@ export function DeleteConfirmationDialog({
size="icon" size="icon"
variant="ghost" variant="ghost"
data-testid="delete-prompt-button" data-testid="delete-prompt-button"
disabled={isDeleting}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
...@@ -62,8 +65,17 @@ export function DeleteConfirmationDialog({ ...@@ -62,8 +65,17 @@ export function DeleteConfirmationDialog({
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onDelete}>Delete</AlertDialogAction> <AlertDialogAction onClick={onDelete} disabled={isDeleting}>
{isDeleting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Deleting...
</>
) : (
"Delete"
)}
</AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
......
import { useState, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Save, Edit2, Loader2 } from "lucide-react";
import { showError } from "@/lib/toast";
import { toast } from "sonner";
import type { CustomTheme } from "@/ipc/ipc_types";
interface EditThemeDialogProps {
theme: CustomTheme;
onUpdateTheme: (params: {
id: number;
name: string;
description?: string;
prompt: string;
}) => Promise<void>;
trigger?: React.ReactNode;
}
export function EditThemeDialog({
theme,
onUpdateTheme,
trigger,
}: EditThemeDialogProps) {
const [open, setOpen] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [draft, setDraft] = useState({
name: "",
description: "",
prompt: "",
});
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Auto-resize textarea function
const adjustTextareaHeight = () => {
const textarea = textareaRef.current;
if (textarea) {
const currentHeight = textarea.style.height;
textarea.style.height = "auto";
const scrollHeight = textarea.scrollHeight;
const maxHeight = window.innerHeight * 0.5;
const minHeight = 150;
const newHeight = Math.min(Math.max(scrollHeight, minHeight), maxHeight);
if (`${newHeight}px` !== currentHeight) {
textarea.style.height = `${newHeight}px`;
}
}
};
// Initialize draft with theme data
useEffect(() => {
if (open) {
setDraft({
name: theme.name,
description: theme.description || "",
prompt: theme.prompt,
});
}
}, [open, theme]);
// Auto-resize textarea when content changes
useEffect(() => {
adjustTextareaHeight();
}, [draft.prompt]);
// Trigger resize when dialog opens
useEffect(() => {
if (open) {
setTimeout(adjustTextareaHeight, 0);
}
}, [open]);
const handleSave = async () => {
if (!draft.name.trim() || !draft.prompt.trim()) return;
setIsSaving(true);
try {
await onUpdateTheme({
id: theme.id,
name: draft.name.trim(),
description: draft.description.trim() || undefined,
prompt: draft.prompt.trim(),
});
toast.success("Theme updated successfully");
setOpen(false);
} catch (error) {
showError(
`Failed to update theme: ${error instanceof Error ? error.message : "Unknown error"}`,
);
} finally {
setIsSaving(false);
}
};
const handleCancel = () => {
setDraft({
name: theme.name,
description: theme.description || "",
prompt: theme.prompt,
});
setOpen(false);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
{trigger ? (
<DialogTrigger asChild>{trigger}</DialogTrigger>
) : (
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button
size="icon"
variant="ghost"
data-testid="edit-theme-button"
>
<Edit2 className="h-4 w-4" />
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent>
<p>Edit theme</p>
</TooltipContent>
</Tooltip>
)}
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Theme</DialogTitle>
<DialogDescription>
Modify your custom theme settings and prompt.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 mt-4">
<div className="space-y-2">
<label htmlFor="edit-theme-name" className="text-sm font-medium">
Theme Name
</label>
<Input
id="edit-theme-name"
placeholder="Theme name"
value={draft.name}
onChange={(e) =>
setDraft((d) => ({ ...d, name: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<label
htmlFor="edit-theme-description"
className="text-sm font-medium"
>
Description (optional)
</label>
<Input
id="edit-theme-description"
placeholder="A brief description of your theme"
value={draft.description}
onChange={(e) =>
setDraft((d) => ({ ...d, description: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<label htmlFor="edit-theme-prompt" className="text-sm font-medium">
Theme Prompt
</label>
<Textarea
id="edit-theme-prompt"
ref={textareaRef}
placeholder="Enter your theme system prompt..."
value={draft.prompt}
onChange={(e) => {
setDraft((d) => ({ ...d, prompt: e.target.value }));
requestAnimationFrame(adjustTextareaHeight);
}}
className="resize-none overflow-y-auto font-mono text-sm"
style={{ minHeight: "150px" }}
/>
</div>
</div>
<DialogFooter className="mt-4">
<Button variant="outline" onClick={handleCancel} disabled={isSaving}>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={isSaving || !draft.name.trim() || !draft.prompt.trim()}
>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" /> Save
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { Link, useRouterState } from "@tanstack/react-router";
import { Palette, FileText } from "lucide-react";
type LibrarySection = {
id: string;
label: string;
to: string;
icon: React.ComponentType<{ className?: string }>;
};
const LIBRARY_SECTIONS: LibrarySection[] = [
{ id: "themes", label: "Themes", to: "/themes", icon: Palette },
{ id: "prompts", label: "Prompts", to: "/library", icon: FileText },
];
export function LibraryList({ show }: { show: boolean }) {
const routerState = useRouterState();
const pathname = routerState.location.pathname;
if (!show) {
return null;
}
return (
<div className="flex flex-col h-full">
<div className="flex-shrink-0 p-4">
<h2 className="text-lg font-semibold tracking-tight">Library</h2>
</div>
<ScrollArea className="flex-grow">
<div className="space-y-1 p-4 pt-0">
{LIBRARY_SECTIONS.map((section) => {
const isActive =
section.to === pathname ||
(section.to !== "/" && pathname.startsWith(section.to));
return (
<Link
key={section.id}
to={section.to}
className={cn(
"w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors",
isActive
? "bg-sidebar-accent text-sidebar-accent-foreground font-semibold"
: "hover:bg-sidebar-accent",
)}
>
<section.icon className="h-4 w-4" />
{section.label}
</Link>
);
})}
</div>
</ScrollArea>
</div>
);
}
...@@ -28,6 +28,7 @@ import { ChatList } from "./ChatList"; ...@@ -28,6 +28,7 @@ import { ChatList } from "./ChatList";
import { AppList } from "./AppList"; import { AppList } from "./AppList";
import { HelpDialog } from "./HelpDialog"; // Import the new dialog import { HelpDialog } from "./HelpDialog"; // Import the new dialog
import { SettingsList } from "./SettingsList"; import { SettingsList } from "./SettingsList";
import { LibraryList } from "./LibraryList";
// Menu items. // Menu items.
const items = [ const items = [
...@@ -48,7 +49,7 @@ const items = [ ...@@ -48,7 +49,7 @@ const items = [
}, },
{ {
title: "Library", title: "Library",
to: "/library", to: "/themes",
icon: BookOpen, icon: BookOpen,
}, },
{ {
...@@ -97,6 +98,9 @@ export function AppSidebar() { ...@@ -97,6 +98,9 @@ export function AppSidebar() {
routerState.location.pathname.startsWith("/app-details"); routerState.location.pathname.startsWith("/app-details");
const isChatRoute = routerState.location.pathname === "/chat"; const isChatRoute = routerState.location.pathname === "/chat";
const isSettingsRoute = routerState.location.pathname.startsWith("/settings"); const isSettingsRoute = routerState.location.pathname.startsWith("/settings");
const isLibraryRoute =
routerState.location.pathname.startsWith("/library") ||
routerState.location.pathname.startsWith("/themes");
let selectedItem: string | null = null; let selectedItem: string | null = null;
if (hoverState === "start-hover:app") { if (hoverState === "start-hover:app") {
...@@ -114,6 +118,8 @@ export function AppSidebar() { ...@@ -114,6 +118,8 @@ export function AppSidebar() {
selectedItem = "Chat"; selectedItem = "Chat";
} else if (isSettingsRoute) { } else if (isSettingsRoute) {
selectedItem = "Settings"; selectedItem = "Settings";
} else if (isLibraryRoute) {
selectedItem = "Library";
} }
} }
...@@ -142,6 +148,7 @@ export function AppSidebar() { ...@@ -142,6 +148,7 @@ export function AppSidebar() {
<AppList show={selectedItem === "Apps"} /> <AppList show={selectedItem === "Apps"} />
<ChatList show={selectedItem === "Chat"} /> <ChatList show={selectedItem === "Chat"} />
<SettingsList show={selectedItem === "Settings"} /> <SettingsList show={selectedItem === "Settings"} />
<LibraryList show={selectedItem === "Library"} />
</div> </div>
</div> </div>
</SidebarContent> </SidebarContent>
......
...@@ -245,3 +245,17 @@ export const mcpToolConsents = sqliteTable( ...@@ -245,3 +245,17 @@ export const mcpToolConsents = sqliteTable(
}, },
(table) => [unique("uniq_mcp_consent").on(table.serverId, table.toolName)], (table) => [unique("uniq_mcp_consent").on(table.serverId, table.toolName)],
); );
// --- Custom Themes table ---
export const customThemes = sqliteTable("custom_themes", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
description: text("description"),
prompt: text("prompt").notNull(),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
});
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { IpcClient } from "@/ipc/ipc_client";
import type {
CustomTheme,
CreateCustomThemeParams,
UpdateCustomThemeParams,
GenerateThemePromptParams,
GenerateThemePromptResult,
} from "@/ipc/ipc_types";
// Query key for custom themes
export const CUSTOM_THEMES_QUERY_KEY = ["custom-themes"];
/**
* Hook to fetch all custom themes.
*/
export function useCustomThemes() {
const query = useQuery({
queryKey: CUSTOM_THEMES_QUERY_KEY,
queryFn: async (): Promise<CustomTheme[]> => {
const ipcClient = IpcClient.getInstance();
return ipcClient.getCustomThemes();
},
meta: {
showErrorToast: true,
},
});
return {
customThemes: query.data ?? [],
isLoading: query.isLoading,
error: query.error,
refetch: query.refetch,
};
}
export function useCreateCustomTheme() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (
params: CreateCustomThemeParams,
): Promise<CustomTheme> => {
const ipcClient = IpcClient.getInstance();
return ipcClient.createCustomTheme(params);
},
onSuccess: () => {
// Invalidate all custom theme queries using prefix matching
queryClient.invalidateQueries({
queryKey: ["custom-themes"],
});
},
});
}
export function useUpdateCustomTheme() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (
params: UpdateCustomThemeParams,
): Promise<CustomTheme> => {
const ipcClient = IpcClient.getInstance();
return ipcClient.updateCustomTheme(params);
},
onSuccess: () => {
// Invalidate all custom theme queries using prefix matching
queryClient.invalidateQueries({
queryKey: ["custom-themes"],
});
},
});
}
export function useDeleteCustomTheme() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number): Promise<void> => {
const ipcClient = IpcClient.getInstance();
await ipcClient.deleteCustomTheme({ id });
},
onSuccess: () => {
// Invalidate all custom theme queries using prefix matching
queryClient.invalidateQueries({
queryKey: ["custom-themes"],
});
},
});
}
export function useGenerateThemePrompt() {
return useMutation({
mutationFn: async (
params: GenerateThemePromptParams,
): Promise<GenerateThemePromptResult> => {
const ipcClient = IpcClient.getInstance();
return ipcClient.generateThemePrompt(params);
},
});
}
...@@ -20,7 +20,7 @@ import { ...@@ -20,7 +20,7 @@ import {
constructSystemPrompt, constructSystemPrompt,
readAiRules, readAiRules,
} from "../../prompts/system_prompt"; } from "../../prompts/system_prompt";
import { getThemePrompt } from "../../shared/themes"; import { getThemePromptById } from "../utils/theme_utils";
import { import {
getSupabaseAvailableSystemPrompt, getSupabaseAvailableSystemPrompt,
SUPABASE_NOT_AVAILABLE_SYSTEM_PROMPT, SUPABASE_NOT_AVAILABLE_SYSTEM_PROMPT,
...@@ -612,7 +612,7 @@ ${componentSnippet} ...@@ -612,7 +612,7 @@ ${componentSnippet}
const aiRules = await readAiRules(getDyadAppPath(updatedChat.app.path)); const aiRules = await readAiRules(getDyadAppPath(updatedChat.app.path));
// Get theme prompt for the app (null themeId means "no theme") // Get theme prompt for the app (null themeId means "no theme")
const themePrompt = getThemePrompt(updatedChat.app.themeId); const themePrompt = await getThemePromptById(updatedChat.app.themeId);
logger.log( logger.log(
`Theme for app ${updatedChat.app.id}: ${updatedChat.app.themeId ?? "none"}, prompt length: ${themePrompt.length} chars`, `Theme for app ${updatedChat.app.id}: ${updatedChat.app.themeId ?? "none"}, prompt length: ${themePrompt.length} chars`,
); );
......
import { createLoggedHandler } from "./safe_handle";
import log from "electron-log";
import { themesData, type Theme } from "../../shared/themes";
import { db } from "../../db";
import { apps } from "../../db/schema";
import { eq, sql } from "drizzle-orm";
import type { SetAppThemeParams, GetAppThemeParams } from "../ipc_types";
const logger = log.scope("themes_handlers");
const handle = createLoggedHandler(logger);
export function registerThemesHandlers() {
handle("get-themes", async (): Promise<Theme[]> => {
return themesData;
});
handle(
"set-app-theme",
async (_, params: SetAppThemeParams): Promise<void> => {
const { appId, themeId } = params;
// Use raw SQL to properly set NULL when themeId is null (representing "no theme")
if (!themeId) {
await db
.update(apps)
.set({ themeId: sql`NULL` })
.where(eq(apps.id, appId));
} else {
await db.update(apps).set({ themeId }).where(eq(apps.id, appId));
}
},
);
handle(
"get-app-theme",
async (_, params: GetAppThemeParams): Promise<string | null> => {
const app = await db.query.apps.findFirst({
where: eq(apps.id, params.appId),
columns: { themeId: true },
});
return app?.themeId ?? null;
},
);
}
...@@ -5,7 +5,7 @@ import { ...@@ -5,7 +5,7 @@ import {
constructSystemPrompt, constructSystemPrompt,
readAiRules, readAiRules,
} from "../../prompts/system_prompt"; } from "../../prompts/system_prompt";
import { getThemePrompt } from "../../shared/themes"; import { getThemePromptById } from "../utils/theme_utils";
import { import {
getSupabaseAvailableSystemPrompt, getSupabaseAvailableSystemPrompt,
SUPABASE_NOT_AVAILABLE_SYSTEM_PROMPT, SUPABASE_NOT_AVAILABLE_SYSTEM_PROMPT,
...@@ -65,7 +65,7 @@ export function registerTokenCountHandlers() { ...@@ -65,7 +65,7 @@ export function registerTokenCountHandlers() {
const mentionedAppNames = parseAppMentions(req.input); const mentionedAppNames = parseAppMentions(req.input);
// Count system prompt tokens // Count system prompt tokens
const themePrompt = getThemePrompt(chat.app?.themeId ?? null); const themePrompt = await getThemePromptById(chat.app?.themeId ?? null);
let systemPrompt = constructSystemPrompt({ let systemPrompt = constructSystemPrompt({
aiRules: await readAiRules(getDyadAppPath(chat.app.path)), aiRules: await readAiRules(getDyadAppPath(chat.app.path)),
chatMode: chatMode:
......
...@@ -95,6 +95,15 @@ import type { ...@@ -95,6 +95,15 @@ import type {
ConsoleEntry, ConsoleEntry,
SetAppThemeParams, SetAppThemeParams,
GetAppThemeParams, GetAppThemeParams,
CustomTheme,
CreateCustomThemeParams,
UpdateCustomThemeParams,
DeleteCustomThemeParams,
GenerateThemePromptParams,
GenerateThemePromptResult,
SaveThemeImageParams,
SaveThemeImageResult,
CleanupThemeImagesParams,
UncommittedFile, UncommittedFile,
} from "./ipc_types"; } from "./ipc_types";
import type { Template } from "../shared/templates"; import type { Template } from "../shared/templates";
...@@ -1686,6 +1695,46 @@ export class IpcClient { ...@@ -1686,6 +1695,46 @@ export class IpcClient {
return this.ipcRenderer.invoke("get-app-theme", params); return this.ipcRenderer.invoke("get-app-theme", params);
} }
public async getCustomThemes(): Promise<CustomTheme[]> {
return this.ipcRenderer.invoke("get-custom-themes");
}
public async createCustomTheme(
params: CreateCustomThemeParams,
): Promise<CustomTheme> {
return this.ipcRenderer.invoke("create-custom-theme", params);
}
public async updateCustomTheme(
params: UpdateCustomThemeParams,
): Promise<CustomTheme> {
return this.ipcRenderer.invoke("update-custom-theme", params);
}
public async deleteCustomTheme(
params: DeleteCustomThemeParams,
): Promise<void> {
await this.ipcRenderer.invoke("delete-custom-theme", params);
}
public async generateThemePrompt(
params: GenerateThemePromptParams,
): Promise<GenerateThemePromptResult> {
return this.ipcRenderer.invoke("generate-theme-prompt", params);
}
public async saveThemeImage(
params: SaveThemeImageParams,
): Promise<SaveThemeImageResult> {
return this.ipcRenderer.invoke("save-theme-image", params);
}
public async cleanupThemeImages(
params: CleanupThemeImagesParams,
): Promise<void> {
await this.ipcRenderer.invoke("cleanup-theme-images", params);
}
// --- Prompts Library --- // --- Prompts Library ---
public async listPrompts(): Promise<PromptDto[]> { public async listPrompts(): Promise<PromptDto[]> {
return this.ipcRenderer.invoke("prompts:list"); return this.ipcRenderer.invoke("prompts:list");
......
...@@ -28,7 +28,7 @@ import { registerCapacitorHandlers } from "./handlers/capacitor_handlers"; ...@@ -28,7 +28,7 @@ import { registerCapacitorHandlers } from "./handlers/capacitor_handlers";
import { registerProblemsHandlers } from "./handlers/problems_handlers"; import { registerProblemsHandlers } from "./handlers/problems_handlers";
import { registerAppEnvVarsHandlers } from "./handlers/app_env_vars_handlers"; import { registerAppEnvVarsHandlers } from "./handlers/app_env_vars_handlers";
import { registerTemplateHandlers } from "./handlers/template_handlers"; import { registerTemplateHandlers } from "./handlers/template_handlers";
import { registerThemesHandlers } from "./handlers/themes_handlers"; import { registerThemesHandlers } from "../pro/main/ipc/handlers/themes_handlers";
import { registerPortalHandlers } from "./handlers/portal_handlers"; import { registerPortalHandlers } from "./handlers/portal_handlers";
import { registerPromptHandlers } from "./handlers/prompt_handlers"; import { registerPromptHandlers } from "./handlers/prompt_handlers";
import { registerHelpBotHandlers } from "./handlers/help_bot_handlers"; import { registerHelpBotHandlers } from "./handlers/help_bot_handlers";
......
...@@ -779,6 +779,65 @@ export interface GetAppThemeParams { ...@@ -779,6 +779,65 @@ export interface GetAppThemeParams {
appId: number; appId: number;
} }
// --- Custom Theme Types ---
export interface CustomTheme {
id: number;
name: string;
description: string | null;
prompt: string;
createdAt: Date;
updatedAt: Date;
}
export interface CreateCustomThemeParams {
name: string;
description?: string;
prompt: string;
}
export interface UpdateCustomThemeParams {
id: number;
name?: string;
description?: string;
prompt?: string;
}
export interface DeleteCustomThemeParams {
id: number;
}
export type ThemeGenerationMode = "inspired" | "high-fidelity";
export type ThemeGenerationModel =
| "gemini-3-pro"
| "claude-opus-4.5"
| "gpt-5.2";
export interface GenerateThemePromptParams {
imagePaths: string[]; // File paths to images (stored in temp directory)
keywords: string;
generationMode: ThemeGenerationMode; // 'inspired' (abstract design system) or 'high-fidelity' (visual recreation)
model: ThemeGenerationModel; // Model to use for generation
}
export interface GenerateThemePromptResult {
prompt: string;
}
// --- Theme Image File Handling ---
export interface SaveThemeImageParams {
data: string; // Base64 encoded image data
filename: string; // Original filename for extension detection
}
export interface SaveThemeImageResult {
path: string; // Path to the saved temp file
}
export interface CleanupThemeImagesParams {
paths: string[]; // Paths to delete
}
// --- Uncommitted Files Types --- // --- Uncommitted Files Types ---
export type UncommittedFileStatus = export type UncommittedFileStatus =
| "added" | "added"
......
import log from "electron-log";
import { db } from "../../db";
import { customThemes } from "../../db/schema";
import { eq } from "drizzle-orm";
import { themesData, type Theme } from "../../shared/themes";
const logger = log.scope("theme_utils");
/**
* Check if a theme ID refers to a custom theme.
* Custom theme IDs are prefixed with "custom:"
*/
export function isCustomThemeId(themeId: string | null): boolean {
return themeId?.startsWith("custom:") ?? false;
}
/**
* Extract the numeric ID from a custom theme ID.
* e.g., "custom:123" -> 123
*/
export function getCustomThemeNumericId(themeId: string): number | null {
if (!isCustomThemeId(themeId)) return null;
const numericId = parseInt(themeId.replace("custom:", ""), 10);
return isNaN(numericId) ? null : numericId;
}
/**
* Get a built-in theme by ID.
*/
export function getBuiltinThemeById(themeId: string | null): Theme | null {
if (!themeId) return null;
return themesData.find((t) => t.id === themeId) ?? null;
}
/**
* Async function to resolve theme prompt by ID.
* Handles both built-in themes (by ID) and custom themes (prefixed with "custom:")
*/
export async function getThemePromptById(
themeId: string | null,
): Promise<string> {
if (!themeId) {
return "";
}
// Check if it's a custom theme
if (isCustomThemeId(themeId)) {
const numericId = getCustomThemeNumericId(themeId);
if (numericId === null) {
logger.warn(`Invalid custom theme ID: ${themeId}`);
return "";
}
const customTheme = await db.query.customThemes.findFirst({
where: eq(customThemes.id, numericId),
});
if (!customTheme) {
logger.warn(`Custom theme not found: ${themeId}`);
return "";
}
return customTheme.prompt;
}
// It's a built-in theme
const builtinTheme = getBuiltinThemeById(themeId);
return builtinTheme?.prompt ?? "";
}
import { useState } from "react";
import {
useCustomThemes,
useUpdateCustomTheme,
useDeleteCustomTheme,
} from "@/hooks/useCustomThemes";
import { CustomThemeDialog } from "@/components/CustomThemeDialog";
import { EditThemeDialog } from "@/components/EditThemeDialog";
import { DeleteConfirmationDialog } from "@/components/DeleteConfirmationDialog";
import { Button } from "@/components/ui/button";
import { Plus, Palette } from "lucide-react";
import { showError } from "@/lib/toast";
import type { CustomTheme } from "@/ipc/ipc_types";
export default function ThemesPage() {
const { customThemes, isLoading } = useCustomThemes();
const [createDialogOpen, setCreateDialogOpen] = useState(false);
return (
<div className="min-h-screen px-8 py-6">
<div className="max-w-6xl mx-auto">
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold mr-4">
<Palette className="inline-block h-8 w-8 mr-2" />
Themes
</h1>
<Button onClick={() => setCreateDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" /> New Theme
</Button>
</div>
{isLoading ? (
<div>Loading...</div>
) : customThemes.length === 0 ? (
<div className="text-muted-foreground">
No custom themes yet. Create one to get started.
</div>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] gap-4">
{customThemes.map((theme) => (
<ThemeCard key={theme.id} theme={theme} />
))}
</div>
)}
<CustomThemeDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
/>
</div>
</div>
);
}
function ThemeCard({ theme }: { theme: CustomTheme }) {
const updateThemeMutation = useUpdateCustomTheme();
const deleteThemeMutation = useDeleteCustomTheme();
const isDeleting = deleteThemeMutation.isPending;
const handleUpdate = async (params: {
id: number;
name: string;
description?: string;
prompt: string;
}) => {
await updateThemeMutation.mutateAsync(params);
};
const handleDelete = async () => {
try {
await deleteThemeMutation.mutateAsync(theme.id);
} catch (error) {
showError(
`Failed to delete theme: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
};
return (
<div
data-testid="theme-card"
className="border rounded-lg p-4 bg-(--background-lightest)"
>
<div className="space-y-2">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<Palette className="h-4 w-4 text-muted-foreground shrink-0" />
<h3 className="text-lg font-semibold truncate">{theme.name}</h3>
</div>
{theme.description && (
<p className="text-sm text-muted-foreground mt-1">
{theme.description}
</p>
)}
</div>
<div className="flex gap-1 shrink-0 ml-2">
<EditThemeDialog theme={theme} onUpdateTheme={handleUpdate} />
<DeleteConfirmationDialog
itemName={theme.name}
itemType="Theme"
onDelete={handleDelete}
isDeleting={isDeleting}
/>
</div>
</div>
<pre className="text-sm whitespace-pre-wrap bg-transparent border rounded p-2 max-h-48 overflow-auto">
{theme.prompt}
</pre>
</div>
</div>
);
}
...@@ -173,6 +173,13 @@ const validInvokeChannels = [ ...@@ -173,6 +173,13 @@ const validInvokeChannels = [
"get-themes", "get-themes",
"set-app-theme", "set-app-theme",
"get-app-theme", "get-app-theme",
"get-custom-themes",
"create-custom-theme",
"update-custom-theme",
"delete-custom-theme",
"generate-theme-prompt",
"save-theme-image",
"cleanup-theme-images",
// Test-only channels // Test-only channels
// These should ALWAYS be guarded with IS_TEST_BUILD in the main process. // These should ALWAYS be guarded with IS_TEST_BUILD in the main process.
// We can't detect with IS_TEST_BUILD in the preload script because // We can't detect with IS_TEST_BUILD in the preload script because
......
差异被折叠。
...@@ -7,11 +7,13 @@ import { providerSettingsRoute } from "./routes/settings/providers/$provider"; ...@@ -7,11 +7,13 @@ import { providerSettingsRoute } from "./routes/settings/providers/$provider";
import { appDetailsRoute } from "./routes/app-details"; import { appDetailsRoute } from "./routes/app-details";
import { hubRoute } from "./routes/hub"; import { hubRoute } from "./routes/hub";
import { libraryRoute } from "./routes/library"; import { libraryRoute } from "./routes/library";
import { themesRoute } from "./routes/themes";
const routeTree = rootRoute.addChildren([ const routeTree = rootRoute.addChildren([
homeRoute, homeRoute,
hubRoute, hubRoute,
libraryRoute, libraryRoute,
themesRoute,
chatRoute, chatRoute,
appDetailsRoute, appDetailsRoute,
settingsRoute.addChildren([providerSettingsRoute]), settingsRoute.addChildren([providerSettingsRoute]),
......
import { createRoute } from "@tanstack/react-router";
import { rootRoute } from "./root";
import ThemesPage from "@/pages/themes";
export const themesRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/themes",
component: ThemesPage,
});
...@@ -70,20 +70,3 @@ export const themesData: Theme[] = [ ...@@ -70,20 +70,3 @@ export const themesData: Theme[] = [
prompt: DEFAULT_THEME_PROMPT, prompt: DEFAULT_THEME_PROMPT,
}, },
]; ];
export function getThemeById(themeId: string | null): Theme | null {
// null means "no theme" - return null
if (!themeId) {
return null;
}
return themesData.find((t) => t.id === themeId) ?? null;
}
export function getThemePrompt(themeId: string | null): string {
// null means "no theme" - return empty string (no prompt)
if (!themeId) {
return "";
}
const theme = getThemeById(themeId);
return theme?.prompt ?? "";
}
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论