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

Media library (#2950)

<!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/2950" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> --------- Co-authored-by: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
上级 f91d4cfa
......@@ -9,7 +9,7 @@ test("add prompt via deep link with base64-encoded data", async ({
await po.navigation.goToLibraryTab();
// Verify library is empty initially
await expect(po.page.getByTestId("prompt-card")).not.toBeVisible();
await expect(po.page.getByTestId("library-prompt-card")).not.toBeVisible();
// Create the prompt data to be encoded
const promptData = {
......@@ -48,5 +48,7 @@ test("add prompt via deep link with base64-encoded data", async ({
// Save the prompt
await po.page.getByRole("button", { name: "Save" }).click();
await expect(po.page.getByTestId("prompt-card")).toMatchAriaSnapshot();
await expect(
po.page.getByTestId("library-prompt-card"),
).toMatchAriaSnapshot();
});
import fs from "fs";
import path from "path";
import { expect } from "@playwright/test";
// TODO: Investigate and enable on Windows — currently skipped due to file
// system timing differences. Ensure CI covers this on macOS/Linux.
import { testSkipIfWindows } from "./helpers/test_helper";
import type { PageObject } from "./helpers/test_helper";
const IMAGE_FIXTURE_PATH = path.join(
__dirname,
"fixtures",
"images",
"logo.png",
);
async function importAppAndSeedMedia({
po,
fixtureName,
files,
}: {
po: PageObject;
fixtureName: string;
files: string[];
}) {
await po.navigation.goToAppsTab();
await po.appManagement.importApp(fixtureName);
// Wait for the title bar to show the imported app name.
// getCurrentAppName() only checks "not 'no app selected'", which races
// on subsequent imports where the title bar already shows a previous app.
await expect(po.appManagement.getTitleBarAppNameButton()).toContainText(
fixtureName,
{ timeout: 15000 },
);
const appName = await po.appManagement.getCurrentAppName();
if (!appName) {
throw new Error("Failed to get app name after import");
}
const appPath = await po.appManagement.getCurrentAppPath();
const mediaDirPath = path.join(appPath, ".dyad", "media");
fs.mkdirSync(mediaDirPath, { recursive: true });
for (const fileName of files) {
fs.copyFileSync(IMAGE_FIXTURE_PATH, path.join(mediaDirPath, fileName));
}
return { appName, appPath, mediaDirPath };
}
async function openMediaFolderByAppName(po: PageObject, appName: string) {
const collapsedFolder = po.page
.locator('[data-testid^="media-folder-"]')
.filter({ hasText: appName })
.first();
await expect(collapsedFolder).toBeVisible({ timeout: 15000 });
await collapsedFolder.click();
await expect(po.page.getByTestId("media-folder-back-button")).toBeVisible();
}
async function openMediaActionsForFile(po: PageObject, fileName: string) {
const thumbnail = po.page
.getByTestId("media-thumbnail")
.filter({ hasText: fileName })
.first();
await expect(thumbnail).toBeVisible();
await thumbnail.getByTestId("media-file-actions-trigger").click();
}
testSkipIfWindows(
"media library - rename, move, delete, and start a new chat with image reference",
async ({ po }) => {
await po.setUp();
const sourceApp = await importAppAndSeedMedia({
po,
fixtureName: "minimal",
files: ["chat-image.png", "move-image.png"],
});
const targetApp = await importAppAndSeedMedia({
po,
fixtureName: "astro",
files: [],
});
await po.navigation.goToLibraryTab();
await po.page.getByRole("link", { name: "Media" }).click();
await openMediaFolderByAppName(po, sourceApp.appName);
await openMediaActionsForFile(po, "move-image.png");
await po.page.getByTestId("media-rename-image").click();
await po.page.getByTestId("media-rename-input").fill("renamed-image");
await po.page.getByTestId("media-rename-confirm-button").click();
const sourceRenamedPath = path.join(
sourceApp.mediaDirPath,
"renamed-image.png",
);
const sourceOldPath = path.join(sourceApp.mediaDirPath, "move-image.png");
await expect.poll(() => fs.existsSync(sourceRenamedPath)).toBe(true);
await expect.poll(() => fs.existsSync(sourceOldPath)).toBe(false);
await openMediaActionsForFile(po, "renamed-image.png");
await po.page.getByTestId("media-move-to-submenu").click();
// The move flow uses a dialog with an AppSearchSelect popover.
await expect(po.page.getByTestId("media-move-dialog")).toBeVisible();
await po.page.getByLabel("Select target app").click();
await po.page.getByRole("button", { name: targetApp.appName }).click();
await po.page.getByTestId("media-move-confirm-button").click();
const targetMovedPath = path.join(
targetApp.mediaDirPath,
"renamed-image.png",
);
await expect.poll(() => fs.existsSync(sourceRenamedPath)).toBe(false);
await expect.poll(() => fs.existsSync(targetMovedPath)).toBe(true);
await po.page.getByTestId("media-folder-back-button").click();
await openMediaFolderByAppName(po, targetApp.appName);
await openMediaActionsForFile(po, "renamed-image.png");
await po.page.getByTestId("media-delete-image").click();
await po.page.getByTestId("media-delete-confirm-button").click();
await expect.poll(() => fs.existsSync(targetMovedPath)).toBe(false);
// After deleting the last file from the target folder, the folder
// disappears from the listing and the view returns to the folder list.
await openMediaFolderByAppName(po, sourceApp.appName);
await openMediaActionsForFile(po, "chat-image.png");
await po.page.getByTestId("media-start-chat-with-image").click();
await expect(po.chatActions.getChatInput()).toBeVisible();
await expect(po.chatActions.getChatInput()).toContainText(
`@chat-image.png`,
);
expect(await po.appManagement.getCurrentAppName()).toBe(sourceApp.appName);
},
);
......@@ -12,7 +12,7 @@ test("create and edit prompt", async ({ po }) => {
});
// Wait for prompt card to be fully rendered
const promptCard = po.page.getByTestId("prompt-card");
const promptCard = po.page.getByTestId("library-prompt-card");
await expect(promptCard).toBeVisible();
await expect(
promptCard.getByRole("heading", { name: "title1" }),
......@@ -48,7 +48,7 @@ test("delete prompt", async ({ po }) => {
await po.page.getByTestId("delete-prompt-button").click();
await po.page.getByRole("button", { name: "Delete" }).click();
await expect(po.page.getByTestId("prompt-card")).not.toBeVisible();
await expect(po.page.getByTestId("library-prompt-card")).not.toBeVisible();
});
test("use prompt", async ({ po }) => {
......
- img
- text: Prompt
- img
- heading "Deep Link Test Prompt" [level=3]
- paragraph: A prompt created via deep link
- button:
- text: "You are a helpful assistant. Please help with: [task here]"
- button "Edit prompt":
- img
- button:
- img
- text: "You are a helpful assistant. Please help with: [task here]"
\ No newline at end of file
- button "Delete prompt":
- img
\ No newline at end of file
......@@ -38,7 +38,7 @@ test("themes management - CRUD operations", async ({ po }) => {
// 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.getByTestId("library-theme-card")).toBeVisible();
await expect(po.page.getByText("My Test Theme")).toBeVisible();
await expect(po.page.getByText("A test theme description")).toBeVisible();
......@@ -268,7 +268,7 @@ test("themes management - AI generator flow", async ({ po }) => {
// 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.getByTestId("library-theme-card")).toBeVisible();
await expect(po.page.getByText("AI Generated Theme")).toBeVisible();
await expect(po.page.getByText("Created via AI generator")).toBeVisible();
});
......@@ -330,7 +330,7 @@ test("themes management - AI generator from website URL", async ({ po }) => {
// Verify dialog closes and theme card appears
await expect(po.page.getByRole("dialog")).not.toBeVisible();
const themeCard = po.page.getByTestId("theme-card");
const themeCard = po.page.getByTestId("library-theme-card");
await expect(themeCard).toBeVisible();
await expect(themeCard.getByText("Website Theme")).toBeVisible();
await expect(themeCard.getByText("Generated from website")).toBeVisible();
......
import { describe, expect, it } from "vitest";
import {
parseMediaMentions,
stripResolvedMediaMentions,
} from "../shared/parse_media_mentions";
describe("parseMediaMentions", () => {
it("parses @media mentions from prompt text", () => {
const prompt = "Check @media:cat.png and @media:dog.png please";
expect(parseMediaMentions(prompt)).toEqual(["cat.png", "dog.png"]);
});
it("ignores trailing punctuation after mention", () => {
const prompt = "Look at @media:cat.png, please";
expect(parseMediaMentions(prompt)).toEqual(["cat.png"]);
});
it("parses @media mentions with URL-encoded filenames (e.g. spaces)", () => {
const prompt = "Check @media:my%20photo.png please";
expect(parseMediaMentions(prompt)).toEqual(["my%20photo.png"]);
});
});
describe("stripResolvedMediaMentions", () => {
it("keeps user text when media mention is followed by adjacent text", () => {
const prompt = "@media:cat.pngdescribe this image";
expect(stripResolvedMediaMentions(prompt, ["cat.png"])).toBe(
"describe this image",
);
});
it("strips only resolved mentions and preserves unresolved ones", () => {
const prompt = "Use @media:cat.png and @media:missing.png now";
expect(stripResolvedMediaMentions(prompt, ["cat.png"])).toBe(
"Use and @media:missing.png now",
);
});
it("strips URL-encoded mentions (filenames with spaces)", () => {
const prompt = "Check @media:my%20photo.png please";
expect(stripResolvedMediaMentions(prompt, ["my%20photo.png"])).toBe(
"Check please",
);
});
});
import { atom } from "jotai";
import type { ImageThemeMode, GenerateImageResponse } from "@/ipc/types";
export type ImageGenerationStatus =
| "pending"
| "success"
| "error"
| "cancelled";
export interface ImageGenerationJob {
id: string;
prompt: string;
themeMode: ImageThemeMode;
targetAppId: number;
targetAppName: string;
status: ImageGenerationStatus;
startedAt: number;
result?: GenerateImageResponse;
error?: string;
}
const THIRTY_MINUTES_MS = 30 * 60 * 1000;
const _imageGenerationJobsAtom = atom<ImageGenerationJob[]>([]);
/** Writable atom that auto-prunes completed jobs older than 30 minutes on every write. */
export const imageGenerationJobsAtom = atom(
(get) => get(_imageGenerationJobsAtom),
(
_get,
set,
update:
| ImageGenerationJob[]
| ((prev: ImageGenerationJob[]) => ImageGenerationJob[]),
) => {
set(_imageGenerationJobsAtom, (prev) => {
const next = typeof update === "function" ? update(prev) : update;
const cutoff = Date.now() - THIRTY_MINUTES_MS;
return next.filter(
(job) => job.status === "pending" || job.startedAt > cutoff,
);
});
},
);
export const pendingImageGenerationsCountAtom = atom((get) => {
const jobs = get(imageGenerationJobsAtom);
return jobs.filter((job) => job.status === "pending").length;
});
import { useState, useMemo } from "react";
import { ChevronsUpDown, Check, Search } from "lucide-react";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
export function AppSearchSelect({
apps,
selectedAppId,
onSelect,
disabled,
}: {
apps: { id: number; name: string }[];
selectedAppId: number | null;
onSelect: (appId: number) => void;
disabled?: boolean;
}) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const filteredApps = useMemo(() => {
if (!search.trim()) return apps;
const q = search.toLowerCase();
return apps.filter((app) => app.name.toLowerCase().includes(q));
}, [apps, search]);
const selectedApp = apps.find((a) => a.id === selectedAppId);
return (
<Popover
open={open}
onOpenChange={(o) => {
setOpen(o);
if (!o) setSearch("");
}}
>
<PopoverTrigger
disabled={disabled}
aria-label="Select target app"
aria-haspopup="listbox"
aria-expanded={open}
className="flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs disabled:cursor-not-allowed disabled:opacity-50"
>
<span className={selectedApp ? "" : "text-muted-foreground"}>
{selectedApp?.name ?? "Select an app..."}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</PopoverTrigger>
<PopoverContent className="w-[--anchor-width] p-0" align="start">
<div className="flex items-center border-b px-3">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<Input
placeholder="Search apps..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-9 border-0 shadow-none focus-visible:ring-0 px-0"
/>
</div>
<div className="max-h-[200px] overflow-y-auto p-1">
{filteredApps.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">
No apps found.
</p>
) : (
filteredApps.map((app) => (
<button
key={app.id}
type="button"
onClick={() => {
onSelect(app.id);
setOpen(false);
setSearch("");
}}
className="relative flex w-full cursor-default items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none hover:bg-accent hover:text-accent-foreground"
>
{app.id === selectedAppId && (
<Check className="absolute left-2 h-4 w-4" />
)}
{app.name}
</button>
))
)}
</div>
</PopoverContent>
</Popover>
);
}
差异被折叠。
import { useState } from "react";
import { useAtomValue } from "jotai";
import { Clock } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
imageGenerationJobsAtom,
pendingImageGenerationsCountAtom,
} from "@/atoms/imageGenerationAtoms";
import { ImageGenerationProgressDialog } from "./ImageGenerationProgressDialog";
export function ImageGenerationProgressButton() {
const recentJobs = useAtomValue(imageGenerationJobsAtom);
const pendingCount = useAtomValue(pendingImageGenerationsCountAtom);
const [dialogOpen, setDialogOpen] = useState(false);
if (recentJobs.length === 0) return null;
return (
<>
<Button
variant="outline"
size="sm"
className="relative"
onClick={() => setDialogOpen(true)}
>
<Clock className="h-4 w-4 mr-1" />
Recent
{pendingCount > 0 && (
<span className="absolute -top-1.5 -right-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] font-medium text-primary-foreground">
{pendingCount}
</span>
)}
</Button>
<ImageGenerationProgressDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
/>
</>
);
}
import { useState, useEffect } from "react";
import { createPortal } from "react-dom";
import { useAtomValue } from "jotai";
import {
Loader2,
CheckCircle2,
XCircle,
Ban,
ChevronDown,
ChevronUp,
ImageIcon,
} from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { imageGenerationJobsAtom } from "@/atoms/imageGenerationAtoms";
import type {
ImageGenerationJob,
ImageGenerationStatus,
} from "@/atoms/imageGenerationAtoms";
import { buildDyadMediaUrl } from "@/lib/dyadMediaUrl";
import { useCancelImageGeneration } from "@/hooks/useGenerateImage";
import { ImageLightbox } from "@/components/chat/ImageLightbox";
const THEME_LABELS: Record<string, string> = {
plain: "Plain",
"3d-clay": "3D / Clay",
"real-photography": "Photography",
"isometric-illustration": "Isometric",
};
function StatusIcon({ status }: { status: ImageGenerationStatus }) {
switch (status) {
case "pending":
return <Loader2 className="w-4 h-4 text-primary animate-spin shrink-0" />;
case "success":
return <CheckCircle2 className="w-4 h-4 text-green-500 shrink-0" />;
case "error":
return <XCircle className="w-4 h-4 text-destructive shrink-0" />;
case "cancelled":
return <Ban className="w-4 h-4 text-muted-foreground shrink-0" />;
}
}
function StatusLabel({ status }: { status: ImageGenerationStatus }) {
switch (status) {
case "pending":
return (
<Badge variant="secondary" className="text-xs">
Generating
</Badge>
);
case "success":
return (
<Badge variant="secondary" className="text-xs text-green-600">
Completed
</Badge>
);
case "error":
return (
<Badge variant="secondary" className="text-xs text-red-600">
Failed
</Badge>
);
case "cancelled":
return (
<Badge variant="secondary" className="text-xs text-muted-foreground">
Cancelled
</Badge>
);
}
}
function useRelativeTime(timestamp: number, intervalMs = 30_000): string {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), intervalMs);
return () => clearInterval(id);
}, [intervalMs]);
const seconds = Math.floor((now - timestamp) / 1000);
if (seconds < 60) return "Just now";
const minutes = Math.floor(seconds / 60);
return `${minutes}m ago`;
}
function RelativeTime({ timestamp }: { timestamp: number }) {
const label = useRelativeTime(timestamp);
return <span className="text-xs text-muted-foreground">{label}</span>;
}
function ImageGenerationCard({ job }: { job: ImageGenerationJob }) {
const [expanded, setExpanded] = useState(false);
const cancelGeneration = useCancelImageGeneration();
const [imgError, setImgError] = useState(false);
const [lightboxOpen, setLightboxOpen] = useState(false);
const truncatedPrompt =
job.prompt.length > 60 ? job.prompt.slice(0, 60) + "…" : job.prompt;
return (
<div className="border border-border rounded-lg overflow-hidden">
{/* Collapsed header - always visible */}
<button
type="button"
className="w-full flex items-center gap-3 p-3 text-left hover:bg-muted/50 transition-colors"
onClick={() => setExpanded(!expanded)}
>
<StatusIcon status={job.status} />
<p className="text-sm flex-1 min-w-0 truncate">{truncatedPrompt}</p>
<RelativeTime timestamp={job.startedAt} />
{expanded ? (
<ChevronUp className="w-4 h-4 text-muted-foreground shrink-0" />
) : (
<ChevronDown className="w-4 h-4 text-muted-foreground shrink-0" />
)}
</button>
{/* Expanded content */}
{expanded && (
<div className="border-t border-border p-3 space-y-3">
{/* Full prompt */}
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">
Prompt
</p>
<p className="text-sm whitespace-pre-wrap">{job.prompt}</p>
</div>
{/* Image preview or placeholder */}
<div>
{job.status === "pending" ? (
<div className="w-full aspect-video max-w-xs rounded-lg border-2 border-dashed border-muted-foreground/25 flex flex-col items-center justify-center gap-2 bg-muted/10">
<Loader2 className="h-6 w-6 text-primary animate-spin" />
<p className="text-xs text-muted-foreground">Generating...</p>
</div>
) : job.status === "success" && job.result ? (
imgError ? (
<div className="w-full max-w-xs aspect-video rounded-lg border bg-muted/10 flex items-center justify-center">
<ImageIcon className="h-6 w-6 text-muted-foreground" />
</div>
) : (
<button
type="button"
className="cursor-pointer"
onClick={() => setLightboxOpen(true)}
>
<img
src={buildDyadMediaUrl(
job.result.appPath,
job.result.fileName,
)}
alt="Generated image"
className="w-full max-w-xs rounded-lg border shadow-sm hover:opacity-90 transition-opacity"
onError={() => setImgError(true)}
/>
</button>
)
) : job.status === "error" ? (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-2 text-xs text-destructive">
{job.error || "Image generation failed"}
</div>
) : job.status === "cancelled" ? (
<div className="w-full aspect-video max-w-xs rounded-lg border-2 border-dashed border-muted-foreground/25 flex flex-col items-center justify-center gap-2 bg-muted/10">
<Ban className="h-6 w-6 text-muted-foreground" />
<p className="text-xs text-muted-foreground">
Generation was cancelled
</p>
</div>
) : null}
</div>
{/* Metadata */}
<div className="flex items-center gap-2 flex-wrap">
<StatusLabel status={job.status} />
<Badge variant="outline" className="text-xs">
{THEME_LABELS[job.themeMode] || job.themeMode}
</Badge>
<span className="text-xs text-muted-foreground">
{job.targetAppName}
</span>
</div>
{/* Cancel button for pending jobs */}
{job.status === "pending" && (
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => cancelGeneration(job.id)}
>
Cancel
</Button>
)}
</div>
)}
{lightboxOpen &&
job.status === "success" &&
job.result &&
createPortal(
<ImageLightbox
imageUrl={buildDyadMediaUrl(
job.result.appPath,
job.result.fileName,
)}
alt={job.prompt}
onClose={() => setLightboxOpen(false)}
onError={() => setImgError(true)}
/>,
document.body,
)}
</div>
);
}
export function ImageGenerationProgressDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const recentJobs = useAtomValue(imageGenerationJobsAtom);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ImageIcon className="h-5 w-5" />
Image Generation
</DialogTitle>
<DialogDescription>
Recent image generations from the last 30 minutes.
</DialogDescription>
</DialogHeader>
<div className="space-y-2 py-2">
{recentJobs.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm">
No recent image generations.
</div>
) : (
recentJobs
.slice()
.sort((a, b) => b.startedAt - a.startedAt)
.map((job) => <ImageGenerationCard key={job.id} job={job} />)
)}
</div>
</DialogContent>
</Dialog>
);
}
import { useState } from "react";
import { createPortal } from "react-dom";
import { toast } from "sonner";
import { Loader2, CheckCircle2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ImageLightbox } from "@/components/chat/ImageLightbox";
import { buildDyadMediaUrl } from "@/lib/dyadMediaUrl";
import type { GenerateImageResponse } from "@/ipc/types";
import { getDefaultStore } from "jotai";
import { imageGenerationJobsAtom } from "@/atoms/imageGenerationAtoms";
const GENERATING_TOAST_ID = "image-gen-progress";
const SUCCESS_TOAST_ID = "image-gen-success";
const SUCCESS_AUTO_DISMISS_MS = 10_000;
function restoreGeneratingToastIfNeeded() {
const store = getDefaultStore();
const pending = store
.get(imageGenerationJobsAtom)
.filter((j) => j.status === "pending").length;
if (pending > 0) {
showImageGeneratingToast(pending);
}
}
function DismissButton({
toastId,
onDismiss,
}: {
toastId: string | number;
onDismiss?: () => void;
}) {
return (
<button
onClick={(e) => {
e.stopPropagation();
toast.dismiss(toastId);
onDismiss?.();
}}
className="absolute -top-2 -right-2 w-5 h-5 rounded-full bg-muted border border-border shadow-sm flex items-center justify-center hover:bg-accent transition-colors z-10"
>
<X className="w-3 h-3 text-muted-foreground" />
</button>
);
}
export function ImageGeneratingToast({
pendingCount,
toastId,
}: {
pendingCount: number;
toastId: string | number;
}) {
return (
<div className="relative overflow-visible bg-background border border-border rounded-xl shadow-lg min-w-[340px] max-w-[420px] p-3">
<DismissButton toastId={toastId} />
<div className="flex items-center gap-3">
<Loader2 className="w-5 h-5 text-primary animate-spin shrink-0" />
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">
{pendingCount > 1
? `Generating ${pendingCount} images…`
: "Generating image…"}
</p>
</div>
</div>
</div>
);
}
export function ImageSuccessToast({
result,
toastId,
}: {
result: GenerateImageResponse;
toastId: string | number;
}) {
const [isLightboxOpen, setIsLightboxOpen] = useState(false);
const imageUrl = buildDyadMediaUrl(result.appPath, result.fileName);
return (
<>
<div className="relative overflow-visible bg-background border border-border rounded-xl shadow-lg min-w-[340px] max-w-[420px] p-3">
<DismissButton
toastId={toastId}
onDismiss={restoreGeneratingToastIfNeeded}
/>
<div className="flex items-center gap-3">
<CheckCircle2 className="w-5 h-5 text-green-500 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground">Image ready</p>
<p className="text-xs text-muted-foreground truncate">
Saved to {result.appName}
</p>
</div>
<Button
size="sm"
variant="outline"
className="shrink-0 text-xs"
onClick={(e) => {
e.stopPropagation();
setIsLightboxOpen(true);
}}
>
Open image
</Button>
</div>
</div>
{isLightboxOpen &&
createPortal(
<ImageLightbox
imageUrl={imageUrl}
alt="Generated image"
filePath={result.filePath}
onClose={() => {
setIsLightboxOpen(false);
toast.dismiss(toastId);
restoreGeneratingToastIfNeeded();
}}
/>,
document.body,
)}
</>
);
}
export function showImageGeneratingToast(
pendingCount: number,
): string | number {
// Dismiss any lingering success toast before showing progress
toast.dismiss(SUCCESS_TOAST_ID);
return toast.custom(
(t) => <ImageGeneratingToast pendingCount={pendingCount} toastId={t} />,
{ id: GENERATING_TOAST_ID, duration: Infinity },
);
}
export function showImageSuccessToast(
result: GenerateImageResponse,
): string | number {
// Dismiss the generating toast before showing success
toast.dismiss(GENERATING_TOAST_ID);
return toast.custom(
(t) => <ImageSuccessToast result={result} toastId={t} />,
{
id: SUCCESS_TOAST_ID,
duration: SUCCESS_AUTO_DISMISS_MS,
onAutoClose: () => restoreGeneratingToastIfNeeded(),
},
);
}
export function dismissImageGenerationToast() {
toast.dismiss(GENERATING_TOAST_ID);
toast.dismiss(SUCCESS_TOAST_ID);
}
import { useState } from "react";
import {
ImageIcon,
Box,
Camera,
Layers,
Sparkles,
Lock,
Loader2,
} from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { useLoadApps } from "@/hooks/useLoadApps";
import { useGenerateImage } from "@/hooks/useGenerateImage";
import { useUserBudgetInfo } from "@/hooks/useUserBudgetInfo";
import { AiAccessBanner } from "./ProBanner";
import { AppSearchSelect } from "./AppSearchSelect";
import type { ImageThemeMode } from "@/ipc/types";
const THEME_MODES: {
value: ImageThemeMode;
label: string;
description: string;
icon: typeof ImageIcon;
}[] = [
{
value: "plain",
label: "Plain",
description: "No style applied",
icon: Sparkles,
},
{
value: "3d-clay",
label: "3D / Clay",
description: "Soft, rounded clay aesthetic",
icon: Box,
},
{
value: "real-photography",
label: "Photography",
description: "Photorealistic DSLR quality",
icon: Camera,
},
{
value: "isometric-illustration",
label: "Isometric",
description: "Clean geometric illustrations",
icon: Layers,
},
];
export function ImageGeneratorDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const [prompt, setPrompt] = useState("");
const [themeMode, setThemeMode] = useState<ImageThemeMode>("plain");
const [targetAppId, setTargetAppId] = useState<number | null>(null);
const { apps } = useLoadApps();
const generateImage = useGenerateImage();
const { userBudget, isLoadingUserBudget: isBudgetLoading } =
useUserBudgetInfo();
const effectiveTargetAppId =
targetAppId ?? (apps.length === 1 ? apps[0].id : null);
const handleGenerate = () => {
if (!prompt.trim() || effectiveTargetAppId === null) return;
const targetApp = apps.find((a) => a.id === effectiveTargetAppId);
if (!targetApp) return;
generateImage.mutate({
requestId: crypto.randomUUID(),
prompt: prompt.trim(),
themeMode,
targetAppId: effectiveTargetAppId,
targetAppName: targetApp.name,
});
// Auto-close dialog immediately after starting generation
handleOpenChange(false);
};
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen) {
setPrompt("");
setThemeMode("plain");
setTargetAppId(null);
generateImage.reset();
}
onOpenChange(nextOpen);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ImageIcon className="h-5 w-5" />
Generate Image
</DialogTitle>
<DialogDescription>
Describe the image you want to generate and choose a visual style.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{isBudgetLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : !userBudget ? (
<div className="space-y-4">
<div className="flex flex-col items-center justify-center py-8 px-4 border-2 border-dashed border-muted-foreground/25 rounded-lg bg-muted/10">
<Lock className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold text-center mb-2">
AI Image Generator
</h3>
<p className="text-sm text-muted-foreground text-center max-w-md">
Generate custom images using AI to use in your apps.
</p>
<p className="text-xs text-muted-foreground/70 mt-2">
Pro-only feature
</p>
</div>
<AiAccessBanner />
</div>
) : (
<>
{/* Prompt */}
<div className="space-y-2">
<Label htmlFor="image-prompt">Prompt</Label>
<Textarea
id="image-prompt"
placeholder="Describe the image you want to create..."
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
className="min-h-[100px] resize-none"
/>
</div>
{/* Theme Mode Selector */}
<div className="space-y-2">
<Label>Style</Label>
<div className="grid grid-cols-2 gap-2">
{THEME_MODES.map((mode) => {
const Icon = mode.icon;
const isSelected = themeMode === mode.value;
return (
<button
key={mode.value}
type="button"
aria-pressed={isSelected}
onClick={() => setThemeMode(mode.value)}
className={`flex items-center gap-3 rounded-lg border p-3 text-left transition-colors ${
isSelected
? "border-primary bg-primary/5"
: "border-border hover:border-primary/30 hover:bg-muted/50"
}`}
>
<Icon
className={`h-5 w-5 shrink-0 ${isSelected ? "text-primary" : "text-muted-foreground"}`}
/>
<div className="min-w-0">
<div
className={`text-sm font-medium ${isSelected ? "text-primary" : ""}`}
>
{mode.label}
</div>
<div className="text-xs text-muted-foreground truncate">
{mode.description}
</div>
</div>
</button>
);
})}
</div>
</div>
{/* Target App Selector */}
<div className="space-y-2">
<Label>Save to App</Label>
<AppSearchSelect
apps={apps}
selectedAppId={effectiveTargetAppId}
onSelect={setTargetAppId}
/>
</div>
</>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<div className="flex items-center gap-2">
{!prompt.trim() || effectiveTargetAppId === null ? (
<p className="text-xs text-muted-foreground">
{!prompt.trim() && effectiveTargetAppId === null
? "Enter a prompt and select an app"
: !prompt.trim()
? "Enter a prompt to generate"
: "Select an app to save to"}
</p>
) : null}
<Button
onClick={handleGenerate}
disabled={!prompt.trim() || effectiveTargetAppId === null}
>
Generate
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
import {
useUpdateCustomTheme,
useDeleteCustomTheme,
} from "@/hooks/useCustomThemes";
import type { PromptItem } from "@/hooks/usePrompts";
import { Badge } from "@/components/ui/badge";
import { Palette, FileText } from "lucide-react";
import { cn } from "@/lib/utils";
import { CreateOrEditPromptDialog } from "@/components/CreatePromptDialog";
import { DeleteConfirmationDialog } from "@/components/DeleteConfirmationDialog";
import { EditThemeDialog } from "@/components/EditThemeDialog";
import { showError } from "@/lib/toast";
import type { CustomTheme } from "@/ipc/types";
export type LibraryItem =
| { type: "theme"; data: CustomTheme }
| { type: "prompt"; data: PromptItem };
const CARD_TYPE_CONFIG = {
theme: {
icon: Palette,
label: "Theme",
badgeClass:
"bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-900/40 dark:text-purple-300 dark:border-purple-800",
},
prompt: {
icon: FileText,
label: "Prompt",
badgeClass:
"bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-900/40 dark:text-blue-300 dark:border-blue-800",
},
} as const;
export function LibraryCard({
item,
onUpdatePrompt,
onDeletePrompt,
}: {
item: LibraryItem;
onUpdatePrompt?: (p: {
id: number;
title: string;
description?: string;
content: string;
}) => Promise<void>;
onDeletePrompt?: (id: number) => Promise<void>;
}) {
const config = CARD_TYPE_CONFIG[item.type];
const Icon = config.icon;
const title = item.type === "theme" ? item.data.name : item.data.title;
const description = item.data.description;
const content = item.type === "theme" ? item.data.prompt : item.data.content;
const slug = item.type === "prompt" ? item.data.slug : null;
return (
<div
data-testid={`library-${item.type}-card`}
className="border rounded-lg p-4 bg-(--background-lightest) relative"
>
<Badge
variant="outline"
className={cn("absolute top-3 right-3 gap-1", config.badgeClass)}
>
<Icon className="h-3 w-3" />
{config.label}
</Badge>
<div className="space-y-2">
<div className="flex items-start justify-between pr-20">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
<h3 className="text-lg font-semibold truncate">{title}</h3>
</div>
{description && (
<p className="text-sm text-muted-foreground mt-1">
{description}
</p>
)}
{slug && (
<p className="text-xs text-muted-foreground mt-1">
Use <code className="font-mono">/{slug}</code> in chat
</p>
)}
</div>
</div>
<pre className="text-sm whitespace-pre-wrap bg-transparent border rounded p-2 max-h-48 overflow-auto">
{content}
</pre>
<div className="flex gap-1 justify-end">
{item.type === "theme" ? (
<ThemeActions theme={item.data} />
) : (
onUpdatePrompt &&
onDeletePrompt && (
<PromptActions
prompt={item.data}
onUpdate={onUpdatePrompt}
onDelete={onDeletePrompt}
/>
)
)}
</div>
</div>
</div>
);
}
function ThemeActions({ 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 (
<>
<EditThemeDialog theme={theme} onUpdateTheme={handleUpdate} />
<DeleteConfirmationDialog
itemName={theme.name}
itemType="Theme"
onDelete={handleDelete}
isDeleting={isDeleting}
/>
</>
);
}
function PromptActions({
prompt,
onUpdate,
onDelete,
}: {
prompt: PromptItem;
onUpdate: (p: {
id: number;
title: string;
description?: string;
content: string;
}) => Promise<void>;
onDelete: (id: number) => Promise<void>;
}) {
return (
<>
<CreateOrEditPromptDialog
mode="edit"
prompt={prompt}
onUpdatePrompt={onUpdate}
/>
<DeleteConfirmationDialog
itemName={prompt.title}
itemType="Prompt"
onDelete={() => onDelete(prompt.id)}
/>
</>
);
}
import { Palette, FileText, BookOpen, Image } from "lucide-react";
import { cn } from "@/lib/utils";
export type FilterType = "all" | "themes" | "prompts" | "media";
const FILTER_OPTIONS: {
key: FilterType;
label: string;
icon: typeof BookOpen;
}[] = [
{ key: "all", label: "All", icon: BookOpen },
{ key: "themes", label: "Themes", icon: Palette },
{ key: "prompts", label: "Prompts", icon: FileText },
{ key: "media", label: "Media", icon: Image },
];
export function LibraryFilterTabs({
active,
onChange,
}: {
active: FilterType;
onChange: (f: FilterType) => void;
}) {
return (
<div className="flex gap-2 mb-6" role="group" aria-label="Library filters">
{FILTER_OPTIONS.map((opt) => (
<button
key={opt.key}
type="button"
aria-pressed={active === opt.key}
onClick={() => onChange(opt.key)}
className={cn(
"inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors",
active === opt.key
? "bg-primary text-primary-foreground"
: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
)}
>
<opt.icon className="h-3.5 w-3.5" />
{opt.label}
</button>
))}
</div>
);
}
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";
import { BookOpen, Palette, FileText, Image } from "lucide-react";
type LibrarySection = {
id: string;
......@@ -11,8 +11,10 @@ type LibrarySection = {
};
const LIBRARY_SECTIONS: LibrarySection[] = [
{ id: "themes", label: "Themes", to: "/themes", icon: Palette },
{ id: "prompts", label: "Prompts", to: "/library", icon: FileText },
{ id: "all", label: "All", to: "/library", icon: BookOpen },
{ id: "themes", label: "Themes", to: "/library/themes", icon: Palette },
{ id: "prompts", label: "Prompts", to: "/library/prompts", icon: FileText },
{ id: "media", label: "Media", to: "/library/media", icon: Image },
];
export function LibraryList({ show }: { show: boolean }) {
......@@ -31,9 +33,14 @@ export function LibraryList({ show }: { show: boolean }) {
<ScrollArea className="flex-grow">
<div className="space-y-1 p-4 pt-0">
{LIBRARY_SECTIONS.map((section) => {
const fullLocation = pathname + routerState.location.searchStr;
const isActive =
section.to === fullLocation ||
section.to === pathname ||
(section.to !== "/" && pathname.startsWith(section.to));
(section.to !== "/" &&
section.to !== "/library" &&
!section.to.includes("?") &&
pathname.startsWith(section.to));
return (
<Link
......
import { Search } from "lucide-react";
import { cn } from "@/lib/utils";
export function LibrarySearchBar({
value,
onChange,
placeholder = "Search themes and prompts...",
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
}) {
return (
<div className="mb-6">
<div
className={cn(
"relative flex items-center border border-border rounded-2xl bg-(--background-lighter) transition-colors duration-200",
"hover:border-primary/30",
"focus-within:border-primary/30 focus-within:ring-1 focus-within:ring-primary/20",
)}
>
<Search className="absolute left-4 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder={placeholder}
aria-label="Search library"
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full bg-transparent py-3 pl-11 pr-4 text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
</div>
);
}
import { Palette, FileText, Plus, ChevronDown, ImagePlus } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export function NewLibraryItemMenu({
onNewPrompt,
onNewTheme,
onNewImage,
}: {
onNewPrompt: () => void;
onNewTheme: () => void;
onNewImage: () => void;
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground shadow hover:bg-primary/90 transition-colors">
<Plus className="h-4 w-4" />
New
<ChevronDown className="h-3.5 w-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onNewPrompt}>
<FileText className="mr-2 h-4 w-4" />
New Prompt
</DropdownMenuItem>
<DropdownMenuItem onClick={onNewTheme}>
<Palette className="mr-2 h-4 w-4" />
New Theme
</DropdownMenuItem>
<DropdownMenuItem onClick={onNewImage}>
<ImagePlus className="mr-2 h-4 w-4" />
Generate Image
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
......@@ -49,7 +49,7 @@ const items = [
},
{
title: "Library",
to: "/themes",
to: "/library",
icon: BookOpen,
},
{
......@@ -98,9 +98,7 @@ export function AppSidebar() {
routerState.location.pathname.startsWith("/app-details");
const isChatRoute = routerState.location.pathname === "/chat";
const isSettingsRoute = routerState.location.pathname.startsWith("/settings");
const isLibraryRoute =
routerState.location.pathname.startsWith("/library") ||
routerState.location.pathname.startsWith("/themes");
const isLibraryRoute = routerState.location.pathname.startsWith("/library");
let selectedItem: string | null = null;
if (hoverState === "start-hover:app") {
......
......@@ -22,6 +22,7 @@ import {
import { KEY_ENTER_COMMAND, COMMAND_PRIORITY_HIGH } from "lexical";
import { useLoadApps } from "@/hooks/useLoadApps";
import { usePrompts } from "@/hooks/usePrompts";
import { useAppMediaFiles } from "@/hooks/useAppMediaFiles";
import { forwardRef } from "react";
import { useAtomValue } from "jotai";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
......@@ -47,6 +48,7 @@ const CustomMenuItem = forwardRef<
const isSkill = item.data?.type === "skill";
const isApp = item.data?.type === "app";
const isHistory = item.data?.type === "history";
const isMedia = item.data?.type === "media";
const label = isSkill
? "Skill"
: isPrompt
......@@ -55,7 +57,9 @@ const CustomMenuItem = forwardRef<
? "App"
: isHistory
? ""
: "File";
: isMedia
? "Media"
: "File";
const value = (item as any)?.value;
// For history items, show full text without label
......@@ -92,7 +96,9 @@ const CustomMenuItem = forwardRef<
? "bg-purple-500 text-white"
: isApp
? "bg-primary text-primary-foreground"
: "bg-blue-600 text-white"
: isMedia
? "bg-amber-500 text-white"
: "bg-blue-600 text-white"
}`}
>
{label}
......@@ -206,6 +212,14 @@ function ExternalValueSyncPlugin({
const title = promptsById[id];
return title ? `@${title}` : _m;
});
// Strip @media: prefix for display
displayText = displayText.replace(/@media:([^\s]+)/g, (_, ref) => {
try {
return `@${decodeURIComponent(ref)}`;
} catch {
return `@${ref}`;
}
});
const currentText = editor.getEditorState().read(() => {
const root = $getRoot();
......@@ -220,10 +234,11 @@ function ExternalValueSyncPlugin({
const paragraph = $createParagraphNode();
// Build nodes from internal value, turning @app:Name and @prompt:<id> into mention nodes
// Build nodes from internal value, turning @app:Name, @prompt:<id>, @file:<path>, and @media:<ref> into mention nodes
let lastIndex = 0;
let match: RegExpExecArray | null;
const combined = /@app:([a-zA-Z0-9_-]+)|@prompt:(\d+)|@file:([^\s]+)/g;
const combined =
/@app:([a-zA-Z0-9_-]+)|@prompt:(\d+)|@file:([^\s]+)|@media:([^\s]+)/g;
while ((match = combined.exec(value)) !== null) {
const start = match.index;
const full = match[0];
......@@ -241,6 +256,14 @@ function ExternalValueSyncPlugin({
} else if (match[3]) {
const filePath = match[3];
paragraph.append($createBeautifulMentionNode("@", filePath));
} else if (match[4]) {
let mediaRef: string;
try {
mediaRef = decodeURIComponent(match[4]);
} catch {
mediaRef = match[4];
}
paragraph.append($createBeautifulMentionNode("@", mediaRef));
}
lastIndex = start + full.length;
}
......@@ -290,6 +313,7 @@ export function LexicalChatInput({
}: LexicalChatInputProps) {
const { apps } = useLoadApps();
const { prompts } = usePrompts();
const { mediaApps } = useAppMediaFiles();
const [shouldClear, setShouldClear] = useState(false);
const historyTriggerActiveRef = useRef(false);
const selectedAppId = useAtomValue(selectedAppIdAtom);
......@@ -366,7 +390,15 @@ export function LexicalChatInput({
type: "file",
}));
result["@"] = [...appMentions, ...promptItems, ...fileItems];
// Build media mention items from the current app's media files only
const currentAppMedia = mediaApps.find(
(app) => app.appId === selectedAppId,
);
const mediaItems = (currentAppMedia?.files ?? []).map((file) => ({
value: file.fileName,
type: "media",
}));
result["@"] = [...mediaItems, ...appMentions, ...promptItems, ...fileItems];
return result;
}, [
......@@ -377,6 +409,7 @@ export function LexicalChatInput({
prompts,
appFiles,
messageHistory,
mediaApps,
]);
const initialConfig = {
......@@ -413,11 +446,32 @@ export function LexicalChatInput({
}
}
// Transform @AppName mentions to @app:AppName format
// This regex matches @AppName where AppName is one of our actual app names
// Short-circuit if there's no "@" symbol in the text
if (textContent.includes("@")) {
// Convert media mentions : @filename -> @media:filename
const currentAppMediaFiles = mediaApps.find(
(app) => app.appId === selectedAppId,
);
if (currentAppMediaFiles) {
// Sort files by name length descending so longer names are matched
// first, preventing prefix collisions (e.g. "cat.png copy.png" vs "cat.png").
const sortedFiles = [...currentAppMediaFiles.files].sort(
(a, b) => b.fileName.length - a.fileName.length,
);
for (const file of sortedFiles) {
const escaped = file.fileName.replace(
/[.*+?^${}()|[\]\\]/g,
"\\$&",
);
const mediaRegex = new RegExp(`@(${escaped})(?![\\w-])`, "g");
textContent = textContent.replace(
mediaRegex,
`@media:${encodeURIComponent(file.fileName)}`,
);
}
}
// Transform @AppName mentions to @app:AppName format
const appNames = apps?.map((app) => app.name) || [];
for (const appName of appNames) {
// Escape special regex characters in app name
......@@ -426,7 +480,7 @@ export function LexicalChatInput({
"\\$&",
);
const mentionRegex = new RegExp(
`@(${escapedAppName})(?![a-zA-Z0-9_-])`,
`@(${escapedAppName})(?![a-zA-Z0-9_/\\-])`,
"g",
);
textContent = textContent.replace(mentionRegex, "@app:$1");
......@@ -451,7 +505,7 @@ export function LexicalChatInput({
onChange(textContent);
});
},
[onChange, apps, prompts, appFiles],
[onChange, apps, prompts, appFiles, mediaApps, selectedAppId],
);
const handleSubmit = useCallback(() => {
......
import { useState } from "react";
import {
Image,
MoreVertical,
MessageSquarePlus,
Pencil,
Trash2,
MoveRight,
Expand,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { buildDyadMediaUrl } from "@/lib/dyadMediaUrl";
import { buttonVariants } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { MediaFile } from "@/ipc/types";
export function MediaFileThumbnail({
file,
appPath,
onStartNewChatWithImage,
onRenameImage,
onMoveImage,
onDeleteImage,
onPreviewImage,
isBusy,
}: {
file: MediaFile;
appPath: string;
onStartNewChatWithImage: (file: MediaFile) => Promise<void>;
onRenameImage: (file: MediaFile) => void;
onMoveImage: (file: MediaFile) => void;
onDeleteImage: (file: MediaFile) => void;
onPreviewImage: (file: MediaFile) => void;
isBusy: boolean;
}) {
const mediaUrl = buildDyadMediaUrl(appPath, file.fileName);
const [imgError, setImgError] = useState(false);
return (
<div
data-testid="media-thumbnail"
data-media-file-name={file.fileName}
className="w-[120px] border rounded-md overflow-hidden bg-secondary/30"
>
<div
role="button"
tabIndex={0}
className="group w-[120px] h-[120px] relative cursor-pointer"
onClick={() => onPreviewImage(file)}
onKeyDown={(e) => {
if (
e.target === e.currentTarget &&
(e.key === "Enter" || e.key === " ")
) {
e.preventDefault();
onPreviewImage(file);
}
}}
>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
data-testid="media-file-actions-trigger"
aria-label={`Media actions for ${file.fileName}`}
className={cn(
buttonVariants({
variant: "secondary",
size: "icon",
}),
"absolute right-1 top-1 size-7",
)}
onClick={(event) => event.stopPropagation()}
>
<MoreVertical className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-52"
onClick={(event) => event.stopPropagation()}
>
<DropdownMenuItem
data-testid="media-start-chat-with-image"
onClick={() => {
void onStartNewChatWithImage(file);
}}
disabled={isBusy}
>
<MessageSquarePlus className="mr-2 h-4 w-4" />
Start New Chat With Image
</DropdownMenuItem>
<DropdownMenuItem
data-testid="media-rename-image"
onClick={() => onRenameImage(file)}
disabled={isBusy}
>
<Pencil className="mr-2 h-4 w-4" />
Rename Image
</DropdownMenuItem>
<DropdownMenuItem
data-testid="media-move-to-submenu"
onClick={() => onMoveImage(file)}
disabled={isBusy}
>
<MoveRight className="mr-2 h-4 w-4" />
Move To
</DropdownMenuItem>
<DropdownMenuItem
data-testid="media-delete-image"
variant="destructive"
onClick={() => onDeleteImage(file)}
disabled={isBusy}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete Image
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{imgError ? (
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
<Image className="h-6 w-6" />
</div>
) : (
<>
<img
src={mediaUrl}
alt={file.fileName}
className="w-full h-full object-cover"
onError={() => setImgError(true)}
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center pointer-events-none">
<Expand className="h-5 w-5 text-white opacity-0 group-hover:opacity-100 transition-opacity drop-shadow-md" />
</div>
</>
)}
</div>
<div className="p-1.5">
<p
className="text-xs truncate text-muted-foreground"
title={file.fileName}
>
{file.fileName}
</p>
</div>
</div>
);
}
import { ArrowLeft, FolderOpen } from "lucide-react";
import type { MediaFile } from "@/ipc/types";
import { MediaFileThumbnail } from "./MediaFileThumbnail";
export function MediaFolderOpen({
appName,
appId,
appPath,
files,
onClose,
onStartNewChatWithImage,
onRenameImage,
onMoveImage,
onDeleteImage,
onPreviewImage,
isBusy,
searchQuery,
}: {
appName: string;
appId: number;
appPath: string;
files: MediaFile[];
onClose: () => void;
onStartNewChatWithImage: (file: MediaFile) => Promise<void>;
onRenameImage: (file: MediaFile) => void;
onMoveImage: (file: MediaFile) => void;
onDeleteImage: (file: MediaFile) => void;
onPreviewImage: (file: MediaFile) => void;
isBusy: boolean;
searchQuery?: string;
}) {
const filteredFiles = searchQuery
? files.filter((f) =>
f.fileName.toLowerCase().includes(searchQuery.toLowerCase()),
)
: files;
return (
<div
data-testid={`media-folder-open-${appId}`}
className="border rounded-lg p-4 bg-[--background-lightest] col-span-full"
>
<div className="flex items-center gap-2 mb-4">
<button
data-testid="media-folder-back-button"
aria-label="Back to folders"
onClick={onClose}
className="p-1 rounded-md hover:bg-secondary transition-colors"
>
<ArrowLeft className="h-4 w-4" />
</button>
<FolderOpen className="h-5 w-5 text-amber-500" />
<h3 className="text-lg font-semibold">{appName}</h3>
<span className="text-sm text-muted-foreground">
({filteredFiles.length} file{filteredFiles.length !== 1 ? "s" : ""})
</span>
</div>
{filteredFiles.length === 0 ? (
<p className="text-muted-foreground text-center py-8">
{searchQuery
? "No files match your search."
: "No media files found."}
</p>
) : (
<div className="flex flex-wrap gap-3">
{filteredFiles.map((file) => (
<MediaFileThumbnail
key={file.fileName}
file={file}
appPath={appPath}
onStartNewChatWithImage={onStartNewChatWithImage}
onRenameImage={onRenameImage}
onMoveImage={onMoveImage}
onDeleteImage={onDeleteImage}
onPreviewImage={onPreviewImage}
isBusy={isBusy}
/>
))}
</div>
)}
</div>
);
}
export function getFileNameWithoutExtension(fileName: string): string {
const extension = getFileExtension(fileName);
if (!extension) return fileName;
return fileName.slice(0, fileName.length - extension.length);
}
export function getFileExtension(fileName: string): string {
const lastDotIndex = fileName.lastIndexOf(".");
if (lastDotIndex <= 0) return "";
return fileName.slice(lastDotIndex);
}
import React, { createContext, useContext, useEffect, useState } from "react";
import React, {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react";
import { useNavigate } from "@tanstack/react-router";
import { ipc, DeepLinkData } from "../ipc/types";
import { useScrollAndNavigateTo } from "@/hooks/useScrollAndNavigateTo";
......@@ -39,11 +45,13 @@ export function DeepLinkProvider({ children }: { children: React.ReactNode }) {
return unsubscribe;
}, [navigate, scrollAndNavigateTo]);
const clearLastDeepLink = useCallback(() => setLastDeepLink(null), []);
return (
<DeepLinkContext.Provider
value={{
lastDeepLink,
clearLastDeepLink: () => setLastDeepLink(null),
clearLastDeepLink,
}}
>
{children}
......
import { useState, useEffect, useCallback } from "react";
import { useDeepLink } from "@/contexts/DeepLinkContext";
import { AddPromptDeepLinkData } from "@/ipc/deep_link_data";
import { showInfo } from "@/lib/toast";
export function useAddPromptDeepLink() {
const { lastDeepLink, clearLastDeepLink } = useDeepLink();
const [dialogOpen, setDialogOpen] = useState(false);
const [prefillData, setPrefillData] = useState<
{ title: string; description: string; content: string } | undefined
>(undefined);
useEffect(() => {
if (lastDeepLink?.type === "add-prompt") {
const deepLink = lastDeepLink as unknown as AddPromptDeepLinkData;
const payload = deepLink.payload;
showInfo(`Prefilled prompt: ${payload.title}`);
setPrefillData({
title: payload.title,
description: payload.description,
content: payload.content,
});
setDialogOpen(true);
clearLastDeepLink();
}
}, [lastDeepLink?.timestamp, clearLastDeepLink]);
const handleDialogClose = useCallback((open: boolean) => {
setDialogOpen(open);
if (!open) {
setPrefillData(undefined);
}
}, []);
return { prefillData, dialogOpen, handleDialogClose, setDialogOpen };
}
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ipc,
type RenameMediaFileParams,
type DeleteMediaFileParams,
type MoveMediaFileParams,
} from "@/ipc/types";
import { queryKeys } from "@/lib/queryKeys";
import { showError, showSuccess } from "@/lib/toast";
import { useSettings } from "./useSettings";
export function useAppMediaFiles() {
const queryClient = useQueryClient();
const { settings } = useSettings();
const query = useQuery({
queryKey: queryKeys.media.all,
queryFn: () => ipc.media.listAllMedia(),
// Media files can be added externally (e.g., via file system), so keep
// staleTime short to pick up new files promptly.
staleTime: settings?.isTestMode ? 0 : 10_000,
});
const renameMutation = useMutation({
mutationFn: (params: RenameMediaFileParams) => {
return ipc.media.renameMediaFile(params);
},
onSuccess: (_, params) => {
queryClient.invalidateQueries({ queryKey: queryKeys.media.all });
showSuccess(`Renamed image "${params.fileName}"`);
},
onError: (error) => {
showError(error);
},
});
const deleteMutation = useMutation({
mutationFn: (params: DeleteMediaFileParams) => {
return ipc.media.deleteMediaFile(params);
},
onSuccess: (_, params) => {
queryClient.invalidateQueries({ queryKey: queryKeys.media.all });
showSuccess(`Deleted image "${params.fileName}"`);
},
onError: (error) => {
showError(error);
},
});
const moveMutation = useMutation({
mutationFn: (params: MoveMediaFileParams) => {
return ipc.media.moveMediaFile(params);
},
onSuccess: (_, params) => {
queryClient.invalidateQueries({ queryKey: queryKeys.media.all });
showSuccess(`Moved image "${params.fileName}"`);
},
onError: (error) => {
showError(error);
},
});
return {
mediaApps: query.data?.apps ?? [],
isLoading: query.isLoading,
renameMediaFile: renameMutation.mutateAsync,
deleteMediaFile: deleteMutation.mutateAsync,
moveMediaFile: moveMutation.mutateAsync,
isMutatingMedia:
renameMutation.isPending ||
deleteMutation.isPending ||
moveMutation.isPending,
};
}
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useSetAtom, useStore } from "jotai";
import { ipc } from "@/ipc/types";
import type { ImageThemeMode } from "@/ipc/types";
import { queryKeys } from "@/lib/queryKeys";
import { showError } from "@/lib/toast";
import {
imageGenerationJobsAtom,
type ImageGenerationJob,
} from "@/atoms/imageGenerationAtoms";
import {
showImageGeneratingToast,
showImageSuccessToast,
dismissImageGenerationToast,
} from "@/components/ImageGenerationToast";
interface StartGenerationParams {
requestId: string;
prompt: string;
themeMode: ImageThemeMode;
targetAppId: number;
targetAppName: string;
}
// Track cancelled job IDs so onError can skip them when the abort error arrives.
// Each entry is auto-cleaned after CANCEL_CLEANUP_MS to prevent unbounded growth.
const cancelledJobIds = new Set<string>();
const CANCEL_CLEANUP_MS = 2 * 60 * 1000; // 2 minutes
function markCancelled(jobId: string) {
cancelledJobIds.add(jobId);
setTimeout(() => cancelledJobIds.delete(jobId), CANCEL_CLEANUP_MS);
}
export function useGenerateImage() {
const queryClient = useQueryClient();
const setJobs = useSetAtom(imageGenerationJobsAtom);
const store = useStore();
const addJob = (job: ImageGenerationJob) => {
setJobs((prev) => [...prev, job]);
};
const updateJob = (id: string, patch: Partial<ImageGenerationJob>) => {
setJobs((prev) =>
prev.map((job) => (job.id === id ? { ...job, ...patch } : job)),
);
};
const getPendingCount = () =>
store.get(imageGenerationJobsAtom).filter((j) => j.status === "pending")
.length;
return useMutation({
mutationFn: (params: StartGenerationParams) => {
return ipc.imageGeneration.generateImage({
prompt: params.prompt,
themeMode: params.themeMode,
targetAppId: params.targetAppId,
requestId: params.requestId,
});
},
onMutate: (params) => {
addJob({
id: params.requestId,
prompt: params.prompt,
themeMode: params.themeMode,
targetAppId: params.targetAppId,
targetAppName: params.targetAppName,
status: "pending",
startedAt: Date.now(),
});
// Show / update the single generating toast with the new pending count
showImageGeneratingToast(getPendingCount());
return { jobId: params.requestId };
},
onSuccess: (result, _params, context) => {
if (!context) return;
// If this job was already cancelled before the response arrived, ignore the success
if (cancelledJobIds.has(context.jobId)) {
cancelledJobIds.delete(context.jobId);
return;
}
updateJob(context.jobId, {
status: "success",
result,
});
queryClient.invalidateQueries({ queryKey: queryKeys.media.all });
// Show success toast (replaces the shared generating toast).
// If there are still pending jobs, the toast's dismiss/close will
// restore the generating toast via restoreGeneratingToastIfNeeded.
showImageSuccessToast(result);
},
onError: (error, _params, context) => {
if (!context) return;
// If this job was cancelled, the abort error is expected — don't show it
if (cancelledJobIds.has(context.jobId)) {
cancelledJobIds.delete(context.jobId);
return;
}
updateJob(context.jobId, {
status: "error",
error: error instanceof Error ? error.message : String(error),
});
const remaining = getPendingCount();
if (remaining > 0) {
// Other jobs still running — update count
showImageGeneratingToast(remaining);
} else {
dismissImageGenerationToast();
}
showError(error);
},
});
}
export function useCancelImageGeneration() {
const setJobs = useSetAtom(imageGenerationJobsAtom);
const store = useStore();
return async (jobId: string) => {
markCancelled(jobId);
setJobs((prev) =>
prev.map((job) =>
job.id === jobId ? { ...job, status: "cancelled" as const } : job,
),
);
// Update or dismiss the generating toast based on remaining pending jobs
const remaining = store
.get(imageGenerationJobsAtom)
.filter((j) => j.status === "pending").length;
if (remaining > 0) {
showImageGeneratingToast(remaining);
} else {
dismissImageGenerationToast();
}
// Signal the backend to abort the request
try {
await ipc.imageGeneration.cancelImageGeneration({ requestId: jobId });
} catch {
// Best-effort cancellation
}
};
}
......@@ -3,6 +3,7 @@ import {
selectedChatIdAtom,
pushRecentViewedChatIdAtom,
addSessionOpenedChatIdAtom,
chatInputValueAtom,
} from "@/atoms/chatAtoms";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { useNavigate } from "@tanstack/react-router";
......@@ -12,6 +13,7 @@ export function useSelectChat() {
const setSelectedAppId = useSetAtom(selectedAppIdAtom);
const pushRecentViewedChatId = useSetAtom(pushRecentViewedChatIdAtom);
const addSessionOpenedChatId = useSetAtom(addSessionOpenedChatIdAtom);
const setChatInputValue = useSetAtom(chatInputValueAtom);
const navigate = useNavigate();
return {
......@@ -19,10 +21,12 @@ export function useSelectChat() {
chatId,
appId,
preserveTabOrder = false,
prefillInput,
}: {
chatId: number;
appId: number;
preserveTabOrder?: boolean;
prefillInput?: string;
}) => {
setSelectedChatId(chatId);
setSelectedAppId(appId);
......@@ -31,10 +35,20 @@ export function useSelectChat() {
if (!preserveTabOrder) {
pushRecentViewedChatId(chatId);
}
navigate({
const navigationResult = navigate({
to: "/chat",
search: { id: chatId },
});
if (prefillInput !== undefined) {
Promise.resolve(navigationResult)
.then(() => {
setChatInputValue(prefillInput);
})
.catch(() => {
// Ignore navigation errors here; navigation handling is centralized.
});
}
},
};
}
......@@ -28,6 +28,7 @@ import {
SUPABASE_NOT_AVAILABLE_SYSTEM_PROMPT,
} from "../../prompts/supabase_prompt";
import { getDyadAppPath } from "../../paths/paths";
import { buildDyadMediaUrl } from "../../lib/dyadMediaUrl";
import { readSettings } from "../../main/settings";
import type { ChatResponseEnd, ChatStreamParams } from "@/ipc/types";
import {
......@@ -78,10 +79,15 @@ import {
import { fileExists } from "../utils/file_utils";
import { extractMentionedAppsCodebases } from "../utils/mention_apps";
import { parseAppMentions } from "@/shared/parse_mention_apps";
import {
parseMediaMentions,
stripResolvedMediaMentions,
} from "@/shared/parse_media_mentions";
import { prompts as promptsTable } from "../../db/schema";
import { inArray } from "drizzle-orm";
import { replacePromptReference } from "../utils/replacePromptReference";
import { replaceSlashSkillReference } from "../utils/replaceSlashSkillReference";
import { resolveMediaMentions } from "../utils/resolve_media_mentions";
import { parsePlanFile, validatePlanId } from "./planUtils";
import { ensureDyadGitignored } from "./gitignoreUtils";
import { DYAD_MEDIA_DIR_NAME } from "../utils/media_path_utils";
......@@ -393,6 +399,44 @@ export function registerChatStreamHandlers() {
logger.error("Failed to expand slash skill references:", e);
}
// Resolve @media: mentions to image attachments
const mediaRefs = parseMediaMentions(userPrompt);
if (mediaRefs.length > 0) {
try {
const resolvedMedia = await resolveMediaMentions(
mediaRefs,
chat.app.path,
chat.app.name,
);
const resolvedMediaRefs = resolvedMedia.map((media) =>
encodeURIComponent(media.fileName),
);
let mediaDisplayInfo = "";
for (const media of resolvedMedia) {
attachmentPaths.push(media.filePath);
const mediaUrl = buildDyadMediaUrl(chat.app.path, media.fileName);
mediaDisplayInfo += `\n<dyad-attachment name="${escapeXmlAttr(media.fileName)}" type="${escapeXmlAttr(media.mimeType)}" url="${escapeXmlAttr(mediaUrl)}" path="${escapeXmlAttr(media.filePath)}" attachment-type="chat-context"></dyad-attachment>\n`;
}
// Strip only resolved @media: tags from the prompt text.
// This preserves adjacent user text when mentions are directly followed
// by text without a whitespace separator.
userPrompt = stripResolvedMediaMentions(
userPrompt,
resolvedMediaRefs,
);
// Build display prompt with attachment tags for inline rendering.
if (mediaDisplayInfo) {
const strippedPrompt = stripResolvedMediaMentions(
displayUserPrompt ?? req.prompt,
resolvedMediaRefs,
);
displayUserPrompt = strippedPrompt + mediaDisplayInfo;
}
} catch (e) {
logger.error("Failed to resolve media mentions:", e);
}
}
// Expand /implement-plan= into full implementation prompt
// Keep the original short form for display in the UI; the expanded
// content is only injected into the AI message history.
......
差异被折叠。
import { createTypedHandler } from "./base";
import { mediaContracts } from "../types/media";
import { db } from "../../db";
import { apps } from "../../db/schema";
import { getDyadAppPath } from "../../paths/paths";
import { safeJoin } from "../utils/path_utils";
import { getMimeType, MIME_TYPE_MAP } from "../utils/mime_utils";
import { DYAD_MEDIA_DIR_NAME } from "../utils/media_path_utils";
import { INVALID_FILE_NAME_CHARS } from "../../shared/media_validation";
import { ensureDyadGitignored } from "./gitignoreUtils";
import { withLock } from "../utils/lock_utils";
import fs from "node:fs";
import path from "node:path";
import { eq } from "drizzle-orm";
import log from "electron-log";
const logger = log.scope("media_handlers");
const SUPPORTED_MEDIA_EXTENSIONS = Object.keys(MIME_TYPE_MAP);
async function getMediaFilesForApp(
appId: number,
appName: string,
appPath: string,
) {
const mediaDir = path.join(appPath, DYAD_MEDIA_DIR_NAME);
try {
await fs.promises.access(mediaDir);
} catch {
return [];
}
const entries = await fs.promises.readdir(mediaDir, { withFileTypes: true });
const mediaEntries = entries.filter((entry) => {
if (!entry.isFile()) return false;
const ext = path.extname(entry.name).toLowerCase();
return SUPPORTED_MEDIA_EXTENSIONS.includes(ext);
});
const results = await Promise.all(
mediaEntries.map(async (entry) => {
const fullPath = path.join(mediaDir, entry.name);
try {
const stat = await fs.promises.stat(fullPath);
return {
fileName: entry.name,
filePath: fullPath,
appId,
appName,
sizeBytes: stat.size,
mimeType: getMimeType(path.extname(entry.name).toLowerCase()),
};
} catch {
// File was deleted between readdir and stat — skip it
return null;
}
}),
);
return results.filter((f) => f !== null);
}
async function withMediaLock<T>(
appIds: number[],
fn: () => Promise<T>,
): Promise<T> {
const uniqueSortedIds = [...new Set(appIds)].sort((a, b) => a - b);
const runWithLock = async (index: number): Promise<T> => {
if (index >= uniqueSortedIds.length) {
return fn();
}
return withLock(`media:${uniqueSortedIds[index]}`, async () =>
runWithLock(index + 1),
);
};
return runWithLock(0);
}
function assertSafeFileName(fileName: string): void {
if (!fileName || fileName.trim().length === 0) {
throw new Error("File name is required");
}
if (fileName !== path.basename(fileName)) {
throw new Error("Invalid file name");
}
if (
fileName.includes("/") ||
fileName.includes("\\") ||
fileName === "." ||
fileName === ".." ||
INVALID_FILE_NAME_CHARS.test(fileName)
) {
throw new Error("Invalid file name");
}
}
function assertSafeBaseName(baseName: string): string {
const trimmed = baseName.trim();
if (!trimmed) {
throw new Error("New image name is required");
}
if (
trimmed.includes("/") ||
trimmed.includes("\\") ||
trimmed === "." ||
trimmed === ".." ||
INVALID_FILE_NAME_CHARS.test(trimmed)
) {
throw new Error("Invalid image name");
}
return trimmed;
}
function assertSupportedMediaExtension(fileName: string): string {
const extension = path.extname(fileName).toLowerCase();
if (!SUPPORTED_MEDIA_EXTENSIONS.includes(extension)) {
throw new Error("Unsupported media file extension");
}
return extension;
}
function getMediaFilePath(appPath: string, fileName: string): string {
assertSafeFileName(fileName);
assertSupportedMediaExtension(fileName);
return safeJoin(appPath, DYAD_MEDIA_DIR_NAME, fileName);
}
function getMediaDirectoryPath(appPath: string): string {
return path.join(appPath, DYAD_MEDIA_DIR_NAME);
}
async function getAppOrThrow(appId: number) {
const app = await db.query.apps.findFirst({
where: eq(apps.id, appId),
});
if (!app) {
throw new Error("App not found");
}
return app;
}
export function registerMediaHandlers() {
createTypedHandler(mediaContracts.listAllMedia, async () => {
const allApps = await db.select().from(apps);
const appResults = await Promise.all(
allApps.map(async (app) => {
const appPath = getDyadAppPath(app.path);
const files = await getMediaFilesForApp(app.id, app.name, appPath);
if (files.length > 0) {
return {
appId: app.id,
appName: app.name,
appPath,
files,
};
}
return null;
}),
);
return { apps: appResults.filter((r) => r !== null) };
});
createTypedHandler(mediaContracts.renameMediaFile, async (_, params) => {
await withMediaLock([params.appId], async () => {
const app = await getAppOrThrow(params.appId);
const appPath = getDyadAppPath(app.path);
const sourcePath = getMediaFilePath(appPath, params.fileName);
const sourceExtension = assertSupportedMediaExtension(params.fileName);
const newBaseName = assertSafeBaseName(params.newBaseName);
const destinationFileName = `${newBaseName}${sourceExtension}`;
assertSafeFileName(destinationFileName);
if (destinationFileName === params.fileName) {
throw new Error("New image name must be different from current name");
}
const destinationPath = safeJoin(
appPath,
DYAD_MEDIA_DIR_NAME,
destinationFileName,
);
// Allow case-only renames on case-insensitive file systems (macOS, Windows)
const isCaseOnlyRename =
destinationFileName.toLowerCase() === params.fileName.toLowerCase();
if (!isCaseOnlyRename && fs.existsSync(destinationPath)) {
throw new Error("A media file with that name already exists");
}
try {
await fs.promises.rename(sourcePath, destinationPath);
} catch (e: any) {
if (e?.code === "ENOENT") {
throw new Error(
"File was modified or deleted before the rename could complete",
);
}
throw e;
}
logger.log(`Renamed media file: ${sourcePath} -> ${destinationPath}`);
});
});
createTypedHandler(mediaContracts.deleteMediaFile, async (_, params) => {
await withMediaLock([params.appId], async () => {
const app = await getAppOrThrow(params.appId);
const appPath = getDyadAppPath(app.path);
const filePath = getMediaFilePath(appPath, params.fileName);
try {
await fs.promises.unlink(filePath);
} catch (e: any) {
if (e?.code === "ENOENT") {
// File already gone — treat delete as idempotent
logger.log(`Media file already deleted: ${filePath}`);
return;
}
throw e;
}
logger.log(`Deleted media file: ${filePath}`);
});
});
createTypedHandler(mediaContracts.moveMediaFile, async (_, params) => {
if (params.sourceAppId === params.targetAppId) {
throw new Error("Source and target apps must be different");
}
await withMediaLock([params.sourceAppId, params.targetAppId], async () => {
const sourceApp = await getAppOrThrow(params.sourceAppId);
const targetApp = await getAppOrThrow(params.targetAppId);
const sourceAppPath = getDyadAppPath(sourceApp.path);
const targetAppPath = getDyadAppPath(targetApp.path);
const sourcePath = getMediaFilePath(sourceAppPath, params.fileName);
if (!fs.existsSync(sourcePath)) {
throw new Error("Media file not found");
}
await ensureDyadGitignored(targetAppPath);
const targetMediaDirectoryPath = getMediaDirectoryPath(targetAppPath);
await fs.promises.mkdir(targetMediaDirectoryPath, { recursive: true });
const destinationPath = safeJoin(
targetAppPath,
DYAD_MEDIA_DIR_NAME,
params.fileName,
);
if (fs.existsSync(destinationPath)) {
throw new Error(
`Target app already has a media file named "${params.fileName}"`,
);
}
try {
await fs.promises.rename(sourcePath, destinationPath);
} catch (e: any) {
if (e?.code === "EXDEV") {
// Cross-device move (e.g. different drives on Windows): copy then delete.
await fs.promises.copyFile(sourcePath, destinationPath);
try {
await fs.promises.unlink(sourcePath);
} catch (unlinkError: any) {
// Source delete failed after copy succeeded — remove the copy
// so we don't end up with duplicates.
try {
await fs.promises.unlink(destinationPath);
} catch {
// Best-effort cleanup; destination may already be gone.
}
throw unlinkError;
}
} else if (e?.code === "ENOENT") {
throw new Error(
"File was modified or deleted before the move could complete",
);
} else {
throw e;
}
}
logger.log(`Moved media file: ${sourcePath} -> ${destinationPath}`);
});
});
}
......@@ -38,6 +38,8 @@ import { registerVisualEditingHandlers } from "../pro/main/ipc/handlers/visual_e
import { registerAgentToolHandlers } from "../pro/main/ipc/handlers/local_agent/agent_tool_handlers";
import { registerFreeAgentQuotaHandlers } from "./handlers/free_agent_quota_handlers";
import { registerPlanHandlers } from "./handlers/plan_handlers";
import { registerMediaHandlers } from "./handlers/media_handlers";
import { registerImageGenerationHandlers } from "./handlers/image_generation_handlers";
export function registerIpcHandlers() {
// Register all IPC handlers by category
......@@ -81,4 +83,6 @@ export function registerIpcHandlers() {
registerAgentToolHandlers();
registerFreeAgentQuotaHandlers();
registerPlanHandlers();
registerMediaHandlers();
registerImageGenerationHandlers();
}
......@@ -40,6 +40,8 @@ import { miscContracts, miscEvents } from "../types/misc";
import { freeAgentQuotaContracts } from "../types/free_agent_quota";
import { planEvents, planContracts } from "../types/plan";
import { audioContracts } from "../types/audio";
import { mediaContracts } from "../types/media";
import { imageGenerationContracts } from "../types/image_generation";
// =============================================================================
// Invoke Channels (derived from all contracts)
......@@ -95,6 +97,8 @@ export const VALID_INVOKE_CHANNELS = [
...getInvokeChannels(freeAgentQuotaContracts),
...getInvokeChannels(planContracts),
...getInvokeChannels(audioContracts),
...getInvokeChannels(mediaContracts),
...getInvokeChannels(imageGenerationContracts),
// Test-only channels
...TEST_INVOKE_CHANNELS,
......
import { z } from "zod";
import { defineContract, createClient } from "../contracts/core";
// =============================================================================
// Image Generation Schemas
// =============================================================================
export const ImageThemeModeSchema = z.enum([
"plain",
"3d-clay",
"real-photography",
"isometric-illustration",
]);
export type ImageThemeMode = z.infer<typeof ImageThemeModeSchema>;
export const GenerateImageParamsSchema = z.object({
prompt: z.string().min(1).max(2000),
themeMode: ImageThemeModeSchema,
targetAppId: z.number(),
requestId: z.string(),
});
export const CancelImageGenerationParamsSchema = z.object({
requestId: z.string(),
});
export const CancelImageGenerationResponseSchema = z.object({
cancelled: z.boolean(),
});
export type GenerateImageParams = z.infer<typeof GenerateImageParamsSchema>;
// Schema for the raw API response from the image generation service
export const ImageGenerationApiResponseSchema = z.object({
created: z.number(),
data: z.array(
z.object({
url: z.string().nullable().optional(),
b64_json: z.string().nullable().optional(),
revised_prompt: z.string().nullable().optional(),
}),
),
});
export const GenerateImageResponseSchema = z.object({
fileName: z.string(),
filePath: z.string(),
appPath: z.string(),
appId: z.number(),
appName: z.string(),
});
export type GenerateImageResponse = z.infer<typeof GenerateImageResponseSchema>;
// =============================================================================
// Image Generation Contracts
// =============================================================================
export const imageGenerationContracts = {
generateImage: defineContract({
channel: "generate-image",
input: GenerateImageParamsSchema,
output: GenerateImageResponseSchema,
}),
cancelImageGeneration: defineContract({
channel: "cancel-image-generation",
input: CancelImageGenerationParamsSchema,
output: CancelImageGenerationResponseSchema,
}),
} as const;
// =============================================================================
// Image Generation Client
// =============================================================================
export const imageGenerationClient = createClient(imageGenerationContracts);
......@@ -51,6 +51,8 @@ export { securityContracts } from "./security";
export { miscContracts, miscEvents } from "./misc";
export { freeAgentQuotaContracts } from "./free_agent_quota";
export { audioContracts } from "./audio";
export { mediaContracts } from "./media";
export { imageGenerationContracts } from "./image_generation";
// =============================================================================
// Client Exports
......@@ -81,6 +83,8 @@ export { securityClient } from "./security";
export { miscClient, miscEventClient } from "./misc";
export { freeAgentQuotaClient } from "./free_agent_quota";
export { audioClient } from "./audio";
export { mediaClient } from "./media";
export { imageGenerationClient } from "./image_generation";
// =============================================================================
// Type Exports
......@@ -296,6 +300,21 @@ export type { FreeAgentQuotaStatus } from "./free_agent_quota";
// Pro types
export type { TranscribeAudioParams, TranscribeAudioResult } from "./audio";
// Media types
export type {
MediaFile,
RenameMediaFileParams,
DeleteMediaFileParams,
MoveMediaFileParams,
} from "./media";
// Image generation types
export type {
ImageThemeMode,
GenerateImageParams,
GenerateImageResponse,
} from "./image_generation";
// =============================================================================
// Schema Exports (for validation in handlers/components)
// =============================================================================
......@@ -353,6 +372,8 @@ import { securityClient } from "./security";
import { miscClient, miscEventClient } from "./misc";
import { freeAgentQuotaClient } from "./free_agent_quota";
import { audioClient } from "./audio";
import { mediaClient } from "./media";
import { imageGenerationClient } from "./image_generation";
/**
* Unified IPC client with all domains organized by namespace.
......@@ -409,6 +430,8 @@ export const ipc = {
misc: miscClient,
freeAgentQuota: freeAgentQuotaClient,
audio: audioClient,
media: mediaClient,
imageGeneration: imageGenerationClient,
// Event clients for main->renderer pub/sub
events: {
......
import { z } from "zod";
import { defineContract, createClient } from "../contracts/core";
// =============================================================================
// Media Schemas
// =============================================================================
/**
* Schema for a single media file item.
*/
const MediaFileSchema = z.object({
fileName: z.string(),
filePath: z.string(),
appId: z.number(),
appName: z.string(),
sizeBytes: z.number(),
mimeType: z.string(),
});
export type MediaFile = z.infer<typeof MediaFileSchema>;
/**
* Schema for listing all media across all apps.
*/
export const ListAllMediaResponseSchema = z.object({
apps: z.array(
z.object({
appId: z.number(),
appName: z.string(),
appPath: z.string(),
files: z.array(MediaFileSchema),
}),
),
});
export const RenameMediaFileParamsSchema = z.object({
appId: z.number(),
fileName: z.string(),
newBaseName: z.string().min(1),
});
export const DeleteMediaFileParamsSchema = z.object({
appId: z.number(),
fileName: z.string(),
});
export const MoveMediaFileParamsSchema = z.object({
sourceAppId: z.number(),
fileName: z.string(),
targetAppId: z.number(),
});
// =============================================================================
// Media Contracts
// =============================================================================
export const mediaContracts = {
listAllMedia: defineContract({
channel: "list-all-media",
input: z.void(),
output: ListAllMediaResponseSchema,
}),
renameMediaFile: defineContract({
channel: "rename-media-file",
input: RenameMediaFileParamsSchema,
output: z.void(),
}),
deleteMediaFile: defineContract({
channel: "delete-media-file",
input: DeleteMediaFileParamsSchema,
output: z.void(),
}),
moveMediaFile: defineContract({
channel: "move-media-file",
input: MoveMediaFileParamsSchema,
output: z.void(),
}),
} as const;
// =============================================================================
// Media Client
// =============================================================================
export const mediaClient = createClient(mediaContracts);
// =============================================================================
// Type Exports
// =============================================================================
export type RenameMediaFileParams = z.infer<typeof RenameMediaFileParamsSchema>;
export type DeleteMediaFileParams = z.infer<typeof DeleteMediaFileParamsSchema>;
export type MoveMediaFileParams = z.infer<typeof MoveMediaFileParamsSchema>;
export const MIME_TYPE_MAP: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
};
export function getMimeType(ext: string): string {
return MIME_TYPE_MAP[ext] || "application/octet-stream";
}
import { getDyadAppPath } from "../../paths/paths";
import { safeJoin } from "./path_utils";
import { getMimeType } from "./mime_utils";
import { DYAD_MEDIA_DIR_NAME } from "./media_path_utils";
import fs from "node:fs";
import path from "node:path";
interface ResolvedMediaFile {
appName: string;
fileName: string;
filePath: string;
mimeType: string;
}
export async function resolveMediaMentions(
mediaRefs: string[],
appPath: string,
appName: string,
): Promise<ResolvedMediaFile[]> {
const resolved: ResolvedMediaFile[] = [];
const resolvedAppPath = getDyadAppPath(appPath);
for (const encodedFileName of mediaRefs) {
try {
const fileName = decodeURIComponent(encodedFileName);
const filePath = safeJoin(resolvedAppPath, DYAD_MEDIA_DIR_NAME, fileName);
if (!fs.existsSync(filePath)) continue;
const ext = path.extname(fileName).toLowerCase();
resolved.push({
appName,
fileName,
filePath,
mimeType: getMimeType(ext),
});
} catch {
// safeJoin throws on path traversal attempts - skip silently
continue;
}
}
return resolved;
}
/**
* Builds a dyad-media:// protocol URL for serving media files in Electron.
*/
export function buildDyadMediaUrl(appPath: string, fileName: string): string {
return `dyad-media://media/${encodeURIComponent(appPath)}/.dyad/media/${encodeURIComponent(fileName)}`;
}
export function filterMediaAppsByQuery<
T extends { appName: string; files: { fileName: string }[] },
>(apps: T[], query: string): T[] {
const trimmed = query.trim();
if (!trimmed) return apps;
const q = trimmed.toLowerCase();
return apps.filter(
(app) =>
app.appName.toLowerCase().includes(q) ||
app.files.some((f) => f.fileName.toLowerCase().includes(q)),
);
}
......@@ -304,6 +304,13 @@ export const queryKeys = {
byApp: ({ appId }: { appId: number | null }) =>
["app-env-vars", appId] as const,
},
// ─────────────────────────────────────────────────────────────────────────────
// Media
// ─────────────────────────────────────────────────────────────────────────────
media: {
all: ["media"] as const,
},
} as const;
// ─────────────────────────────────────────────────────────────────────────────
......@@ -364,6 +371,5 @@ export type AppQueryKey =
| QueryKeyOf<(typeof queryKeys.supabase)[keyof typeof queryKeys.supabase]>
| QueryKeyOf<(typeof queryKeys.github)[keyof typeof queryKeys.github]>
| QueryKeyOf<(typeof queryKeys.neon)[keyof typeof queryKeys.neon]>
| QueryKeyOf<
(typeof queryKeys.appEnvVars)[keyof typeof queryKeys.appEnvVars]
>;
| QueryKeyOf<(typeof queryKeys.appEnvVars)[keyof typeof queryKeys.appEnvVars]>
| QueryKeyOf<(typeof queryKeys.media)[keyof typeof queryKeys.media]>;
......@@ -141,7 +141,7 @@ export async function onReady() {
// to correctly handle absolute paths (which contain encoded slashes).
const pathSegments = url.pathname.slice(1).split("/");
if (
pathSegments.length < 4 ||
pathSegments.length !== 4 ||
pathSegments[1] !== ".dyad" ||
pathSegments[2] !== "media"
) {
......@@ -149,7 +149,16 @@ export async function onReady() {
}
const appPathRaw = decodeURIComponent(pathSegments[0]);
const filename = decodeURIComponent(pathSegments.slice(3).join("/"));
const filename = decodeURIComponent(pathSegments[3]);
// Defense-in-depth: reject filenames with path separators or traversal
if (
filename.includes("..") ||
filename.includes("/") ||
filename.includes("\\")
) {
return new Response("Forbidden", { status: 403 });
}
// Resolve the app directory, handling both relative names and absolute
// paths from imported apps (skipCopy).
......
import { useState, useMemo } from "react";
import { usePrompts } from "@/hooks/usePrompts";
import { useCustomThemes } from "@/hooks/useCustomThemes";
import { useAppMediaFiles } from "@/hooks/useAppMediaFiles";
import { useLoadApps } from "@/hooks/useLoadApps";
import { useAddPromptDeepLink } from "@/hooks/useAddPromptDeepLink";
import { BookOpen, Loader2 } from "lucide-react";
import { CreateOrEditPromptDialog } from "@/components/CreatePromptDialog";
import { CustomThemeDialog } from "@/components/CustomThemeDialog";
import { NewLibraryItemMenu } from "@/components/NewLibraryItemMenu";
import { LibraryCard, type LibraryItem } from "@/components/LibraryCard";
import { LibrarySearchBar } from "@/components/LibrarySearchBar";
import {
LibraryFilterTabs,
type FilterType,
} from "@/components/LibraryFilterTabs";
import { DyadAppMediaFolder } from "@/components/DyadAppMediaFolder";
import { ImageGeneratorDialog } from "@/components/ImageGeneratorDialog";
import { ImageGenerationProgressButton } from "@/components/ImageGenerationProgressButton";
import { filterMediaAppsByQuery } from "@/lib/mediaUtils";
// ---------------------------------------------------------------------------
// Main Library Homepage
// ---------------------------------------------------------------------------
export default function LibraryHomePage() {
const [searchQuery, setSearchQuery] = useState("");
const [activeFilter, setActiveFilter] = useState<FilterType>(() => {
const params = new URLSearchParams(window.location.search);
const filter = params.get("filter");
if (filter === "themes" || filter === "prompts" || filter === "media")
return filter;
return "all";
});
const {
prompts,
isLoading: promptsLoading,
createPrompt,
updatePrompt,
deletePrompt,
} = usePrompts();
const { customThemes, isLoading: themesLoading } = useCustomThemes();
const {
mediaApps,
isLoading: mediaLoading,
renameMediaFile,
deleteMediaFile,
moveMediaFile,
isMutatingMedia,
} = useAppMediaFiles();
const { apps: allApps } = useLoadApps();
const [createThemeDialogOpen, setCreateThemeDialogOpen] = useState(false);
const [imageGeneratorOpen, setImageGeneratorOpen] = useState(false);
// Deep link support
const {
prefillData,
dialogOpen: promptDialogOpen,
handleDialogClose: handlePromptDialogClose,
setDialogOpen: setPromptDialogOpen,
} = useAddPromptDeepLink();
const isLoading = promptsLoading || themesLoading || mediaLoading;
const filteredItems = useMemo(() => {
if (activeFilter === "media") return [];
let items: LibraryItem[] = [];
if (activeFilter === "all" || activeFilter === "themes") {
items.push(
...customThemes.map((t) => ({ type: "theme" as const, data: t })),
);
}
if (activeFilter === "all" || activeFilter === "prompts") {
items.push(...prompts.map((p) => ({ type: "prompt" as const, data: p })));
}
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
items = items.filter((item) => {
if (item.type === "theme") {
return (
item.data.name.toLowerCase().includes(q) ||
(item.data.description?.toLowerCase().includes(q) ?? false) ||
item.data.prompt.toLowerCase().includes(q)
);
}
return (
item.data.title.toLowerCase().includes(q) ||
(item.data.description?.toLowerCase().includes(q) ?? false) ||
item.data.content.toLowerCase().includes(q)
);
});
}
// Sort by updatedAt descending
items.sort((a, b) => {
const dateA =
a.data.updatedAt instanceof Date
? a.data.updatedAt
: new Date(a.data.updatedAt);
const dateB =
b.data.updatedAt instanceof Date
? b.data.updatedAt
: new Date(b.data.updatedAt);
return dateB.getTime() - dateA.getTime();
});
return items;
}, [customThemes, prompts, activeFilter, searchQuery]);
const filteredMediaApps = useMemo(() => {
if (activeFilter === "themes" || activeFilter === "prompts") return [];
return filterMediaAppsByQuery(mediaApps, searchQuery);
}, [mediaApps, activeFilter, searchQuery]);
const hasNoResults =
filteredItems.length === 0 && filteredMediaApps.length === 0;
return (
<div className="min-h-screen w-full">
<div className="px-8 py-6">
<div className="max-w-6xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold">
<BookOpen className="inline-block h-8 w-8 mr-2" />
Library
</h1>
<div className="flex items-center gap-2">
<ImageGenerationProgressButton />
<NewLibraryItemMenu
onNewPrompt={() => setPromptDialogOpen(true)}
onNewTheme={() => setCreateThemeDialogOpen(true)}
onNewImage={() => setImageGeneratorOpen(true)}
/>
</div>
</div>
{/* Dialogs (controlled externally) */}
<CreateOrEditPromptDialog
mode="create"
onCreatePrompt={createPrompt}
prefillData={prefillData}
isOpen={promptDialogOpen}
onOpenChange={handlePromptDialogClose}
trigger={<span />}
/>
{/* Search Bar */}
<LibrarySearchBar value={searchQuery} onChange={setSearchQuery} />
{/* Filter Tabs */}
<LibraryFilterTabs active={activeFilter} onChange={setActiveFilter} />
{/* Grid */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : hasNoResults ? (
<div className="text-muted-foreground text-center py-12">
{searchQuery
? "No results found."
: activeFilter === "media"
? "No media files yet."
: activeFilter === "themes"
? "No themes yet."
: activeFilter === "prompts"
? "No prompts yet."
: "No items in your library yet."}
</div>
) : (
<div
data-testid="library-grid"
className="grid grid-cols-[repeat(auto-fit,minmax(280px,1fr))] gap-4"
>
{filteredItems.map((item) => (
<LibraryCard
key={`${item.type}-${item.data.id}`}
item={item}
onUpdatePrompt={updatePrompt}
onDeletePrompt={deletePrompt}
/>
))}
{filteredMediaApps.map((app) => (
<DyadAppMediaFolder
key={`media-${app.appId}`}
appId={app.appId}
appPath={app.appPath}
appName={app.appName}
files={app.files}
allApps={allApps}
onRenameMediaFile={renameMediaFile}
onDeleteMediaFile={deleteMediaFile}
onMoveMediaFile={moveMediaFile}
isMutatingMedia={isMutatingMedia}
searchQuery={searchQuery}
/>
))}
</div>
)}
</div>
<CustomThemeDialog
open={createThemeDialogOpen}
onOpenChange={setCreateThemeDialogOpen}
/>
<ImageGeneratorDialog
open={imageGeneratorOpen}
onOpenChange={setImageGeneratorOpen}
/>
</div>
</div>
);
}
import React, { useState, useEffect } from "react";
import { usePrompts } from "@/hooks/usePrompts";
import {
CreatePromptDialog,
CreateOrEditPromptDialog,
} from "@/components/CreatePromptDialog";
import { DeleteConfirmationDialog } from "@/components/DeleteConfirmationDialog";
import { useDeepLink } from "@/contexts/DeepLinkContext";
import { AddPromptDeepLinkData } from "@/ipc/deep_link_data";
import { showInfo } from "@/lib/toast";
import { useAddPromptDeepLink } from "@/hooks/useAddPromptDeepLink";
import { CreatePromptDialog } from "@/components/CreatePromptDialog";
import { LibraryCard } from "@/components/LibraryCard";
export default function LibraryPage() {
const { prompts, isLoading, createPrompt, updatePrompt, deletePrompt } =
usePrompts();
const { lastDeepLink, clearLastDeepLink } = useDeepLink();
const [dialogOpen, setDialogOpen] = useState(false);
const [prefillData, setPrefillData] = useState<
| {
title: string;
description: string;
content: string;
}
| undefined
>(undefined);
useEffect(() => {
const handleDeepLink = async () => {
if (lastDeepLink?.type === "add-prompt") {
const deepLink = lastDeepLink as unknown as AddPromptDeepLinkData;
const payload = deepLink.payload;
showInfo(`Prefilled prompt: ${payload.title}`);
setPrefillData({
title: payload.title,
description: payload.description,
content: payload.content,
});
setDialogOpen(true);
clearLastDeepLink();
}
};
handleDeepLink();
}, [lastDeepLink?.timestamp, clearLastDeepLink]);
const handleDialogClose = (open: boolean) => {
setDialogOpen(open);
if (!open) {
// Clear prefill data when dialog closes
setPrefillData(undefined);
}
};
const { prefillData, dialogOpen, handleDialogClose } = useAddPromptDeepLink();
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">Library: Prompts</h1>
<CreatePromptDialog
onCreatePrompt={createPrompt}
prefillData={prefillData}
isOpen={dialogOpen}
onOpenChange={handleDialogClose}
/>
<div className="w-full px-4 py-6 sm:px-6 lg:px-8">
<div className="mx-auto max-w-6xl">
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h1 className="text-2xl font-bold sm:text-3xl">Library: Prompts</h1>
<div className="shrink-0">
<CreatePromptDialog
onCreatePrompt={createPrompt}
prefillData={prefillData}
isOpen={dialogOpen}
onOpenChange={handleDialogClose}
/>
</div>
</div>
{isLoading ? (
......@@ -69,13 +30,13 @@ export default function LibraryPage() {
No prompts yet. Create one to get started.
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{prompts.map((p) => (
<PromptCard
<LibraryCard
key={p.id}
prompt={p}
onUpdate={updatePrompt}
onDelete={deletePrompt}
item={{ type: "prompt", data: p }}
onUpdatePrompt={updatePrompt}
onDeletePrompt={deletePrompt}
/>
))}
</div>
......@@ -84,68 +45,3 @@ export default function LibraryPage() {
</div>
);
}
import { slugForPrompt } from "@/ipc/utils/replaceSlashSkillReference";
function PromptCard({
prompt,
onUpdate,
onDelete,
}: {
prompt: {
id: number;
title: string;
description: string | null;
content: string;
slug: string | null;
};
onUpdate: (p: {
id: number;
title: string;
description?: string;
content: string;
slug?: string | null;
}) => Promise<void>;
onDelete: (id: number) => Promise<void>;
}) {
const slashCommand = slugForPrompt(prompt);
return (
<div
data-testid="prompt-card"
className="border rounded-lg p-4 bg-(--background-lightest) min-w-80"
>
<div className="space-y-2">
<div className="flex items-start justify-between">
<div>
<h3 className="text-lg font-semibold">{prompt.title}</h3>
{prompt.description && (
<p className="text-sm text-muted-foreground">
{prompt.description}
</p>
)}
{slashCommand && (
<p className="text-xs text-muted-foreground mt-1 font-mono">
Use /{slashCommand} in chat
</p>
)}
</div>
<div className="flex gap-2">
<CreateOrEditPromptDialog
mode="edit"
prompt={prompt}
onUpdatePrompt={onUpdate}
/>
<DeleteConfirmationDialog
itemName={prompt.title}
itemType="Prompt"
onDelete={() => onDelete(prompt.id)}
/>
</div>
</div>
<pre className="text-sm whitespace-pre-wrap bg-transparent border rounded p-2 max-h-48 overflow-auto">
{prompt.content}
</pre>
</div>
</div>
);
}
import { useState } from "react";
import { useAppMediaFiles } from "@/hooks/useAppMediaFiles";
import { useLoadApps } from "@/hooks/useLoadApps";
import { Image, ImagePlus, Loader2 } from "lucide-react";
import { DyadAppMediaFolder } from "@/components/DyadAppMediaFolder";
import { LibrarySearchBar } from "@/components/LibrarySearchBar";
import { Button } from "@/components/ui/button";
import { ImageGeneratorDialog } from "@/components/ImageGeneratorDialog";
import { ImageGenerationProgressButton } from "@/components/ImageGenerationProgressButton";
import { filterMediaAppsByQuery } from "@/lib/mediaUtils";
export default function MediaPage() {
const {
mediaApps,
isLoading,
renameMediaFile,
deleteMediaFile,
moveMediaFile,
isMutatingMedia,
} = useAppMediaFiles();
const { apps: allApps } = useLoadApps();
const [searchQuery, setSearchQuery] = useState("");
const [imageGeneratorOpen, setImageGeneratorOpen] = useState(false);
const filteredMediaApps = filterMediaAppsByQuery(mediaApps, searchQuery);
return (
<div className="w-full px-4 py-6 sm:px-6 lg:px-8">
<div className="mx-auto max-w-6xl">
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h1 className="flex items-center text-2xl font-bold sm:text-3xl">
<Image className="mr-2 h-7 w-7 sm:h-8 sm:w-8" />
Media
</h1>
<div className="flex items-center gap-2">
<ImageGenerationProgressButton />
<Button onClick={() => setImageGeneratorOpen(true)}>
<ImagePlus className="mr-2 h-4 w-4" />
Generate Image
</Button>
</div>
</div>
<LibrarySearchBar
value={searchQuery}
onChange={setSearchQuery}
placeholder="Search images..."
/>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : filteredMediaApps.length === 0 ? (
<div className="text-muted-foreground text-center py-12">
{searchQuery
? "No results found."
: "No media files yet. Media files from your apps will appear here."}
</div>
) : (
<div className="grid grid-cols-[repeat(auto-fit,minmax(280px,1fr))] gap-4">
{filteredMediaApps.map((app) => (
<DyadAppMediaFolder
key={`media-${app.appId}`}
appId={app.appId}
appPath={app.appPath}
appName={app.appName}
files={app.files}
allApps={allApps}
onRenameMediaFile={renameMediaFile}
onDeleteMediaFile={deleteMediaFile}
onMoveMediaFile={moveMediaFile}
isMutatingMedia={isMutatingMedia}
searchQuery={searchQuery}
/>
))}
</div>
)}
</div>
<ImageGeneratorDialog
open={imageGeneratorOpen}
onOpenChange={setImageGeneratorOpen}
/>
</div>
);
}
import { useState } from "react";
import {
useCustomThemes,
useUpdateCustomTheme,
useDeleteCustomTheme,
} from "@/hooks/useCustomThemes";
import { useCustomThemes } 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/types";
import { LibraryCard } from "@/components/LibraryCard";
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" />
<div className="w-full px-4 py-6 sm:px-6 lg:px-8">
<div className="mx-auto max-w-6xl">
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h1 className="flex items-center text-2xl font-bold sm:text-3xl">
<Palette className="mr-2 h-7 w-7 sm:h-8 sm:w-8" />
Themes
</h1>
<Button onClick={() => setCreateDialogOpen(true)}>
<Button
className="w-full sm:w-auto"
onClick={() => setCreateDialogOpen(true)}
>
<Plus className="mr-2 h-4 w-4" /> New Theme
</Button>
</div>
......@@ -36,9 +32,12 @@ export default function ThemesPage() {
No custom themes yet. Create one to get started.
</div>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{customThemes.map((theme) => (
<ThemeCard key={theme.id} theme={theme} />
<LibraryCard
key={theme.id}
item={{ type: "theme", data: theme }}
/>
))}
</div>
)}
......@@ -51,63 +50,3 @@ export default function ThemesPage() {
</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>
);
}
......@@ -11,6 +11,7 @@ import {
} from "./types";
import { engineFetch } from "./engine_fetch";
import { DYAD_MEDIA_DIR_NAME } from "@/ipc/utils/media_path_utils";
import { ImageGenerationApiResponseSchema } from "@/ipc/types/image_generation";
const logger = log.scope("generate_image");
......@@ -22,17 +23,6 @@ const generateImageSchema = z.object({
),
});
const imageDataSchema = z.object({
url: z.string().nullable().optional(),
b64_json: z.string().nullable().optional(),
revised_prompt: z.string().nullable().optional(),
});
const generateImageResponseSchema = z.object({
created: z.number(),
data: z.array(imageDataSchema),
});
const DESCRIPTION = `Generate an image using AI based on a text prompt. The generated image is saved to the project's .dyad/media directory.
### When to Use
......@@ -59,7 +49,7 @@ The tool returns the file path in .dyad/media. Use the copy_file tool to copy it
async function callGenerateImage(
prompt: string,
ctx: Pick<AgentContext, "dyadRequestId">,
): Promise<z.infer<typeof imageDataSchema>> {
): Promise<z.infer<typeof ImageGenerationApiResponseSchema>["data"][number]> {
const response = await engineFetch(ctx, "/images/generations", {
method: "POST",
body: JSON.stringify({
......@@ -75,7 +65,7 @@ async function callGenerateImage(
);
}
const data = generateImageResponseSchema.parse(await response.json());
const data = ImageGenerationApiResponseSchema.parse(await response.json());
if (!data.data || data.data.length === 0) {
throw new Error("Image generation returned no results");
......@@ -85,7 +75,7 @@ async function callGenerateImage(
}
async function saveGeneratedImage(
imageData: z.infer<typeof imageDataSchema>,
imageData: z.infer<typeof ImageGenerationApiResponseSchema>["data"][number],
appPath: string,
): Promise<string> {
const mediaDir = path.join(appPath, DYAD_MEDIA_DIR_NAME);
......
......@@ -8,12 +8,16 @@ import { appDetailsRoute } from "./routes/app-details";
import { hubRoute } from "./routes/hub";
import { libraryRoute } from "./routes/library";
import { themesRoute } from "./routes/themes";
import { promptsRoute } from "./routes/prompts";
import { mediaRoute } from "./routes/media";
const routeTree = rootRoute.addChildren([
homeRoute,
hubRoute,
libraryRoute,
themesRoute,
promptsRoute,
mediaRoute,
chatRoute,
appDetailsRoute,
settingsRoute.addChildren([providerSettingsRoute]),
......
import { Route } from "@tanstack/react-router";
import { rootRoute } from "./root";
import LibraryPage from "@/pages/library";
import LibraryHomePage from "@/pages/library-home";
export const libraryRoute = new Route({
getParentRoute: () => rootRoute,
path: "/library",
component: LibraryPage,
component: LibraryHomePage,
});
import { createRoute } from "@tanstack/react-router";
import { rootRoute } from "./root";
import MediaPage from "@/pages/media";
export const mediaRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/library/media",
component: MediaPage,
});
import { createRoute } from "@tanstack/react-router";
import { rootRoute } from "./root";
import LibraryPage from "@/pages/library";
export const promptsRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/library/prompts",
component: LibraryPage,
});
......@@ -4,6 +4,6 @@ import ThemesPage from "@/pages/themes";
export const themesRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/themes",
path: "/library/themes",
component: ThemesPage,
});
/**
* Shared media validation constants used by both frontend and backend.
*/
export const INVALID_FILE_NAME_CHARS = /[<>:"/\\|?*\x00-\x1F]/;
function escapeRegExp(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
// The @media: prefix uses a colon to distinguish from CSS @media queries,
// which are followed by a space (e.g., "@media screen"). Mentions are always
// created programmatically as @media:<encoded-filename>.
export function parseMediaMentions(prompt: string): string[] {
// Match only characters that encodeURIComponent can produce so that
// trailing sentence punctuation (commas, semicolons, etc.) is excluded.
const regex = /@media:([\w.%\-!~*'()]*[\w%\-!~*'()])/g;
const mentions: string[] = [];
let match;
while ((match = regex.exec(prompt)) !== null) {
mentions.push(match[1]);
}
return mentions;
}
/**
* Strip resolved @media mentions from prompt text while preserving all other text.
* This only removes exact mention tokens that were successfully resolved.
*/
export function stripResolvedMediaMentions(
prompt: string,
resolvedMediaRefs: string[],
): string {
if (resolvedMediaRefs.length === 0) {
return prompt.trim();
}
let stripped = prompt;
for (const mediaRef of resolvedMediaRefs) {
const token = `@media:${mediaRef}`;
// Replace the token and collapse only the immediate surrounding spaces
// (not newlines or other whitespace) left behind by removal.
stripped = stripped.replace(
new RegExp(`[ ]*${escapeRegExp(token)}[ ]*`, "g"),
" ",
);
}
return stripped.trim();
}
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论