refactor: simplify ImageUploadDrawer usage and integrate it into Chatbox for improved image handling

This commit is contained in:
ecwu
2025-01-05 20:32:19 +08:00
parent c84cc7d9e8
commit 709cad3138
4 changed files with 337 additions and 328 deletions

View File

@@ -8,26 +8,28 @@ import {
DrawerFooter, DrawerFooter,
DrawerHeader, DrawerHeader,
DrawerTitle, DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"; } from "@/components/ui/drawer";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { AppContext } from "@/pages/App"; import { AppContext } from "@/pages/App";
import { PaintBucketIcon } from "lucide-react";
interface Props { interface Props {
showGenImage: boolean; disableFactor: boolean[];
setShowGenImage: (se: boolean) => void;
} }
interface ImageResponse { interface ImageResponse {
url?: string; url?: string;
b64_json?: string; b64_json?: string;
revised_prompt: string; revised_prompt: string;
} }
export function ImageGenDrawer({ showGenImage, setShowGenImage }: Props) { export function ImageGenDrawer({ disableFactor }: Props) {
const ctx = useContext(AppContext); const ctx = useContext(AppContext);
if (ctx === null) return <></>; if (ctx === null) return <></>;
const [showGenImage, setShowGenImage] = useState(false);
const [imageGenPrompt, setImageGenPrompt] = useState(""); const [imageGenPrompt, setImageGenPrompt] = useState("");
const [imageGenModel, setImageGenModel] = useState("dall-e-3"); const [imageGenModel, setImageGenModel] = useState("dall-e-3");
const [imageGenN, setImageGenN] = useState(1); const [imageGenN, setImageGenN] = useState(1);
@@ -39,175 +41,199 @@ export function ImageGenDrawer({ showGenImage, setShowGenImage }: Props) {
const [imageGenGenerating, setImageGenGenerating] = useState(false); const [imageGenGenerating, setImageGenGenerating] = useState(false);
useState("b64_json"); useState("b64_json");
return ( return (
<Drawer open={showGenImage} onOpenChange={setShowGenImage}> <>
<DrawerContent> {ctx.chatStore.image_gen_api && ctx.chatStore.image_gen_key ? (
<div className="mx-auto w-full max-w-lg"> <Drawer open={showGenImage} onOpenChange={setShowGenImage}>
<DrawerHeader> <DrawerTrigger>
<DrawerTitle>Generate Image</DrawerTitle> <Button
</DrawerHeader> variant="ghost"
<div className="flex flex-col"> size="icon"
<span className="flex flex-col justify-between m-1 p-1"> type="button"
<Label>Prompt: </Label> disabled={disableFactor.some((factor) => factor)}
<Textarea >
className="textarea textarea-sm textarea-bordered" <PaintBucketIcon className="size-4" />
value={imageGenPrompt} <span className="sr-only">Generate Image</span>
onChange={(e: any) => { </Button>
setImageGenPrompt(e.target.value); </DrawerTrigger>
}} <DrawerContent>
/> <div className="mx-auto w-full max-w-lg">
</span> <DrawerHeader>
<span className="flex flex-row justify-between items-center m-1 p-1"> <DrawerTitle>Generate Image</DrawerTitle>
<label>Model: </label> </DrawerHeader>
<select <div className="flex flex-col">
className="select select-sm select-bordered" <span className="flex flex-col justify-between m-1 p-1">
value={imageGenModel} <Label>Prompt: </Label>
onChange={(e: any) => { <Textarea
setImageGenModel(e.target.value); className="textarea textarea-sm textarea-bordered"
}} value={imageGenPrompt}
> onChange={(e: any) => {
<option value="dall-e-3">DALL-E 3</option> setImageGenPrompt(e.target.value);
<option value="dall-e-2">DALL-E 2</option> }}
</select> />
</span> </span>
<span className="flex flex-row justify-between items-center m-1 p-1"> <span className="flex flex-row justify-between items-center m-1 p-1">
<label>n: </label> <label>Model: </label>
<input <select
className="input input-sm input-bordered" className="select select-sm select-bordered"
value={imageGenN} value={imageGenModel}
type="number" onChange={(e: any) => {
min={1} setImageGenModel(e.target.value);
max={10} }}
onChange={(e: any) => setImageGenN(parseInt(e.target.value))} >
/> <option value="dall-e-3">DALL-E 3</option>
</span> <option value="dall-e-2">DALL-E 2</option>
<span className="flex flex-row justify-between items-center m-1 p-1"> </select>
<label>Quality: </label> </span>
<select <span className="flex flex-row justify-between items-center m-1 p-1">
className="select select-sm select-bordered" <label>n: </label>
value={imageGenQuality} <input
onChange={(e: any) => setImageGEnQuality(e.target.value)} className="input input-sm input-bordered"
> value={imageGenN}
<option value="hd">HD</option> type="number"
<option value="standard">Standard</option> min={1}
</select> max={10}
</span> onChange={(e: any) =>
<span className="flex flex-row justify-between items-center m-1 p-1"> setImageGenN(parseInt(e.target.value))
<label>Response Format: </label>
<select
className="select select-sm select-bordered"
value={imageGenResponseFormat}
onChange={(e: any) => setImageGenResponseFormat(e.target.value)}
>
<option value="b64_json">b64_json</option>
<option value="url">url</option>
</select>
</span>
<span className="flex flex-row justify-between items-center m-1 p-1">
<label>Size: </label>
<select
className="select select-sm select-bordered"
value={imageGenSize}
onChange={(e: any) => setImageGenSize(e.target.value)}
>
<option value="256x256">256x256 (dall-e-2)</option>
<option value="512x512">512x512 (dall-e-2)</option>
<option value="1024x1024">1024x1024 (dall-e-2/3)</option>
<option value="1792x1024">1792x1024 (dall-e-3)</option>
<option value="1024x1792">1024x1792 (dall-e-3)</option>
</select>
</span>
<span className="flex flex-row justify-between items-center m-1 p-1">
<label>Style (only dall-e-3): </label>
<select
className="select select-sm select-bordered"
value={imageGenStyle}
onChange={(e: any) => setImageGenStyle(e.target.value)}
>
<option value="vivid">vivid</option>
<option value="natural">natural</option>
</select>
</span>
<span className="flex flex-row justify-between items-center m-1 p-1">
<Button
variant="default"
size="sm"
disabled={imageGenGenerating}
onClick={async () => {
try {
setImageGenGenerating(true);
const body: any = {
prompt: imageGenPrompt,
model: imageGenModel,
n: imageGenN,
quality: imageGenQuality,
response_format: imageGenResponseFormat,
size: imageGenSize,
};
if (imageGenModel === "dall-e-3") {
body.style = imageGenStyle;
} }
const resp: ImageResponse[] = ( />
await fetch(ctx.chatStore.image_gen_api, { </span>
method: "POST", <span className="flex flex-row justify-between items-center m-1 p-1">
headers: { <label>Quality: </label>
"Content-Type": "application/json", <select
Authorization: `Bearer ${ctx.chatStore.image_gen_key}`, className="select select-sm select-bordered"
}, value={imageGenQuality}
body: JSON.stringify(body), onChange={(e: any) => setImageGEnQuality(e.target.value)}
}).then((resp) => resp.json()) >
).data; <option value="hd">HD</option>
console.log("image gen resp", resp); <option value="standard">Standard</option>
</select>
for (const image of resp) { </span>
let url = ""; <span className="flex flex-row justify-between items-center m-1 p-1">
if (image.url) url = image.url; <label>Response Format: </label>
if (image.b64_json) <select
url = "data:image/png;base64," + image.b64_json; className="select select-sm select-bordered"
if (!url) continue; value={imageGenResponseFormat}
onChange={(e: any) =>
ctx.chatStore.history.push({ setImageGenResponseFormat(e.target.value)
role: "assistant", }
content: [ >
{ <option value="b64_json">b64_json</option>
type: "image_url", <option value="url">url</option>
image_url: { </select>
url, </span>
detail: "low", <span className="flex flex-row justify-between items-center m-1 p-1">
<label>Size: </label>
<select
className="select select-sm select-bordered"
value={imageGenSize}
onChange={(e: any) => setImageGenSize(e.target.value)}
>
<option value="256x256">256x256 (dall-e-2)</option>
<option value="512x512">512x512 (dall-e-2)</option>
<option value="1024x1024">1024x1024 (dall-e-2/3)</option>
<option value="1792x1024">1792x1024 (dall-e-3)</option>
<option value="1024x1792">1024x1792 (dall-e-3)</option>
</select>
</span>
<span className="flex flex-row justify-between items-center m-1 p-1">
<label>Style (only dall-e-3): </label>
<select
className="select select-sm select-bordered"
value={imageGenStyle}
onChange={(e: any) => setImageGenStyle(e.target.value)}
>
<option value="vivid">vivid</option>
<option value="natural">natural</option>
</select>
</span>
<span className="flex flex-row justify-between items-center m-1 p-1">
<Button
variant="default"
size="sm"
disabled={imageGenGenerating}
onClick={async () => {
try {
setImageGenGenerating(true);
const body: any = {
prompt: imageGenPrompt,
model: imageGenModel,
n: imageGenN,
quality: imageGenQuality,
response_format: imageGenResponseFormat,
size: imageGenSize,
};
if (imageGenModel === "dall-e-3") {
body.style = imageGenStyle;
}
const resp: ImageResponse[] = (
await fetch(ctx.chatStore.image_gen_api, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${ctx.chatStore.image_gen_key}`,
}, },
}, body: JSON.stringify(body),
{ }).then((resp) => resp.json())
type: "text", ).data;
text: image.revised_prompt, console.log("image gen resp", resp);
},
],
hide: false,
token: 65,
example: false,
audio: null,
logprobs: null,
response_model_name: imageGenModel,
});
ctx.setChatStore({ ...ctx.chatStore }); for (const image of resp) {
} let url = "";
} catch (e) { if (image.url) url = image.url;
console.error(e); if (image.b64_json)
alert("Failed to generate image: " + e); url = "data:image/png;base64," + image.b64_json;
} finally { if (!url) continue;
setImageGenGenerating(false);
}
}}
>
{Tr("Generate")}
</Button>
</span>
</div>
<DrawerFooter> ctx.chatStore.history.push({
<Button onClick={() => setShowGenImage(false)}>Done</Button> role: "assistant",
</DrawerFooter> content: [
</div> {
</DrawerContent> type: "image_url",
</Drawer> image_url: {
url,
detail: "low",
},
},
{
type: "text",
text: image.revised_prompt,
},
],
hide: false,
token: 65,
example: false,
audio: null,
logprobs: null,
response_model_name: imageGenModel,
});
ctx.setChatStore({ ...ctx.chatStore });
}
} catch (e) {
console.error(e);
alert("Failed to generate image: " + e);
} finally {
setImageGenGenerating(false);
}
}}
>
{Tr("Generate")}
</Button>
</span>
</div>
<DrawerFooter>
<Button onClick={() => setShowGenImage(false)}>Done</Button>
</DrawerFooter>
</div>
</DrawerContent>
</Drawer>
) : (
<Button variant="ghost" size="icon" type="button" disabled={true}>
<PaintBucketIcon className="size-4" />
<span className="sr-only">Generate Image</span>
</Button>
)}
</>
); );
} }

View File

@@ -8,33 +8,38 @@ import {
DrawerFooter, DrawerFooter,
DrawerHeader, DrawerHeader,
DrawerTitle, DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"; } from "@/components/ui/drawer";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { PenIcon, XIcon } from "lucide-react"; import { PenIcon, XIcon, ImageIcon } from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Separator } from "@/components/ui/separator";
import { AppContext } from "@/pages/App"; import { AppContext } from "@/pages/App";
interface Props { interface Props {
images: MessageDetail[]; images: MessageDetail[];
showAddImage: boolean;
setShowAddImage: (se: boolean) => void;
setImages: (images: MessageDetail[]) => void; setImages: (images: MessageDetail[]) => void;
disableFactor: boolean[];
} }
export function ImageUploadDrawer({ export function ImageUploadDrawer({ setImages, images, disableFactor }: Props) {
showAddImage,
setShowAddImage,
setImages,
images,
}: Props) {
const ctx = useContext(AppContext); const ctx = useContext(AppContext);
if (ctx === null) return <></>; if (ctx === null) return <></>;
const [showAddImage, setShowAddImage] = useState(false);
const [enableHighResolution, setEnableHighResolution] = useState(true); const [enableHighResolution, setEnableHighResolution] = useState(true);
useState("b64_json"); useState("b64_json");
return ( return (
<Drawer open={showAddImage} onOpenChange={setShowAddImage}> <Drawer open={showAddImage} onOpenChange={setShowAddImage}>
<DrawerTrigger>
<Button
variant="ghost"
size="icon"
type="button"
disabled={disableFactor.some((factor) => factor)}
>
<ImageIcon className="size-4" />
<span className="sr-only">Add Image</span>
</Button>
</DrawerTrigger>
<DrawerContent> <DrawerContent>
<div className="mx-auto w-full max-w-lg"> <div className="mx-auto w-full max-w-lg">
<DrawerHeader> <DrawerHeader>

View File

@@ -1,7 +1,6 @@
import { createRef, useContext } from "react"; import { createRef, useContext } from "react";
import { ChatStore } from "@/types/chatstore"; import { useState, Dispatch } from "react";
import { useEffect, useState, Dispatch } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { AudioWaveformIcon, CircleStopIcon, MicIcon } from "lucide-react"; import { AudioWaveformIcon, CircleStopIcon, MicIcon } from "lucide-react";
import { AppContext } from "@/pages/App"; import { AppContext } from "@/pages/App";
@@ -18,128 +17,137 @@ const WhisperButton = (props: {
const mediaRef = createRef(); const mediaRef = createRef();
const [isRecording, setIsRecording] = useState("Mic"); const [isRecording, setIsRecording] = useState("Mic");
return ( return (
<Button <>
variant="ghost" {chatStore.whisper_api && chatStore.whisper_key ? (
size="icon" <Button
className={`m-1 p-1 ${isRecording !== "Mic" ? "animate-pulse" : ""}`} variant="ghost"
disabled={isRecording === "Transcribing"} size="icon"
ref={mediaRef as any} className={`${isRecording !== "Mic" ? "animate-pulse" : ""}`}
onClick={async (event) => { disabled={isRecording === "Transcribing"}
event.preventDefault(); // Prevent the default behavior ref={mediaRef as any}
onClick={async (event) => {
event.preventDefault(); // Prevent the default behavior
if (isRecording === "Recording") { if (isRecording === "Recording") {
// @ts-ignore // @ts-ignore
window.mediaRecorder.stop(); window.mediaRecorder.stop();
setIsRecording("Transcribing"); setIsRecording("Transcribing");
return; return;
} }
// build prompt // build prompt
const prompt = [chatStore.systemMessageContent] const prompt = [chatStore.systemMessageContent]
.concat( .concat(
chatStore.history chatStore.history
.filter(({ hide }) => !hide) .filter(({ hide }) => !hide)
.slice(chatStore.postBeginIndex) .slice(chatStore.postBeginIndex)
.map(({ content }) => { .map(({ content }) => {
if (typeof content === "string") { if (typeof content === "string") {
return content; return content;
} else { } else {
return content.map((c) => c?.text).join(" "); return content.map((c) => c?.text).join(" ");
} }
}) })
) )
.concat([inputMsg]) .concat([inputMsg])
.join(" "); .join(" ");
console.log({ prompt }); console.log({ prompt });
setIsRecording("Recording"); setIsRecording("Recording");
console.log("start recording"); console.log("start recording");
try { try {
const mediaRecorder = new MediaRecorder( const mediaRecorder = new MediaRecorder(
await navigator.mediaDevices.getUserMedia({ await navigator.mediaDevices.getUserMedia({
audio: true, audio: true,
}), }),
{ audioBitsPerSecond: 64 * 1000 } { audioBitsPerSecond: 64 * 1000 }
); );
// mount mediaRecorder to ref // mount mediaRecorder to ref
// @ts-ignore // @ts-ignore
window.mediaRecorder = mediaRecorder; window.mediaRecorder = mediaRecorder;
mediaRecorder.start(); mediaRecorder.start();
const audioChunks: Blob[] = []; const audioChunks: Blob[] = [];
mediaRecorder.addEventListener("dataavailable", (event) => { mediaRecorder.addEventListener("dataavailable", (event) => {
audioChunks.push(event.data); audioChunks.push(event.data);
}); });
mediaRecorder.addEventListener("stop", async () => { mediaRecorder.addEventListener("stop", async () => {
// Stop the MediaRecorder // Stop the MediaRecorder
mediaRecorder.stop(); mediaRecorder.stop();
// Stop the media stream // Stop the media stream
mediaRecorder.stream.getTracks()[0].stop(); mediaRecorder.stream.getTracks()[0].stop();
setIsRecording("Transcribing"); setIsRecording("Transcribing");
const audioBlob = new Blob(audioChunks); const audioBlob = new Blob(audioChunks);
const audioUrl = URL.createObjectURL(audioBlob); const audioUrl = URL.createObjectURL(audioBlob);
console.log({ audioUrl }); console.log({ audioUrl });
const audio = new Audio(audioUrl); const audio = new Audio(audioUrl);
// audio.play(); // audio.play();
const reader = new FileReader(); const reader = new FileReader();
reader.readAsDataURL(audioBlob); reader.readAsDataURL(audioBlob);
// file-like object with mimetype // file-like object with mimetype
const blob = new Blob([audioBlob], { const blob = new Blob([audioBlob], {
type: "application/octet-stream", type: "application/octet-stream",
});
reader.onloadend = async () => {
try {
const base64data = reader.result;
// post to openai whisper api
const formData = new FormData();
// append file
formData.append("file", blob, "audio.ogg");
formData.append("model", "whisper-1");
formData.append("response_format", "text");
formData.append("prompt", prompt);
const response = await fetch(chatStore.whisper_api, {
method: "POST",
headers: {
Authorization: `Bearer ${
chatStore.whisper_key || chatStore.apiKey
}`,
},
body: formData,
}); });
const text = await response.text(); reader.onloadend = async () => {
try {
const base64data = reader.result;
setInputMsg(inputMsg ? inputMsg + " " + text : text); // post to openai whisper api
} catch (error) { const formData = new FormData();
alert(error); // append file
console.log(error); formData.append("file", blob, "audio.ogg");
} finally { formData.append("model", "whisper-1");
setIsRecording("Mic"); formData.append("response_format", "text");
} formData.append("prompt", prompt);
};
}); const response = await fetch(chatStore.whisper_api, {
} catch (error) { method: "POST",
alert(error); headers: {
console.log(error); Authorization: `Bearer ${
setIsRecording("Mic"); chatStore.whisper_key || chatStore.apiKey
} }`,
}} },
> body: formData,
{isRecording === "Mic" ? ( });
<MicIcon />
) : isRecording === "Recording" ? ( const text = await response.text();
<CircleStopIcon />
setInputMsg(inputMsg ? inputMsg + " " + text : text);
} catch (error) {
alert(error);
console.log(error);
} finally {
setIsRecording("Mic");
}
};
});
} catch (error) {
alert(error);
console.log(error);
setIsRecording("Mic");
}
}}
>
{isRecording === "Mic" ? (
<MicIcon />
) : isRecording === "Recording" ? (
<CircleStopIcon />
) : (
<AudioWaveformIcon />
)}
</Button>
) : ( ) : (
<AudioWaveformIcon /> <Button variant="ghost" size="icon" disabled={true}>
<MicIcon />
</Button>
)} )}
</Button> <span className="sr-only">Use Microphone</span>
</>
); );
}; };

View File

@@ -584,33 +584,14 @@ export default function ChatBOX() {
className="min-h-12 resize-none rounded-lg bg-background border-0 p-3 shadow-none focus-visible:ring-0" className="min-h-12 resize-none rounded-lg bg-background border-0 p-3 shadow-none focus-visible:ring-0"
/> />
<div className="flex items-center p-3 pt-0"> <div className="flex items-center p-3 pt-0">
<Button <ImageUploadDrawer
variant="ghost" images={images}
size="icon" setImages={setImages}
type="button" disableFactor={[showGenerating]}
onClick={() => setShowAddImage(true)} />
disabled={showGenerating} <ImageGenDrawer disableFactor={[showGenerating]} />
>
<ImageIcon className="size-4" />
<span className="sr-only">Add Image</span>
</Button>
<Button
variant="ghost"
size="icon"
type="button"
onClick={() => setShowGenImage(true)}
disabled={showGenerating}
>
<PaintBucketIcon className="size-4" />
<span className="sr-only">Generate Image</span>
</Button>
{chatStore.whisper_api && chatStore.whisper_key && ( <WhisperButton inputMsg={inputMsg} setInputMsg={setInputMsg} />
<>
<WhisperButton inputMsg={inputMsg} setInputMsg={setInputMsg} />
<span className="sr-only">Use Microphone</span>
</>
)}
<Button <Button
size="sm" size="sm"
@@ -628,17 +609,6 @@ export default function ChatBOX() {
</Button> </Button>
</div> </div>
</form> </form>
<ImageUploadDrawer
setShowAddImage={setShowAddImage}
images={images}
showAddImage={showAddImage}
setImages={setImages}
/>
<ImageGenDrawer
showGenImage={showGenImage}
setShowGenImage={setShowGenImage}
/>
</div> </div>
</> </>
); );