Merge pull request #10 from heimoshuiyu/dev
Major Structure Refactor and Feature Enhancements
This commit is contained in:
369
src/addImage.tsx
369
src/addImage.tsx
@@ -1,369 +0,0 @@
|
||||
import { useContext, useState } from "react";
|
||||
import { ChatStore } from "@/types/chatstore";
|
||||
import { MessageDetail } from "@/chatgpt";
|
||||
import { Tr } from "@/translate";
|
||||
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/components/ui/drawer";
|
||||
|
||||
import { Button } from "./components/ui/button";
|
||||
import { PenIcon, XIcon } from "lucide-react";
|
||||
import { Checkbox } from "./components/ui/checkbox";
|
||||
import { Label } from "./components/ui/label";
|
||||
import { Textarea } from "./components/ui/textarea";
|
||||
import { Separator } from "./components/ui/separator";
|
||||
import { AppContext } from "./pages/App";
|
||||
|
||||
interface Props {
|
||||
images: MessageDetail[];
|
||||
showAddImage: boolean;
|
||||
setShowAddImage: (se: boolean) => void;
|
||||
setImages: (images: MessageDetail[]) => void;
|
||||
}
|
||||
interface ImageResponse {
|
||||
url?: string;
|
||||
b64_json?: string;
|
||||
revised_prompt: string;
|
||||
}
|
||||
export function AddImage({
|
||||
showAddImage,
|
||||
setShowAddImage,
|
||||
setImages,
|
||||
images,
|
||||
}: Props) {
|
||||
const ctx = useContext(AppContext);
|
||||
if (ctx === null) return <></>;
|
||||
|
||||
const [enableHighResolution, setEnableHighResolution] = useState(true);
|
||||
const [imageGenPrompt, setImageGenPrompt] = useState("");
|
||||
const [imageGenModel, setImageGenModel] = useState("dall-e-3");
|
||||
const [imageGenN, setImageGenN] = useState(1);
|
||||
const [imageGenQuality, setImageGEnQuality] = useState("standard");
|
||||
const [imageGenResponseFormat, setImageGenResponseFormat] =
|
||||
useState("b64_json");
|
||||
const [imageGenSize, setImageGenSize] = useState("1024x1024");
|
||||
const [imageGenStyle, setImageGenStyle] = useState("vivid");
|
||||
const [imageGenGenerating, setImageGenGenerating] = useState(false);
|
||||
useState("b64_json");
|
||||
return (
|
||||
<Drawer open={showAddImage} onOpenChange={setShowAddImage}>
|
||||
<DrawerContent>
|
||||
<div className="mx-auto w-full max-w-lg">
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Add Images</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={false}
|
||||
onClick={() => {
|
||||
const image_url = prompt("Image URL");
|
||||
if (!image_url) {
|
||||
return;
|
||||
}
|
||||
setImages([
|
||||
...images,
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: image_url,
|
||||
detail: enableHighResolution ? "high" : "low",
|
||||
},
|
||||
},
|
||||
]);
|
||||
}}
|
||||
>
|
||||
Add from URL
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={false}
|
||||
onClick={() => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "image/*";
|
||||
input.onchange = (event) => {
|
||||
const file = (event.target as HTMLInputElement).files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onloadend = () => {
|
||||
const base64data = reader.result;
|
||||
setImages([
|
||||
...images,
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: String(base64data),
|
||||
detail: enableHighResolution ? "high" : "low",
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
};
|
||||
input.click();
|
||||
}}
|
||||
>
|
||||
Add from local file
|
||||
</Button>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={enableHighResolution}
|
||||
onCheckedChange={(checked) =>
|
||||
setEnableHighResolution(checked === true)
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="terms"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
High resolution
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="my-2" />
|
||||
{ctx.chatStore.image_gen_api && ctx.chatStore.image_gen_key && (
|
||||
<div className="flex flex-col">
|
||||
<h3>Generate Image</h3>
|
||||
<span className="flex flex-col justify-between m-1 p-1">
|
||||
<Label>Prompt: </Label>
|
||||
<Textarea
|
||||
className="textarea textarea-sm textarea-bordered"
|
||||
value={imageGenPrompt}
|
||||
onChange={(e: any) => {
|
||||
setImageGenPrompt(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<span className="flex flex-row justify-between items-center m-1 p-1">
|
||||
<label>Model: </label>
|
||||
<select
|
||||
className="select select-sm select-bordered"
|
||||
value={imageGenModel}
|
||||
onChange={(e: any) => {
|
||||
setImageGenModel(e.target.value);
|
||||
}}
|
||||
>
|
||||
<option value="dall-e-3">DALL-E 3</option>
|
||||
<option value="dall-e-2">DALL-E 2</option>
|
||||
</select>
|
||||
</span>
|
||||
<span className="flex flex-row justify-between items-center m-1 p-1">
|
||||
<label>n: </label>
|
||||
<input
|
||||
className="input input-sm input-bordered"
|
||||
value={imageGenN}
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
onChange={(e: any) => setImageGenN(parseInt(e.target.value))}
|
||||
/>
|
||||
</span>
|
||||
<span className="flex flex-row justify-between items-center m-1 p-1">
|
||||
<label>Quality: </label>
|
||||
<select
|
||||
className="select select-sm select-bordered"
|
||||
value={imageGenQuality}
|
||||
onChange={(e: any) => setImageGEnQuality(e.target.value)}
|
||||
>
|
||||
<option value="hd">HD</option>
|
||||
<option value="standard">Standard</option>
|
||||
</select>
|
||||
</span>
|
||||
<span className="flex flex-row justify-between items-center m-1 p-1">
|
||||
<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, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${ctx.chatStore.image_gen_key}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}).then((resp) => resp.json())
|
||||
).data;
|
||||
console.log("image gen resp", resp);
|
||||
|
||||
for (const image of resp) {
|
||||
let url = "";
|
||||
if (image.url) url = image.url;
|
||||
if (image.b64_json)
|
||||
url = "data:image/png;base64," + image.b64_json;
|
||||
if (!url) continue;
|
||||
|
||||
ctx.chatStore.history.push({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "image_url",
|
||||
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>
|
||||
)}
|
||||
<div className="flex flex-wrap">
|
||||
{images.map((image, index) => (
|
||||
<div className="flex flex-col">
|
||||
{image.type === "image_url" && (
|
||||
<img
|
||||
className="rounded m-1 p-1 border-2 border-gray-400 w-32"
|
||||
src={image.image_url?.url}
|
||||
/>
|
||||
)}
|
||||
<span className="flex justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const image_url = prompt("Image URL");
|
||||
if (!image_url) {
|
||||
return;
|
||||
}
|
||||
images[index].image_url = {
|
||||
url: image_url,
|
||||
detail: enableHighResolution ? "high" : "low",
|
||||
};
|
||||
setImages([...images]);
|
||||
}}
|
||||
>
|
||||
<PenIcon />
|
||||
</Button>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`hires-${index}`}
|
||||
checked={image.image_url?.detail === "high"}
|
||||
onCheckedChange={() => {
|
||||
if (image.image_url === undefined) return;
|
||||
image.image_url.detail =
|
||||
image.image_url?.detail === "low" ? "high" : "low";
|
||||
setImages([...images]);
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`hires-${index}`}
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
HiRes
|
||||
</label>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (!confirm("Are you sure to delete this image?")) {
|
||||
return;
|
||||
}
|
||||
images.splice(index, 1);
|
||||
setImages([...images]);
|
||||
}}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DrawerFooter>
|
||||
<Button onClick={() => setShowAddImage(false)}>Done</Button>
|
||||
</DrawerFooter>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
243
src/components/ImageGenDrawer.tsx
Normal file
243
src/components/ImageGenDrawer.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import { useContext, useState } from "react";
|
||||
import { MessageDetail } from "@/chatgpt";
|
||||
import { Tr } from "@/translate";
|
||||
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/components/ui/drawer";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { AppContext } from "@/pages/App";
|
||||
import { PaintBucketIcon } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
disableFactor: boolean[];
|
||||
}
|
||||
interface ImageResponse {
|
||||
url?: string;
|
||||
b64_json?: string;
|
||||
revised_prompt: string;
|
||||
}
|
||||
export function ImageGenDrawer({ disableFactor }: Props) {
|
||||
const ctx = useContext(AppContext);
|
||||
if (ctx === null) return <></>;
|
||||
|
||||
const [showGenImage, setShowGenImage] = useState(false);
|
||||
const [imageGenPrompt, setImageGenPrompt] = useState("");
|
||||
const [imageGenModel, setImageGenModel] = useState("dall-e-3");
|
||||
const [imageGenN, setImageGenN] = useState(1);
|
||||
const [imageGenQuality, setImageGEnQuality] = useState("standard");
|
||||
const [imageGenResponseFormat, setImageGenResponseFormat] =
|
||||
useState("b64_json");
|
||||
const [imageGenSize, setImageGenSize] = useState("1024x1024");
|
||||
const [imageGenStyle, setImageGenStyle] = useState("vivid");
|
||||
const [imageGenGenerating, setImageGenGenerating] = useState(false);
|
||||
useState("b64_json");
|
||||
return (
|
||||
<>
|
||||
{ctx.chatStore.image_gen_api && ctx.chatStore.image_gen_key ? (
|
||||
<Drawer open={showGenImage} onOpenChange={setShowGenImage}>
|
||||
<DrawerTrigger>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
type="button"
|
||||
disabled={disableFactor.some((factor) => factor)}
|
||||
>
|
||||
<PaintBucketIcon className="size-4" />
|
||||
<span className="sr-only">Generate Image</span>
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerDescription className="sr-only">
|
||||
Generate images using the DALL-E model.
|
||||
</DrawerDescription>
|
||||
<DrawerContent>
|
||||
<div className="mx-auto w-full max-w-lg">
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Generate Image</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<div className="flex flex-col">
|
||||
<span className="flex flex-col justify-between m-1 p-1">
|
||||
<Label>Prompt: </Label>
|
||||
<Textarea
|
||||
className="textarea textarea-sm textarea-bordered"
|
||||
value={imageGenPrompt}
|
||||
onChange={(e: any) => {
|
||||
setImageGenPrompt(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<span className="flex flex-row justify-between items-center m-1 p-1">
|
||||
<label>Model: </label>
|
||||
<select
|
||||
className="select select-sm select-bordered"
|
||||
value={imageGenModel}
|
||||
onChange={(e: any) => {
|
||||
setImageGenModel(e.target.value);
|
||||
}}
|
||||
>
|
||||
<option value="dall-e-3">DALL-E 3</option>
|
||||
<option value="dall-e-2">DALL-E 2</option>
|
||||
</select>
|
||||
</span>
|
||||
<span className="flex flex-row justify-between items-center m-1 p-1">
|
||||
<label>n: </label>
|
||||
<input
|
||||
className="input input-sm input-bordered"
|
||||
value={imageGenN}
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
onChange={(e: any) =>
|
||||
setImageGenN(parseInt(e.target.value))
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<span className="flex flex-row justify-between items-center m-1 p-1">
|
||||
<label>Quality: </label>
|
||||
<select
|
||||
className="select select-sm select-bordered"
|
||||
value={imageGenQuality}
|
||||
onChange={(e: any) => setImageGEnQuality(e.target.value)}
|
||||
>
|
||||
<option value="hd">HD</option>
|
||||
<option value="standard">Standard</option>
|
||||
</select>
|
||||
</span>
|
||||
<span className="flex flex-row justify-between items-center m-1 p-1">
|
||||
<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, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${ctx.chatStore.image_gen_key}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}).then((resp) => resp.json())
|
||||
).data;
|
||||
console.log("image gen resp", resp);
|
||||
|
||||
for (const image of resp) {
|
||||
let url = "";
|
||||
if (image.url) url = image.url;
|
||||
if (image.b64_json)
|
||||
url = "data:image/png;base64," + image.b64_json;
|
||||
if (!url) continue;
|
||||
|
||||
ctx.chatStore.history.push({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "image_url",
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
196
src/components/ImageUploadDrawer.tsx
Normal file
196
src/components/ImageUploadDrawer.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { useContext, useState } from "react";
|
||||
import { MessageDetail } from "@/chatgpt";
|
||||
import { Tr } from "@/translate";
|
||||
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/components/ui/drawer";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PenIcon, XIcon, ImageIcon } from "lucide-react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { AppContext } from "@/pages/App";
|
||||
|
||||
interface Props {
|
||||
images: MessageDetail[];
|
||||
setImages: (images: MessageDetail[]) => void;
|
||||
disableFactor: boolean[];
|
||||
}
|
||||
export function ImageUploadDrawer({ setImages, images, disableFactor }: Props) {
|
||||
const ctx = useContext(AppContext);
|
||||
if (ctx === null) return <></>;
|
||||
const [showAddImage, setShowAddImage] = useState(false);
|
||||
const [enableHighResolution, setEnableHighResolution] = useState(true);
|
||||
useState("b64_json");
|
||||
return (
|
||||
<Drawer open={showAddImage} onOpenChange={setShowAddImage}>
|
||||
<DrawerTrigger asChild>
|
||||
<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>
|
||||
<DrawerDescription className="sr-only">
|
||||
Add images to the chat.
|
||||
</DrawerDescription>
|
||||
<DrawerContent>
|
||||
<div className="mx-auto w-full max-w-lg">
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Add Images</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={false}
|
||||
onClick={() => {
|
||||
const image_url = prompt("Image URL");
|
||||
if (!image_url) {
|
||||
return;
|
||||
}
|
||||
setImages([
|
||||
...images,
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: image_url,
|
||||
detail: enableHighResolution ? "high" : "low",
|
||||
},
|
||||
},
|
||||
]);
|
||||
}}
|
||||
>
|
||||
Add from URL
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={false}
|
||||
onClick={() => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "image/*";
|
||||
input.onchange = (event) => {
|
||||
const file = (event.target as HTMLInputElement).files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onloadend = () => {
|
||||
const base64data = reader.result;
|
||||
setImages([
|
||||
...images,
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: String(base64data),
|
||||
detail: enableHighResolution ? "high" : "low",
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
};
|
||||
input.click();
|
||||
}}
|
||||
>
|
||||
Add from local file
|
||||
</Button>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={enableHighResolution}
|
||||
onCheckedChange={(checked) =>
|
||||
setEnableHighResolution(checked === true)
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="terms"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
High resolution
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap">
|
||||
{images.map((image, index) => (
|
||||
<div className="flex flex-col">
|
||||
{image.type === "image_url" && (
|
||||
<img
|
||||
className="rounded m-1 p-1 border-2 border-gray-400 w-32"
|
||||
src={image.image_url?.url}
|
||||
/>
|
||||
)}
|
||||
<span className="flex justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const image_url = prompt("Image URL");
|
||||
if (!image_url) {
|
||||
return;
|
||||
}
|
||||
images[index].image_url = {
|
||||
url: image_url,
|
||||
detail: enableHighResolution ? "high" : "low",
|
||||
};
|
||||
setImages([...images]);
|
||||
}}
|
||||
>
|
||||
<PenIcon />
|
||||
</Button>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`hires-${index}`}
|
||||
checked={image.image_url?.detail === "high"}
|
||||
onCheckedChange={() => {
|
||||
if (image.image_url === undefined) return;
|
||||
image.image_url.detail =
|
||||
image.image_url?.detail === "low" ? "high" : "low";
|
||||
setImages([...images]);
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`hires-${index}`}
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
HiRes
|
||||
</label>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (!confirm("Are you sure to delete this image?")) {
|
||||
return;
|
||||
}
|
||||
images.splice(index, 1);
|
||||
setImages([...images]);
|
||||
}}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DrawerFooter>
|
||||
<Button onClick={() => setShowAddImage(false)}>Done</Button>
|
||||
</DrawerFooter>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
239
src/components/ListAPI.tsx
Normal file
239
src/components/ListAPI.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import React from "react";
|
||||
import { ChatStore, TemplateAPI } from "@/types/chatstore";
|
||||
import { Tr } from "@/translate";
|
||||
|
||||
import {
|
||||
NavigationMenuContent,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuTrigger,
|
||||
} from "@/components/ui/navigation-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useContext } from "react";
|
||||
import { AppContext } from "@/pages/App";
|
||||
import {
|
||||
NavigationMenu,
|
||||
NavigationMenuList,
|
||||
} from "@/components/ui/navigation-menu";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { BrushIcon } from "lucide-react";
|
||||
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
interface APITemplateDropdownProps {
|
||||
label: string;
|
||||
shortLabel: string;
|
||||
ctx: any;
|
||||
apiField: string;
|
||||
keyField: string;
|
||||
}
|
||||
function APIsDropdownList({
|
||||
label,
|
||||
shortLabel,
|
||||
ctx,
|
||||
apiField,
|
||||
keyField,
|
||||
}: APITemplateDropdownProps) {
|
||||
let API = ctx.templateAPIs;
|
||||
if (label === "Chat API") {
|
||||
API = ctx.templateAPIs;
|
||||
} else if (label === "Whisper API") {
|
||||
API = ctx.templateAPIsWhisper;
|
||||
} else if (label === "TTS API") {
|
||||
API = ctx.templateAPIsTTS;
|
||||
} else if (label === "Image Gen API") {
|
||||
API = ctx.templateAPIsImageGen;
|
||||
}
|
||||
|
||||
return (
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuTrigger>
|
||||
<span className="lg:hidden">{shortLabel}</span>
|
||||
<span className="hidden lg:inline">
|
||||
{label}{" "}
|
||||
{API.find(
|
||||
(t: TemplateAPI) =>
|
||||
ctx.chatStore[apiField as keyof ChatStore] === t.endpoint &&
|
||||
ctx.chatStore[keyField as keyof ChatStore] === t.key
|
||||
)?.name &&
|
||||
`: ${
|
||||
API.find(
|
||||
(t: TemplateAPI) =>
|
||||
ctx.chatStore[apiField as keyof ChatStore] === t.endpoint &&
|
||||
ctx.chatStore[keyField as keyof ChatStore] === t.key
|
||||
)?.name
|
||||
}`}
|
||||
</span>
|
||||
</NavigationMenuTrigger>
|
||||
<NavigationMenuContent>
|
||||
<ul className="grid w-[400px] gap-3 p-4 md:w-[500px] md:grid-cols-2 lg:w-[600px] ">
|
||||
{API.map((t: TemplateAPI, index: number) => (
|
||||
<li key={index}>
|
||||
<NavigationMenuLink asChild>
|
||||
<a
|
||||
onClick={() => {
|
||||
// @ts-ignore
|
||||
ctx.chatStore[apiField as keyof ChatStore] = t.endpoint;
|
||||
// @ts-ignore
|
||||
ctx.chatStore[keyField] = t.key;
|
||||
ctx.setChatStore({ ...ctx.chatStore });
|
||||
}}
|
||||
className={cn(
|
||||
"block select-none space-y-1 rounded-md p-3 leading-none no-underline outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground",
|
||||
ctx.chatStore[apiField as keyof ChatStore] === t.endpoint &&
|
||||
ctx.chatStore[keyField as keyof ChatStore] === t.key
|
||||
? "bg-accent text-accent-foreground"
|
||||
: ""
|
||||
)}
|
||||
>
|
||||
<div className="text-sm font-medium leading-none">
|
||||
{t.name}
|
||||
</div>
|
||||
<p className="line-clamp-2 text-sm leading-snug text-muted-foreground">
|
||||
{new URL(t.endpoint).host}
|
||||
</p>
|
||||
</a>
|
||||
</NavigationMenuLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</NavigationMenuContent>
|
||||
</NavigationMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolsDropdownList() {
|
||||
const ctx = useContext(AppContext);
|
||||
if (!ctx) return <div>error</div>;
|
||||
const { toast } = useToast();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const { chatStore, setChatStore } = ctx;
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-4 mx-3">
|
||||
<p className="text-sm text-muted-foreground">{Tr(`Tools`)}</p>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="w-[150px] justify-start">
|
||||
{chatStore.toolsString ? (
|
||||
<>
|
||||
{
|
||||
ctx.templateTools.find(
|
||||
(t) => t.toolsString === chatStore.toolsString
|
||||
)?.name
|
||||
}
|
||||
</>
|
||||
) : (
|
||||
<>+ {Tr(`Set tools`)}</>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" side="bottom" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="You can search..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>{Tr(`No results found.`)}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{chatStore.toolsString && (
|
||||
<CommandItem
|
||||
key={-1}
|
||||
value=""
|
||||
onSelect={() => {
|
||||
chatStore.toolsString = "";
|
||||
setChatStore({ ...chatStore });
|
||||
toast({
|
||||
title: "Tools Cleaned",
|
||||
description: "Tools cleaned successfully",
|
||||
});
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<BrushIcon /> {Tr(`Clear tools`)}
|
||||
</CommandItem>
|
||||
)}
|
||||
{ctx.templateTools.map((t, index) => (
|
||||
<CommandItem
|
||||
key={index}
|
||||
value={t.toolsString}
|
||||
onSelect={(value) => {
|
||||
chatStore.toolsString = value;
|
||||
setChatStore({ ...chatStore });
|
||||
}}
|
||||
>
|
||||
{t.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const APIListMenu: React.FC = () => {
|
||||
const ctx = useContext(AppContext);
|
||||
if (!ctx) return <div>error</div>;
|
||||
return (
|
||||
<div className="flex flex-col m-2 gap-2 w-full">
|
||||
{ctx.templateTools.length > 0 && <ToolsDropdownList />}
|
||||
<NavigationMenu>
|
||||
<NavigationMenuList>
|
||||
{ctx.templateAPIs.length > 0 && (
|
||||
<APIsDropdownList
|
||||
label="Chat API"
|
||||
shortLabel="Chat"
|
||||
ctx={ctx}
|
||||
apiField="apiEndpoint"
|
||||
keyField="apiKey"
|
||||
/>
|
||||
)}
|
||||
{ctx.templateAPIsWhisper.length > 0 && (
|
||||
<APIsDropdownList
|
||||
label="Whisper API"
|
||||
shortLabel="Whisper"
|
||||
ctx={ctx}
|
||||
apiField="whisper_api"
|
||||
keyField="whisper_key"
|
||||
/>
|
||||
)}
|
||||
{ctx.templateAPIsTTS.length > 0 && (
|
||||
<APIsDropdownList
|
||||
label="TTS API"
|
||||
shortLabel="TTS"
|
||||
ctx={ctx}
|
||||
apiField="tts_api"
|
||||
keyField="tts_key"
|
||||
/>
|
||||
)}
|
||||
{ctx.templateAPIsImageGen.length > 0 && (
|
||||
<APIsDropdownList
|
||||
label="Image Gen API"
|
||||
shortLabel="ImgGen"
|
||||
ctx={ctx}
|
||||
apiField="image_gen_api"
|
||||
keyField="image_gen_key"
|
||||
/>
|
||||
)}
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default APIListMenu;
|
||||
@@ -1,41 +1,239 @@
|
||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import Markdown from "react-markdown";
|
||||
import { useContext, useState } from "react";
|
||||
import { useContext, useState, useMemo } from "react";
|
||||
import { ChatStoreMessage } from "@/types/chatstore";
|
||||
import { addTotalCost } from "@/utils/totalCost";
|
||||
|
||||
import { Tr, langCodeContext, LANG_OPTIONS } from "@/translate";
|
||||
import { ChatStore, ChatStoreMessage } from "@/types/chatstore";
|
||||
import { calculate_token_length, getMessageText } from "@/chatgpt";
|
||||
import TTSButton, { TTSPlay } from "@/tts";
|
||||
import { MessageHide } from "@/messageHide";
|
||||
import { MessageDetail } from "@/messageDetail";
|
||||
import { MessageToolCall } from "@/messageToolCall";
|
||||
import { MessageToolResp } from "@/messageToolResp";
|
||||
import { EditMessage } from "@/editMessage";
|
||||
import logprobToColor from "@/logprob";
|
||||
import { Tr } from "@/translate";
|
||||
import { getMessageText } from "@/chatgpt";
|
||||
import { EditMessage } from "@/components/editMessage";
|
||||
import logprobToColor from "@/utils/logprob";
|
||||
import {
|
||||
ChatBubble,
|
||||
ChatBubbleAvatar,
|
||||
ChatBubbleMessage,
|
||||
ChatBubbleAction,
|
||||
ChatBubbleActionWrapper,
|
||||
} from "@/components/ui/chat/chat-bubble";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import {
|
||||
ClipboardIcon,
|
||||
PencilIcon,
|
||||
MessageSquareOffIcon,
|
||||
MessageSquarePlusIcon,
|
||||
AudioLinesIcon,
|
||||
LoaderCircleIcon,
|
||||
} from "lucide-react";
|
||||
import { AppContext } from "./pages/App";
|
||||
import { AppContext } from "@/pages/App";
|
||||
|
||||
export const isVailedJSON = (str: string): boolean => {
|
||||
try {
|
||||
JSON.parse(str);
|
||||
} catch (e) {
|
||||
return false;
|
||||
interface HideMessageProps {
|
||||
chat: ChatStoreMessage;
|
||||
}
|
||||
return true;
|
||||
|
||||
function MessageHide({ chat }: HideMessageProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{getMessageText(chat).split("\n")[0].slice(0, 28)} ...</span>
|
||||
</div>
|
||||
<div className="flex mt-2 justify-center">
|
||||
<Badge variant="destructive">Removed from context</Badge>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface MessageDetailProps {
|
||||
chat: ChatStoreMessage;
|
||||
renderMarkdown: boolean;
|
||||
}
|
||||
function MessageDetail({ chat, renderMarkdown }: MessageDetailProps) {
|
||||
if (typeof chat.content === "string") {
|
||||
return <div></div>;
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
{chat.content.map((mdt) =>
|
||||
mdt.type === "text" ? (
|
||||
chat.hide ? (
|
||||
mdt.text?.split("\n")[0].slice(0, 16) + " ..."
|
||||
) : renderMarkdown ? (
|
||||
<Markdown>{mdt.text}</Markdown>
|
||||
) : (
|
||||
mdt.text
|
||||
)
|
||||
) : (
|
||||
<img
|
||||
className="my-2 rounded-md max-w-64 max-h-64"
|
||||
src={mdt.image_url?.url}
|
||||
key={mdt.image_url?.url}
|
||||
onClick={() => {
|
||||
window.open(mdt.image_url?.url, "_blank");
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToolCallMessageProps {
|
||||
chat: ChatStoreMessage;
|
||||
copyToClipboard: (text: string) => void;
|
||||
}
|
||||
function MessageToolCall({ chat, copyToClipboard }: ToolCallMessageProps) {
|
||||
return (
|
||||
<div className="message-content">
|
||||
{chat.tool_calls?.map((tool_call) => (
|
||||
<div className="bg-blue-300 dark:bg-blue-800 p-1 rounded my-1">
|
||||
<strong>
|
||||
Tool Call ID:{" "}
|
||||
<span
|
||||
className="p-1 m-1 rounded cursor-pointer hover:opacity-50 hover:underline"
|
||||
onClick={() => copyToClipboard(String(tool_call.id))}
|
||||
>
|
||||
{tool_call?.id}
|
||||
</span>
|
||||
</strong>
|
||||
<p>Type: {tool_call?.type}</p>
|
||||
<p>
|
||||
Function:
|
||||
<span
|
||||
className="p-1 m-1 rounded cursor-pointer hover:opacity-50 hover:underline"
|
||||
onClick={() => copyToClipboard(tool_call.function.name)}
|
||||
>
|
||||
{tool_call.function.name}
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
Arguments:
|
||||
<span
|
||||
className="p-1 m-1 rounded cursor-pointer hover:opacity-50 hover:underline"
|
||||
onClick={() => copyToClipboard(tool_call.function.arguments)}
|
||||
>
|
||||
{tool_call.function.arguments}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
{/* [TODO] */}
|
||||
{chat.content as string}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToolRespondMessageProps {
|
||||
chat: ChatStoreMessage;
|
||||
copyToClipboard: (text: string) => void;
|
||||
}
|
||||
function MessageToolResp({ chat, copyToClipboard }: ToolRespondMessageProps) {
|
||||
return (
|
||||
<div className="message-content">
|
||||
<div className="bg-blue-300 dark:bg-blue-800 p-1 rounded my-1">
|
||||
<strong>
|
||||
Tool Response ID:{" "}
|
||||
<span
|
||||
className="p-1 m-1 rounded cursor-pointer hover:opacity-50 hover:underline"
|
||||
onClick={() => copyToClipboard(String(chat.tool_call_id))}
|
||||
>
|
||||
{chat.tool_call_id}
|
||||
</span>
|
||||
</strong>
|
||||
{/* [TODO] */}
|
||||
<p>{chat.content as string}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TTSProps {
|
||||
chat: ChatStoreMessage;
|
||||
}
|
||||
interface TTSPlayProps {
|
||||
chat: ChatStoreMessage;
|
||||
}
|
||||
export function TTSPlay(props: TTSPlayProps) {
|
||||
const src = useMemo(() => {
|
||||
if (props.chat.audio instanceof Blob) {
|
||||
return URL.createObjectURL(props.chat.audio);
|
||||
}
|
||||
return "";
|
||||
}, [props.chat.audio]);
|
||||
|
||||
if (props.chat.hide) {
|
||||
return <></>;
|
||||
}
|
||||
if (props.chat.audio instanceof Blob) {
|
||||
return <audio className="w-64" src={src} controls />;
|
||||
}
|
||||
return <></>;
|
||||
}
|
||||
function TTSButton(props: TTSProps) {
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const ctx = useContext(AppContext);
|
||||
if (!ctx) return <div>error</div>;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
const api = ctx.chatStore.tts_api;
|
||||
const api_key = ctx.chatStore.tts_key;
|
||||
const model = "tts-1";
|
||||
const input = getMessageText(props.chat);
|
||||
const voice = ctx.chatStore.tts_voice;
|
||||
|
||||
const body: Record<string, any> = {
|
||||
model,
|
||||
input,
|
||||
voice,
|
||||
response_format: ctx.chatStore.tts_format || "mp3",
|
||||
};
|
||||
if (ctx.chatStore.tts_speed_enabled) {
|
||||
body["speed"] = ctx.chatStore.tts_speed;
|
||||
}
|
||||
|
||||
setGenerating(true);
|
||||
|
||||
fetch(api, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${api_key}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
.then((response) => response.blob())
|
||||
.then((blob) => {
|
||||
// update price
|
||||
const cost = (input.length * 0.015) / 1000;
|
||||
ctx.chatStore.cost += cost;
|
||||
addTotalCost(cost);
|
||||
ctx.setChatStore({ ...ctx.chatStore });
|
||||
|
||||
// save blob
|
||||
props.chat.audio = blob;
|
||||
ctx.setChatStore({ ...ctx.chatStore });
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const audio = new Audio(url);
|
||||
audio.play();
|
||||
})
|
||||
.finally(() => {
|
||||
setGenerating(false);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{generating ? (
|
||||
<LoaderCircleIcon className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<AudioLinesIcon className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Message(props: { messageIndex: number }) {
|
||||
const ctx = useContext(AppContext);
|
||||
@@ -45,47 +243,8 @@ export default function Message(props: { messageIndex: number }) {
|
||||
|
||||
const chat = chatStore.history[messageIndex];
|
||||
const [showEdit, setShowEdit] = useState(false);
|
||||
const [showCopiedHint, setShowCopiedHint] = useState(false);
|
||||
const [renderMarkdown, setRenderWorkdown] = useState(false);
|
||||
const [renderColor, setRenderColor] = useState(false);
|
||||
const DeleteIcon = () => (
|
||||
<button
|
||||
onClick={() => {
|
||||
chatStore.history[messageIndex].hide =
|
||||
!chatStore.history[messageIndex].hide;
|
||||
|
||||
//chatStore.totalTokens =
|
||||
chatStore.totalTokens = 0;
|
||||
for (const i of chatStore.history
|
||||
.filter(({ hide }) => !hide)
|
||||
.slice(chatStore.postBeginIndex)
|
||||
.map(({ token }) => token)) {
|
||||
chatStore.totalTokens += i;
|
||||
}
|
||||
setChatStore({ ...chatStore });
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
);
|
||||
const CopiedHint = () => (
|
||||
<div role="alert" className="alert">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className="stroke-info h-6 w-6 shrink-0"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
<span>{Tr("Message copied to clipboard!")}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const { toast } = useToast();
|
||||
const copyToClipboard = async (text: string) => {
|
||||
@@ -113,20 +272,6 @@ export default function Message(props: { messageIndex: number }) {
|
||||
}
|
||||
};
|
||||
|
||||
const CopyIcon = ({ textToCopy }: { textToCopy: string }) => {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
copyToClipboard(textToCopy);
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{chatStore.postBeginIndex !== 0 &&
|
||||
39
src/components/ModeToggle.tsx
Normal file
39
src/components/ModeToggle.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useTheme, Theme } from "@/components/ThemeProvider";
|
||||
|
||||
export function ModeToggle() {
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<Select onValueChange={(value) => setTheme(value as Theme)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select theme" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Theme</SelectLabel>
|
||||
<SelectItem value="light">Light</SelectItem>
|
||||
<SelectItem value="dark">Dark</SelectItem>
|
||||
<SelectItem value="system">System</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { IDBPDatabase } from "idb";
|
||||
import { useRef, useState, Dispatch, useContext } from "react";
|
||||
|
||||
import { ChatStore } from "@/types/chatstore";
|
||||
import { MessageDetail } from "./chatgpt";
|
||||
import { MessageDetail } from "../chatgpt";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
@@ -16,15 +15,15 @@ import {
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination";
|
||||
|
||||
import { Input } from "./components/ui/input";
|
||||
import { AppContext } from "./pages/App";
|
||||
import { Input } from "./ui/input";
|
||||
import { AppContext } from "../pages/App";
|
||||
import { Button } from "./ui/button";
|
||||
import { SearchIcon } from "lucide-react";
|
||||
|
||||
interface ChatStoreSearchResult {
|
||||
key: IDBValidKey;
|
||||
@@ -33,10 +32,7 @@ interface ChatStoreSearchResult {
|
||||
preview: string;
|
||||
}
|
||||
|
||||
export default function Search(props: {
|
||||
show: boolean;
|
||||
setShow: (show: boolean) => void;
|
||||
}) {
|
||||
export default function Search() {
|
||||
const ctx = useContext(AppContext);
|
||||
if (ctx === null) return <></>;
|
||||
const { setSelectedChatIndex, db } = ctx;
|
||||
@@ -46,9 +42,15 @@ export default function Search(props: {
|
||||
const [searchingNow, setSearchingNow] = useState<number>(0);
|
||||
const [pageIndex, setPageIndex] = useState<number>(0);
|
||||
const searchAbortRef = useRef<AbortController | null>(null);
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<Dialog open={props.show} onOpenChange={props.setShow}>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<SearchIcon />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[80%]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Search</DialogTitle>
|
||||
@@ -160,7 +162,7 @@ export default function Search(props: {
|
||||
key={result.key as number}
|
||||
onClick={() => {
|
||||
setSelectedChatIndex(parseInt(result.key.toString()));
|
||||
props.setShow(false);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="m-1 p-1 font-bold">
|
||||
@@ -3,17 +3,12 @@ import { themeChange } from "theme-change";
|
||||
import { useRef } from "react";
|
||||
import { useContext, useEffect, useState, Dispatch } from "react";
|
||||
import { clearTotalCost, getTotalCost } from "@/utils/totalCost";
|
||||
import {
|
||||
ChatStore,
|
||||
TemplateChatStore,
|
||||
TemplateAPI,
|
||||
TemplateTools,
|
||||
} from "@/types/chatstore";
|
||||
import { ChatStore, TemplateChatStore, TemplateTools } from "@/types/chatstore";
|
||||
import { models } from "@/types/models";
|
||||
import { tr, Tr, langCodeContext, LANG_OPTIONS } from "@/translate";
|
||||
import { isVailedJSON } from "@/message";
|
||||
import { SetAPIsTemplate } from "@/setAPIsTemplate";
|
||||
import { autoHeight } from "@/textarea";
|
||||
import { isVailedJSON } from "@/utils/isVailedJSON";
|
||||
import { SetAPIsTemplate } from "@/components/setAPIsTemplate";
|
||||
import { autoHeight } from "@/utils/textAreaHelp";
|
||||
import { getDefaultParams } from "@/utils/getDefaultParam";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -53,6 +48,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -61,18 +57,20 @@ import {
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
BanIcon,
|
||||
CheckIcon,
|
||||
CircleEllipsisIcon,
|
||||
CogIcon,
|
||||
Ellipsis,
|
||||
EyeIcon,
|
||||
InfoIcon,
|
||||
KeyIcon,
|
||||
ListIcon,
|
||||
MoveHorizontalIcon,
|
||||
SaveIcon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
@@ -475,7 +473,152 @@ const Choice = (props: {
|
||||
);
|
||||
};
|
||||
|
||||
export default (props: { setShow: Dispatch<boolean> }) => {
|
||||
const APIShowBlock = (props: {
|
||||
ctx: any;
|
||||
index: number;
|
||||
label: string;
|
||||
type: string;
|
||||
apiField: string;
|
||||
keyField: string;
|
||||
}) => {
|
||||
return (
|
||||
<div className="border-b border-gray-200 pb-4 pt-4">
|
||||
<Badge variant="outline">{props.type}</Badge> <Label>{props.label}</Label>
|
||||
<div className="mt-4">
|
||||
<div className="grid w-full max-w-sm items-center gap-1.5 mt-2">
|
||||
<Label>Endpoint</Label> {props.apiField}
|
||||
</div>
|
||||
<div className="grid w-full max-w-sm items-center gap-1.5 mt-2">
|
||||
<Label>Key</Label>
|
||||
{props.keyField ? (
|
||||
props.keyField
|
||||
) : (
|
||||
<span className="text-gray-500 italic">empty</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2 mr-2"
|
||||
onClick={() => {
|
||||
const name = prompt(`Give template ${props.label} a new name`);
|
||||
if (!name) return;
|
||||
if (props.type === "Chat") {
|
||||
props.ctx.templateAPIs[props.index].name = name;
|
||||
props.ctx.setTemplateAPIs(structuredClone(props.ctx.templateAPIs));
|
||||
} else if (props.type === "Whisper") {
|
||||
props.ctx.templateAPIsWhisper[props.index].name = name;
|
||||
props.ctx.setTemplateAPIsWhisper(
|
||||
structuredClone(props.ctx.templateAPIsWhisper)
|
||||
);
|
||||
} else if (props.type === "TTS") {
|
||||
props.ctx.templateAPIsTTS[props.index].name = name;
|
||||
props.ctx.setTemplateAPIsTTS(
|
||||
structuredClone(props.ctx.templateAPIsTTS)
|
||||
);
|
||||
} else if (props.type === "ImgGen") {
|
||||
props.ctx.templateAPIsImageGen[props.index].name = name;
|
||||
props.ctx.setTemplateAPIsImageGen(
|
||||
structuredClone(props.ctx.templateAPIsImageGen)
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Change Name
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={() => {
|
||||
if (!props.ctx) return;
|
||||
if (
|
||||
!confirm(
|
||||
`Are you sure to delete ${props.label}(${props.type}) API?`
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (props.type === "Chat") {
|
||||
props.ctx.templateAPIs.splice(props.index, 1);
|
||||
props.ctx.setTemplateAPIs(structuredClone(props.ctx.templateAPIs));
|
||||
} else if (props.type === "Whisper") {
|
||||
props.ctx.templateAPIsWhisper.splice(props.index, 1);
|
||||
props.ctx.setTemplateAPIsWhisper(
|
||||
structuredClone(props.ctx.templateAPIsWhisper)
|
||||
);
|
||||
} else if (props.type === "TTS") {
|
||||
props.ctx.templateAPIsTTS.splice(props.index, 1);
|
||||
props.ctx.setTemplateAPIsTTS(
|
||||
structuredClone(props.ctx.templateAPIsTTS)
|
||||
);
|
||||
} else if (props.type === "ImgGen") {
|
||||
props.ctx.templateAPIsImageGen.splice(props.index, 1);
|
||||
props.ctx.setTemplateAPIsImageGen(
|
||||
structuredClone(props.ctx.templateAPIsImageGen)
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ToolsShowBlock = (props: {
|
||||
ctx: any;
|
||||
index: number;
|
||||
label: string;
|
||||
content: string;
|
||||
}) => {
|
||||
return (
|
||||
<div className="border-b border-gray-200 pb-4 pt-4">
|
||||
<Badge variant="outline">Tool</Badge> <Label>{props.label}</Label>
|
||||
<div className="mt-4">
|
||||
<div className="grid w-full max-w-sm items-center gap-1.5 mt-2">
|
||||
<Label>Content</Label>
|
||||
<ScrollArea className="w-72 whitespace-nowrap rounded-md border">
|
||||
<pre className="text-xs">
|
||||
{JSON.stringify(JSON.parse(props.content), null, 2)}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2 mr-2"
|
||||
onClick={() => {
|
||||
const name = prompt(`Give the tool ${props.label} a new name`);
|
||||
if (!name) return;
|
||||
props.ctx.templateTools[props.index].name = name;
|
||||
props.ctx.setTemplateTools(structuredClone(props.ctx.templateTools));
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={() => {
|
||||
if (!props.ctx) return;
|
||||
if (!confirm(`Are you sure to delete ${props.label} Tool?`)) {
|
||||
return;
|
||||
}
|
||||
props.ctx.templateTools.splice(props.index, 1);
|
||||
props.ctx.setTemplateTools(structuredClone(props.ctx.templateTools));
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default (props: {}) => {
|
||||
const ctx = useContext(AppContext);
|
||||
if (ctx === null) return <></>;
|
||||
|
||||
@@ -497,13 +640,14 @@ export default (props: { setShow: Dispatch<boolean> }) => {
|
||||
const [totalCost, setTotalCost] = useState(getTotalCost());
|
||||
// @ts-ignore
|
||||
const { langCode, setLangCode } = useContext(langCodeContext);
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
themeChange(false);
|
||||
const handleKeyPress = (event: any) => {
|
||||
if (event.keyCode === 27) {
|
||||
// keyCode for ESC key is 27
|
||||
props.setShow(false);
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -514,10 +658,13 @@ export default (props: { setShow: Dispatch<boolean> }) => {
|
||||
};
|
||||
}, []); // The empty dependency array ensures that the effect runs only once
|
||||
return (
|
||||
<Sheet>
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline" className="flex-grow">
|
||||
{Tr("Settings")}
|
||||
{(!ctx.chatStore.apiKey || !ctx.chatStore.apiEndpoint) && (
|
||||
<TriangleAlertIcon className="w-4 h-4 ml-1 text-yellow-500" />
|
||||
)}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="flex flex-col overflow-scroll">
|
||||
@@ -576,25 +723,68 @@ export default (props: { setShow: Dispatch<boolean> }) => {
|
||||
<div className="box">
|
||||
<div className="flex justify-evenly flex-wrap">
|
||||
{ctx.chatStore.toolsString.trim() && (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">{Tr(`Save Tools`)}</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Save the tool as Template</DialogTitle>
|
||||
<DialogDescription>
|
||||
Once saved, you can easily access your tools from
|
||||
the dropdown menu.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="grid flex-1 gap-2">
|
||||
<Label htmlFor="toolsName" className="sr-only">
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
id="toolsName"
|
||||
placeholder="Type Something..."
|
||||
/>
|
||||
<Label
|
||||
id="toolsNameError"
|
||||
className="text-red-600"
|
||||
></Label>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="sm:justify-start">
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="px-3"
|
||||
onClick={() => {
|
||||
const name = prompt(
|
||||
`Give this **Tools** template a name:`
|
||||
);
|
||||
if (!name) {
|
||||
alert("No template name specified");
|
||||
const name = document.getElementById(
|
||||
"toolsName" as string
|
||||
) as HTMLInputElement;
|
||||
if (!name.value) {
|
||||
const errorLabel = document.getElementById(
|
||||
"toolsNameError" as string
|
||||
) as HTMLLabelElement;
|
||||
if (errorLabel) {
|
||||
errorLabel.textContent =
|
||||
"Tool name is required.";
|
||||
}
|
||||
return;
|
||||
}
|
||||
const newToolsTmp: TemplateTools = {
|
||||
name,
|
||||
name: name.value,
|
||||
toolsString: ctx.chatStore.toolsString,
|
||||
};
|
||||
ctx.templateTools.push(newToolsTmp);
|
||||
ctx.setTemplateTools([...ctx.templateTools]);
|
||||
}}
|
||||
>
|
||||
{Tr(`Save Tools`)}
|
||||
<SaveIcon className="w-4 h-4" /> Save
|
||||
<span className="sr-only">Save</span>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -854,8 +1044,8 @@ export default (props: { setShow: Dispatch<boolean> }) => {
|
||||
label="Chat API"
|
||||
endpoint={ctx.chatStore.apiEndpoint}
|
||||
APIkey={ctx.chatStore.apiKey}
|
||||
tmps={ctx.templateAPIs}
|
||||
setTmps={ctx.setTemplateAPIs}
|
||||
temps={ctx.templateAPIs}
|
||||
setTemps={ctx.setTemplateAPIs}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -959,8 +1149,8 @@ export default (props: { setShow: Dispatch<boolean> }) => {
|
||||
label="Whisper API"
|
||||
endpoint={ctx.chatStore.whisper_api}
|
||||
APIkey={ctx.chatStore.whisper_key}
|
||||
tmps={ctx.templateAPIsWhisper}
|
||||
setTmps={ctx.setTemplateAPIsWhisper}
|
||||
temps={ctx.templateAPIsWhisper}
|
||||
setTemps={ctx.setTemplateAPIsWhisper}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -992,8 +1182,8 @@ export default (props: { setShow: Dispatch<boolean> }) => {
|
||||
label="TTS API"
|
||||
endpoint={ctx.chatStore.tts_api}
|
||||
APIkey={ctx.chatStore.tts_key}
|
||||
tmps={ctx.templateAPIsTTS}
|
||||
setTmps={ctx.setTemplateAPIsTTS}
|
||||
temps={ctx.templateAPIsTTS}
|
||||
setTemps={ctx.setTemplateAPIsTTS}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -1117,13 +1307,76 @@ export default (props: { setShow: Dispatch<boolean> }) => {
|
||||
label="Image Gen API"
|
||||
endpoint={ctx.chatStore.image_gen_api}
|
||||
APIkey={ctx.chatStore.image_gen_key}
|
||||
tmps={ctx.templateAPIsImageGen}
|
||||
setTmps={ctx.setTemplateAPIsImageGen}
|
||||
temps={ctx.templateAPIsImageGen}
|
||||
setTemps={ctx.setTemplateAPIsImageGen}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="templates">
|
||||
<AccordionTrigger>Saved Template</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
{ctx.templateAPIs.map((template, index) => (
|
||||
<div key={index}>
|
||||
<APIShowBlock
|
||||
ctx={ctx}
|
||||
index={index}
|
||||
label={template.name}
|
||||
type="Chat"
|
||||
apiField={template.endpoint}
|
||||
keyField={template.key}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{ctx.templateAPIsWhisper.map((template, index) => (
|
||||
<div key={index}>
|
||||
<APIShowBlock
|
||||
ctx={ctx}
|
||||
index={index}
|
||||
label={template.name}
|
||||
type="Whisper"
|
||||
apiField={template.endpoint}
|
||||
keyField={template.key}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{ctx.templateAPIsTTS.map((template, index) => (
|
||||
<div key={index}>
|
||||
<APIShowBlock
|
||||
ctx={ctx}
|
||||
index={index}
|
||||
label={template.name}
|
||||
type="TTS"
|
||||
apiField={template.endpoint}
|
||||
keyField={template.key}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{ctx.templateAPIsImageGen.map((template, index) => (
|
||||
<div key={index}>
|
||||
<APIShowBlock
|
||||
ctx={ctx}
|
||||
index={index}
|
||||
label={template.name}
|
||||
type="ImgGen"
|
||||
apiField={template.endpoint}
|
||||
keyField={template.key}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{ctx.templateTools.map((template, index) => (
|
||||
<div key={index}>
|
||||
<ToolsShowBlock
|
||||
ctx={ctx}
|
||||
index={index}
|
||||
label={template.name}
|
||||
content={template.toolsString}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
<div className="pt-4 space-y-2">
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
import {
|
||||
CubeIcon,
|
||||
BanknotesIcon,
|
||||
ChatBubbleLeftEllipsisIcon,
|
||||
ScissorsIcon,
|
||||
SwatchIcon,
|
||||
SparklesIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
|
||||
import { ChatStore } from "@/types/chatstore";
|
||||
import { models } from "@/types/models";
|
||||
import { Tr } from "@/translate";
|
||||
import { getTotalCost } from "@/utils/totalCost";
|
||||
|
||||
const StatusBar = (props: {
|
||||
chatStore: ChatStore;
|
||||
setShowSettings: (show: boolean) => void;
|
||||
setShowSearch: (show: boolean) => void;
|
||||
}) => {
|
||||
const { chatStore, setShowSettings, setShowSearch } = props;
|
||||
return (
|
||||
<div className="navbar bg-base-100 p-0">
|
||||
<div className="navbar-start">
|
||||
<div className="dropdown lg:hidden">
|
||||
<div tabIndex={0} role="button" className="btn btn-ghost btn-circle">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 6h16M4 12h16M4 18h7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<ul
|
||||
tabIndex={0}
|
||||
className="menu menu-sm dropdown-content bg-base-100 rounded-box z-[1] mt-3 w-52 p-2 shadow"
|
||||
>
|
||||
<li>
|
||||
<p>
|
||||
<ChatBubbleLeftEllipsisIcon className="h-4 w-4" />
|
||||
Tokens: {chatStore.totalTokens}/{chatStore.maxTokens}
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
<ScissorsIcon className="h-4 w-4" />
|
||||
Cut:
|
||||
{chatStore.postBeginIndex}/
|
||||
{chatStore.history.filter(({ hide }) => !hide).length}
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
<BanknotesIcon className="h-4 w-4" />
|
||||
Cost: ${chatStore.cost?.toFixed(4)}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="navbar-center cursor-pointer py-1"
|
||||
onClick={() => {
|
||||
setShowSettings(true);
|
||||
}}
|
||||
>
|
||||
{/* the long staus bar */}
|
||||
<div className="stats shadow hidden lg:inline-grid">
|
||||
<div className="stat">
|
||||
<div className="stat-figure text-secondary">
|
||||
<CubeIcon className="h-10 w-10" />
|
||||
</div>
|
||||
<div className="stat-title">Model</div>
|
||||
<div className="stat-value text-base">{chatStore.model}</div>
|
||||
<div className="stat-desc">
|
||||
{models[chatStore.model]?.price?.prompt * 1000 * 1000} $/M tokens
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="stat-figure text-secondary">
|
||||
<SwatchIcon className="h-10 w-10" />
|
||||
</div>
|
||||
<div className="stat-title">Mode</div>
|
||||
<div className="stat-value text-base">
|
||||
{chatStore.streamMode ? Tr("STREAM") : Tr("FETCH")}
|
||||
</div>
|
||||
<div className="stat-desc">STREAM/FETCH</div>
|
||||
</div>
|
||||
|
||||
<div className="stat">
|
||||
<div className="stat-figure text-secondary">
|
||||
<ChatBubbleLeftEllipsisIcon className="h-10 w-10" />
|
||||
</div>
|
||||
<div className="stat-title">Tokens</div>
|
||||
<div className="stat-value text-base">{chatStore.totalTokens}</div>
|
||||
<div className="stat-desc">Max: {chatStore.maxTokens}</div>
|
||||
</div>
|
||||
|
||||
<div className="stat">
|
||||
<div className="stat-figure text-secondary">
|
||||
<ScissorsIcon className="h-10 w-10" />
|
||||
</div>
|
||||
<div className="stat-title">Cut</div>
|
||||
<div className="stat-value text-base">
|
||||
{chatStore.postBeginIndex}
|
||||
</div>
|
||||
<div className="stat-desc">
|
||||
Max: {chatStore.history.filter(({ hide }) => !hide).length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat">
|
||||
<div className="stat-figure text-secondary">
|
||||
<BanknotesIcon className="h-10 w-10" />
|
||||
</div>
|
||||
<div className="stat-title">Cost</div>
|
||||
<div className="stat-value text-base">
|
||||
${chatStore.cost?.toFixed(4)}
|
||||
</div>
|
||||
<div className="stat-desc">
|
||||
Accumulated: ${getTotalCost().toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* the short status bar */}
|
||||
<div className="indicator lg:hidden">
|
||||
{chatStore.totalTokens !== 0 && (
|
||||
<span className="indicator-item badge badge-primary">
|
||||
Tokens: {chatStore.totalTokens}
|
||||
</span>
|
||||
)}
|
||||
<a className="btn btn-ghost text-base sm:text-xl p-0">
|
||||
<SparklesIcon className="h-4 w-4 hidden sm:block" />
|
||||
{chatStore.model}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="navbar-end">
|
||||
<button
|
||||
className="btn btn-ghost btn-circle"
|
||||
onClick={(event) => {
|
||||
// stop propagation to parent
|
||||
event.stopPropagation();
|
||||
|
||||
setShowSearch(true);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost btn-circle hidden sm:block"
|
||||
onClick={() => setShowSettings(true)}
|
||||
>
|
||||
<div className="indicator">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
className="h-6 w-6"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<span className="badge badge-xs badge-primary indicator-item"></span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatusBar;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
type Theme = "dark" | "light" | "system";
|
||||
export type Theme = "dark" | "light" | "system";
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode;
|
||||
@@ -1,15 +1,8 @@
|
||||
import { createRef, useContext } from "react";
|
||||
|
||||
import { ChatStore } from "@/types/chatstore";
|
||||
import { useEffect, useState, Dispatch } from "react";
|
||||
import { useState, Dispatch } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AudioWaveform,
|
||||
AudioWaveformIcon,
|
||||
CircleStopIcon,
|
||||
MicIcon,
|
||||
VoicemailIcon,
|
||||
} from "lucide-react";
|
||||
import { AudioWaveformIcon, CircleStopIcon, MicIcon } from "lucide-react";
|
||||
import { AppContext } from "@/pages/App";
|
||||
|
||||
const WhisperButton = (props: {
|
||||
@@ -24,10 +17,12 @@ const WhisperButton = (props: {
|
||||
const mediaRef = createRef();
|
||||
const [isRecording, setIsRecording] = useState("Mic");
|
||||
return (
|
||||
<>
|
||||
{chatStore.whisper_api && chatStore.whisper_key ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`m-1 p-1 ${isRecording !== "Mic" ? "animate-pulse" : ""}`}
|
||||
className={`${isRecording !== "Mic" ? "animate-pulse" : ""}`}
|
||||
disabled={isRecording === "Transcribing"}
|
||||
ref={mediaRef as any}
|
||||
onClick={async (event) => {
|
||||
@@ -146,6 +141,13 @@ const WhisperButton = (props: {
|
||||
<AudioWaveformIcon />
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="ghost" size="icon" disabled={true}>
|
||||
<MicIcon />
|
||||
</Button>
|
||||
)}
|
||||
<span className="sr-only">Use Microphone</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, useEffect, Dispatch, useContext } from "react";
|
||||
import { Tr, langCodeContext, LANG_OPTIONS, tr } from "@/translate";
|
||||
import { ChatStore, ChatStoreMessage } from "@/types/chatstore";
|
||||
import { EditMessageString } from "@/editMessageString";
|
||||
import { EditMessageDetail } from "@/editMessageDetail";
|
||||
import { EditMessageString } from "@/components/editMessageString";
|
||||
import { EditMessageDetail } from "@/components/editMessageDetail";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "./components/ui/button";
|
||||
import { AppContext } from "./pages/App";
|
||||
import { Button } from "./ui/button";
|
||||
import { AppContext } from "../pages/App";
|
||||
|
||||
interface EditMessageProps {
|
||||
chat: ChatStoreMessage;
|
||||
@@ -13,9 +13,9 @@ import {
|
||||
DrawerTrigger,
|
||||
} from "@/components/ui/drawer";
|
||||
|
||||
import { Button } from "./components/ui/button";
|
||||
import { Button } from "./ui/button";
|
||||
import { useContext } from "react";
|
||||
import { AppContext } from "./pages/App";
|
||||
import { AppContext } from "../pages/App";
|
||||
|
||||
interface Props {
|
||||
chat: ChatStoreMessage;
|
||||
@@ -1,11 +1,11 @@
|
||||
import { ChatStore, ChatStoreMessage } from "@/types/chatstore";
|
||||
import { isVailedJSON } from "@/message";
|
||||
import { isVailedJSON } from "@/utils/isVailedJSON";
|
||||
import { calculate_token_length } from "@/chatgpt";
|
||||
import { Tr } from "@/translate";
|
||||
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useContext } from "react";
|
||||
import { AppContext } from "./pages/App";
|
||||
import { AppContext } from "../pages/App";
|
||||
|
||||
interface Props {
|
||||
chat: ChatStoreMessage;
|
||||
@@ -1,37 +0,0 @@
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
|
||||
export function ModeToggle() {
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||
Light
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||
Dark
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||
System
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
143
src/components/navbar.tsx
Normal file
143
src/components/navbar.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import React from "react";
|
||||
import { Badge } from "./ui/badge";
|
||||
import {
|
||||
Menubar,
|
||||
MenubarContent,
|
||||
MenubarItem,
|
||||
MenubarMenu,
|
||||
MenubarSeparator,
|
||||
MenubarTrigger,
|
||||
MenubarCheckboxItem,
|
||||
} from "@/components/ui/menubar";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
EllipsisIcon,
|
||||
WholeWordIcon,
|
||||
CircleDollarSignIcon,
|
||||
RulerIcon,
|
||||
ReceiptIcon,
|
||||
WalletIcon,
|
||||
ArrowUpDownIcon,
|
||||
ScissorsIcon,
|
||||
} from "lucide-react";
|
||||
import { AppContext } from "@/pages/App";
|
||||
import { models } from "@/types/models";
|
||||
import { getTotalCost } from "@/utils/totalCost";
|
||||
import { Tr } from "@/translate";
|
||||
|
||||
import { useContext } from "react";
|
||||
import Search from "@/components/Search";
|
||||
import Settings from "@/components/Settings";
|
||||
|
||||
const Navbar: React.FC = () => {
|
||||
const ctx = useContext(AppContext);
|
||||
if (!ctx) return <div>error</div>;
|
||||
const { chatStore, setChatStore } = ctx;
|
||||
|
||||
return (
|
||||
<header className="flex sticky top-0 bg-background h-14 shrink-0 items-center border-b z-50">
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<div className="flex items-center gap-2 px-3">
|
||||
<SidebarTrigger />
|
||||
<Separator orientation="vertical" className="mr-2 h-4" />
|
||||
<h1 className="text-lg font-bold">{chatStore.model}</h1>
|
||||
<div className="flex justify-between items-center gap-2">
|
||||
<div>
|
||||
<div className="dropdown lg:hidden flex items-center gap-2">
|
||||
<Badge variant="outline">
|
||||
{chatStore.totalTokens.toString()}
|
||||
</Badge>
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<EllipsisIcon />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<p>
|
||||
Tokens: {chatStore.totalTokens}/{chatStore.maxTokens}
|
||||
</p>
|
||||
<p>
|
||||
Cut(s): {chatStore.postBeginIndex}/
|
||||
{chatStore.history.filter(({ hide }) => !hide).length}
|
||||
</p>
|
||||
<p>
|
||||
Cost: ${chatStore.cost?.toFixed(4)} / $
|
||||
{getTotalCost().toFixed(2)}
|
||||
</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="hidden lg:inline-grid">
|
||||
<Menubar>
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger>
|
||||
<WholeWordIcon className="w-4 h-4 mr-2" />{" "}
|
||||
{chatStore.totalTokens}
|
||||
<CircleDollarSignIcon className="w-4 h-4 mx-2" />
|
||||
{chatStore.cost?.toFixed(4)}
|
||||
</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem>
|
||||
<RulerIcon className="w-4 h-4 mr-2" />
|
||||
Max Length: {chatStore.maxTokens}
|
||||
</MenubarItem>
|
||||
<MenubarItem>
|
||||
<ReceiptIcon className="w-4 h-4 mr-2" />
|
||||
Price:{" "}
|
||||
{models[chatStore.model]?.price?.prompt * 1000 * 1000}$
|
||||
/ 1M input tokens
|
||||
</MenubarItem>
|
||||
<MenubarItem>
|
||||
<WalletIcon className="w-4 h-4 mr-2" />
|
||||
Total: {getTotalCost().toFixed(2)}$
|
||||
</MenubarItem>
|
||||
<MenubarItem>
|
||||
<ArrowUpDownIcon className="w-4 h-4 mr-2" />
|
||||
{chatStore.streamMode ? (
|
||||
<>
|
||||
<span>{Tr("STREAM")}</span>·
|
||||
<span style={{ color: "gray" }}>{Tr("FETCH")}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span style={{ color: "gray" }}>
|
||||
{Tr("STREAM")}
|
||||
</span>
|
||||
·<span>{Tr("FETCH")}</span>
|
||||
</>
|
||||
)}
|
||||
</MenubarItem>
|
||||
<MenubarItem>
|
||||
<ScissorsIcon className="w-4 h-4 mr-2" />
|
||||
{chatStore.postBeginIndex} / {chatStore.history.length}
|
||||
</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem disabled>
|
||||
Switch to Model (TODO):
|
||||
</MenubarItem>
|
||||
<MenubarCheckboxItem checked>gpt-4o</MenubarCheckboxItem>
|
||||
<MenubarCheckboxItem>gpt-o1</MenubarCheckboxItem>
|
||||
<MenubarCheckboxItem>gpt-o1-mini</MenubarCheckboxItem>
|
||||
<MenubarCheckboxItem>gpt-o3</MenubarCheckboxItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
</Menubar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex ml-auto gap-2 px-3">
|
||||
<Settings />
|
||||
<Search />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
91
src/components/setAPIsTemplate.tsx
Normal file
91
src/components/setAPIsTemplate.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { TemplateAPI } from "@/types/chatstore";
|
||||
import { Tr } from "@/translate";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { SaveIcon } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
temps: TemplateAPI[];
|
||||
setTemps: (temps: TemplateAPI[]) => void;
|
||||
label: string;
|
||||
endpoint: string;
|
||||
APIkey: string;
|
||||
}
|
||||
export function SetAPIsTemplate({
|
||||
endpoint,
|
||||
APIkey,
|
||||
temps: temps,
|
||||
setTemps: setTemps,
|
||||
label,
|
||||
}: Props) {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">{Tr(`Save ${label}`)}</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Save {label} as Template</DialogTitle>
|
||||
<DialogDescription>
|
||||
Once saved, you can easily access your templates from the dropdown
|
||||
menu.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="grid flex-1 gap-2">
|
||||
<Label htmlFor="templateName" className="sr-only">
|
||||
Name
|
||||
</Label>
|
||||
<Input id="templateName" placeholder="Type Something..." />
|
||||
<Label id="templateNameError" className="text-red-600"></Label>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="sm:justify-start">
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="px-3"
|
||||
onClick={() => {
|
||||
const name = document.getElementById(
|
||||
"templateName"
|
||||
) as HTMLInputElement;
|
||||
if (!name.value) {
|
||||
const errorLabel = document.getElementById(
|
||||
"templateNameError"
|
||||
) as HTMLLabelElement;
|
||||
if (errorLabel) {
|
||||
errorLabel.textContent = "Template name is required.";
|
||||
}
|
||||
return;
|
||||
}
|
||||
const temp: TemplateAPI = {
|
||||
name: name.value,
|
||||
endpoint,
|
||||
key: APIkey,
|
||||
};
|
||||
temps.push(temp);
|
||||
setTemps([...temps]);
|
||||
}}
|
||||
>
|
||||
<SaveIcon className="w-4 h-4" /> Save
|
||||
<span className="sr-only">Save</span>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
113
src/listAPIs.tsx
113
src/listAPIs.tsx
@@ -1,113 +0,0 @@
|
||||
import { ChatStore, TemplateAPI } from "@/types/chatstore";
|
||||
import { Tr } from "@/translate";
|
||||
|
||||
import {
|
||||
NavigationMenu,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuList,
|
||||
NavigationMenuTrigger,
|
||||
NavigationMenuViewport,
|
||||
} from "@/components/ui/navigation-menu";
|
||||
import { Button } from "./components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useContext } from "react";
|
||||
import { AppContext } from "./pages/App";
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
apiField: string;
|
||||
keyField: string;
|
||||
}
|
||||
export function ListAPIs({ label, apiField, keyField }: Props) {
|
||||
const ctx = useContext(AppContext);
|
||||
if (ctx === null) return <></>;
|
||||
|
||||
return (
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuTrigger>
|
||||
{label}{" "}
|
||||
<span className="hidden lg:inline">
|
||||
{ctx.templateAPIs.find(
|
||||
(t) =>
|
||||
ctx.chatStore[apiField as keyof ChatStore] === t.endpoint &&
|
||||
ctx.chatStore[keyField as keyof ChatStore] === t.key
|
||||
)?.name &&
|
||||
`: ${
|
||||
ctx.templateAPIs.find(
|
||||
(t) =>
|
||||
ctx.chatStore[apiField as keyof ChatStore] === t.endpoint &&
|
||||
ctx.chatStore[keyField as keyof ChatStore] === t.key
|
||||
)?.name
|
||||
}`}
|
||||
</span>
|
||||
</NavigationMenuTrigger>
|
||||
<NavigationMenuContent>
|
||||
<ul className="grid w-[400px] gap-3 p-4 md:w-[500px] md:grid-cols-2 lg:w-[600px] ">
|
||||
{ctx.templateAPIs.map((t, index) => (
|
||||
<li>
|
||||
<NavigationMenuLink asChild>
|
||||
<a
|
||||
onClick={() => {
|
||||
// @ts-ignore
|
||||
ctx.chatStore[apiField as keyof ChatStore] = t.endpoint;
|
||||
// @ts-ignore
|
||||
ctx.chatStore[keyField] = t.key;
|
||||
ctx.setChatStore({ ...ctx.chatStore });
|
||||
}}
|
||||
className={cn(
|
||||
"block select-none space-y-1 rounded-md p-3 leading-none no-underline outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground",
|
||||
ctx.chatStore[apiField as keyof ChatStore] === t.endpoint &&
|
||||
ctx.chatStore[keyField as keyof ChatStore] === t.key
|
||||
? "bg-accent text-accent-foreground"
|
||||
: ""
|
||||
)}
|
||||
>
|
||||
<div className="text-sm font-medium leading-none">
|
||||
{t.name}
|
||||
</div>
|
||||
<p className="line-clamp-2 text-sm leading-snug text-muted-foreground">
|
||||
{new URL(t.endpoint).host}
|
||||
</p>
|
||||
</a>
|
||||
</NavigationMenuLink>
|
||||
<div className="mt-2 flex justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const name = prompt(`Give **${label}** template a name`);
|
||||
if (!name) return;
|
||||
t.name = name;
|
||||
ctx.setTemplateAPIs(structuredClone(ctx.templateAPIs));
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (
|
||||
!confirm(
|
||||
`Are you sure to delete this **${label}** template?`
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
ctx.templateAPIs.splice(index, 1);
|
||||
ctx.setTemplateAPIs(structuredClone(ctx.templateAPIs));
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</NavigationMenuContent>
|
||||
</NavigationMenuItem>
|
||||
);
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import { ChatStore, TemplateTools } from "@/types/chatstore";
|
||||
import { Tr } from "@/translate";
|
||||
import {
|
||||
NavigationMenu,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuList,
|
||||
NavigationMenuTrigger,
|
||||
NavigationMenuViewport,
|
||||
} from "@/components/ui/navigation-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "./components/ui/button";
|
||||
import { useContext } from "react";
|
||||
import { AppContext } from "./pages/App";
|
||||
|
||||
export function ListToolsTempaltes() {
|
||||
const ctx = useContext(AppContext);
|
||||
if (!ctx) return <div>error</div>;
|
||||
|
||||
const { chatStore, setChatStore } = ctx;
|
||||
|
||||
return (
|
||||
<NavigationMenuItem className="p-3">
|
||||
<NavigationMenuTrigger>
|
||||
<span>{Tr(`Saved tools templates`)}</span>
|
||||
<Button
|
||||
variant="link"
|
||||
className="ml-2 text-sm"
|
||||
onClick={() => {
|
||||
chatStore.toolsString = "";
|
||||
setChatStore({ ...chatStore });
|
||||
}}
|
||||
>
|
||||
{Tr(`Clear`)}
|
||||
</Button>
|
||||
</NavigationMenuTrigger>
|
||||
<NavigationMenuContent>
|
||||
<ul className="grid w-[400px] gap-3 p-4 md:w-[500px] md:grid-cols-2 lg:w-[600px]">
|
||||
{ctx.templateTools.map((t, index) => (
|
||||
<li key={index}>
|
||||
<NavigationMenuLink asChild>
|
||||
<a
|
||||
onClick={() => {
|
||||
chatStore.toolsString = t.toolsString;
|
||||
setChatStore({ ...chatStore });
|
||||
}}
|
||||
className={cn(
|
||||
"block select-none space-y-1 rounded-md p-3 leading-none no-underline outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground",
|
||||
chatStore.toolsString === t.toolsString
|
||||
? "bg-accent text-accent-foreground"
|
||||
: ""
|
||||
)}
|
||||
>
|
||||
<div className="text-sm font-medium leading-none">
|
||||
{t.name}
|
||||
</div>
|
||||
<p className="line-clamp-2 text-sm leading-snug text-muted-foreground">
|
||||
{t.toolsString}
|
||||
</p>
|
||||
</a>
|
||||
</NavigationMenuLink>
|
||||
<div className="mt-2 flex justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const name = prompt(`Give **tools** template a name`);
|
||||
if (!name) return;
|
||||
t.name = name;
|
||||
ctx.setTemplateTools(structuredClone(ctx.templateTools));
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (
|
||||
!confirm(
|
||||
`Are you sure to delete this **tools** template?`
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
ctx.templateTools.splice(index, 1);
|
||||
ctx.setTemplateTools(structuredClone(ctx.templateTools));
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</NavigationMenuContent>
|
||||
</NavigationMenuItem>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { App } from "@/pages/App";
|
||||
import { Tr, langCodeContext, LANG_OPTIONS } from "@/translate";
|
||||
import { SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { ThemeProvider } from "@/components/ThemeProvider";
|
||||
|
||||
function Base() {
|
||||
const [langCode, _setLangCode] = useState("en-US");
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { ChatStoreMessage } from "@/types/chatstore";
|
||||
import Markdown from "react-markdown";
|
||||
|
||||
interface Props {
|
||||
chat: ChatStoreMessage;
|
||||
renderMarkdown: boolean;
|
||||
}
|
||||
export function MessageDetail({ chat, renderMarkdown }: Props) {
|
||||
if (typeof chat.content === "string") {
|
||||
return <div></div>;
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
{chat.content.map((mdt) =>
|
||||
mdt.type === "text" ? (
|
||||
chat.hide ? (
|
||||
mdt.text?.split("\n")[0].slice(0, 16) + " ..."
|
||||
) : renderMarkdown ? (
|
||||
<Markdown>{mdt.text}</Markdown>
|
||||
) : (
|
||||
mdt.text
|
||||
)
|
||||
) : (
|
||||
<img
|
||||
className="my-2 rounded-md max-w-64 max-h-64"
|
||||
src={mdt.image_url?.url}
|
||||
onClick={() => {
|
||||
window.open(mdt.image_url?.url, "_blank");
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { ChatStoreMessage } from "@/types/chatstore";
|
||||
import { getMessageText } from "@/chatgpt";
|
||||
import { Badge } from "./components/ui/badge";
|
||||
|
||||
interface Props {
|
||||
chat: ChatStoreMessage;
|
||||
}
|
||||
|
||||
export function MessageHide({ chat }: Props) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{getMessageText(chat).split("\n")[0].slice(0, 28)} ...</span>
|
||||
</div>
|
||||
<div className="flex mt-2 justify-center">
|
||||
<Badge variant="destructive">Removed from context</Badge>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { ChatStoreMessage } from "@/types/chatstore";
|
||||
|
||||
interface Props {
|
||||
chat: ChatStoreMessage;
|
||||
copyToClipboard: (text: string) => void;
|
||||
}
|
||||
export function MessageToolCall({ chat, copyToClipboard }: Props) {
|
||||
return (
|
||||
<div className="message-content">
|
||||
{chat.tool_calls?.map((tool_call) => (
|
||||
<div className="bg-blue-300 dark:bg-blue-800 p-1 rounded my-1">
|
||||
<strong>
|
||||
Tool Call ID:{" "}
|
||||
<span
|
||||
className="p-1 m-1 rounded cursor-pointer hover:opacity-50 hover:underline"
|
||||
onClick={() => copyToClipboard(String(tool_call.id))}
|
||||
>
|
||||
{tool_call?.id}
|
||||
</span>
|
||||
</strong>
|
||||
<p>Type: {tool_call?.type}</p>
|
||||
<p>
|
||||
Function:
|
||||
<span
|
||||
className="p-1 m-1 rounded cursor-pointer hover:opacity-50 hover:underline"
|
||||
onClick={() => copyToClipboard(tool_call.function.name)}
|
||||
>
|
||||
{tool_call.function.name}
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
Arguments:
|
||||
<span
|
||||
className="p-1 m-1 rounded cursor-pointer hover:opacity-50 hover:underline"
|
||||
onClick={() => copyToClipboard(tool_call.function.arguments)}
|
||||
>
|
||||
{tool_call.function.arguments}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
{/* [TODO] */}
|
||||
{chat.content as string}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { ChatStoreMessage } from "@/types/chatstore";
|
||||
|
||||
interface Props {
|
||||
chat: ChatStoreMessage;
|
||||
copyToClipboard: (text: string) => void;
|
||||
}
|
||||
export function MessageToolResp({ chat, copyToClipboard }: Props) {
|
||||
return (
|
||||
<div className="message-content">
|
||||
<div className="bg-blue-300 dark:bg-blue-800 p-1 rounded my-1">
|
||||
<strong>
|
||||
Tool Response ID:{" "}
|
||||
<span
|
||||
className="p-1 m-1 rounded cursor-pointer hover:opacity-50 hover:underline"
|
||||
onClick={() => copyToClipboard(String(chat.tool_call_id))}
|
||||
>
|
||||
{chat.tool_call_id}
|
||||
</span>
|
||||
</strong>
|
||||
{/* [TODO] */}
|
||||
<p>{chat.content as string}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -113,7 +113,9 @@ import {
|
||||
RulerIcon,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ModeToggle } from "@/components/mode-toggle";
|
||||
import { ModeToggle } from "@/components/ModeToggle";
|
||||
|
||||
import Navbar from "@/components/navbar";
|
||||
|
||||
export function App() {
|
||||
// init selected index
|
||||
@@ -213,6 +215,10 @@ export function App() {
|
||||
const newKey = await (await db).add(STORAGE_NAME, newChatStore(chatStore));
|
||||
setSelectedChatIndex(newKey as number);
|
||||
setAllChatStoreIndexes(await (await db).getAllKeys(STORAGE_NAME));
|
||||
toast({
|
||||
title: "New chat session created",
|
||||
description: `A new chat session (ID. ${newKey}) has been created.`,
|
||||
});
|
||||
};
|
||||
const handleNewChatStore = async () => {
|
||||
return handleNewChatStoreWithOldOne(chatStore);
|
||||
@@ -356,7 +362,27 @@ export function App() {
|
||||
console.log("[PERFORMANCE!] reading localStorage");
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppContext.Provider
|
||||
value={{
|
||||
db,
|
||||
chatStore,
|
||||
setChatStore,
|
||||
selectedChatIndex,
|
||||
setSelectedChatIndex,
|
||||
templates,
|
||||
setTemplates,
|
||||
templateAPIs,
|
||||
setTemplateAPIs,
|
||||
templateAPIsWhisper,
|
||||
setTemplateAPIsWhisper,
|
||||
templateAPIsTTS,
|
||||
setTemplateAPIsTTS,
|
||||
templateAPIsImageGen,
|
||||
setTemplateAPIsImageGen,
|
||||
templateTools,
|
||||
setTemplateTools,
|
||||
}}
|
||||
>
|
||||
<Sidebar>
|
||||
<SidebarHeader>
|
||||
<Button onClick={handleNewChatStore}>
|
||||
@@ -392,6 +418,7 @@ export function App() {
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<ModeToggle />
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive">{Tr("DEL")}</Button>
|
||||
@@ -422,126 +449,9 @@ export function App() {
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
<SidebarInset>
|
||||
<header className="flex sticky top-0 bg-background h-16 shrink-0 items-center gap-2 border-b z-50">
|
||||
<div className="flex items-center gap-2 px-3">
|
||||
<SidebarTrigger />
|
||||
<Separator orientation="vertical" className="mr-2 h-4" />
|
||||
<h1 className="text-lg font-bold">{chatStore.model}</h1>
|
||||
<div className="flex justify-between items-center gap-2">
|
||||
<div>
|
||||
<div className="dropdown lg:hidden flex items-center gap-2">
|
||||
<Badge variant="outline">
|
||||
{chatStore.totalTokens.toString()}
|
||||
</Badge>
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<EllipsisIcon />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<p>
|
||||
Tokens: {chatStore.totalTokens}/{chatStore.maxTokens}
|
||||
</p>
|
||||
<p>
|
||||
Cut(s): {chatStore.postBeginIndex}/
|
||||
{chatStore.history.filter(({ hide }) => !hide).length}
|
||||
</p>
|
||||
<p>
|
||||
Cost: ${chatStore.cost?.toFixed(4)} / $
|
||||
{getTotalCost().toFixed(2)}
|
||||
</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="hidden lg:inline-grid">
|
||||
<Menubar>
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger>
|
||||
<WholeWordIcon className="w-4 h-4 mr-2" />{" "}
|
||||
{chatStore.totalTokens}
|
||||
<CircleDollarSignIcon className="w-4 h-4 mx-2" />
|
||||
{chatStore.cost?.toFixed(4)}
|
||||
</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem>
|
||||
<RulerIcon className="w-4 h-4 mr-2" />
|
||||
Max Length: {chatStore.maxTokens}
|
||||
</MenubarItem>
|
||||
<MenubarItem>
|
||||
<ReceiptIcon className="w-4 h-4 mr-2" />
|
||||
Price:{" "}
|
||||
{models[chatStore.model]?.price?.prompt * 1000 * 1000}
|
||||
$ / 1M input tokens
|
||||
</MenubarItem>
|
||||
<MenubarItem>
|
||||
<WalletIcon className="w-4 h-4 mr-2" />
|
||||
Total: {getTotalCost().toFixed(2)}$
|
||||
</MenubarItem>
|
||||
<MenubarItem>
|
||||
<ArrowUpDownIcon className="w-4 h-4 mr-2" />
|
||||
{chatStore.streamMode ? (
|
||||
<>
|
||||
<span>{Tr("STREAM")}</span>·
|
||||
<span style={{ color: "gray" }}>
|
||||
{Tr("FETCH")}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span style={{ color: "gray" }}>
|
||||
{Tr("STREAM")}
|
||||
</span>
|
||||
·<span>{Tr("FETCH")}</span>
|
||||
</>
|
||||
)}
|
||||
</MenubarItem>
|
||||
<MenubarItem>
|
||||
<ScissorsIcon className="w-4 h-4 mr-2" />
|
||||
{chatStore.postBeginIndex} /{" "}
|
||||
{chatStore.history.length}
|
||||
</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem disabled>
|
||||
Switch to Model (TODO):
|
||||
</MenubarItem>
|
||||
<MenubarCheckboxItem checked>
|
||||
gpt-4o
|
||||
</MenubarCheckboxItem>
|
||||
<MenubarCheckboxItem>gpt-o1</MenubarCheckboxItem>
|
||||
<MenubarCheckboxItem>gpt-o1-mini</MenubarCheckboxItem>
|
||||
<MenubarCheckboxItem>gpt-o3</MenubarCheckboxItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
</Menubar>
|
||||
</div>
|
||||
</div>
|
||||
<ModeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<AppContext.Provider
|
||||
value={{
|
||||
db,
|
||||
chatStore,
|
||||
setChatStore,
|
||||
selectedChatIndex,
|
||||
setSelectedChatIndex,
|
||||
templates,
|
||||
setTemplates,
|
||||
templateAPIs,
|
||||
setTemplateAPIs,
|
||||
templateAPIsWhisper,
|
||||
setTemplateAPIsWhisper,
|
||||
templateAPIsTTS,
|
||||
setTemplateAPIsTTS,
|
||||
templateAPIsImageGen,
|
||||
setTemplateAPIsImageGen,
|
||||
templateTools,
|
||||
setTemplateTools,
|
||||
}}
|
||||
>
|
||||
<Navbar />
|
||||
<ChatBOX />
|
||||
</AppContext.Provider>
|
||||
</SidebarInset>
|
||||
</>
|
||||
</AppContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { IDBPDatabase } from "idb";
|
||||
import { useContext, useRef } from "react";
|
||||
import { useEffect, useState, Dispatch } from "react";
|
||||
import { Tr, langCodeContext, LANG_OPTIONS } from "@/translate";
|
||||
import { addTotalCost, getTotalCost } from "@/utils/totalCost";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Tr } from "@/translate";
|
||||
import { addTotalCost } from "@/utils/totalCost";
|
||||
import ChatGPT, {
|
||||
calculate_token_length,
|
||||
FetchResponse,
|
||||
@@ -12,33 +11,17 @@ import ChatGPT, {
|
||||
Logprobs,
|
||||
Usage,
|
||||
} from "@/chatgpt";
|
||||
import {
|
||||
ChatStore,
|
||||
ChatStoreMessage,
|
||||
TemplateChatStore,
|
||||
TemplateAPI,
|
||||
TemplateTools,
|
||||
} from "../types/chatstore";
|
||||
import Message from "@/message";
|
||||
import { ChatStoreMessage } from "../types/chatstore";
|
||||
import Message from "@/components/MessageBubble";
|
||||
import { models } from "@/types/models";
|
||||
import Settings from "@/components/Settings";
|
||||
import { AddImage } from "@/addImage";
|
||||
import { ListAPIs } from "@/listAPIs";
|
||||
import { ListToolsTempaltes } from "@/listToolsTemplates";
|
||||
import { autoHeight } from "@/textarea";
|
||||
import Search from "@/search";
|
||||
import Templates from "@/components/Templates";
|
||||
import { ImageUploadDrawer } from "@/components/ImageUploadDrawer";
|
||||
import { autoHeight } from "@/utils/textAreaHelp";
|
||||
import VersionHint from "@/components/VersionHint";
|
||||
import StatusBar from "@/components/StatusBar";
|
||||
import WhisperButton from "@/components/WhisperButton";
|
||||
import AddToolMsg from "./AddToolMsg";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ChatInput } from "@/components/ui/chat/chat-input";
|
||||
import {
|
||||
ChatBubble,
|
||||
ChatBubbleAvatar,
|
||||
ChatBubbleMessage,
|
||||
ChatBubbleAction,
|
||||
ChatBubbleActionWrapper,
|
||||
@@ -46,33 +29,19 @@ import {
|
||||
|
||||
import { ChatMessageList } from "@/components/ui/chat/chat-message-list";
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
ArrowUpIcon,
|
||||
ArrowDownToDotIcon,
|
||||
CornerDownLeftIcon,
|
||||
CornerLeftUpIcon,
|
||||
CornerUpLeftIcon,
|
||||
GlobeIcon,
|
||||
ImageIcon,
|
||||
CornerRightUpIcon,
|
||||
InfoIcon,
|
||||
KeyIcon,
|
||||
SearchIcon,
|
||||
Settings2,
|
||||
Settings2Icon,
|
||||
ScissorsIcon,
|
||||
} from "lucide-react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
NavigationMenu,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuList,
|
||||
NavigationMenuTrigger,
|
||||
navigationMenuTriggerStyle,
|
||||
} from "@/components/ui/navigation-menu";
|
||||
|
||||
import { AppContext } from "./App";
|
||||
import { addToRange } from "react-day-picker";
|
||||
import APIListMenu from "@/components/ListAPI";
|
||||
import { ImageGenDrawer } from "@/components/ImageGenDrawer";
|
||||
|
||||
export default function ChatBOX() {
|
||||
const ctx = useContext(AppContext);
|
||||
@@ -89,11 +58,10 @@ export default function ChatBOX() {
|
||||
const [inputMsg, setInputMsg] = useState("");
|
||||
const [images, setImages] = useState<MessageDetail[]>([]);
|
||||
const [showAddImage, setShowAddImage] = useState(false);
|
||||
const [showGenImage, setShowGenImage] = useState(false);
|
||||
const [showGenerating, setShowGenerating] = useState(false);
|
||||
const [generatingMessage, setGeneratingMessage] = useState("");
|
||||
const [showRetry, setShowRetry] = useState(false);
|
||||
const [showAddToolMsg, setShowAddToolMsg] = useState(false);
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
let default_follow = localStorage.getItem("follow");
|
||||
if (default_follow === null) {
|
||||
default_follow = "true";
|
||||
@@ -423,116 +391,38 @@ export default function ChatBOX() {
|
||||
}
|
||||
};
|
||||
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const userInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col p-2 gap-2 w-full">
|
||||
<div className="flex items-center gap-2 justify-between">
|
||||
{true && <Settings setShow={setShowSettings} />}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setShowSearch(true)}
|
||||
>
|
||||
<SearchIcon />
|
||||
</Button>
|
||||
</div>
|
||||
{showSearch && <Search show={showSearch} setShow={setShowSearch} />}
|
||||
|
||||
{!chatStore.apiKey && (
|
||||
<Alert>
|
||||
<KeyIcon className="h-4 w-4" />
|
||||
<AlertTitle>Heads up!</AlertTitle>
|
||||
<AlertDescription>
|
||||
{Tr("Please click above to set")} (OpenAI) API KEY
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{!chatStore.apiEndpoint && (
|
||||
<Alert>
|
||||
<GlobeIcon className="h-4 w-4" />
|
||||
<AlertTitle>Heads up!</AlertTitle>
|
||||
<AlertDescription>
|
||||
{Tr("Please click above to set")} API Endpoint
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<NavigationMenu>
|
||||
<NavigationMenuList>
|
||||
{ctx.templateAPIs.length > 0 && (
|
||||
<ListAPIs label="API" apiField="apiEndpoint" keyField="apiKey" />
|
||||
)}
|
||||
{ctx.templateAPIsWhisper.length > 0 && (
|
||||
<ListAPIs
|
||||
label="Whisper API"
|
||||
apiField="whisper_api"
|
||||
keyField="whisper_key"
|
||||
/>
|
||||
)}
|
||||
{ctx.templateAPIsTTS.length > 0 && (
|
||||
<ListAPIs label="TTS API" apiField="tts_api" keyField="tts_key" />
|
||||
)}
|
||||
{ctx.templateAPIsImageGen.length > 0 && (
|
||||
<ListAPIs
|
||||
label="Image Gen API"
|
||||
apiField="image_gen_api"
|
||||
keyField="image_gen_key"
|
||||
/>
|
||||
)}
|
||||
{ctx.templateTools.length > 0 && <ListToolsTempaltes />}
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
</div>
|
||||
<APIListMenu />
|
||||
<div className="grow flex flex-col p-2 w-full">
|
||||
<ChatMessageList>
|
||||
{chatStore.history.filter((msg) => !msg.example).length == 0 && (
|
||||
<div className="bg-base-200 break-all p-3 my-3 text-left">
|
||||
<h2>
|
||||
<span>{Tr("Saved prompt templates")}</span>
|
||||
<Button
|
||||
variant="link"
|
||||
className="mx-2"
|
||||
onClick={() => {
|
||||
chatStore.systemMessageContent = "";
|
||||
chatStore.toolsString = "";
|
||||
chatStore.history = [];
|
||||
setChatStore({ ...chatStore });
|
||||
}}
|
||||
>
|
||||
{Tr("Reset Current")}
|
||||
</Button>
|
||||
</h2>
|
||||
<div className="divider"></div>
|
||||
<div className="flex flex-wrap">
|
||||
<Templates />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{chatStore.history.length === 0 && (
|
||||
<Alert variant="default" className="my-3">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle>{Tr("No chat history here")}</AlertTitle>
|
||||
<AlertTitle>
|
||||
{Tr("This is a new chat session, start by typing a message")}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="flex flex-col gap-1 mt-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings2Icon className="h-4 w-4" />
|
||||
<CornerRightUpIcon className="h-4 w-4" />
|
||||
<span>
|
||||
{Tr("Model")}: {chatStore.model}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ArrowUpIcon className="h-4 w-4" />
|
||||
<span>
|
||||
{Tr("Click above to change the settings of this chat")}
|
||||
{Tr(
|
||||
"Settings button located at the top right corner can be used to change the settings of this chat"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CornerLeftUpIcon className="h-4 w-4" />
|
||||
<span>{Tr("Click the corner to create a new chat")}</span>
|
||||
<span>
|
||||
{Tr(
|
||||
"'New' button located at the top left corner can be used to create a new chat"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangleIcon className="h-4 w-4" />
|
||||
<ArrowDownToDotIcon className="h-4 w-4" />
|
||||
<span>
|
||||
{Tr(
|
||||
"All chat history and settings are stored in the local browser"
|
||||
@@ -549,7 +439,8 @@ export default function ChatBOX() {
|
||||
<div className="text-sm font-bold">System Prompt</div>
|
||||
<div
|
||||
className="cursor-pointer"
|
||||
onClick={() => setShowSettings(true)}
|
||||
// onClick={() => setShowSettings(true)}
|
||||
// TODO: add a button to show settings
|
||||
>
|
||||
{chatStore.systemMessageContent}
|
||||
</div>
|
||||
@@ -558,14 +449,19 @@ export default function ChatBOX() {
|
||||
<ChatBubbleActionWrapper>
|
||||
<ChatBubbleAction
|
||||
className="size-7"
|
||||
icon={<Settings2Icon className="size-4" />}
|
||||
onClick={() => setShowSettings(true)}
|
||||
icon={<ScissorsIcon className="size-4" />}
|
||||
onClick={() => {
|
||||
chatStore.systemMessageContent = "";
|
||||
chatStore.toolsString = "";
|
||||
chatStore.history = [];
|
||||
setChatStore({ ...chatStore });
|
||||
}}
|
||||
/>
|
||||
</ChatBubbleActionWrapper>
|
||||
</ChatBubble>
|
||||
)}
|
||||
{chatStore.history.map((_, messageIndex) => (
|
||||
<Message messageIndex={messageIndex} />
|
||||
<Message messageIndex={messageIndex} key={messageIndex} />
|
||||
))}
|
||||
{showGenerating && (
|
||||
<ChatBubble variant="received">
|
||||
@@ -686,23 +582,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"
|
||||
/>
|
||||
<div className="flex items-center p-3 pt-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => setShowAddImage(true)}
|
||||
disabled={showGenerating}
|
||||
>
|
||||
<ImageIcon className="size-4" />
|
||||
<span className="sr-only">Add Image</span>
|
||||
</Button>
|
||||
<ImageUploadDrawer
|
||||
images={images}
|
||||
setImages={setImages}
|
||||
disableFactor={[showGenerating]}
|
||||
/>
|
||||
<ImageGenDrawer disableFactor={[showGenerating]} />
|
||||
|
||||
{chatStore.whisper_api && chatStore.whisper_key && (
|
||||
<>
|
||||
<WhisperButton inputMsg={inputMsg} setInputMsg={setInputMsg} />
|
||||
<span className="sr-only">Use Microphone</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -720,13 +607,6 @@ export default function ChatBOX() {
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<AddImage
|
||||
setShowAddImage={setShowAddImage}
|
||||
images={images}
|
||||
showAddImage={showAddImage}
|
||||
setImages={setImages}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { TemplateAPI } from "@/types/chatstore";
|
||||
import { Tr } from "@/translate";
|
||||
import { Button } from "./components/ui/button";
|
||||
|
||||
interface Props {
|
||||
tmps: TemplateAPI[];
|
||||
setTmps: (tmps: TemplateAPI[]) => void;
|
||||
label: string;
|
||||
endpoint: string;
|
||||
APIkey: string;
|
||||
}
|
||||
export function SetAPIsTemplate({
|
||||
endpoint,
|
||||
APIkey,
|
||||
tmps,
|
||||
setTmps,
|
||||
label,
|
||||
}: Props) {
|
||||
return (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
onClick={() => {
|
||||
const name = prompt(`Give this **${label}** template a name:`);
|
||||
if (!name) {
|
||||
alert("No template name specified");
|
||||
return;
|
||||
}
|
||||
const tmp: TemplateAPI = {
|
||||
name,
|
||||
endpoint,
|
||||
key: APIkey,
|
||||
};
|
||||
tmps.push(tmp);
|
||||
setTmps([...tmps]);
|
||||
}}
|
||||
>
|
||||
{Tr(`Save ${label}`)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -11,9 +11,17 @@ const LANG_MAP: Record<string, string> = {
|
||||
cost: "消费",
|
||||
stream: "流式返回",
|
||||
fetch: "一次获取",
|
||||
Tools: "工具",
|
||||
Clear: "清空",
|
||||
"saved api templates": "已保存的 API 模板",
|
||||
"saved prompt templates": "已保存的提示模板",
|
||||
"no chat history here": "暂无历史对话记录",
|
||||
"This is a new chat session, start by typing a message":
|
||||
"这是一个新对话,开始输入消息",
|
||||
"Settings button located at the top right corner can be used to change the settings of this chat":
|
||||
"右上角的设置按钮可用于更改此对话的设置",
|
||||
"'New' button located at the top left corner can be used to create a new chat":
|
||||
"左上角的 '新' 按钮可用于创建新对话",
|
||||
"click above to change the settings of this chat":
|
||||
"点击上方更改此对话的参数(请勿泄漏)",
|
||||
"click the NEW to create a new chat": "点击左上角 NEW 新建对话",
|
||||
|
||||
97
src/tts.tsx
97
src/tts.tsx
@@ -1,97 +0,0 @@
|
||||
import { SpeakerWaveIcon } from "@heroicons/react/24/outline";
|
||||
import { useContext, useMemo, useState } from "react";
|
||||
|
||||
import { addTotalCost } from "@/utils/totalCost";
|
||||
import { ChatStore, ChatStoreMessage } from "@/types/chatstore";
|
||||
import { Message, getMessageText } from "@/chatgpt";
|
||||
import { AudioLinesIcon, LoaderCircleIcon } from "lucide-react";
|
||||
import { Button } from "./components/ui/button";
|
||||
import { AppContext } from "./pages/App";
|
||||
|
||||
interface TTSProps {
|
||||
chat: ChatStoreMessage;
|
||||
}
|
||||
interface TTSPlayProps {
|
||||
chat: ChatStoreMessage;
|
||||
}
|
||||
export function TTSPlay(props: TTSPlayProps) {
|
||||
const src = useMemo(() => {
|
||||
if (props.chat.audio instanceof Blob) {
|
||||
return URL.createObjectURL(props.chat.audio);
|
||||
}
|
||||
return "";
|
||||
}, [props.chat.audio]);
|
||||
|
||||
if (props.chat.hide) {
|
||||
return <></>;
|
||||
}
|
||||
if (props.chat.audio instanceof Blob) {
|
||||
return <audio className="w-64" src={src} controls />;
|
||||
}
|
||||
return <></>;
|
||||
}
|
||||
export default function TTSButton(props: TTSProps) {
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const ctx = useContext(AppContext);
|
||||
if (!ctx) return <div>error</div>;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
const api = ctx.chatStore.tts_api;
|
||||
const api_key = ctx.chatStore.tts_key;
|
||||
const model = "tts-1";
|
||||
const input = getMessageText(props.chat);
|
||||
const voice = ctx.chatStore.tts_voice;
|
||||
|
||||
const body: Record<string, any> = {
|
||||
model,
|
||||
input,
|
||||
voice,
|
||||
response_format: ctx.chatStore.tts_format || "mp3",
|
||||
};
|
||||
if (ctx.chatStore.tts_speed_enabled) {
|
||||
body["speed"] = ctx.chatStore.tts_speed;
|
||||
}
|
||||
|
||||
setGenerating(true);
|
||||
|
||||
fetch(api, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${api_key}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
.then((response) => response.blob())
|
||||
.then((blob) => {
|
||||
// update price
|
||||
const cost = (input.length * 0.015) / 1000;
|
||||
ctx.chatStore.cost += cost;
|
||||
addTotalCost(cost);
|
||||
ctx.setChatStore({ ...ctx.chatStore });
|
||||
|
||||
// save blob
|
||||
props.chat.audio = blob;
|
||||
ctx.setChatStore({ ...ctx.chatStore });
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const audio = new Audio(url);
|
||||
audio.play();
|
||||
})
|
||||
.finally(() => {
|
||||
setGenerating(false);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{generating ? (
|
||||
<LoaderCircleIcon className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<AudioLinesIcon className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
8
src/utils/isVailedJSON.ts
Normal file
8
src/utils/isVailedJSON.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export const isVailedJSON = (str: string): boolean => {
|
||||
try {
|
||||
JSON.parse(str);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
Reference in New Issue
Block a user