Merge pull request #10 from heimoshuiyu/dev

Major Structure Refactor and Feature Enhancements
This commit is contained in:
2025-01-06 14:15:07 +08:00
committed by GitHub
32 changed files with 1698 additions and 1628 deletions

View File

@@ -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>
);
}

View 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>
)}
</>
);
}

View 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
View 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;

View File

@@ -1,41 +1,239 @@
import { XMarkIcon } from "@heroicons/react/24/outline"; import { XMarkIcon } from "@heroicons/react/24/outline";
import Markdown from "react-markdown"; 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 { Tr } from "@/translate";
import { ChatStore, ChatStoreMessage } from "@/types/chatstore"; import { getMessageText } from "@/chatgpt";
import { calculate_token_length, getMessageText } from "@/chatgpt"; import { EditMessage } from "@/components/editMessage";
import TTSButton, { TTSPlay } from "@/tts"; import logprobToColor from "@/utils/logprob";
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 { import {
ChatBubble, ChatBubble,
ChatBubbleAvatar,
ChatBubbleMessage, ChatBubbleMessage,
ChatBubbleAction, ChatBubbleAction,
ChatBubbleActionWrapper, ChatBubbleActionWrapper,
} from "@/components/ui/chat/chat-bubble"; } 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 { useToast } from "@/hooks/use-toast";
import { import {
ClipboardIcon, ClipboardIcon,
PencilIcon, PencilIcon,
MessageSquareOffIcon, MessageSquareOffIcon,
MessageSquarePlusIcon, MessageSquarePlusIcon,
AudioLinesIcon,
LoaderCircleIcon,
} from "lucide-react"; } from "lucide-react";
import { AppContext } from "./pages/App"; import { AppContext } from "@/pages/App";
export const isVailedJSON = (str: string): boolean => { interface HideMessageProps {
try { chat: ChatStoreMessage;
JSON.parse(str); }
} catch (e) {
return false; 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 true; 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 }) { export default function Message(props: { messageIndex: number }) {
const ctx = useContext(AppContext); const ctx = useContext(AppContext);
@@ -45,47 +243,8 @@ export default function Message(props: { messageIndex: number }) {
const chat = chatStore.history[messageIndex]; const chat = chatStore.history[messageIndex];
const [showEdit, setShowEdit] = useState(false); const [showEdit, setShowEdit] = useState(false);
const [showCopiedHint, setShowCopiedHint] = useState(false);
const [renderMarkdown, setRenderWorkdown] = useState(false); const [renderMarkdown, setRenderWorkdown] = useState(false);
const [renderColor, setRenderColor] = 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 { toast } = useToast();
const copyToClipboard = async (text: string) => { 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 ( return (
<> <>
{chatStore.postBeginIndex !== 0 && {chatStore.postBeginIndex !== 0 &&

View 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>
);
}

View File

@@ -1,8 +1,7 @@
import { IDBPDatabase } from "idb";
import { useRef, useState, Dispatch, useContext } from "react"; import { useRef, useState, Dispatch, useContext } from "react";
import { ChatStore } from "@/types/chatstore"; import { ChatStore } from "@/types/chatstore";
import { MessageDetail } from "./chatgpt"; import { MessageDetail } from "../chatgpt";
import { import {
Dialog, Dialog,
@@ -16,15 +15,15 @@ import {
import { import {
Pagination, Pagination,
PaginationContent, PaginationContent,
PaginationEllipsis,
PaginationItem, PaginationItem,
PaginationLink,
PaginationNext, PaginationNext,
PaginationPrevious, PaginationPrevious,
} from "@/components/ui/pagination"; } from "@/components/ui/pagination";
import { Input } from "./components/ui/input"; import { Input } from "./ui/input";
import { AppContext } from "./pages/App"; import { AppContext } from "../pages/App";
import { Button } from "./ui/button";
import { SearchIcon } from "lucide-react";
interface ChatStoreSearchResult { interface ChatStoreSearchResult {
key: IDBValidKey; key: IDBValidKey;
@@ -33,10 +32,7 @@ interface ChatStoreSearchResult {
preview: string; preview: string;
} }
export default function Search(props: { export default function Search() {
show: boolean;
setShow: (show: boolean) => void;
}) {
const ctx = useContext(AppContext); const ctx = useContext(AppContext);
if (ctx === null) return <></>; if (ctx === null) return <></>;
const { setSelectedChatIndex, db } = ctx; const { setSelectedChatIndex, db } = ctx;
@@ -46,9 +42,15 @@ export default function Search(props: {
const [searchingNow, setSearchingNow] = useState<number>(0); const [searchingNow, setSearchingNow] = useState<number>(0);
const [pageIndex, setPageIndex] = useState<number>(0); const [pageIndex, setPageIndex] = useState<number>(0);
const searchAbortRef = useRef<AbortController | null>(null); const searchAbortRef = useRef<AbortController | null>(null);
const [open, setOpen] = useState<boolean>(false);
return ( 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%]"> <DialogContent className="sm:max-w-[80%]">
<DialogHeader> <DialogHeader>
<DialogTitle>Search</DialogTitle> <DialogTitle>Search</DialogTitle>
@@ -160,7 +162,7 @@ export default function Search(props: {
key={result.key as number} key={result.key as number}
onClick={() => { onClick={() => {
setSelectedChatIndex(parseInt(result.key.toString())); setSelectedChatIndex(parseInt(result.key.toString()));
props.setShow(false); setOpen(false);
}} }}
> >
<div className="m-1 p-1 font-bold"> <div className="m-1 p-1 font-bold">

View File

@@ -3,17 +3,12 @@ import { themeChange } from "theme-change";
import { useRef } from "react"; import { useRef } from "react";
import { useContext, useEffect, useState, Dispatch } from "react"; import { useContext, useEffect, useState, Dispatch } from "react";
import { clearTotalCost, getTotalCost } from "@/utils/totalCost"; import { clearTotalCost, getTotalCost } from "@/utils/totalCost";
import { import { ChatStore, TemplateChatStore, TemplateTools } from "@/types/chatstore";
ChatStore,
TemplateChatStore,
TemplateAPI,
TemplateTools,
} from "@/types/chatstore";
import { models } from "@/types/models"; import { models } from "@/types/models";
import { tr, Tr, langCodeContext, LANG_OPTIONS } from "@/translate"; import { tr, Tr, langCodeContext, LANG_OPTIONS } from "@/translate";
import { isVailedJSON } from "@/message"; import { isVailedJSON } from "@/utils/isVailedJSON";
import { SetAPIsTemplate } from "@/setAPIsTemplate"; import { SetAPIsTemplate } from "@/components/setAPIsTemplate";
import { autoHeight } from "@/textarea"; import { autoHeight } from "@/utils/textAreaHelp";
import { getDefaultParams } from "@/utils/getDefaultParam"; import { getDefaultParams } from "@/utils/getDefaultParam";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -53,6 +48,7 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { import {
Dialog, Dialog,
DialogClose,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
@@ -61,18 +57,20 @@ import {
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { import {
BanIcon, BanIcon,
CheckIcon, CheckIcon,
CircleEllipsisIcon, CircleEllipsisIcon,
CogIcon, CogIcon,
Ellipsis,
EyeIcon, EyeIcon,
InfoIcon, InfoIcon,
KeyIcon, KeyIcon,
ListIcon, ListIcon,
MoveHorizontalIcon, MoveHorizontalIcon,
SaveIcon,
TriangleAlertIcon,
} from "lucide-react"; } from "lucide-react";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Slider } from "@/components/ui/slider"; 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); const ctx = useContext(AppContext);
if (ctx === null) return <></>; if (ctx === null) return <></>;
@@ -497,13 +640,14 @@ export default (props: { setShow: Dispatch<boolean> }) => {
const [totalCost, setTotalCost] = useState(getTotalCost()); const [totalCost, setTotalCost] = useState(getTotalCost());
// @ts-ignore // @ts-ignore
const { langCode, setLangCode } = useContext(langCodeContext); const { langCode, setLangCode } = useContext(langCodeContext);
const [open, setOpen] = useState<boolean>(false);
useEffect(() => { useEffect(() => {
themeChange(false); themeChange(false);
const handleKeyPress = (event: any) => { const handleKeyPress = (event: any) => {
if (event.keyCode === 27) { if (event.keyCode === 27) {
// keyCode for ESC key is 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 }, []); // The empty dependency array ensures that the effect runs only once
return ( return (
<Sheet> <Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild> <SheetTrigger asChild>
<Button variant="outline" className="flex-grow"> <Button variant="outline" className="flex-grow">
{Tr("Settings")} {Tr("Settings")}
{(!ctx.chatStore.apiKey || !ctx.chatStore.apiEndpoint) && (
<TriangleAlertIcon className="w-4 h-4 ml-1 text-yellow-500" />
)}
</Button> </Button>
</SheetTrigger> </SheetTrigger>
<SheetContent className="flex flex-col overflow-scroll"> <SheetContent className="flex flex-col overflow-scroll">
@@ -576,25 +723,68 @@ export default (props: { setShow: Dispatch<boolean> }) => {
<div className="box"> <div className="box">
<div className="flex justify-evenly flex-wrap"> <div className="flex justify-evenly flex-wrap">
{ctx.chatStore.toolsString.trim() && ( {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 <Button
type="submit"
size="sm"
className="px-3"
onClick={() => { onClick={() => {
const name = prompt( const name = document.getElementById(
`Give this **Tools** template a name:` "toolsName" as string
); ) as HTMLInputElement;
if (!name) { if (!name.value) {
alert("No template name specified"); const errorLabel = document.getElementById(
"toolsNameError" as string
) as HTMLLabelElement;
if (errorLabel) {
errorLabel.textContent =
"Tool name is required.";
}
return; return;
} }
const newToolsTmp: TemplateTools = { const newToolsTmp: TemplateTools = {
name, name: name.value,
toolsString: ctx.chatStore.toolsString, toolsString: ctx.chatStore.toolsString,
}; };
ctx.templateTools.push(newToolsTmp); ctx.templateTools.push(newToolsTmp);
ctx.setTemplateTools([...ctx.templateTools]); ctx.setTemplateTools([...ctx.templateTools]);
}} }}
> >
{Tr(`Save Tools`)} <SaveIcon className="w-4 h-4" /> Save
<span className="sr-only">Save</span>
</Button> </Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)} )}
</div> </div>
</div> </div>
@@ -854,8 +1044,8 @@ export default (props: { setShow: Dispatch<boolean> }) => {
label="Chat API" label="Chat API"
endpoint={ctx.chatStore.apiEndpoint} endpoint={ctx.chatStore.apiEndpoint}
APIkey={ctx.chatStore.apiKey} APIkey={ctx.chatStore.apiKey}
tmps={ctx.templateAPIs} temps={ctx.templateAPIs}
setTmps={ctx.setTemplateAPIs} setTemps={ctx.setTemplateAPIs}
/> />
</CardContent> </CardContent>
</Card> </Card>
@@ -959,8 +1149,8 @@ export default (props: { setShow: Dispatch<boolean> }) => {
label="Whisper API" label="Whisper API"
endpoint={ctx.chatStore.whisper_api} endpoint={ctx.chatStore.whisper_api}
APIkey={ctx.chatStore.whisper_key} APIkey={ctx.chatStore.whisper_key}
tmps={ctx.templateAPIsWhisper} temps={ctx.templateAPIsWhisper}
setTmps={ctx.setTemplateAPIsWhisper} setTemps={ctx.setTemplateAPIsWhisper}
/> />
</CardContent> </CardContent>
</Card> </Card>
@@ -992,8 +1182,8 @@ export default (props: { setShow: Dispatch<boolean> }) => {
label="TTS API" label="TTS API"
endpoint={ctx.chatStore.tts_api} endpoint={ctx.chatStore.tts_api}
APIkey={ctx.chatStore.tts_key} APIkey={ctx.chatStore.tts_key}
tmps={ctx.templateAPIsTTS} temps={ctx.templateAPIsTTS}
setTmps={ctx.setTemplateAPIsTTS} setTemps={ctx.setTemplateAPIsTTS}
/> />
</CardContent> </CardContent>
</Card> </Card>
@@ -1117,13 +1307,76 @@ export default (props: { setShow: Dispatch<boolean> }) => {
label="Image Gen API" label="Image Gen API"
endpoint={ctx.chatStore.image_gen_api} endpoint={ctx.chatStore.image_gen_api}
APIkey={ctx.chatStore.image_gen_key} APIkey={ctx.chatStore.image_gen_key}
tmps={ctx.templateAPIsImageGen} temps={ctx.templateAPIsImageGen}
setTmps={ctx.setTemplateAPIsImageGen} setTemps={ctx.setTemplateAPIsImageGen}
/> />
</CardContent> </CardContent>
</Card> </Card>
</AccordionContent> </AccordionContent>
</AccordionItem> </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> </Accordion>
<div className="pt-4 space-y-2"> <div className="pt-4 space-y-2">
<p className="text-sm text-muted-foreground text-center"> <p className="text-sm text-muted-foreground text-center">

View File

@@ -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;

View File

@@ -1,6 +1,6 @@
import { createContext, useContext, useEffect, useState } from "react"; import { createContext, useContext, useEffect, useState } from "react";
type Theme = "dark" | "light" | "system"; export type Theme = "dark" | "light" | "system";
type ThemeProviderProps = { type ThemeProviderProps = {
children: React.ReactNode; children: React.ReactNode;

View File

@@ -1,15 +1,8 @@
import { createRef, useContext } from "react"; import { createRef, useContext } from "react";
import { ChatStore } from "@/types/chatstore"; import { useState, Dispatch } from "react";
import { useEffect, useState, Dispatch } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import { AudioWaveformIcon, CircleStopIcon, MicIcon } from "lucide-react";
AudioWaveform,
AudioWaveformIcon,
CircleStopIcon,
MicIcon,
VoicemailIcon,
} from "lucide-react";
import { AppContext } from "@/pages/App"; import { AppContext } from "@/pages/App";
const WhisperButton = (props: { const WhisperButton = (props: {
@@ -24,10 +17,12 @@ const WhisperButton = (props: {
const mediaRef = createRef(); const mediaRef = createRef();
const [isRecording, setIsRecording] = useState("Mic"); const [isRecording, setIsRecording] = useState("Mic");
return ( return (
<>
{chatStore.whisper_api && chatStore.whisper_key ? (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className={`m-1 p-1 ${isRecording !== "Mic" ? "animate-pulse" : ""}`} className={`${isRecording !== "Mic" ? "animate-pulse" : ""}`}
disabled={isRecording === "Transcribing"} disabled={isRecording === "Transcribing"}
ref={mediaRef as any} ref={mediaRef as any}
onClick={async (event) => { onClick={async (event) => {
@@ -146,6 +141,13 @@ const WhisperButton = (props: {
<AudioWaveformIcon /> <AudioWaveformIcon />
)} )}
</Button> </Button>
) : (
<Button variant="ghost" size="icon" disabled={true}>
<MicIcon />
</Button>
)}
<span className="sr-only">Use Microphone</span>
</>
); );
}; };

View File

@@ -1,8 +1,8 @@
import { useState, useEffect, Dispatch, useContext } from "react"; import { useState, useEffect, Dispatch, useContext } from "react";
import { Tr, langCodeContext, LANG_OPTIONS, tr } from "@/translate"; import { Tr, langCodeContext, LANG_OPTIONS, tr } from "@/translate";
import { ChatStore, ChatStoreMessage } from "@/types/chatstore"; import { ChatStore, ChatStoreMessage } from "@/types/chatstore";
import { EditMessageString } from "@/editMessageString"; import { EditMessageString } from "@/components/editMessageString";
import { EditMessageDetail } from "@/editMessageDetail"; import { EditMessageDetail } from "@/components/editMessageDetail";
import { import {
Dialog, Dialog,
@@ -12,8 +12,8 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Button } from "./components/ui/button"; import { Button } from "./ui/button";
import { AppContext } from "./pages/App"; import { AppContext } from "../pages/App";
interface EditMessageProps { interface EditMessageProps {
chat: ChatStoreMessage; chat: ChatStoreMessage;

View File

@@ -13,9 +13,9 @@ import {
DrawerTrigger, DrawerTrigger,
} from "@/components/ui/drawer"; } from "@/components/ui/drawer";
import { Button } from "./components/ui/button"; import { Button } from "./ui/button";
import { useContext } from "react"; import { useContext } from "react";
import { AppContext } from "./pages/App"; import { AppContext } from "../pages/App";
interface Props { interface Props {
chat: ChatStoreMessage; chat: ChatStoreMessage;

View File

@@ -1,11 +1,11 @@
import { ChatStore, ChatStoreMessage } from "@/types/chatstore"; import { ChatStore, ChatStoreMessage } from "@/types/chatstore";
import { isVailedJSON } from "@/message"; import { isVailedJSON } from "@/utils/isVailedJSON";
import { calculate_token_length } from "@/chatgpt"; import { calculate_token_length } from "@/chatgpt";
import { Tr } from "@/translate"; import { Tr } from "@/translate";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { useContext } from "react"; import { useContext } from "react";
import { AppContext } from "./pages/App"; import { AppContext } from "../pages/App";
interface Props { interface Props {
chat: ChatStoreMessage; chat: ChatStoreMessage;

View File

@@ -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
View 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;

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -5,7 +5,7 @@ import { App } from "@/pages/App";
import { Tr, langCodeContext, LANG_OPTIONS } from "@/translate"; import { Tr, langCodeContext, LANG_OPTIONS } from "@/translate";
import { SidebarProvider } from "@/components/ui/sidebar"; import { SidebarProvider } from "@/components/ui/sidebar";
import { Toaster } from "@/components/ui/toaster"; import { Toaster } from "@/components/ui/toaster";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/ThemeProvider";
function Base() { function Base() {
const [langCode, _setLangCode] = useState("en-US"); const [langCode, _setLangCode] = useState("en-US");

View File

@@ -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>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -113,7 +113,9 @@ import {
RulerIcon, RulerIcon,
} from "lucide-react"; } from "lucide-react";
import { Badge } from "@/components/ui/badge"; 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() { export function App() {
// init selected index // init selected index
@@ -213,6 +215,10 @@ export function App() {
const newKey = await (await db).add(STORAGE_NAME, newChatStore(chatStore)); const newKey = await (await db).add(STORAGE_NAME, newChatStore(chatStore));
setSelectedChatIndex(newKey as number); setSelectedChatIndex(newKey as number);
setAllChatStoreIndexes(await (await db).getAllKeys(STORAGE_NAME)); 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 () => { const handleNewChatStore = async () => {
return handleNewChatStoreWithOldOne(chatStore); return handleNewChatStoreWithOldOne(chatStore);
@@ -356,7 +362,27 @@ export function App() {
console.log("[PERFORMANCE!] reading localStorage"); console.log("[PERFORMANCE!] reading localStorage");
return ( return (
<> <AppContext.Provider
value={{
db,
chatStore,
setChatStore,
selectedChatIndex,
setSelectedChatIndex,
templates,
setTemplates,
templateAPIs,
setTemplateAPIs,
templateAPIsWhisper,
setTemplateAPIsWhisper,
templateAPIsTTS,
setTemplateAPIsTTS,
templateAPIsImageGen,
setTemplateAPIsImageGen,
templateTools,
setTemplateTools,
}}
>
<Sidebar> <Sidebar>
<SidebarHeader> <SidebarHeader>
<Button onClick={handleNewChatStore}> <Button onClick={handleNewChatStore}>
@@ -392,6 +418,7 @@ export function App() {
</SidebarGroup> </SidebarGroup>
</SidebarContent> </SidebarContent>
<SidebarFooter> <SidebarFooter>
<ModeToggle />
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button variant="destructive">{Tr("DEL")}</Button> <Button variant="destructive">{Tr("DEL")}</Button>
@@ -422,126 +449,9 @@ export function App() {
<SidebarRail /> <SidebarRail />
</Sidebar> </Sidebar>
<SidebarInset> <SidebarInset>
<header className="flex sticky top-0 bg-background h-16 shrink-0 items-center gap-2 border-b z-50"> <Navbar />
<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,
}}
>
<ChatBOX /> <ChatBOX />
</AppContext.Provider>
</SidebarInset> </SidebarInset>
</> </AppContext.Provider>
); );
} }

View File

@@ -1,8 +1,7 @@
import { IDBPDatabase } from "idb";
import { useContext, useRef } from "react"; import { useContext, useRef } from "react";
import { useEffect, useState, Dispatch } from "react"; import { useEffect, useState } from "react";
import { Tr, langCodeContext, LANG_OPTIONS } from "@/translate"; import { Tr } from "@/translate";
import { addTotalCost, getTotalCost } from "@/utils/totalCost"; import { addTotalCost } from "@/utils/totalCost";
import ChatGPT, { import ChatGPT, {
calculate_token_length, calculate_token_length,
FetchResponse, FetchResponse,
@@ -12,33 +11,17 @@ import ChatGPT, {
Logprobs, Logprobs,
Usage, Usage,
} from "@/chatgpt"; } from "@/chatgpt";
import { import { ChatStoreMessage } from "../types/chatstore";
ChatStore, import Message from "@/components/MessageBubble";
ChatStoreMessage,
TemplateChatStore,
TemplateAPI,
TemplateTools,
} from "../types/chatstore";
import Message from "@/message";
import { models } from "@/types/models"; import { models } from "@/types/models";
import Settings from "@/components/Settings"; import { ImageUploadDrawer } from "@/components/ImageUploadDrawer";
import { AddImage } from "@/addImage"; import { autoHeight } from "@/utils/textAreaHelp";
import { ListAPIs } from "@/listAPIs";
import { ListToolsTempaltes } from "@/listToolsTemplates";
import { autoHeight } from "@/textarea";
import Search from "@/search";
import Templates from "@/components/Templates";
import VersionHint from "@/components/VersionHint"; import VersionHint from "@/components/VersionHint";
import StatusBar from "@/components/StatusBar";
import WhisperButton from "@/components/WhisperButton"; import WhisperButton from "@/components/WhisperButton";
import AddToolMsg from "./AddToolMsg";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ChatInput } from "@/components/ui/chat/chat-input"; import { ChatInput } from "@/components/ui/chat/chat-input";
import { import {
ChatBubble, ChatBubble,
ChatBubbleAvatar,
ChatBubbleMessage, ChatBubbleMessage,
ChatBubbleAction, ChatBubbleAction,
ChatBubbleActionWrapper, ChatBubbleActionWrapper,
@@ -46,33 +29,19 @@ import {
import { ChatMessageList } from "@/components/ui/chat/chat-message-list"; import { ChatMessageList } from "@/components/ui/chat/chat-message-list";
import { import {
AlertTriangleIcon, ArrowDownToDotIcon,
ArrowUpIcon,
CornerDownLeftIcon, CornerDownLeftIcon,
CornerLeftUpIcon, CornerLeftUpIcon,
CornerUpLeftIcon, CornerRightUpIcon,
GlobeIcon,
ImageIcon,
InfoIcon, InfoIcon,
KeyIcon, ScissorsIcon,
SearchIcon,
Settings2,
Settings2Icon,
} from "lucide-react"; } from "lucide-react";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; 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 { AppContext } from "./App";
import { addToRange } from "react-day-picker"; import APIListMenu from "@/components/ListAPI";
import { ImageGenDrawer } from "@/components/ImageGenDrawer";
export default function ChatBOX() { export default function ChatBOX() {
const ctx = useContext(AppContext); const ctx = useContext(AppContext);
@@ -89,11 +58,10 @@ export default function ChatBOX() {
const [inputMsg, setInputMsg] = useState(""); const [inputMsg, setInputMsg] = useState("");
const [images, setImages] = useState<MessageDetail[]>([]); const [images, setImages] = useState<MessageDetail[]>([]);
const [showAddImage, setShowAddImage] = useState(false); const [showAddImage, setShowAddImage] = useState(false);
const [showGenImage, setShowGenImage] = useState(false);
const [showGenerating, setShowGenerating] = useState(false); const [showGenerating, setShowGenerating] = useState(false);
const [generatingMessage, setGeneratingMessage] = useState(""); const [generatingMessage, setGeneratingMessage] = useState("");
const [showRetry, setShowRetry] = useState(false); const [showRetry, setShowRetry] = useState(false);
const [showAddToolMsg, setShowAddToolMsg] = useState(false);
const [showSearch, setShowSearch] = useState(false);
let default_follow = localStorage.getItem("follow"); let default_follow = localStorage.getItem("follow");
if (default_follow === null) { if (default_follow === null) {
default_follow = "true"; default_follow = "true";
@@ -423,116 +391,38 @@ export default function ChatBOX() {
} }
}; };
const [showSettings, setShowSettings] = useState(false);
const userInputRef = useRef<HTMLInputElement>(null); const userInputRef = useRef<HTMLInputElement>(null);
return ( return (
<> <>
<div className="flex flex-col p-2 gap-2 w-full"> <APIListMenu />
<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>
<div className="grow flex flex-col p-2 w-full"> <div className="grow flex flex-col p-2 w-full">
<ChatMessageList> <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 && ( {chatStore.history.length === 0 && (
<Alert variant="default" className="my-3"> <Alert variant="default" className="my-3">
<InfoIcon className="h-4 w-4" /> <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"> <AlertDescription className="flex flex-col gap-1 mt-5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Settings2Icon className="h-4 w-4" /> <CornerRightUpIcon className="h-4 w-4" />
<span> <span>
{Tr("Model")}: {chatStore.model} {Tr(
</span> "Settings button located at the top right corner can be used to change the settings of this chat"
</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")}
</span> </span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CornerLeftUpIcon className="h-4 w-4" /> <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>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<AlertTriangleIcon className="h-4 w-4" /> <ArrowDownToDotIcon className="h-4 w-4" />
<span> <span>
{Tr( {Tr(
"All chat history and settings are stored in the local browser" "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="text-sm font-bold">System Prompt</div>
<div <div
className="cursor-pointer" className="cursor-pointer"
onClick={() => setShowSettings(true)} // onClick={() => setShowSettings(true)}
// TODO: add a button to show settings
> >
{chatStore.systemMessageContent} {chatStore.systemMessageContent}
</div> </div>
@@ -558,14 +449,19 @@ export default function ChatBOX() {
<ChatBubbleActionWrapper> <ChatBubbleActionWrapper>
<ChatBubbleAction <ChatBubbleAction
className="size-7" className="size-7"
icon={<Settings2Icon className="size-4" />} icon={<ScissorsIcon className="size-4" />}
onClick={() => setShowSettings(true)} onClick={() => {
chatStore.systemMessageContent = "";
chatStore.toolsString = "";
chatStore.history = [];
setChatStore({ ...chatStore });
}}
/> />
</ChatBubbleActionWrapper> </ChatBubbleActionWrapper>
</ChatBubble> </ChatBubble>
)} )}
{chatStore.history.map((_, messageIndex) => ( {chatStore.history.map((_, messageIndex) => (
<Message messageIndex={messageIndex} /> <Message messageIndex={messageIndex} key={messageIndex} />
))} ))}
{showGenerating && ( {showGenerating && (
<ChatBubble variant="received"> <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" className="min-h-12 resize-none rounded-lg bg-background border-0 p-3 shadow-none focus-visible:ring-0"
/> />
<div className="flex items-center p-3 pt-0"> <div className="flex items-center p-3 pt-0">
<Button <ImageUploadDrawer
variant="ghost" images={images}
size="icon" setImages={setImages}
type="button" disableFactor={[showGenerating]}
onClick={() => setShowAddImage(true)} />
disabled={showGenerating} <ImageGenDrawer disableFactor={[showGenerating]} />
>
<ImageIcon className="size-4" />
<span className="sr-only">Add Image</span>
</Button>
{chatStore.whisper_api && chatStore.whisper_key && (
<>
<WhisperButton inputMsg={inputMsg} setInputMsg={setInputMsg} /> <WhisperButton inputMsg={inputMsg} setInputMsg={setInputMsg} />
<span className="sr-only">Use Microphone</span>
</>
)}
<Button <Button
size="sm" size="sm"
@@ -720,13 +607,6 @@ export default function ChatBOX() {
</Button> </Button>
</div> </div>
</form> </form>
<AddImage
setShowAddImage={setShowAddImage}
images={images}
showAddImage={showAddImage}
setImages={setImages}
/>
</div> </div>
</> </>
); );

View File

@@ -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>
);
}

View File

@@ -11,9 +11,17 @@ const LANG_MAP: Record<string, string> = {
cost: "消费", cost: "消费",
stream: "流式返回", stream: "流式返回",
fetch: "一次获取", fetch: "一次获取",
Tools: "工具",
Clear: "清空",
"saved api templates": "已保存的 API 模板", "saved api templates": "已保存的 API 模板",
"saved prompt templates": "已保存的提示模板", "saved prompt templates": "已保存的提示模板",
"no chat history here": "暂无历史对话记录", "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 above to change the settings of this chat":
"点击上方更改此对话的参数(请勿泄漏)", "点击上方更改此对话的参数(请勿泄漏)",
"click the NEW to create a new chat": "点击左上角 NEW 新建对话", "click the NEW to create a new chat": "点击左上角 NEW 新建对话",

View File

@@ -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>
);
}

View File

@@ -0,0 +1,8 @@
export const isVailedJSON = (str: string): boolean => {
try {
JSON.parse(str);
} catch (e) {
return false;
}
return true;
};