返回 presentation-ai
ReplaceImage.tsx
根目录 / src / components / presentation / agent / tools / ReplaceImage.tsx
1 "use client";
2
3 import { ImageIcon, Loader2, X } from "lucide-react";
4 import { useEffect, useMemo, useState } from "react";
5
6 import { Button } from "@/components/ui/button";
7 import { Input } from "@/components/ui/input";
8 import { Label } from "@/components/ui/label";
9 import { Textarea } from "@/components/ui/textarea";
10 import {
11 executeToolAction,
12 getSlidesToUpdate,
13 } from "@/hooks/presentation/agentTools";
14
15 type Scope = "all" | undefined;
16
17 export function PresentationReplaceImageCall({
18 imageUrl,
19 imagePrompt,
20 imageSource,
21 scope,
22 slideIds,
23 stockImageProvider,
24 loading,
25 }: {
26 imageUrl?: string;
27 imagePrompt?: string;
28 imageSource?: "ai" | "stock" | "gif";
29 scope?: Scope;
30 slideIds?: string[];
31 stockImageProvider?: "unsplash" | "pixabay" | "google";
32 loading?: boolean;
33 }) {
34 const [url, setUrl] = useState(imageUrl ?? "");
35 const [prompt, setPrompt] = useState(imagePrompt ?? "");
36 const [isEditing, setIsEditing] = useState(false);
37
38 const targetSlides = useMemo(
39 () => getSlidesToUpdate(scope, slideIds),
40 [scope, slideIds],
41 );
42
43 const isImageUrl = useMemo(() => {
44 if (!url) return false;
45 try {
46 return /\.(jpg|jpeg|png|gif|webp)$/i.test(url);
47 } catch {
48 return false;
49 }
50 }, [url]);
51
52 useEffect(() => {
53 if (!imageUrl && !imagePrompt) return;
54 setUrl(imageUrl ?? "");
55 setPrompt(imagePrompt ?? "");
56 }, [imageUrl, imagePrompt]);
57
58 const apply = () => {
59 if (!url && !prompt) return;
60 try {
61 executeToolAction({
62 action: "replace_image",
63 scope,
64 slideIds: scope === "all" ? undefined : targetSlides,
65 ...(url ? { imageUrl: url } : {}),
66 ...(prompt ? { imagePrompt: prompt } : {}),
67 ...(imageSource ? { imageSource } : {}),
68 ...(stockImageProvider ? { stockImageProvider } : {}),
69 });
70 } catch (error) {
71 console.error("Error replacing image:", error);
72 } finally {
73 setIsEditing(false);
74 }
75 };
76
77 const slideCount = targetSlides?.length ?? 0;
78
79 // Show loading state when no values are available (AI still filling args)
80 if (!imageUrl && !imagePrompt && !url && !prompt)
81 return (
82 <div className="w-full rounded-lg border bg-card p-3">
83 <div className="flex items-center gap-2">
84 <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
85 <span className="text-sm text-muted-foreground">
86 Updating image...
87 </span>
88 </div>
89 </div>
90 );
91
92 if (isEditing) {
93 return (
94 <div className="space-y-4 rounded-lg border bg-card p-4">
95 <div className="flex items-center justify-between">
96 <div className="flex items-center gap-2">
97 <ImageIcon className="h-4 w-4 text-muted-foreground" />
98 <span className="text-sm font-medium">Edit Image</span>
99 <span className="text-xs text-muted-foreground">
100 ({slideCount} slide{slideCount !== 1 ? "s" : ""})
101 </span>
102 </div>
103 <Button
104 size="sm"
105 variant="ghost"
106 onClick={() => setIsEditing(false)}
107 className="h-7 w-7 p-0"
108 >
109 <X className="h-4 w-4" />
110 </Button>
111 </div>
112
113 <div className="space-y-3">
114 <div className="space-y-1.5">
115 <Label className="text-xs">Image URL</Label>
116 <Input
117 value={url}
118 onChange={(e) => setUrl(e.target.value || "")}
119 placeholder="https://example.com/image.jpg"
120 className="h-9"
121 />
122 </div>
123 <div className="space-y-1.5">
124 <Label className="text-xs">Or Generate with Prompt</Label>
125 <Textarea
126 value={prompt}
127 onChange={(e) => setPrompt(e.target.value || "")}
128 placeholder="A modern abstract background..."
129 className="min-h-15 resize-none"
130 />
131 </div>
132 </div>
133
134 <div className="flex gap-2">
135 <Button
136 size="sm"
137 onClick={apply}
138 disabled={loading || (!url && !prompt)}
139 className="flex-1"
140 >
141 Apply
142 </Button>
143 <Button
144 size="sm"
145 variant="outline"
146 onClick={() => setIsEditing(false)}
147 className="flex-1"
148 >
149 Cancel
150 </Button>
151 </div>
152 </div>
153 );
154 }
155
156 return (
157 <button
158 type="button"
159 onClick={() => setIsEditing(true)}
160 disabled={loading}
161 className="w-full rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/50 disabled:opacity-60"
162 >
163 <div className="flex items-center justify-between">
164 <div className="flex min-w-0 items-center gap-3">
165 <ImageIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
166 <div className="min-w-0 flex-1">
167 <div className="flex items-center gap-2">
168 <span className="text-sm font-medium">Image</span>
169 <span className="text-xs text-muted-foreground">
170 {slideCount} slide{slideCount !== 1 ? "s" : ""}
171 </span>
172 </div>
173 {url ? (
174 <div className="mt-1 flex items-center gap-2">
175 {isImageUrl && (
176 // biome-ignore lint/performance/noImgElement: We can't use Image component
177 <img
178 src={url || "/placeholder.svg"}
179 alt="Preview"
180 className="h-8 w-8 rounded object-cover"
181 />
182 )}
183 <span className="line-clamp-1 text-xs text-ellipsis text-muted-foreground">
184 {url}
185 </span>
186 </div>
187 ) : prompt ? (
188 <p className="mt-1 line-clamp-1 max-w-full text-xs text-ellipsis text-muted-foreground">
189 Generated: {prompt}
190 </p>
191 ) : (
192 <p className="mt-1 text-xs text-muted-foreground">No image set</p>
193 )}
194 </div>
195 </div>
196 </div>
197 </button>
198 );
199 }
200
201 export function PresentationReplaceImageResult({
202 message,
203 }: {
204 message?: string;
205 }) {
206 return (
207 <div className="rounded-lg border border-green-200 bg-green-50 px-3 py-2 dark:border-green-900 dark:bg-green-950/20">
208 <span className="text-sm text-green-900 dark:text-green-100">
209 {message ?? "Image updated"}
210 </span>
211 </div>
212 );
213 }
214
214 lines Plain Text