Compare commits
100 Commits
8192649e16
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
7793d94514
|
|||
|
24973eabfe
|
|||
|
6078d8a2c3
|
|||
|
d830b92fbf
|
|||
|
7694ed6792
|
|||
|
6b8426868a
|
|||
| e18dd9b680 | |||
|
13295bd24d
|
|||
|
aa83f10657
|
|||
| 95b319db7d | |||
|
8f24489959
|
|||
|
39c3860c78
|
|||
|
812ce3cc1f
|
|||
|
667b334dfc
|
|||
|
9b32948cfa
|
|||
|
9fbd9b98c2
|
|||
|
14df7bebac
|
|||
|
e4919bb91f
|
|||
|
2a39ff885a
|
|||
|
c03dbef798
|
|||
|
8cd43bec72
|
|||
|
ed5f561148
|
|||
|
|
8d4a9b840a | ||
|
|
8db892caf7 | ||
|
|
d18040dca1 | ||
|
|
a5f7447f4f | ||
|
|
332a645e34 | ||
|
c37a99f06d
|
|||
|
|
3e89e88c1d | ||
|
5b4a0507ae
|
|||
|
75bf4a419d
|
|||
|
7dea556a56
|
|||
|
79d5ded088
|
|||
|
9e173b8955
|
|||
|
|
2193ce11df | ||
|
|
3b17ca791b | ||
|
|
d51c283e55 | ||
|
|
55e8186479 | ||
|
|
233397ba46 | ||
|
|
c13bce63a9 | ||
|
|
25fcd1f685 | ||
|
0b3610935b
|
|||
|
7aee52d5a2
|
|||
|
6b78308bb5
|
|||
|
edcdc70a2b
|
|||
|
3151fb8477
|
|||
| 1146d514d3 | |||
|
|
26cd5d1022 | ||
|
|
cb1d25bbf6 | ||
|
|
02935d7a0f | ||
|
|
0af9230c6e | ||
|
|
53f806ae3b | ||
|
99d9e4d3f1
|
|||
|
e34cc7375d
|
|||
|
ba64aec5b0
|
|||
|
f0db9e6b03
|
|||
|
74775b5265
|
|||
|
394da2217c
|
|||
|
137186e760
|
|||
|
d736c12ac1
|
|||
|
99e557c1a8
|
|||
|
b68224b13b
|
|||
|
20a152b899
|
|||
| 9cacc5c6d3 | |||
|
001eca79f6
|
|||
|
a4b8ed441c
|
|||
| cdae105f3f | |||
|
|
04cd1a36e1 | ||
| d98ad885b2 | |||
|
c43c24d3d5
|
|||
|
|
765c2c446c | ||
|
|
5effd0a3f4 | ||
|
|
af6ccad35b | ||
|
|
a7bbe1e000 | ||
|
|
22e3760b7f | ||
|
|
78d40a8bf7 | ||
|
|
0e1529a4d2 | ||
|
|
709cad3138 | ||
|
|
c84cc7d9e8 | ||
|
|
c4dc89784d | ||
|
|
40f61dd6f9 | ||
|
|
c92b8f04cc | ||
|
|
75a431360b | ||
|
|
76d50317e9 | ||
|
|
9383cf045a | ||
|
|
47f63364b1 | ||
|
|
3663193f50 | ||
|
|
f3d08afcdd | ||
|
|
503bf6a9bb | ||
|
|
236d48e72d | ||
|
|
1f9c75b91e | ||
|
|
34360e5370 | ||
|
|
3728766d7f | ||
|
|
3060543ee7 | ||
|
|
c421792b9f | ||
|
|
45d405adf3 | ||
|
|
30583a421d | ||
|
|
6fe1012270 | ||
|
|
5b4c4bffe0 | ||
|
|
a63502ae2b |
@@ -1,27 +0,0 @@
|
|||||||
name: Build static content
|
|
||||||
|
|
||||||
on:
|
|
||||||
# Runs on pushes targeting the default branch
|
|
||||||
push:
|
|
||||||
branches: ["master"]
|
|
||||||
|
|
||||||
# Allows you to run this workflow manually from the Actions tab
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- name: Use Node.js 20.x
|
|
||||||
uses: actions/setup-node@v3
|
|
||||||
with:
|
|
||||||
node-version: 20.x
|
|
||||||
cache: 'npm'
|
|
||||||
- run: npm install
|
|
||||||
- run: npm run build
|
|
||||||
- name: Upload artifact
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: dist-files
|
|
||||||
path: './dist/'
|
|
||||||
4
.github/workflows/pages.yml
vendored
4
.github/workflows/pages.yml
vendored
@@ -39,10 +39,10 @@ jobs:
|
|||||||
- name: Setup Pages
|
- name: Setup Pages
|
||||||
uses: actions/configure-pages@v3
|
uses: actions/configure-pages@v3
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-pages-artifact@v1
|
uses: actions/upload-pages-artifact@v3
|
||||||
with:
|
with:
|
||||||
# Upload entire repository
|
# Upload entire repository
|
||||||
path: './dist/'
|
path: './dist/'
|
||||||
- name: Deploy to GitHub Pages
|
- name: Deploy to GitHub Pages
|
||||||
id: deployment
|
id: deployment
|
||||||
uses: actions/deploy-pages@v1
|
uses: actions/deploy-pages@v4
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
content="A simple API playground for OpenAI ChatGPT API"
|
content="A simple API playground for OpenAI ChatGPT API"
|
||||||
/>
|
/>
|
||||||
<title>ChatGPT API Web</title>
|
<title>ChatGPT API Web</title>
|
||||||
|
<link rel="manifest" href="manifest.json" />
|
||||||
|
<meta name="theme-color" content="#ffffff" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
6765
package-lock.json
generated
6765
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -11,6 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
"@hookform/resolvers": "^3.9.1",
|
"@hookform/resolvers": "^3.9.1",
|
||||||
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@radix-ui/react-accordion": "^1.2.2",
|
"@radix-ui/react-accordion": "^1.2.2",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.4",
|
"@radix-ui/react-alert-dialog": "^1.1.4",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.1",
|
"@radix-ui/react-aspect-ratio": "^1.1.1",
|
||||||
@@ -38,15 +39,18 @@
|
|||||||
"@radix-ui/react-toggle": "^1.1.1",
|
"@radix-ui/react-toggle": "^1.1.1",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.1",
|
"@radix-ui/react-toggle-group": "^1.1.1",
|
||||||
"@radix-ui/react-tooltip": "^1.1.6",
|
"@radix-ui/react-tooltip": "^1.1.6",
|
||||||
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@types/react": "^18.0.0",
|
"@types/react": "^18.0.0",
|
||||||
"@types/react-dom": "^18.0.0",
|
"@types/react-dom": "^18.0.0",
|
||||||
"@types/ungap__structured-clone": "^1.2.0",
|
"@types/ungap__structured-clone": "^1.2.0",
|
||||||
"@ungap/structured-clone": "^1.2.1",
|
"@ungap/structured-clone": "^1.2.1",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "1.0.4",
|
"cmdk": "1.0.4",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"embla-carousel-react": "^8.5.1",
|
"embla-carousel-react": "^8.5.1",
|
||||||
|
"highlight.js": "^11.11.1",
|
||||||
"idb": "^8.0.1",
|
"idb": "^8.0.1",
|
||||||
"input-otp": "^1.4.1",
|
"input-otp": "^1.4.1",
|
||||||
"lucide-react": "^0.469.0",
|
"lucide-react": "^0.469.0",
|
||||||
@@ -55,9 +59,12 @@
|
|||||||
"react-day-picker": "9.4.4",
|
"react-day-picker": "9.4.4",
|
||||||
"react-dom": "^18.0.0",
|
"react-dom": "^18.0.0",
|
||||||
"react-hook-form": "^7.54.2",
|
"react-hook-form": "^7.54.2",
|
||||||
"react-markdown": "^9.0.1",
|
"react-markdown": "^9.0.3",
|
||||||
"react-resizable-panels": "^2.1.7",
|
"react-resizable-panels": "^2.1.7",
|
||||||
"recharts": "^2.15.0",
|
"recharts": "^2.15.0",
|
||||||
|
"rehype-highlight": "^7.0.2",
|
||||||
|
"rehype-katex": "^7.0.1",
|
||||||
|
"remark-math": "^6.0.0",
|
||||||
"sakura.css": "^1.5.0",
|
"sakura.css": "^1.5.0",
|
||||||
"sonner": "^1.7.1",
|
"sonner": "^1.7.1",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
@@ -72,6 +79,8 @@
|
|||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"theme-change": "^2.5.0",
|
"theme-change": "^2.5.0",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vite": "^6.0.6"
|
"vite": "^6.0.6",
|
||||||
|
"vite-plugin-pwa": "^0.21.1",
|
||||||
|
"workbox-window": "^7.3.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
7654
pnpm-lock.yaml
generated
Normal file
7654
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
BIN
public/favicon.png
Normal file
BIN
public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
15
public/manifest.json
Normal file
15
public/manifest.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "ChatGPT API Web",
|
||||||
|
"short_name": "CAW",
|
||||||
|
"start_url": ".",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#000000",
|
||||||
|
"theme_color": "#000000",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "favicon.png",
|
||||||
|
"sizes": "256x256",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
369
src/addImage.tsx
369
src/addImage.tsx
@@ -1,369 +0,0 @@
|
|||||||
import { useContext, useState } from "react";
|
|
||||||
import { ChatStore } from "@/types/chatstore";
|
|
||||||
import { MessageDetail } from "@/chatgpt";
|
|
||||||
import { Tr } from "@/translate";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Drawer,
|
|
||||||
DrawerClose,
|
|
||||||
DrawerContent,
|
|
||||||
DrawerDescription,
|
|
||||||
DrawerFooter,
|
|
||||||
DrawerHeader,
|
|
||||||
DrawerTitle,
|
|
||||||
DrawerTrigger,
|
|
||||||
} from "@/components/ui/drawer";
|
|
||||||
|
|
||||||
import { Button } from "./components/ui/button";
|
|
||||||
import { PenIcon, XIcon } from "lucide-react";
|
|
||||||
import { Checkbox } from "./components/ui/checkbox";
|
|
||||||
import { Label } from "./components/ui/label";
|
|
||||||
import { Textarea } from "./components/ui/textarea";
|
|
||||||
import { Separator } from "./components/ui/separator";
|
|
||||||
import { AppContext } from "./pages/App";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
images: MessageDetail[];
|
|
||||||
showAddImage: boolean;
|
|
||||||
setShowAddImage: (se: boolean) => void;
|
|
||||||
setImages: (images: MessageDetail[]) => void;
|
|
||||||
}
|
|
||||||
interface ImageResponse {
|
|
||||||
url?: string;
|
|
||||||
b64_json?: string;
|
|
||||||
revised_prompt: string;
|
|
||||||
}
|
|
||||||
export function AddImage({
|
|
||||||
showAddImage,
|
|
||||||
setShowAddImage,
|
|
||||||
setImages,
|
|
||||||
images,
|
|
||||||
}: Props) {
|
|
||||||
const ctx = useContext(AppContext);
|
|
||||||
if (ctx === null) return <></>;
|
|
||||||
|
|
||||||
const [enableHighResolution, setEnableHighResolution] = useState(true);
|
|
||||||
const [imageGenPrompt, setImageGenPrompt] = useState("");
|
|
||||||
const [imageGenModel, setImageGenModel] = useState("dall-e-3");
|
|
||||||
const [imageGenN, setImageGenN] = useState(1);
|
|
||||||
const [imageGenQuality, setImageGEnQuality] = useState("standard");
|
|
||||||
const [imageGenResponseFormat, setImageGenResponseFormat] =
|
|
||||||
useState("b64_json");
|
|
||||||
const [imageGenSize, setImageGenSize] = useState("1024x1024");
|
|
||||||
const [imageGenStyle, setImageGenStyle] = useState("vivid");
|
|
||||||
const [imageGenGenerating, setImageGenGenerating] = useState(false);
|
|
||||||
useState("b64_json");
|
|
||||||
return (
|
|
||||||
<Drawer open={showAddImage} onOpenChange={setShowAddImage}>
|
|
||||||
<DrawerContent>
|
|
||||||
<div className="mx-auto w-full max-w-lg">
|
|
||||||
<DrawerHeader>
|
|
||||||
<DrawerTitle>Add Images</DrawerTitle>
|
|
||||||
</DrawerHeader>
|
|
||||||
<div className="flex gap-2 items-center">
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
disabled={false}
|
|
||||||
onClick={() => {
|
|
||||||
const image_url = prompt("Image URL");
|
|
||||||
if (!image_url) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setImages([
|
|
||||||
...images,
|
|
||||||
{
|
|
||||||
type: "image_url",
|
|
||||||
image_url: {
|
|
||||||
url: image_url,
|
|
||||||
detail: enableHighResolution ? "high" : "low",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Add from URL
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
disabled={false}
|
|
||||||
onClick={() => {
|
|
||||||
const input = document.createElement("input");
|
|
||||||
input.type = "file";
|
|
||||||
input.accept = "image/*";
|
|
||||||
input.onchange = (event) => {
|
|
||||||
const file = (event.target as HTMLInputElement).files?.[0];
|
|
||||||
if (!file) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
reader.onloadend = () => {
|
|
||||||
const base64data = reader.result;
|
|
||||||
setImages([
|
|
||||||
...images,
|
|
||||||
{
|
|
||||||
type: "image_url",
|
|
||||||
image_url: {
|
|
||||||
url: String(base64data),
|
|
||||||
detail: enableHighResolution ? "high" : "low",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
input.click();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Add from local file
|
|
||||||
</Button>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Checkbox
|
|
||||||
checked={enableHighResolution}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
setEnableHighResolution(checked === true)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor="terms"
|
|
||||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
|
||||||
>
|
|
||||||
High resolution
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Separator className="my-2" />
|
|
||||||
{ctx.chatStore.image_gen_api && ctx.chatStore.image_gen_key && (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<h3>Generate Image</h3>
|
|
||||||
<span className="flex flex-col justify-between m-1 p-1">
|
|
||||||
<Label>Prompt: </Label>
|
|
||||||
<Textarea
|
|
||||||
className="textarea textarea-sm textarea-bordered"
|
|
||||||
value={imageGenPrompt}
|
|
||||||
onChange={(e: any) => {
|
|
||||||
setImageGenPrompt(e.target.value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span className="flex flex-row justify-between items-center m-1 p-1">
|
|
||||||
<label>Model: </label>
|
|
||||||
<select
|
|
||||||
className="select select-sm select-bordered"
|
|
||||||
value={imageGenModel}
|
|
||||||
onChange={(e: any) => {
|
|
||||||
setImageGenModel(e.target.value);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="dall-e-3">DALL-E 3</option>
|
|
||||||
<option value="dall-e-2">DALL-E 2</option>
|
|
||||||
</select>
|
|
||||||
</span>
|
|
||||||
<span className="flex flex-row justify-between items-center m-1 p-1">
|
|
||||||
<label>n: </label>
|
|
||||||
<input
|
|
||||||
className="input input-sm input-bordered"
|
|
||||||
value={imageGenN}
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={10}
|
|
||||||
onChange={(e: any) => setImageGenN(parseInt(e.target.value))}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span className="flex flex-row justify-between items-center m-1 p-1">
|
|
||||||
<label>Quality: </label>
|
|
||||||
<select
|
|
||||||
className="select select-sm select-bordered"
|
|
||||||
value={imageGenQuality}
|
|
||||||
onChange={(e: any) => setImageGEnQuality(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="hd">HD</option>
|
|
||||||
<option value="standard">Standard</option>
|
|
||||||
</select>
|
|
||||||
</span>
|
|
||||||
<span className="flex flex-row justify-between items-center m-1 p-1">
|
|
||||||
<label>Response Format: </label>
|
|
||||||
<select
|
|
||||||
className="select select-sm select-bordered"
|
|
||||||
value={imageGenResponseFormat}
|
|
||||||
onChange={(e: any) =>
|
|
||||||
setImageGenResponseFormat(e.target.value)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="b64_json">b64_json</option>
|
|
||||||
<option value="url">url</option>
|
|
||||||
</select>
|
|
||||||
</span>
|
|
||||||
<span className="flex flex-row justify-between items-center m-1 p-1">
|
|
||||||
<label>Size: </label>
|
|
||||||
<select
|
|
||||||
className="select select-sm select-bordered"
|
|
||||||
value={imageGenSize}
|
|
||||||
onChange={(e: any) => setImageGenSize(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="256x256">256x256 (dall-e-2)</option>
|
|
||||||
<option value="512x512">512x512 (dall-e-2)</option>
|
|
||||||
<option value="1024x1024">1024x1024 (dall-e-2/3)</option>
|
|
||||||
<option value="1792x1024">1792x1024 (dall-e-3)</option>
|
|
||||||
<option value="1024x1792">1024x1792 (dall-e-3)</option>
|
|
||||||
</select>
|
|
||||||
</span>
|
|
||||||
<span className="flex flex-row justify-between items-center m-1 p-1">
|
|
||||||
<label>Style (only dall-e-3): </label>
|
|
||||||
<select
|
|
||||||
className="select select-sm select-bordered"
|
|
||||||
value={imageGenStyle}
|
|
||||||
onChange={(e: any) => setImageGenStyle(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="vivid">vivid</option>
|
|
||||||
<option value="natural">natural</option>
|
|
||||||
</select>
|
|
||||||
</span>
|
|
||||||
<span className="flex flex-row justify-between items-center m-1 p-1">
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
disabled={imageGenGenerating}
|
|
||||||
onClick={async () => {
|
|
||||||
try {
|
|
||||||
setImageGenGenerating(true);
|
|
||||||
const body: any = {
|
|
||||||
prompt: imageGenPrompt,
|
|
||||||
model: imageGenModel,
|
|
||||||
n: imageGenN,
|
|
||||||
quality: imageGenQuality,
|
|
||||||
response_format: imageGenResponseFormat,
|
|
||||||
size: imageGenSize,
|
|
||||||
};
|
|
||||||
if (imageGenModel === "dall-e-3") {
|
|
||||||
body.style = imageGenStyle;
|
|
||||||
}
|
|
||||||
const resp: ImageResponse[] = (
|
|
||||||
await fetch(ctx.chatStore.image_gen_api, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${ctx.chatStore.image_gen_key}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
}).then((resp) => resp.json())
|
|
||||||
).data;
|
|
||||||
console.log("image gen resp", resp);
|
|
||||||
|
|
||||||
for (const image of resp) {
|
|
||||||
let url = "";
|
|
||||||
if (image.url) url = image.url;
|
|
||||||
if (image.b64_json)
|
|
||||||
url = "data:image/png;base64," + image.b64_json;
|
|
||||||
if (!url) continue;
|
|
||||||
|
|
||||||
ctx.chatStore.history.push({
|
|
||||||
role: "assistant",
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "image_url",
|
|
||||||
image_url: {
|
|
||||||
url,
|
|
||||||
detail: "low",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: image.revised_prompt,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
hide: false,
|
|
||||||
token: 65,
|
|
||||||
example: false,
|
|
||||||
audio: null,
|
|
||||||
logprobs: null,
|
|
||||||
response_model_name: imageGenModel,
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.setChatStore({ ...ctx.chatStore });
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
alert("Failed to generate image: " + e);
|
|
||||||
} finally {
|
|
||||||
setImageGenGenerating(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{Tr("Generate")}
|
|
||||||
</Button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-wrap">
|
|
||||||
{images.map((image, index) => (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
{image.type === "image_url" && (
|
|
||||||
<img
|
|
||||||
className="rounded m-1 p-1 border-2 border-gray-400 w-32"
|
|
||||||
src={image.image_url?.url}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span className="flex justify-between">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
const image_url = prompt("Image URL");
|
|
||||||
if (!image_url) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
images[index].image_url = {
|
|
||||||
url: image_url,
|
|
||||||
detail: enableHighResolution ? "high" : "low",
|
|
||||||
};
|
|
||||||
setImages([...images]);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PenIcon />
|
|
||||||
</Button>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Checkbox
|
|
||||||
id={`hires-${index}`}
|
|
||||||
checked={image.image_url?.detail === "high"}
|
|
||||||
onCheckedChange={() => {
|
|
||||||
if (image.image_url === undefined) return;
|
|
||||||
image.image_url.detail =
|
|
||||||
image.image_url?.detail === "low" ? "high" : "low";
|
|
||||||
setImages([...images]);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor={`hires-${index}`}
|
|
||||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
|
||||||
>
|
|
||||||
HiRes
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
if (!confirm("Are you sure to delete this image?")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
images.splice(index, 1);
|
|
||||||
setImages([...images]);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<XIcon />
|
|
||||||
</Button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DrawerFooter>
|
|
||||||
<Button onClick={() => setShowAddImage(false)}>Done</Button>
|
|
||||||
</DrawerFooter>
|
|
||||||
</div>
|
|
||||||
</DrawerContent>
|
|
||||||
</Drawer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { DefaultModel } from "@/const";
|
import { DefaultModel } from "@/const";
|
||||||
|
import { MutableRefObject } from "react";
|
||||||
|
|
||||||
export interface ImageURL {
|
export interface ImageURL {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -22,6 +23,7 @@ export interface ToolCall {
|
|||||||
export interface Message {
|
export interface Message {
|
||||||
role: "system" | "user" | "assistant" | "tool";
|
role: "system" | "user" | "assistant" | "tool";
|
||||||
content: string | MessageDetail[];
|
content: string | MessageDetail[];
|
||||||
|
reasoning_content?: string | null;
|
||||||
name?: "example_user" | "example_assistant";
|
name?: "example_user" | "example_assistant";
|
||||||
tool_calls?: ToolCall[];
|
tool_calls?: ToolCall[];
|
||||||
tool_call_id?: string;
|
tool_call_id?: string;
|
||||||
@@ -30,6 +32,7 @@ export interface Message {
|
|||||||
interface Delta {
|
interface Delta {
|
||||||
role?: string;
|
role?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
|
reasoning_content?: string;
|
||||||
tool_calls?: ToolCall[];
|
tool_calls?: ToolCall[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +92,7 @@ export const getMessageText = (message: Message): string => {
|
|||||||
})
|
})
|
||||||
.join("\n");
|
.join("\n");
|
||||||
}
|
}
|
||||||
return message.content;
|
return message.content.trim();
|
||||||
}
|
}
|
||||||
return message.content
|
return message.content
|
||||||
.filter((c) => c.type === "text")
|
.filter((c) => c.type === "text")
|
||||||
@@ -162,7 +165,9 @@ class Chat {
|
|||||||
top_p: number;
|
top_p: number;
|
||||||
enable_top_p: boolean;
|
enable_top_p: boolean;
|
||||||
presence_penalty: number;
|
presence_penalty: number;
|
||||||
|
presence_penalty_enabled: boolean;
|
||||||
frequency_penalty: number;
|
frequency_penalty: number;
|
||||||
|
frequency_penalty_enabled: boolean;
|
||||||
json_mode: boolean;
|
json_mode: boolean;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -181,7 +186,9 @@ class Chat {
|
|||||||
top_p = 1,
|
top_p = 1,
|
||||||
enable_top_p = false,
|
enable_top_p = false,
|
||||||
presence_penalty = 0,
|
presence_penalty = 0,
|
||||||
|
presence_penalty_enabled = false,
|
||||||
frequency_penalty = 0,
|
frequency_penalty = 0,
|
||||||
|
frequency_penalty_enabled = false,
|
||||||
json_mode = false,
|
json_mode = false,
|
||||||
} = {}
|
} = {}
|
||||||
) {
|
) {
|
||||||
@@ -201,11 +208,13 @@ class Chat {
|
|||||||
this.top_p = top_p;
|
this.top_p = top_p;
|
||||||
this.enable_top_p = enable_top_p;
|
this.enable_top_p = enable_top_p;
|
||||||
this.presence_penalty = presence_penalty;
|
this.presence_penalty = presence_penalty;
|
||||||
|
this.presence_penalty_enabled = presence_penalty_enabled;
|
||||||
this.frequency_penalty = frequency_penalty;
|
this.frequency_penalty = frequency_penalty;
|
||||||
|
this.frequency_penalty_enabled = frequency_penalty_enabled;
|
||||||
this.json_mode = json_mode;
|
this.json_mode = json_mode;
|
||||||
}
|
}
|
||||||
|
|
||||||
_fetch(stream = false, logprobs = false) {
|
_fetch(stream = false, logprobs = false, signal: AbortSignal) {
|
||||||
// perform role type check
|
// perform role type check
|
||||||
let hasNonSystemMessage = false;
|
let hasNonSystemMessage = false;
|
||||||
for (const msg of this.messages) {
|
for (const msg of this.messages) {
|
||||||
@@ -239,8 +248,6 @@ class Chat {
|
|||||||
model: this.model,
|
model: this.model,
|
||||||
messages,
|
messages,
|
||||||
stream,
|
stream,
|
||||||
presence_penalty: this.presence_penalty,
|
|
||||||
frequency_penalty: this.frequency_penalty,
|
|
||||||
};
|
};
|
||||||
if (stream) {
|
if (stream) {
|
||||||
body["stream_options"] = {
|
body["stream_options"] = {
|
||||||
@@ -256,6 +263,12 @@ class Chat {
|
|||||||
if (this.enable_max_gen_tokens) {
|
if (this.enable_max_gen_tokens) {
|
||||||
body["max_tokens"] = this.max_gen_tokens;
|
body["max_tokens"] = this.max_gen_tokens;
|
||||||
}
|
}
|
||||||
|
if (this.presence_penalty_enabled) {
|
||||||
|
body["presence_penalty"] = this.presence_penalty;
|
||||||
|
}
|
||||||
|
if (this.frequency_penalty_enabled) {
|
||||||
|
body["frequency_penalty"] = this.frequency_penalty;
|
||||||
|
}
|
||||||
if (this.json_mode) {
|
if (this.json_mode) {
|
||||||
body["response_format"] = {
|
body["response_format"] = {
|
||||||
type: "json_object",
|
type: "json_object",
|
||||||
@@ -289,10 +302,11 @@ class Chat {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
|
signal,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async *processStreamResponse(resp: Response) {
|
async *processStreamResponse(resp: Response, signal?: AbortSignal) {
|
||||||
const reader = resp?.body?.pipeThrough(new TextDecoderStream()).getReader();
|
const reader = resp?.body?.pipeThrough(new TextDecoderStream()).getReader();
|
||||||
if (reader === undefined) {
|
if (reader === undefined) {
|
||||||
console.log("reader is undefined");
|
console.log("reader is undefined");
|
||||||
@@ -301,6 +315,11 @@ class Chat {
|
|||||||
let receiving = true;
|
let receiving = true;
|
||||||
let buffer = "";
|
let buffer = "";
|
||||||
while (receiving) {
|
while (receiving) {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
reader.cancel();
|
||||||
|
console.log("signal aborted in stream response");
|
||||||
|
break;
|
||||||
|
}
|
||||||
const { value, done } = await reader.read();
|
const { value, done } = await reader.read();
|
||||||
if (done) break;
|
if (done) break;
|
||||||
|
|
||||||
@@ -319,6 +338,9 @@ class Chat {
|
|||||||
console.log("line", line);
|
console.log("line", line);
|
||||||
try {
|
try {
|
||||||
const jsonStr = line.slice("data:".length).trim();
|
const jsonStr = line.slice("data:".length).trim();
|
||||||
|
if (jsonStr === "keep-alive") { // for deepseek https://api-docs.deepseek.com/quick_start/rate_limit
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const json = JSON.parse(jsonStr) as StreamingResponseChunk;
|
const json = JSON.parse(jsonStr) as StreamingResponseChunk;
|
||||||
yield json;
|
yield json;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
69
src/components/ConversationTitle..tsx
Normal file
69
src/components/ConversationTitle..tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { STORAGE_NAME } from "@/const";
|
||||||
|
import { AppChatStoreContext, AppContext } from "@/pages/App";
|
||||||
|
import { ChatStore } from "@/types/chatstore";
|
||||||
|
import { memo, useContext, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
const ConversationTitle = ({ chatStoreIndex }: { chatStoreIndex: number }) => {
|
||||||
|
const { db, selectedChatIndex } = useContext(AppContext);
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
|
||||||
|
const getTitle = async () => {
|
||||||
|
const chatStore = (await (
|
||||||
|
await db
|
||||||
|
).get(STORAGE_NAME, chatStoreIndex)) as ChatStore;
|
||||||
|
if (chatStore.history.length === 0) {
|
||||||
|
setTitle(`${chatStoreIndex}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const content = chatStore.history[0]?.content;
|
||||||
|
if (!content) {
|
||||||
|
setTitle(`${chatStoreIndex}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof content === "string") {
|
||||||
|
console.log(content);
|
||||||
|
setTitle(content.substring(0, 39));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
getTitle();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => {
|
||||||
|
try {
|
||||||
|
getTitle();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CachedConversationTitle = memo(
|
||||||
|
({
|
||||||
|
chatStoreIndex,
|
||||||
|
selectedChatStoreIndex,
|
||||||
|
}: {
|
||||||
|
chatStoreIndex: number;
|
||||||
|
selectedChatStoreIndex: number;
|
||||||
|
}) => {
|
||||||
|
return <ConversationTitle chatStoreIndex={chatStoreIndex} />;
|
||||||
|
},
|
||||||
|
(prevProps, nextProps) => {
|
||||||
|
return nextProps.selectedChatStoreIndex === nextProps.chatStoreIndex;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default CachedConversationTitle;
|
||||||
244
src/components/ImageGenDrawer.tsx
Normal file
244
src/components/ImageGenDrawer.tsx
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
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 { AppChatStoreContext, 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 { chatStore, setChatStore } = useContext(AppChatStoreContext);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
{chatStore.image_gen_api && 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(chatStore.image_gen_api, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${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;
|
||||||
|
|
||||||
|
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,
|
||||||
|
reasoning_content: null,
|
||||||
|
usage: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
setChatStore({ ...chatStore });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert("Failed to generate image: " + e);
|
||||||
|
} finally {
|
||||||
|
setImageGenGenerating(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tr>Generate</Tr>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
195
src/components/ImageUploadDrawer.tsx
Normal file
195
src/components/ImageUploadDrawer.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
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);
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
src/components/ImportDialog.tsx
Normal file
129
src/components/ImportDialog.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { Tr } from "@/translate";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "./ui/alert-dialog";
|
||||||
|
import { useContext } from "react";
|
||||||
|
import { AppChatStoreContext, AppContext } from "@/pages/App";
|
||||||
|
import { STORAGE_NAME } from "@/const";
|
||||||
|
|
||||||
|
const Item = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div className="mt-2">{children}</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ImportDialog = ({
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
}) => {
|
||||||
|
const { handleNewChatStoreWithOldOne } = useContext(AppContext);
|
||||||
|
const { chatStore } = useContext(AppChatStoreContext);
|
||||||
|
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const api = params.get("api");
|
||||||
|
const key = params.get("key");
|
||||||
|
const sys = params.get("sys");
|
||||||
|
const mode = params.get("mode");
|
||||||
|
const model = params.get("model");
|
||||||
|
const max = params.get("max");
|
||||||
|
const temp = params.get("temp");
|
||||||
|
const dev = params.get("dev");
|
||||||
|
const whisper_api = params.get("whisper-api");
|
||||||
|
const whisper_key = params.get("whisper-key");
|
||||||
|
const tts_api = params.get("tts-api");
|
||||||
|
const tts_key = params.get("tts-key");
|
||||||
|
return (
|
||||||
|
<AlertDialog open={open}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
<Tr>Import Configuration</Tr>
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription className="message-content">
|
||||||
|
<Tr>There are some configurations in the URL, import them?</Tr>
|
||||||
|
{key && <Item>Key: {key}</Item>}
|
||||||
|
{api && <Item>API: {api}</Item>}
|
||||||
|
{sys && <Item>Sys: {sys}</Item>}
|
||||||
|
{mode && <Item>Mode: {mode}</Item>}
|
||||||
|
{model && <Item>Model: {model}</Item>}
|
||||||
|
{max && <Item>Max: {max}</Item>}
|
||||||
|
{temp && <Item>Temp: {temp}</Item>}
|
||||||
|
{dev && <Item>Dev: {dev}</Item>}
|
||||||
|
{whisper_api && <Item>Whisper API: {whisper_api}</Item>}
|
||||||
|
{whisper_key && <Item>Whisper Key: {whisper_key}</Item>}
|
||||||
|
{tts_api && <div className="mt-2">TTS API: {tts_api}</div>}
|
||||||
|
{tts_key && <div className="mt-2">TTS Key: {tts_key}</div>}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={() => setOpen(false)}>
|
||||||
|
<Tr>Cancel</Tr>
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={async () => {
|
||||||
|
params.delete("key");
|
||||||
|
params.delete("api");
|
||||||
|
params.delete("sys");
|
||||||
|
params.delete("mode");
|
||||||
|
params.delete("model");
|
||||||
|
params.delete("max");
|
||||||
|
params.delete("temp");
|
||||||
|
params.delete("dev");
|
||||||
|
params.delete("whisper-api");
|
||||||
|
params.delete("whisper-key");
|
||||||
|
params.delete("tts-api");
|
||||||
|
params.delete("tts-key");
|
||||||
|
|
||||||
|
const newChatStore = structuredClone(chatStore);
|
||||||
|
if (key) newChatStore.apiKey = key;
|
||||||
|
if (api) newChatStore.apiEndpoint = api;
|
||||||
|
if (sys) newChatStore.systemMessageContent = sys;
|
||||||
|
if (mode) newChatStore.streamMode = mode === "stream";
|
||||||
|
if (model) newChatStore.model = model;
|
||||||
|
if (max) {
|
||||||
|
try {
|
||||||
|
newChatStore.maxTokens = parseInt(max);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (temp) {
|
||||||
|
try {
|
||||||
|
newChatStore.temperature = parseFloat(temp);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dev) newChatStore.develop_mode = dev === "true";
|
||||||
|
if (whisper_api) newChatStore.whisper_api = whisper_api;
|
||||||
|
if (whisper_key) newChatStore.whisper_key = whisper_key;
|
||||||
|
if (tts_api) newChatStore.tts_api = tts_api;
|
||||||
|
if (tts_key) newChatStore.tts_key = tts_key;
|
||||||
|
|
||||||
|
await handleNewChatStoreWithOldOne(newChatStore);
|
||||||
|
|
||||||
|
const newUrl =
|
||||||
|
window.location.pathname +
|
||||||
|
(params.toString() ? `?${params}` : "");
|
||||||
|
window.history.replaceState(null, "", newUrl); // 替换URL不刷新页面
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tr>Import</Tr>
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImportDialog;
|
||||||
648
src/components/ListAPI.tsx
Normal file
648
src/components/ListAPI.tsx
Normal file
@@ -0,0 +1,648 @@
|
|||||||
|
import React, { useContext, useState, useRef } from "react";
|
||||||
|
import {
|
||||||
|
ChatStore,
|
||||||
|
TemplateAPI,
|
||||||
|
TemplateChatStore,
|
||||||
|
TemplateTools,
|
||||||
|
} from "@/types/chatstore";
|
||||||
|
import { Tr } from "@/translate";
|
||||||
|
import Editor from "@monaco-editor/react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { AppChatStoreContext, 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, DeleteIcon, EditIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { newChatStore } from "@/types/newChatstore";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
DialogClose,
|
||||||
|
} from "./ui/dialog";
|
||||||
|
import { Textarea } from "./ui/textarea";
|
||||||
|
import { Label } from "./ui/label";
|
||||||
|
import { Input } from "./ui/input";
|
||||||
|
import { SetAPIsTemplate } from "./setAPIsTemplate";
|
||||||
|
import { isVailedJSON } from "@/utils/isVailedJSON";
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { ConfirmationDialog } from "./ui/confirmation-dialog";
|
||||||
|
|
||||||
|
interface APITemplateDropdownProps {
|
||||||
|
label: string;
|
||||||
|
shortLabel: string;
|
||||||
|
apiField: string;
|
||||||
|
keyField: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditTemplateDialogProps {
|
||||||
|
template: TemplateAPI;
|
||||||
|
onSave: (updatedTemplate: TemplateAPI) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditTemplateDialog({ template, onSave, onClose }: EditTemplateDialogProps) {
|
||||||
|
const [name, setName] = useState(template.name);
|
||||||
|
const [endpoint, setEndpoint] = useState(template.endpoint);
|
||||||
|
const [key, setKey] = useState(template.key);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!name.trim()) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Template name cannot be empty",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSave({
|
||||||
|
...template,
|
||||||
|
name: name.trim(),
|
||||||
|
endpoint: endpoint.trim(),
|
||||||
|
key: key.trim(),
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={onClose}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Template</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="name">Template Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="endpoint">API Endpoint</Label>
|
||||||
|
<Input
|
||||||
|
id="endpoint"
|
||||||
|
value={endpoint}
|
||||||
|
onChange={(e) => setEndpoint(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="key">API Key</Label>
|
||||||
|
<Input
|
||||||
|
id="key"
|
||||||
|
value={key}
|
||||||
|
onChange={(e) => setKey(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant="outline">Cancel</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button onClick={handleSave}>Save Changes</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function APIsDropdownList({
|
||||||
|
label,
|
||||||
|
shortLabel,
|
||||||
|
apiField,
|
||||||
|
keyField,
|
||||||
|
}: APITemplateDropdownProps) {
|
||||||
|
const { chatStore, setChatStore } = useContext(AppChatStoreContext);
|
||||||
|
const {
|
||||||
|
templates,
|
||||||
|
templateAPIs,
|
||||||
|
templateAPIsImageGen,
|
||||||
|
templateAPIsTTS,
|
||||||
|
templateAPIsWhisper,
|
||||||
|
setTemplates,
|
||||||
|
setTemplateAPIs,
|
||||||
|
setTemplateAPIsImageGen,
|
||||||
|
setTemplateAPIsTTS,
|
||||||
|
setTemplateAPIsWhisper,
|
||||||
|
setTemplateTools,
|
||||||
|
} = useContext(AppContext);
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const [editingTemplate, setEditingTemplate] = useState<TemplateAPI | null>(null);
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [templateToDelete, setTemplateToDelete] = useState<TemplateAPI | null>(null);
|
||||||
|
|
||||||
|
let API = templateAPIs;
|
||||||
|
let setAPI = setTemplateAPIs;
|
||||||
|
if (label === "Chat API") {
|
||||||
|
API = templateAPIs;
|
||||||
|
setAPI = setTemplateAPIs;
|
||||||
|
} else if (label === "Whisper API") {
|
||||||
|
API = templateAPIsWhisper;
|
||||||
|
setAPI = setTemplateAPIsWhisper;
|
||||||
|
} else if (label === "TTS API") {
|
||||||
|
API = templateAPIsTTS;
|
||||||
|
setAPI = setTemplateAPIsTTS;
|
||||||
|
} else if (label === "Image Gen API") {
|
||||||
|
API = templateAPIsImageGen;
|
||||||
|
setAPI = setTemplateAPIsImageGen;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (template: TemplateAPI) => {
|
||||||
|
setEditingTemplate(template);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = (updatedTemplate: TemplateAPI) => {
|
||||||
|
const index = API.findIndex(t => t.name === updatedTemplate.name);
|
||||||
|
if (index !== -1) {
|
||||||
|
const newAPI = [...API];
|
||||||
|
newAPI[index] = updatedTemplate;
|
||||||
|
setAPI(newAPI);
|
||||||
|
toast({
|
||||||
|
title: "Success",
|
||||||
|
description: "Template updated successfully",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (template: TemplateAPI) => {
|
||||||
|
setTemplateToDelete(template);
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
if (templateToDelete) {
|
||||||
|
const newAPI = API.filter(t => t.name !== templateToDelete.name);
|
||||||
|
setAPI(newAPI);
|
||||||
|
toast({
|
||||||
|
title: "Success",
|
||||||
|
description: "Template deleted successfully",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center space-x-4 mx-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
<Tr>{label}</Tr>
|
||||||
|
</p>
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" className="w-[150px] justify-start">
|
||||||
|
{API.find(
|
||||||
|
(t: TemplateAPI) =>
|
||||||
|
chatStore[apiField as keyof ChatStore] === t.endpoint &&
|
||||||
|
chatStore[keyField as keyof ChatStore] === t.key
|
||||||
|
)?.name || `+ ${shortLabel}`}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0" side="bottom" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="Search template..." />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>
|
||||||
|
<Tr>No results found.</Tr>
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{API.map((t: TemplateAPI, index: number) => (
|
||||||
|
<CommandItem
|
||||||
|
key={index}
|
||||||
|
value={t.name}
|
||||||
|
onSelect={() => {
|
||||||
|
setChatStore({
|
||||||
|
...chatStore,
|
||||||
|
[apiField]: t.endpoint,
|
||||||
|
[keyField]: t.key,
|
||||||
|
});
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<span>{t.name}</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleEdit(t);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EditIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDelete(t);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeleteIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
{editingTemplate && (
|
||||||
|
<EditTemplateDialog
|
||||||
|
template={editingTemplate}
|
||||||
|
onSave={handleSave}
|
||||||
|
onClose={() => setEditingTemplate(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ConfirmationDialog
|
||||||
|
isOpen={deleteDialogOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setTemplateToDelete(null);
|
||||||
|
}}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
title="Delete Template"
|
||||||
|
description={`Are you sure you want to delete "${templateToDelete?.name}"?`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolsDropdownList() {
|
||||||
|
const { chatStore, setChatStore } = useContext(AppChatStoreContext);
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
|
||||||
|
const ctx = useContext(AppContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center space-x-4 mx-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
<Tr>Tools</Tr>
|
||||||
|
</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</Tr>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0" side="bottom" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="You can search..." />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>
|
||||||
|
<Tr>No results found.</Tr>
|
||||||
|
</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</Tr>
|
||||||
|
</CommandItem>
|
||||||
|
)}
|
||||||
|
{ctx.templateTools.map((t: TemplateTools, index: number) => (
|
||||||
|
<CommandItem
|
||||||
|
key={index}
|
||||||
|
value={t.toolsString}
|
||||||
|
onSelect={(value) => {
|
||||||
|
chatStore.toolsString = value;
|
||||||
|
setChatStore({ ...chatStore });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.name}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditChatTemplateDialogProps {
|
||||||
|
template: TemplateChatStore;
|
||||||
|
onSave: (updatedTemplate: TemplateChatStore) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditChatTemplateDialog({ template, onSave, onClose }: EditChatTemplateDialogProps) {
|
||||||
|
const [name, setName] = useState(template.name);
|
||||||
|
const [jsonContent, setJsonContent] = useState(() => {
|
||||||
|
const { name: _, ...rest } = template;
|
||||||
|
return JSON.stringify(rest, null, 2);
|
||||||
|
});
|
||||||
|
const [editor, setEditor] = useState<any>(null);
|
||||||
|
|
||||||
|
const handleEditorDidMount = (editor: any) => {
|
||||||
|
setEditor(editor);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFormat = () => {
|
||||||
|
if (editor) {
|
||||||
|
editor.getAction('editor.action.formatDocument').run();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!name.trim()) {
|
||||||
|
toast.error('Template name cannot be empty');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedJson = JSON.parse(jsonContent);
|
||||||
|
const updatedTemplate: TemplateChatStore = {
|
||||||
|
name: name.trim(),
|
||||||
|
...parsedJson
|
||||||
|
};
|
||||||
|
onSave(updatedTemplate);
|
||||||
|
toast.success('Template updated successfully');
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Invalid JSON format');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={onClose}>
|
||||||
|
<DialogContent className="max-w-4xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Template</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="name">Template Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Enter template name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Template Content (JSON)</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="absolute right-2 top-2 z-10"
|
||||||
|
onClick={handleFormat}
|
||||||
|
>
|
||||||
|
Format JSON
|
||||||
|
</Button>
|
||||||
|
<div className="h-[400px] border rounded-md">
|
||||||
|
<Editor
|
||||||
|
height="400px"
|
||||||
|
defaultLanguage="json"
|
||||||
|
value={jsonContent}
|
||||||
|
onChange={(value) => setJsonContent(value || '')}
|
||||||
|
onMount={handleEditorDidMount}
|
||||||
|
options={{
|
||||||
|
minimap: { enabled: false },
|
||||||
|
fontSize: 14,
|
||||||
|
lineNumbers: 'on',
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
automaticLayout: true,
|
||||||
|
tabSize: 2,
|
||||||
|
wordWrap: 'on'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave}>Save Changes</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChatTemplateDropdownList() {
|
||||||
|
const ctx = useContext(AppContext);
|
||||||
|
const { chatStore, setChatStore } = useContext(AppChatStoreContext);
|
||||||
|
const { templates, setTemplates } = useContext(AppContext);
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const [editingTemplate, setEditingTemplate] = useState<TemplateChatStore | null>(null);
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
|
||||||
|
const [templateToApply, setTemplateToApply] = useState<TemplateChatStore | null>(null);
|
||||||
|
|
||||||
|
const handleEdit = (template: TemplateChatStore) => {
|
||||||
|
setEditingTemplate(template);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = (updatedTemplate: TemplateChatStore) => {
|
||||||
|
const index = templates.findIndex(t => t.name === updatedTemplate.name);
|
||||||
|
if (index !== -1) {
|
||||||
|
const newTemplates = [...templates];
|
||||||
|
newTemplates[index] = updatedTemplate;
|
||||||
|
setTemplates(newTemplates);
|
||||||
|
toast({
|
||||||
|
title: "Success",
|
||||||
|
description: "Template updated successfully",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (template: TemplateChatStore) => {
|
||||||
|
setTemplateToApply(template);
|
||||||
|
setConfirmDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTemplateSelect = (template: TemplateChatStore) => {
|
||||||
|
if (chatStore.history.length > 0 || chatStore.systemMessageContent) {
|
||||||
|
setTemplateToApply(template);
|
||||||
|
setConfirmDialogOpen(true);
|
||||||
|
} else {
|
||||||
|
applyTemplate(template);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyTemplate = (template: TemplateChatStore) => {
|
||||||
|
setChatStore({
|
||||||
|
...newChatStore({
|
||||||
|
...chatStore,
|
||||||
|
...{
|
||||||
|
use_this_history: template.history ?? chatStore.history,
|
||||||
|
},
|
||||||
|
...template,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center space-x-4 mx-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
<Tr>Chat Template</Tr>
|
||||||
|
</p>
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" className="w-[150px] justify-start">
|
||||||
|
<Tr>Select Template</Tr>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0" side="bottom" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="Search template..." />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>
|
||||||
|
<Tr>No results found.</Tr>
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{templates.map((t: TemplateChatStore, index: number) => (
|
||||||
|
<CommandItem
|
||||||
|
key={index}
|
||||||
|
value={t.name}
|
||||||
|
onSelect={() => handleTemplateSelect(structuredClone(t))}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<span>{t.name}</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleEdit(t);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EditIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDelete(t);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeleteIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
{editingTemplate && (
|
||||||
|
<EditChatTemplateDialog
|
||||||
|
template={editingTemplate}
|
||||||
|
onSave={handleSave}
|
||||||
|
onClose={() => setEditingTemplate(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ConfirmationDialog
|
||||||
|
isOpen={confirmDialogOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setConfirmDialogOpen(false);
|
||||||
|
setTemplateToApply(null);
|
||||||
|
}}
|
||||||
|
onConfirm={() => templateToApply && applyTemplate(templateToApply)}
|
||||||
|
title="Replace Chat History"
|
||||||
|
description="This will replace the current chat history. Are you sure?"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const APIListMenu: React.FC = () => {
|
||||||
|
const ctx = useContext(AppContext);
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col my-2 gap-2 w-full">
|
||||||
|
{ctx.templateTools.length > 0 && <ToolsDropdownList />}
|
||||||
|
{ctx.templates.length > 0 && <ChatTemplateDropdownList />}
|
||||||
|
{ctx.templateAPIs.length > 0 && (
|
||||||
|
<APIsDropdownList
|
||||||
|
label="Chat API"
|
||||||
|
shortLabel="Chat"
|
||||||
|
apiField="apiEndpoint"
|
||||||
|
keyField="apiKey"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{ctx.templateAPIsWhisper.length > 0 && (
|
||||||
|
<APIsDropdownList
|
||||||
|
label="Whisper API"
|
||||||
|
shortLabel="Whisper"
|
||||||
|
apiField="whisper_api"
|
||||||
|
keyField="whisper_key"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{ctx.templateAPIsTTS.length > 0 && (
|
||||||
|
<APIsDropdownList
|
||||||
|
label="TTS API"
|
||||||
|
shortLabel="TTS"
|
||||||
|
apiField="tts_api"
|
||||||
|
keyField="tts_key"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{ctx.templateAPIsImageGen.length > 0 && (
|
||||||
|
<APIsDropdownList
|
||||||
|
label="Image Gen API"
|
||||||
|
shortLabel="ImgGen"
|
||||||
|
apiField="image_gen_api"
|
||||||
|
keyField="image_gen_key"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default APIListMenu;
|
||||||
592
src/components/MessageBubble.tsx
Normal file
592
src/components/MessageBubble.tsx
Normal file
@@ -0,0 +1,592 @@
|
|||||||
|
import { LightBulbIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||||
|
import Markdown from "react-markdown";
|
||||||
|
import remarkMath from "remark-math";
|
||||||
|
import rehypeKatex from "rehype-katex";
|
||||||
|
import rehypeHighlight from "rehype-highlight";
|
||||||
|
import "katex/dist/katex.min.css";
|
||||||
|
import {
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
useMemo,
|
||||||
|
useInsertionEffect,
|
||||||
|
useEffect,
|
||||||
|
} from "react";
|
||||||
|
import { ChatStoreMessage } from "@/types/chatstore";
|
||||||
|
import { addTotalCost } from "@/utils/totalCost";
|
||||||
|
|
||||||
|
import { Tr } from "@/translate";
|
||||||
|
import { getMessageText } from "@/chatgpt";
|
||||||
|
import { EditMessage } from "@/components/editMessage";
|
||||||
|
import logprobToColor from "@/utils/logprob";
|
||||||
|
import {
|
||||||
|
ChatBubble,
|
||||||
|
ChatBubbleMessage,
|
||||||
|
ChatBubbleAction,
|
||||||
|
ChatBubbleActionWrapper,
|
||||||
|
} from "@/components/ui/chat/chat-bubble";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@/components/ui/collapsible";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import {
|
||||||
|
ClipboardIcon,
|
||||||
|
PencilIcon,
|
||||||
|
MessageSquareOffIcon,
|
||||||
|
MessageSquarePlusIcon,
|
||||||
|
AudioLinesIcon,
|
||||||
|
LoaderCircleIcon,
|
||||||
|
ChevronsUpDownIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { AppChatStoreContext, AppContext } from "@/pages/App";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
|
||||||
|
|
||||||
|
interface HideMessageProps {
|
||||||
|
chat: ChatStoreMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageHide({ chat }: HideMessageProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<span>{getMessageText(chat).trim().slice(0, 28)} ...</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex mt-2 justify-center">
|
||||||
|
<Badge variant="destructive">
|
||||||
|
<Tr>Removed from context</Tr>
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessageDetailProps {
|
||||||
|
chat: ChatStoreMessage;
|
||||||
|
renderMarkdown: boolean;
|
||||||
|
}
|
||||||
|
function MessageDetail({ chat, renderMarkdown }: MessageDetailProps) {
|
||||||
|
if (typeof chat.content === "string") {
|
||||||
|
return <div></div>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{chat.content.map((mdt) =>
|
||||||
|
mdt.type === "text" ? (
|
||||||
|
chat.hide ? (
|
||||||
|
mdt.text?.trim().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 { chatStore, setChatStore } = useContext(AppChatStoreContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => {
|
||||||
|
const api = chatStore.tts_api;
|
||||||
|
const api_key = chatStore.tts_key;
|
||||||
|
const model = "tts-1";
|
||||||
|
const input = getMessageText(props.chat);
|
||||||
|
const voice = chatStore.tts_voice;
|
||||||
|
|
||||||
|
const body: Record<string, any> = {
|
||||||
|
model,
|
||||||
|
input,
|
||||||
|
voice,
|
||||||
|
response_format: chatStore.tts_format || "mp3",
|
||||||
|
};
|
||||||
|
if (chatStore.tts_speed_enabled) {
|
||||||
|
body["speed"] = 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;
|
||||||
|
chatStore.cost += cost;
|
||||||
|
addTotalCost(cost);
|
||||||
|
setChatStore({ ...chatStore });
|
||||||
|
|
||||||
|
// save blob
|
||||||
|
props.chat.audio = blob;
|
||||||
|
setChatStore({ ...chatStore });
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const audio = new Audio(url);
|
||||||
|
audio.play();
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setGenerating(false);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{generating ? (
|
||||||
|
<LoaderCircleIcon className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<AudioLinesIcon className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Message(props: { messageIndex: number }) {
|
||||||
|
const { messageIndex } = props;
|
||||||
|
const { chatStore, setChatStore } = useContext(AppChatStoreContext);
|
||||||
|
|
||||||
|
const chat = chatStore.history[messageIndex];
|
||||||
|
const [showEdit, setShowEdit] = useState(false);
|
||||||
|
const { defaultRenderMD } = useContext(AppContext);
|
||||||
|
const [renderMarkdown, setRenderWorkdown] = useState(defaultRenderMD);
|
||||||
|
const [renderColor, setRenderColor] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRenderWorkdown(defaultRenderMD);
|
||||||
|
}, [defaultRenderMD]);
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
const copyToClipboard = async (text: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
toast({
|
||||||
|
description: <Tr>Message copied to clipboard!</Tr>,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const textArea = document.createElement("textarea");
|
||||||
|
textArea.value = text;
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.select();
|
||||||
|
try {
|
||||||
|
document.execCommand("copy");
|
||||||
|
toast({
|
||||||
|
description: <Tr>Message copied to clipboard!</Tr>,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
description: <Tr>Failed to copy to clipboard</Tr>,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{chatStore.postBeginIndex !== 0 &&
|
||||||
|
!chatStore.history[messageIndex].hide &&
|
||||||
|
chatStore.postBeginIndex ===
|
||||||
|
chatStore.history.slice(0, messageIndex).filter(({ hide }) => !hide)
|
||||||
|
.length && (
|
||||||
|
<div className="flex items-center relative justify-center">
|
||||||
|
<hr className="w-full h-px my-4 border-0" />
|
||||||
|
<span className="absolute px-3 rounded p-1">
|
||||||
|
Above messages are "forgotten"
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{chat.role === "assistant" ? (
|
||||||
|
<div className="pb-4">
|
||||||
|
{chat.reasoning_content ? (
|
||||||
|
<Card className="bg-muted hover:bg-muted/80 mb-5 w-full">
|
||||||
|
<Collapsible>
|
||||||
|
<div className="flex items-center justify-between px-3 py-1">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<h4 className="font-semibold text-sm">
|
||||||
|
Think Content of {chat.response_model_name}
|
||||||
|
</h4>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
<LightBulbIcon className="h-3 w-3 text-gray-500" />
|
||||||
|
<span className="sr-only">Toggle</span>
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CollapsibleContent className="ml-5 text-gray-500 message-content p">
|
||||||
|
{chat.reasoning_content.trim()}
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
<div>
|
||||||
|
{chat.hide ? (
|
||||||
|
<MessageHide chat={chat} />
|
||||||
|
) : typeof chat.content !== "string" ? (
|
||||||
|
<MessageDetail chat={chat} renderMarkdown={renderMarkdown} />
|
||||||
|
) : chat.tool_calls ? (
|
||||||
|
<MessageToolCall chat={chat} copyToClipboard={copyToClipboard} />
|
||||||
|
) : renderMarkdown ? (
|
||||||
|
<Markdown
|
||||||
|
remarkPlugins={[remarkMath]}
|
||||||
|
rehypePlugins={[rehypeKatex, rehypeHighlight]}
|
||||||
|
disallowedElements={[
|
||||||
|
"script",
|
||||||
|
"iframe",
|
||||||
|
"object",
|
||||||
|
"embed",
|
||||||
|
"hr",
|
||||||
|
]}
|
||||||
|
// allowElement={(element) => {
|
||||||
|
// return [
|
||||||
|
// "p",
|
||||||
|
// "em",
|
||||||
|
// "strong",
|
||||||
|
// "del",
|
||||||
|
// "code",
|
||||||
|
// "inlineCode",
|
||||||
|
// "blockquote",
|
||||||
|
// "ul",
|
||||||
|
// "ol",
|
||||||
|
// "li",
|
||||||
|
// "pre",
|
||||||
|
// ].includes(element.tagName);
|
||||||
|
// }}
|
||||||
|
className={"prose max-w-none md:max-w-[75%]"}
|
||||||
|
>
|
||||||
|
{getMessageText(chat)}
|
||||||
|
</Markdown>
|
||||||
|
) : (
|
||||||
|
<div className="message-content max-w-full md:max-w-[100%]">
|
||||||
|
{chat.content &&
|
||||||
|
(chat.logprobs && renderColor
|
||||||
|
? chat.logprobs.content
|
||||||
|
.filter((c) => c.token)
|
||||||
|
.map((c) => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: logprobToColor(c.logprob),
|
||||||
|
display: "inline",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.token}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
: getMessageText(chat))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<TTSPlay chat={chat} />
|
||||||
|
</div>
|
||||||
|
<div className="flex md:opacity-0 hover:opacity-100 transition-opacity">
|
||||||
|
<ChatBubbleAction
|
||||||
|
icon={
|
||||||
|
chat.hide ? (
|
||||||
|
<MessageSquarePlusIcon className="size-4" />
|
||||||
|
) : (
|
||||||
|
<MessageSquareOffIcon className="size-4" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
chatStore.history[messageIndex].hide =
|
||||||
|
!chatStore.history[messageIndex].hide;
|
||||||
|
chatStore.totalTokens = 0;
|
||||||
|
for (const i of chatStore.history
|
||||||
|
.filter(({ hide }) => !hide)
|
||||||
|
.slice(chatStore.postBeginIndex)
|
||||||
|
.map(({ token }) => token)) {
|
||||||
|
chatStore.totalTokens += i;
|
||||||
|
}
|
||||||
|
setChatStore({ ...chatStore });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ChatBubbleAction
|
||||||
|
icon={<PencilIcon className="size-4" />}
|
||||||
|
onClick={() => setShowEdit(true)}
|
||||||
|
/>
|
||||||
|
<ChatBubbleAction
|
||||||
|
icon={<ClipboardIcon className="size-4" />}
|
||||||
|
onClick={() => copyToClipboard(getMessageText(chat))}
|
||||||
|
/>
|
||||||
|
{chatStore.tts_api && chatStore.tts_key && (
|
||||||
|
<TTSButton chat={chat} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ChatBubble variant="sent" className="flex-row-reverse">
|
||||||
|
<ChatBubbleMessage isLoading={false}>
|
||||||
|
{chat.hide ? (
|
||||||
|
<MessageHide chat={chat} />
|
||||||
|
) : typeof chat.content !== "string" ? (
|
||||||
|
<MessageDetail chat={chat} renderMarkdown={renderMarkdown} />
|
||||||
|
) : chat.tool_calls ? (
|
||||||
|
<MessageToolCall chat={chat} copyToClipboard={copyToClipboard} />
|
||||||
|
) : chat.role === "tool" ? (
|
||||||
|
<MessageToolResp chat={chat} copyToClipboard={copyToClipboard} />
|
||||||
|
) : renderMarkdown ? (
|
||||||
|
<Markdown
|
||||||
|
components={{
|
||||||
|
p: ({ children, node }: any) => {
|
||||||
|
if (node?.parent?.type === "listItem") {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
return <p>{children}</p>;
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getMessageText(chat)}
|
||||||
|
</Markdown>
|
||||||
|
) : (
|
||||||
|
<div className="message-content">
|
||||||
|
{chat.content &&
|
||||||
|
(chat.logprobs && renderColor
|
||||||
|
? chat.logprobs.content
|
||||||
|
.filter((c) => c.token)
|
||||||
|
.map((c) => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: logprobToColor(c.logprob),
|
||||||
|
display: "inline",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.token}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
: getMessageText(chat))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<TTSPlay chat={chat} />
|
||||||
|
</ChatBubbleMessage>
|
||||||
|
<ChatBubbleActionWrapper>
|
||||||
|
<ChatBubbleAction
|
||||||
|
icon={
|
||||||
|
chat.hide ? (
|
||||||
|
<MessageSquarePlusIcon className="size-4" />
|
||||||
|
) : (
|
||||||
|
<MessageSquareOffIcon className="size-4" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
chatStore.history[messageIndex].hide =
|
||||||
|
!chatStore.history[messageIndex].hide;
|
||||||
|
chatStore.totalTokens = 0;
|
||||||
|
for (const i of chatStore.history
|
||||||
|
.filter(({ hide }) => !hide)
|
||||||
|
.slice(chatStore.postBeginIndex)
|
||||||
|
.map(({ token }) => token)) {
|
||||||
|
chatStore.totalTokens += i;
|
||||||
|
}
|
||||||
|
setChatStore({ ...chatStore });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ChatBubbleAction
|
||||||
|
icon={<PencilIcon className="size-4" />}
|
||||||
|
onClick={() => setShowEdit(true)}
|
||||||
|
/>
|
||||||
|
<ChatBubbleAction
|
||||||
|
icon={<ClipboardIcon className="size-4" />}
|
||||||
|
onClick={() => copyToClipboard(getMessageText(chat))}
|
||||||
|
/>
|
||||||
|
{chatStore.tts_api && chatStore.tts_key && (
|
||||||
|
<TTSButton chat={chat} />
|
||||||
|
)}
|
||||||
|
</ChatBubbleActionWrapper>
|
||||||
|
</ChatBubble>
|
||||||
|
)}
|
||||||
|
<EditMessage showEdit={showEdit} setShowEdit={setShowEdit} chat={chat} />
|
||||||
|
{chatStore.develop_mode && (
|
||||||
|
<div
|
||||||
|
className={`flex flex-wrap items-center gap-2 mt-2 ${
|
||||||
|
chat.role !== "assistant" ? "justify-end" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm">token</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={chat.token}
|
||||||
|
className="h-8 w-16 rounded-md border border-input bg-background px-2 text-sm"
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center justify-center rounded-sm opacity-70 hover:opacity-100 h-8 w-8"
|
||||||
|
onClick={() => {
|
||||||
|
chatStore.history.splice(messageIndex, 1);
|
||||||
|
chatStore.postBeginIndex = Math.max(
|
||||||
|
chatStore.postBeginIndex - 1,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
chatStore.totalTokens = 0;
|
||||||
|
for (const i of chatStore.history
|
||||||
|
.filter(({ hide }) => !hide)
|
||||||
|
.slice(chatStore.postBeginIndex)
|
||||||
|
.map(({ token }) => token)) {
|
||||||
|
chatStore.totalTokens += i;
|
||||||
|
}
|
||||||
|
setChatStore({ ...chatStore });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XMarkIcon className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 rounded border-primary"
|
||||||
|
checked={chat.example}
|
||||||
|
onChange={() => {
|
||||||
|
chat.example = !chat.example;
|
||||||
|
setChatStore({ ...chatStore });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
<Tr>example</Tr>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 rounded border-primary"
|
||||||
|
checked={renderMarkdown}
|
||||||
|
onChange={() => setRenderWorkdown(!renderMarkdown)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
<Tr>render</Tr>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 rounded border-primary"
|
||||||
|
checked={renderColor}
|
||||||
|
onChange={() => setRenderColor(!renderColor)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
<Tr>color</Tr>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{chat.response_model_name && (
|
||||||
|
<>
|
||||||
|
<span className="opacity-50">{chat.response_model_name}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/components/ModeToggle.tsx
Normal file
42
src/components/ModeToggle.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
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)}
|
||||||
|
defaultValue={useTheme().theme}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Select theme" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectLabel>Theme</SelectLabel>
|
||||||
|
<SelectItem value="light">Light</SelectItem>
|
||||||
|
<SelectItem value="dark">Dark</SelectItem>
|
||||||
|
<SelectItem value="system">System</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import { IDBPDatabase } from "idb";
|
|
||||||
import { useRef, useState, Dispatch, useContext } from "react";
|
import { 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 { App, AppContext } from "../pages/App";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { SearchIcon } from "lucide-react";
|
||||||
|
|
||||||
interface ChatStoreSearchResult {
|
interface ChatStoreSearchResult {
|
||||||
key: IDBValidKey;
|
key: IDBValidKey;
|
||||||
@@ -33,22 +32,23 @@ interface ChatStoreSearchResult {
|
|||||||
preview: string;
|
preview: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Search(props: {
|
export default function Search() {
|
||||||
show: boolean;
|
const { setSelectedChatIndex, db } = useContext(AppContext);
|
||||||
setShow: (show: boolean) => void;
|
|
||||||
}) {
|
|
||||||
const ctx = useContext(AppContext);
|
|
||||||
if (ctx === null) return <></>;
|
|
||||||
const { setSelectedChatIndex, db } = ctx;
|
|
||||||
|
|
||||||
const [searchResult, setSearchResult] = useState<ChatStoreSearchResult[]>([]);
|
const [searchResult, setSearchResult] = useState<ChatStoreSearchResult[]>([]);
|
||||||
const [searching, setSearching] = useState<boolean>(false);
|
const [searching, setSearching] = useState<boolean>(false);
|
||||||
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 +160,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">
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
|
||||||
187
src/components/TemplateAttributeDialog.tsx
Normal file
187
src/components/TemplateAttributeDialog.tsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { ChatStore } from "@/types/chatstore";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { ControlledInput } from "@/components/ui/controlled-input";
|
||||||
|
import { tr } from "@/translate";
|
||||||
|
|
||||||
|
interface TemplateAttributeDialogProps {
|
||||||
|
chatStore: ChatStore;
|
||||||
|
onSave: (name: string, selectedAttributes: Partial<ChatStore>) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
open: boolean;
|
||||||
|
langCode: "en-US" | "zh-CN";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TemplateAttributeDialog({
|
||||||
|
chatStore,
|
||||||
|
onSave,
|
||||||
|
onClose,
|
||||||
|
open,
|
||||||
|
langCode,
|
||||||
|
}: TemplateAttributeDialogProps) {
|
||||||
|
// Create a map of all ChatStore attributes and their selection state
|
||||||
|
const [selectedAttributes, setSelectedAttributes] = useState<
|
||||||
|
Record<string, boolean>
|
||||||
|
>(() => {
|
||||||
|
const initial: Record<string, boolean> = {};
|
||||||
|
// Initialize all attributes as selected by default
|
||||||
|
Object.keys(chatStore).forEach((key) => {
|
||||||
|
initial[key] = true;
|
||||||
|
});
|
||||||
|
return initial;
|
||||||
|
});
|
||||||
|
|
||||||
|
const [templateName, setTemplateName] = useState("");
|
||||||
|
const [nameError, setNameError] = useState("");
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
// Validate name
|
||||||
|
if (!templateName.trim()) {
|
||||||
|
setNameError(tr("Template name is required", langCode));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setNameError("");
|
||||||
|
|
||||||
|
// Create a new object with only the selected attributes
|
||||||
|
const filteredStore = {} as Partial<ChatStore>;
|
||||||
|
Object.entries(selectedAttributes).forEach(([key, isSelected]) => {
|
||||||
|
if (isSelected) {
|
||||||
|
const typedKey = key as keyof ChatStore;
|
||||||
|
// Use type assertion to ensure type safety
|
||||||
|
(filteredStore as any)[typedKey] = chatStore[typedKey];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
onSave(templateName, structuredClone(filteredStore));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAll = (checked: boolean) => {
|
||||||
|
const newSelected = { ...selectedAttributes };
|
||||||
|
Object.keys(newSelected).forEach((key) => {
|
||||||
|
newSelected[key] = checked;
|
||||||
|
});
|
||||||
|
setSelectedAttributes(newSelected);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatValue = (value: any): string => {
|
||||||
|
if (value === null || value === undefined) return "null";
|
||||||
|
if (typeof value === "object") {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return `[${value.length} items]`;
|
||||||
|
}
|
||||||
|
return "{...}";
|
||||||
|
}
|
||||||
|
if (typeof value === "string") {
|
||||||
|
if (value.length > 50) {
|
||||||
|
return value.substring(0, 47) + "...";
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Select Template Attributes</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Choose which attributes to include in your template. Unselected
|
||||||
|
attributes will be omitted.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="template-name">Template Name</Label>
|
||||||
|
<ControlledInput
|
||||||
|
id="template-name"
|
||||||
|
value={templateName}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTemplateName(e.target.value);
|
||||||
|
setNameError("");
|
||||||
|
}}
|
||||||
|
placeholder={tr("Enter template name", langCode)}
|
||||||
|
/>
|
||||||
|
{nameError && <p className="text-sm text-red-500">{nameError}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="select-all"
|
||||||
|
checked={Object.values(selectedAttributes).every((v) => v)}
|
||||||
|
onCheckedChange={(checked) => toggleAll(checked as boolean)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="select-all">Select All</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="h-[400px] rounded-md border p-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
{Object.keys(chatStore).map((key) => (
|
||||||
|
<div key={key} className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id={key}
|
||||||
|
checked={selectedAttributes[key]}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setSelectedAttributes((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[key]: checked as boolean,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<Label htmlFor={key} className="text-sm">
|
||||||
|
{key}
|
||||||
|
</Label>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatValue(chatStore[key as keyof ChatStore])}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p className="max-w-xs break-words">
|
||||||
|
{JSON.stringify(
|
||||||
|
chatStore[key as keyof ChatStore],
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave}>Save Template</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
import { AppContext } from "@/pages/App";
|
import { AppChatStoreContext, AppContext } from "@/pages/App";
|
||||||
import { TemplateChatStore } from "@/types/chatstore";
|
import { TemplateChatStore } from "@/types/chatstore";
|
||||||
import { ChatStore } from "@/types/chatstore";
|
import { ChatStore } from "@/types/chatstore";
|
||||||
import { getDefaultParams } from "@/utils/getDefaultParam";
|
|
||||||
import { useContext } from "react";
|
import { useContext } from "react";
|
||||||
|
|
||||||
const Templates = () => {
|
const Templates = () => {
|
||||||
const ctx = useContext(AppContext);
|
const ctx = useContext(AppContext);
|
||||||
if (ctx === null) return <></>;
|
const { templates, setTemplates } = useContext(AppContext);
|
||||||
const { templates, chatStore, setChatStore, setTemplates } = ctx;
|
const { chatStore, setChatStore } = useContext(AppChatStoreContext);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -18,51 +17,6 @@ const Templates = () => {
|
|||||||
const newChatStore: ChatStore = structuredClone(t);
|
const newChatStore: ChatStore = structuredClone(t);
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
delete newChatStore.name;
|
delete newChatStore.name;
|
||||||
if (!newChatStore.apiEndpoint) {
|
|
||||||
newChatStore.apiEndpoint = getDefaultParams(
|
|
||||||
"api",
|
|
||||||
chatStore.apiEndpoint
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!newChatStore.apiKey) {
|
|
||||||
newChatStore.apiKey = getDefaultParams("key", chatStore.apiKey);
|
|
||||||
}
|
|
||||||
if (!newChatStore.whisper_api) {
|
|
||||||
newChatStore.whisper_api = getDefaultParams(
|
|
||||||
"whisper-api",
|
|
||||||
chatStore.whisper_api
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!newChatStore.whisper_key) {
|
|
||||||
newChatStore.whisper_key = getDefaultParams(
|
|
||||||
"whisper-key",
|
|
||||||
chatStore.whisper_key
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!newChatStore.tts_api) {
|
|
||||||
newChatStore.tts_api = getDefaultParams(
|
|
||||||
"tts-api",
|
|
||||||
chatStore.tts_api
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!newChatStore.tts_key) {
|
|
||||||
newChatStore.tts_key = getDefaultParams(
|
|
||||||
"tts-key",
|
|
||||||
chatStore.tts_key
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!newChatStore.image_gen_api) {
|
|
||||||
newChatStore.image_gen_api = getDefaultParams(
|
|
||||||
"image-gen-api",
|
|
||||||
chatStore.image_gen_api
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!newChatStore.image_gen_key) {
|
|
||||||
newChatStore.image_gen_key = getDefaultParams(
|
|
||||||
"image-gen-key",
|
|
||||||
chatStore.image_gen_key
|
|
||||||
);
|
|
||||||
}
|
|
||||||
newChatStore.cost = 0;
|
newChatStore.cost = 0;
|
||||||
|
|
||||||
// manage undefined value because of version update
|
// manage undefined value because of version update
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -1,19 +1,16 @@
|
|||||||
import { ChatStore } from "@/types/chatstore";
|
import { ChatStore } from "@/types/chatstore";
|
||||||
import { Tr } from "@/translate";
|
import { Tr } from "@/translate";
|
||||||
import { useContext } from "react";
|
import { useContext } from "react";
|
||||||
import { AppContext } from "@/pages/App";
|
import { AppChatStoreContext, AppContext } from "@/pages/App";
|
||||||
|
|
||||||
const VersionHint = () => {
|
const VersionHint = () => {
|
||||||
const ctx = useContext(AppContext);
|
const { chatStore } = useContext(AppChatStoreContext);
|
||||||
if (!ctx) return <div>error</div>;
|
|
||||||
|
|
||||||
const { chatStore } = ctx;
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{chatStore.chatgpt_api_web_version < "v1.3.0" && (
|
{chatStore.chatgpt_api_web_version < "v1.3.0" && (
|
||||||
<p className="p-2 my-2 text-center dark:text-white">
|
<p className="p-2 my-2 text-center dark:text-white">
|
||||||
<br />
|
<br />
|
||||||
{Tr("Warning: current chatStore version")}:{" "}
|
<Tr>Warning: current chatStore version</Tr>:{" "}
|
||||||
{chatStore.chatgpt_api_web_version} {"< v1.3.0"}
|
{chatStore.chatgpt_api_web_version} {"< v1.3.0"}
|
||||||
<br />
|
<br />
|
||||||
v1.3.0
|
v1.3.0
|
||||||
@@ -25,7 +22,7 @@ const VersionHint = () => {
|
|||||||
{chatStore.chatgpt_api_web_version < "v1.4.0" && (
|
{chatStore.chatgpt_api_web_version < "v1.4.0" && (
|
||||||
<p className="p-2 my-2 text-center dark:text-white">
|
<p className="p-2 my-2 text-center dark:text-white">
|
||||||
<br />
|
<br />
|
||||||
{Tr("Warning: current chatStore version")}:{" "}
|
<Tr>Warning: current chatStore version</Tr>:{" "}
|
||||||
{chatStore.chatgpt_api_web_version} {"< v1.4.0"}
|
{chatStore.chatgpt_api_web_version} {"< v1.4.0"}
|
||||||
<br />
|
<br />
|
||||||
v1.4.0 增加了更多参数,继续使用旧版可能因参数确实导致未定义的行为
|
v1.4.0 增加了更多参数,继续使用旧版可能因参数确实导致未定义的行为
|
||||||
@@ -37,7 +34,7 @@ const VersionHint = () => {
|
|||||||
<p className="p-2 my-2 text-center dark:text-white">
|
<p className="p-2 my-2 text-center dark:text-white">
|
||||||
<br />
|
<br />
|
||||||
提示:当前会话版本 {chatStore.chatgpt_api_web_version}
|
提示:当前会话版本 {chatStore.chatgpt_api_web_version}
|
||||||
{Tr("Warning: current chatStore version")}:{" "}
|
<Tr>Warning: current chatStore version</Tr>:{" "}
|
||||||
{chatStore.chatgpt_api_web_version} {"< v1.6.0"}
|
{chatStore.chatgpt_api_web_version} {"< v1.6.0"}
|
||||||
。
|
。
|
||||||
<br />
|
<br />
|
||||||
|
|||||||
@@ -1,151 +1,151 @@
|
|||||||
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,
|
import { AppChatStoreContext, AppContext } from "@/pages/App";
|
||||||
AudioWaveformIcon,
|
|
||||||
CircleStopIcon,
|
|
||||||
MicIcon,
|
|
||||||
VoicemailIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { AppContext } from "@/pages/App";
|
|
||||||
|
|
||||||
const WhisperButton = (props: {
|
const WhisperButton = (props: {
|
||||||
inputMsg: string;
|
inputMsg: string;
|
||||||
setInputMsg: Dispatch<string>;
|
setInputMsg: Dispatch<string>;
|
||||||
}) => {
|
}) => {
|
||||||
const ctx = useContext(AppContext);
|
const { chatStore } = useContext(AppChatStoreContext);
|
||||||
if (!ctx) return <div>error</div>;
|
|
||||||
const { chatStore } = ctx;
|
|
||||||
|
|
||||||
const { inputMsg, setInputMsg } = props;
|
const { inputMsg, setInputMsg } = props;
|
||||||
const mediaRef = createRef();
|
const mediaRef = createRef();
|
||||||
const [isRecording, setIsRecording] = useState("Mic");
|
const [isRecording, setIsRecording] = useState("Mic");
|
||||||
return (
|
return (
|
||||||
<Button
|
<>
|
||||||
variant="ghost"
|
{chatStore.whisper_api && chatStore.whisper_key ? (
|
||||||
size="icon"
|
<Button
|
||||||
className={`m-1 p-1 ${isRecording !== "Mic" ? "animate-pulse" : ""}`}
|
variant="ghost"
|
||||||
disabled={isRecording === "Transcribing"}
|
size="icon"
|
||||||
ref={mediaRef as any}
|
className={`${isRecording !== "Mic" ? "animate-pulse" : ""}`}
|
||||||
onClick={async (event) => {
|
disabled={isRecording === "Transcribing"}
|
||||||
event.preventDefault(); // Prevent the default behavior
|
ref={mediaRef as any}
|
||||||
|
onClick={async (event) => {
|
||||||
|
event.preventDefault(); // Prevent the default behavior
|
||||||
|
|
||||||
if (isRecording === "Recording") {
|
if (isRecording === "Recording") {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
window.mediaRecorder.stop();
|
window.mediaRecorder.stop();
|
||||||
setIsRecording("Transcribing");
|
setIsRecording("Transcribing");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// build prompt
|
// build prompt
|
||||||
const prompt = [chatStore.systemMessageContent]
|
const prompt = [chatStore.systemMessageContent]
|
||||||
.concat(
|
.concat(
|
||||||
chatStore.history
|
chatStore.history
|
||||||
.filter(({ hide }) => !hide)
|
.filter(({ hide }) => !hide)
|
||||||
.slice(chatStore.postBeginIndex)
|
.slice(chatStore.postBeginIndex)
|
||||||
.map(({ content }) => {
|
.map(({ content }) => {
|
||||||
if (typeof content === "string") {
|
if (typeof content === "string") {
|
||||||
return content;
|
return content;
|
||||||
} else {
|
} else {
|
||||||
return content.map((c) => c?.text).join(" ");
|
return content.map((c) => c?.text).join(" ");
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.concat([inputMsg])
|
.concat([inputMsg])
|
||||||
.join(" ");
|
.join(" ");
|
||||||
console.log({ prompt });
|
console.log({ prompt });
|
||||||
|
|
||||||
setIsRecording("Recording");
|
setIsRecording("Recording");
|
||||||
console.log("start recording");
|
console.log("start recording");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const mediaRecorder = new MediaRecorder(
|
const mediaRecorder = new MediaRecorder(
|
||||||
await navigator.mediaDevices.getUserMedia({
|
await navigator.mediaDevices.getUserMedia({
|
||||||
audio: true,
|
audio: true,
|
||||||
}),
|
}),
|
||||||
{ audioBitsPerSecond: 64 * 1000 }
|
{ audioBitsPerSecond: 64 * 1000 }
|
||||||
);
|
);
|
||||||
|
|
||||||
// mount mediaRecorder to ref
|
// mount mediaRecorder to ref
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
window.mediaRecorder = mediaRecorder;
|
window.mediaRecorder = mediaRecorder;
|
||||||
|
|
||||||
mediaRecorder.start();
|
mediaRecorder.start();
|
||||||
const audioChunks: Blob[] = [];
|
const audioChunks: Blob[] = [];
|
||||||
mediaRecorder.addEventListener("dataavailable", (event) => {
|
mediaRecorder.addEventListener("dataavailable", (event) => {
|
||||||
audioChunks.push(event.data);
|
audioChunks.push(event.data);
|
||||||
});
|
});
|
||||||
mediaRecorder.addEventListener("stop", async () => {
|
mediaRecorder.addEventListener("stop", async () => {
|
||||||
// Stop the MediaRecorder
|
// Stop the MediaRecorder
|
||||||
mediaRecorder.stop();
|
mediaRecorder.stop();
|
||||||
// Stop the media stream
|
// Stop the media stream
|
||||||
mediaRecorder.stream.getTracks()[0].stop();
|
mediaRecorder.stream.getTracks()[0].stop();
|
||||||
|
|
||||||
setIsRecording("Transcribing");
|
setIsRecording("Transcribing");
|
||||||
const audioBlob = new Blob(audioChunks);
|
const audioBlob = new Blob(audioChunks);
|
||||||
const audioUrl = URL.createObjectURL(audioBlob);
|
const audioUrl = URL.createObjectURL(audioBlob);
|
||||||
console.log({ audioUrl });
|
console.log({ audioUrl });
|
||||||
const audio = new Audio(audioUrl);
|
const audio = new Audio(audioUrl);
|
||||||
// audio.play();
|
// audio.play();
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.readAsDataURL(audioBlob);
|
reader.readAsDataURL(audioBlob);
|
||||||
|
|
||||||
// file-like object with mimetype
|
// file-like object with mimetype
|
||||||
const blob = new Blob([audioBlob], {
|
const blob = new Blob([audioBlob], {
|
||||||
type: "application/octet-stream",
|
type: "application/octet-stream",
|
||||||
});
|
|
||||||
|
|
||||||
reader.onloadend = async () => {
|
|
||||||
try {
|
|
||||||
const base64data = reader.result;
|
|
||||||
|
|
||||||
// post to openai whisper api
|
|
||||||
const formData = new FormData();
|
|
||||||
// append file
|
|
||||||
formData.append("file", blob, "audio.ogg");
|
|
||||||
formData.append("model", "whisper-1");
|
|
||||||
formData.append("response_format", "text");
|
|
||||||
formData.append("prompt", prompt);
|
|
||||||
|
|
||||||
const response = await fetch(chatStore.whisper_api, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${
|
|
||||||
chatStore.whisper_key || chatStore.apiKey
|
|
||||||
}`,
|
|
||||||
},
|
|
||||||
body: formData,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const text = await response.text();
|
reader.onloadend = async () => {
|
||||||
|
try {
|
||||||
|
const base64data = reader.result;
|
||||||
|
|
||||||
setInputMsg(inputMsg ? inputMsg + " " + text : text);
|
// post to openai whisper api
|
||||||
} catch (error) {
|
const formData = new FormData();
|
||||||
alert(error);
|
// append file
|
||||||
console.log(error);
|
formData.append("file", blob, "audio.ogg");
|
||||||
} finally {
|
formData.append("model", "whisper-1");
|
||||||
setIsRecording("Mic");
|
formData.append("response_format", "text");
|
||||||
}
|
formData.append("prompt", prompt);
|
||||||
};
|
|
||||||
});
|
const response = await fetch(chatStore.whisper_api, {
|
||||||
} catch (error) {
|
method: "POST",
|
||||||
alert(error);
|
headers: {
|
||||||
console.log(error);
|
Authorization: `Bearer ${
|
||||||
setIsRecording("Mic");
|
chatStore.whisper_key || chatStore.apiKey
|
||||||
}
|
}`,
|
||||||
}}
|
},
|
||||||
>
|
body: formData,
|
||||||
{isRecording === "Mic" ? (
|
});
|
||||||
<MicIcon />
|
|
||||||
) : isRecording === "Recording" ? (
|
const text = await response.text();
|
||||||
<CircleStopIcon />
|
|
||||||
|
setInputMsg(inputMsg ? inputMsg + " " + text : text);
|
||||||
|
} catch (error) {
|
||||||
|
alert(error);
|
||||||
|
console.log(error);
|
||||||
|
} finally {
|
||||||
|
setIsRecording("Mic");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
alert(error);
|
||||||
|
console.log(error);
|
||||||
|
setIsRecording("Mic");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isRecording === "Mic" ? (
|
||||||
|
<MicIcon />
|
||||||
|
) : isRecording === "Recording" ? (
|
||||||
|
<CircleStopIcon />
|
||||||
|
) : (
|
||||||
|
<AudioWaveformIcon />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<AudioWaveformIcon />
|
<Button variant="ghost" size="icon" disabled={true}>
|
||||||
|
<MicIcon />
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Button>
|
<span className="sr-only">Use Microphone</span>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,9 @@ 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 { AppChatStoreContext, AppContext } from "../pages/App";
|
||||||
|
import { ConfirmationDialog } from "./ui/confirmation-dialog";
|
||||||
|
|
||||||
interface EditMessageProps {
|
interface EditMessageProps {
|
||||||
chat: ChatStoreMessage;
|
chat: ChatStoreMessage;
|
||||||
@@ -21,11 +22,20 @@ interface EditMessageProps {
|
|||||||
setShowEdit: Dispatch<boolean>;
|
setShowEdit: Dispatch<boolean>;
|
||||||
}
|
}
|
||||||
export function EditMessage(props: EditMessageProps) {
|
export function EditMessage(props: EditMessageProps) {
|
||||||
const ctx = useContext(AppContext);
|
const { chatStore, setChatStore } = useContext(AppChatStoreContext);
|
||||||
if (!ctx) return <div>error</div>;
|
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||||
|
|
||||||
const { showEdit, setShowEdit, chat } = props;
|
const { showEdit, setShowEdit, chat } = props;
|
||||||
|
|
||||||
|
const handleSwitchMessageType = () => {
|
||||||
|
if (typeof chat.content === "string") {
|
||||||
|
chat.content = [];
|
||||||
|
} else {
|
||||||
|
chat.content = "";
|
||||||
|
}
|
||||||
|
setChatStore({ ...chatStore });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={showEdit} onOpenChange={setShowEdit}>
|
<Dialog open={showEdit} onOpenChange={setShowEdit}>
|
||||||
{/* <DialogTrigger>
|
{/* <DialogTrigger>
|
||||||
@@ -43,23 +53,11 @@ export function EditMessage(props: EditMessageProps) {
|
|||||||
) : (
|
) : (
|
||||||
<EditMessageDetail chat={chat} setShowEdit={setShowEdit} />
|
<EditMessageDetail chat={chat} setShowEdit={setShowEdit} />
|
||||||
)}
|
)}
|
||||||
{ctx.chatStore.develop_mode && (
|
{chatStore.develop_mode && (
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={() => {
|
onClick={() => setShowConfirmDialog(true)}
|
||||||
const confirm = window.confirm(
|
|
||||||
"Change message type will clear the content, are you sure?"
|
|
||||||
);
|
|
||||||
if (!confirm) return;
|
|
||||||
|
|
||||||
if (typeof chat.content === "string") {
|
|
||||||
chat.content = [];
|
|
||||||
} else {
|
|
||||||
chat.content = "";
|
|
||||||
}
|
|
||||||
ctx.setChatStore({ ...ctx.chatStore });
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Switch to{" "}
|
Switch to{" "}
|
||||||
{typeof chat.content === "string"
|
{typeof chat.content === "string"
|
||||||
@@ -67,8 +65,15 @@ export function EditMessage(props: EditMessageProps) {
|
|||||||
: "string message"}
|
: "string message"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button onClick={() => setShowEdit(false)}>Save & Close</Button>
|
<Button onClick={() => setShowEdit(false)}>Close</Button>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
<ConfirmationDialog
|
||||||
|
isOpen={showConfirmDialog}
|
||||||
|
onClose={() => setShowConfirmDialog(false)}
|
||||||
|
onConfirm={handleSwitchMessageType}
|
||||||
|
title="Switch Message Type"
|
||||||
|
description="Change message type will clear the content, are you sure?"
|
||||||
|
/>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -13,19 +13,16 @@ 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 { AppChatStoreContext, AppContext } from "../pages/App";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
chat: ChatStoreMessage;
|
chat: ChatStoreMessage;
|
||||||
setShowEdit: (se: boolean) => void;
|
setShowEdit: (se: boolean) => void;
|
||||||
}
|
}
|
||||||
export function EditMessageDetail({ chat, setShowEdit }: Props) {
|
export function EditMessageDetail({ chat, setShowEdit }: Props) {
|
||||||
const ctx = useContext(AppContext);
|
const { chatStore, setChatStore } = useContext(AppChatStoreContext);
|
||||||
if (!ctx) return <div>error</div>;
|
|
||||||
|
|
||||||
const { chatStore, setChatStore } = ctx;
|
|
||||||
|
|
||||||
if (typeof chat.content !== "object") return <div>error</div>;
|
if (typeof chat.content !== "object") return <div>error</div>;
|
||||||
return (
|
return (
|
||||||
@@ -84,7 +81,7 @@ export function EditMessageDetail({ chat, setShowEdit }: Props) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{Tr("Edit URL")}
|
<Tr>Edit URL</Tr>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="bg-blue-300 p-1 rounded m-1"
|
className="bg-blue-300 p-1 rounded m-1"
|
||||||
@@ -113,7 +110,7 @@ export function EditMessageDetail({ chat, setShowEdit }: Props) {
|
|||||||
input.click();
|
input.click();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{Tr("Upload")}
|
<Tr>Upload</Tr>
|
||||||
</Button>
|
</Button>
|
||||||
<span
|
<span
|
||||||
className="bg-blue-300 p-1 rounded m-1"
|
className="bg-blue-300 p-1 rounded m-1"
|
||||||
@@ -158,7 +155,7 @@ export function EditMessageDetail({ chat, setShowEdit }: Props) {
|
|||||||
setChatStore({ ...chatStore });
|
setChatStore({ ...chatStore });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{Tr("Add text")}
|
<Tr>Add text</Tr>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className={"m-2 p-1 rounded bg-green-500"}
|
className={"m-2 p-1 rounded bg-green-500"}
|
||||||
@@ -174,7 +171,7 @@ export function EditMessageDetail({ chat, setShowEdit }: Props) {
|
|||||||
setChatStore({ ...chatStore });
|
setChatStore({ ...chatStore });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{Tr("Add image")}
|
<Tr>Add image</Tr>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<DrawerFooter>
|
<DrawerFooter>
|
||||||
@@ -182,7 +179,7 @@ export function EditMessageDetail({ chat, setShowEdit }: Props) {
|
|||||||
className="bg-blue-500 p-2 rounded"
|
className="bg-blue-500 p-2 rounded"
|
||||||
onClick={() => setShowEdit(false)}
|
onClick={() => setShowEdit(false)}
|
||||||
>
|
>
|
||||||
{Tr("Close")}
|
<Tr>Close</Tr>
|
||||||
</Button>
|
</Button>
|
||||||
</DrawerFooter>
|
</DrawerFooter>
|
||||||
</DrawerContent>
|
</DrawerContent>
|
||||||
@@ -1,21 +1,18 @@
|
|||||||
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 { AppChatStoreContext, AppContext } from "../pages/App";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
chat: ChatStoreMessage;
|
chat: ChatStoreMessage;
|
||||||
setShowEdit: (se: boolean) => void;
|
setShowEdit: (se: boolean) => void;
|
||||||
}
|
}
|
||||||
export function EditMessageString({ chat, setShowEdit }: Props) {
|
export function EditMessageString({ chat, setShowEdit }: Props) {
|
||||||
const ctx = useContext(AppContext);
|
const { chatStore, setChatStore } = useContext(AppChatStoreContext);
|
||||||
if (!ctx) return <div>error</div>;
|
|
||||||
|
|
||||||
const { chatStore, setChatStore } = ctx;
|
|
||||||
|
|
||||||
if (typeof chat.content !== "string") return <div>error</div>;
|
if (typeof chat.content !== "string") return <div>error</div>;
|
||||||
return (
|
return (
|
||||||
@@ -76,7 +73,7 @@ export function EditMessageString({ chat, setShowEdit }: Props) {
|
|||||||
setChatStore({ ...chatStore });
|
setChatStore({ ...chatStore });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{Tr("Delete this tool call")}
|
<Tr>Delete this tool call</Tr>
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
<hr className="my-2" />
|
<hr className="my-2" />
|
||||||
@@ -97,7 +94,7 @@ export function EditMessageString({ chat, setShowEdit }: Props) {
|
|||||||
setChatStore({ ...chatStore });
|
setChatStore({ ...chatStore });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{Tr("Add a tool call")}
|
<Tr>Add a tool call</Tr>
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -105,7 +102,7 @@ export function EditMessageString({ chat, setShowEdit }: Props) {
|
|||||||
<Textarea
|
<Textarea
|
||||||
className="w-full h-32 my-2"
|
className="w-full h-32 my-2"
|
||||||
value={chat.content}
|
value={chat.content}
|
||||||
onChange={(event) => {
|
onBlur={(event) => {
|
||||||
chat.content = event.target.value;
|
chat.content = event.target.value;
|
||||||
chat.token = calculate_token_length(chat.content);
|
chat.token = calculate_token_length(chat.content);
|
||||||
setChatStore({ ...chatStore });
|
setChatStore({ ...chatStore });
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
93
src/components/navbar.tsx
Normal file
93
src/components/navbar.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
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 { AppChatStoreContext, AppContext } from "@/pages/App";
|
||||||
|
import { models } from "@/types/models";
|
||||||
|
import { getTotalCost } from "@/utils/totalCost";
|
||||||
|
import { Tr } from "@/translate";
|
||||||
|
|
||||||
|
import { useContext } from "react";
|
||||||
|
import Settings from "@/components/Settings";
|
||||||
|
import APIListMenu from "./ListAPI";
|
||||||
|
|
||||||
|
const Navbar: React.FC = () => {
|
||||||
|
const { chatStore, setChatStore } = useContext(AppChatStoreContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="flex sticky top-0 bg-background h-14 shrink-0 items-center border-b z-30">
|
||||||
|
<div className="flex flex-col w-full">
|
||||||
|
<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 break-all">{chatStore.model}</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex ml-auto gap-2 px-3">
|
||||||
|
<Settings />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger className="absolute left-1/2 bottom-0 transform -translate-x-1/2 translate-y-1/2">
|
||||||
|
<div className="rounded-full bg-primary/10 hover:bg-primary/20 p-1 cursor-pointer">
|
||||||
|
<EllipsisIcon className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-screen">
|
||||||
|
<div className="flex justify-between items-center px-4 py-2 border-b">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ReceiptIcon className="w-4 h-4" />
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
<Tr>Total Tokens</Tr>: {chatStore.totalTokens.toString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<WalletIcon className="w-4 h-4" />
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
<Tr>Session Cost</Tr>: ${chatStore.cost.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center px-4 py-2 border-b">
|
||||||
|
<div></div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<WalletIcon className="w-4 h-4" />
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
<Tr>Accumulated Cost</Tr>: ${getTotalCost().toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<APIListMenu />
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Navbar;
|
||||||
93
src/components/setAPIsTemplate.tsx
Normal file
93
src/components/setAPIsTemplate.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
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</Tr>${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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -177,7 +177,7 @@ const ChatBubbleActionWrapper = React.forwardRef<
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute z-50 translate-y-full flex opacity-0 group-hover:opacity-100 transition-opacity duration-200",
|
"absolute z-30 translate-y-full flex opacity-0 group-hover:opacity-100 transition-opacity duration-200",
|
||||||
variant === "sent" ? "flex-row-reverse" : "",
|
variant === "sent" ? "flex-row-reverse" : "",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,19 +6,55 @@ interface ChatInputProps
|
|||||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||||
|
|
||||||
const ChatInput = React.forwardRef<HTMLTextAreaElement, ChatInputProps>(
|
const ChatInput = React.forwardRef<HTMLTextAreaElement, ChatInputProps>(
|
||||||
({ className, ...props }, ref) => (
|
({ className, onChange, ...props }, ref) => {
|
||||||
<Textarea
|
const internalRef = React.useRef<HTMLTextAreaElement>(null);
|
||||||
autoComplete="off"
|
|
||||||
ref={ref}
|
// Combine the forwarded ref with the internal ref
|
||||||
name="message"
|
React.useImperativeHandle(
|
||||||
className={cn(
|
ref,
|
||||||
"max-h-12 px-4 py-3 bg-background text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 w-full rounded-md flex items-center h-16 resize-none",
|
() => internalRef.current as HTMLTextAreaElement
|
||||||
className
|
);
|
||||||
)}
|
|
||||||
{...props}
|
// Function to adjust the height of the textarea
|
||||||
/>
|
const adjustHeight = () => {
|
||||||
)
|
if (internalRef.current) {
|
||||||
|
// Reset the height to auto to calculate the new height
|
||||||
|
internalRef.current.style.height = "auto";
|
||||||
|
// Set the height to the scrollHeight (content height)
|
||||||
|
internalRef.current.style.height = `${internalRef.current.scrollHeight}px`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Adjust height whenever the content changes
|
||||||
|
React.useEffect(() => {
|
||||||
|
adjustHeight();
|
||||||
|
}, [props.value]); // Run whenever the value changes
|
||||||
|
|
||||||
|
// Handle input changes
|
||||||
|
const handleInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
adjustHeight();
|
||||||
|
if (onChange) {
|
||||||
|
onChange(e); // Call the passed onChange handler
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Textarea
|
||||||
|
mockOnChange={false}
|
||||||
|
autoComplete="off"
|
||||||
|
ref={internalRef}
|
||||||
|
name="message"
|
||||||
|
className={cn(
|
||||||
|
"max-h-48 px-4 py-3 bg-background text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 w-full rounded-md flex items-center resize-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onChange={handleInput}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
ChatInput.displayName = "ChatInput";
|
ChatInput.displayName = "ChatInput";
|
||||||
|
|
||||||
export { ChatInput };
|
export { ChatInput };
|
||||||
|
|||||||
48
src/components/ui/confirmation-dialog.tsx
Normal file
48
src/components/ui/confirmation-dialog.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
DialogDescription,
|
||||||
|
} from "./dialog";
|
||||||
|
import { Button } from "./button";
|
||||||
|
|
||||||
|
interface ConfirmationDialogProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmationDialog({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
title,
|
||||||
|
description
|
||||||
|
}: ConfirmationDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={() => {
|
||||||
|
onConfirm();
|
||||||
|
onClose();
|
||||||
|
}}>
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
src/components/ui/controlled-input.tsx
Normal file
21
src/components/ui/controlled-input.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { ComponentProps, forwardRef } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const ControlledInput = forwardRef<HTMLInputElement, ComponentProps<"input">>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
ControlledInput.displayName = "ControlledInput";
|
||||||
|
|
||||||
|
export { ControlledInput };
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import * as React from "react";
|
import { useState, ComponentProps, forwardRef } from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
const Input = forwardRef<HTMLInputElement, ComponentProps<"input">>(
|
||||||
({ className, type, ...props }, ref) => {
|
({ className, type, value, ...props }, ref) => {
|
||||||
|
const [innerValue, setInnerValue] = useState(value);
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
|
value={innerValue}
|
||||||
|
onChange={(e) => setInnerValue(e.target.value)}
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Slot } from "@radix-ui/react-slot";
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
import { VariantProps, cva } from "class-variance-authority";
|
import { VariantProps, cva } from "class-variance-authority";
|
||||||
import { PanelLeft } from "lucide-react";
|
import { PanelLeft, X } from "lucide-react";
|
||||||
|
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -192,21 +192,41 @@ const Sidebar = React.forwardRef<
|
|||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
<div className="md:hidden">
|
||||||
<SheetContent
|
{/* 遮罩层 */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-40 bg-black/80 transition-opacity duration-500",
|
||||||
|
openMobile ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||||
|
)}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
setOpenMobile(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 侧边栏 */}
|
||||||
|
<div
|
||||||
data-sidebar="sidebar"
|
data-sidebar="sidebar"
|
||||||
data-mobile="true"
|
data-mobile="true"
|
||||||
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
className={cn(
|
||||||
|
"fixed inset-y-0 z-50 h-svh w-[--sidebar-width-mobile] bg-sidebar p-0 text-sidebar-foreground transition-transform duration-500 ease-in-out",
|
||||||
|
side === "left"
|
||||||
|
? "-translate-x-full left-0"
|
||||||
|
: "translate-x-full right-0",
|
||||||
|
openMobile && "translate-x-0"
|
||||||
|
)}
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
"--sidebar-width-mobile": SIDEBAR_WIDTH_MOBILE,
|
||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
side={side}
|
{...props}
|
||||||
>
|
>
|
||||||
<div className="flex h-full w-full flex-col">{children}</div>
|
<div className="flex h-full w-full flex-col">{children}</div>
|
||||||
</SheetContent>
|
</div>
|
||||||
</Sheet>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
import * as React from "react";
|
import { forwardRef, ComponentProps, useState } from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Textarea = React.forwardRef<
|
const Textarea = forwardRef<
|
||||||
HTMLTextAreaElement,
|
HTMLTextAreaElement,
|
||||||
React.ComponentProps<"textarea">
|
ComponentProps<"textarea"> & { mockOnChange?: boolean }
|
||||||
>(({ className, ...props }, ref) => {
|
>(({ className, value, onChange, mockOnChange = true, ...props }, ref) => {
|
||||||
|
const [innerValue, setInnerValue] = useState(value);
|
||||||
return (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
|
value={mockOnChange ? innerValue : value}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (mockOnChange) {
|
||||||
|
setInnerValue(e.target.value);
|
||||||
|
} else {
|
||||||
|
onChange?.(e);
|
||||||
|
}
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
className
|
className
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
@import "highlight.js/styles/monokai.css";
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|||||||
113
src/listAPIs.tsx
113
src/listAPIs.tsx
@@ -1,113 +0,0 @@
|
|||||||
import { ChatStore, TemplateAPI } from "@/types/chatstore";
|
|
||||||
import { Tr } from "@/translate";
|
|
||||||
|
|
||||||
import {
|
|
||||||
NavigationMenu,
|
|
||||||
NavigationMenuContent,
|
|
||||||
NavigationMenuIndicator,
|
|
||||||
NavigationMenuItem,
|
|
||||||
NavigationMenuLink,
|
|
||||||
NavigationMenuList,
|
|
||||||
NavigationMenuTrigger,
|
|
||||||
NavigationMenuViewport,
|
|
||||||
} from "@/components/ui/navigation-menu";
|
|
||||||
import { Button } from "./components/ui/button";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { useContext } from "react";
|
|
||||||
import { AppContext } from "./pages/App";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
label: string;
|
|
||||||
apiField: string;
|
|
||||||
keyField: string;
|
|
||||||
}
|
|
||||||
export function ListAPIs({ label, apiField, keyField }: Props) {
|
|
||||||
const ctx = useContext(AppContext);
|
|
||||||
if (ctx === null) return <></>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NavigationMenuItem>
|
|
||||||
<NavigationMenuTrigger>
|
|
||||||
{label}{" "}
|
|
||||||
<span className="hidden lg:inline">
|
|
||||||
{ctx.templateAPIs.find(
|
|
||||||
(t) =>
|
|
||||||
ctx.chatStore[apiField as keyof ChatStore] === t.endpoint &&
|
|
||||||
ctx.chatStore[keyField as keyof ChatStore] === t.key
|
|
||||||
)?.name &&
|
|
||||||
`: ${
|
|
||||||
ctx.templateAPIs.find(
|
|
||||||
(t) =>
|
|
||||||
ctx.chatStore[apiField as keyof ChatStore] === t.endpoint &&
|
|
||||||
ctx.chatStore[keyField as keyof ChatStore] === t.key
|
|
||||||
)?.name
|
|
||||||
}`}
|
|
||||||
</span>
|
|
||||||
</NavigationMenuTrigger>
|
|
||||||
<NavigationMenuContent>
|
|
||||||
<ul className="grid w-[400px] gap-3 p-4 md:w-[500px] md:grid-cols-2 lg:w-[600px] ">
|
|
||||||
{ctx.templateAPIs.map((t, index) => (
|
|
||||||
<li>
|
|
||||||
<NavigationMenuLink asChild>
|
|
||||||
<a
|
|
||||||
onClick={() => {
|
|
||||||
// @ts-ignore
|
|
||||||
ctx.chatStore[apiField as keyof ChatStore] = t.endpoint;
|
|
||||||
// @ts-ignore
|
|
||||||
ctx.chatStore[keyField] = t.key;
|
|
||||||
ctx.setChatStore({ ...ctx.chatStore });
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"block select-none space-y-1 rounded-md p-3 leading-none no-underline outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground",
|
|
||||||
ctx.chatStore[apiField as keyof ChatStore] === t.endpoint &&
|
|
||||||
ctx.chatStore[keyField as keyof ChatStore] === t.key
|
|
||||||
? "bg-accent text-accent-foreground"
|
|
||||||
: ""
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="text-sm font-medium leading-none">
|
|
||||||
{t.name}
|
|
||||||
</div>
|
|
||||||
<p className="line-clamp-2 text-sm leading-snug text-muted-foreground">
|
|
||||||
{new URL(t.endpoint).host}
|
|
||||||
</p>
|
|
||||||
</a>
|
|
||||||
</NavigationMenuLink>
|
|
||||||
<div className="mt-2 flex justify-between">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
const name = prompt(`Give **${label}** template a name`);
|
|
||||||
if (!name) return;
|
|
||||||
t.name = name;
|
|
||||||
ctx.setTemplateAPIs(structuredClone(ctx.templateAPIs));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
if (
|
|
||||||
!confirm(
|
|
||||||
`Are you sure to delete this **${label}** template?`
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ctx.templateAPIs.splice(index, 1);
|
|
||||||
ctx.setTemplateAPIs(structuredClone(ctx.templateAPIs));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</NavigationMenuContent>
|
|
||||||
</NavigationMenuItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import { ChatStore, TemplateTools } from "@/types/chatstore";
|
|
||||||
import { Tr } from "@/translate";
|
|
||||||
import {
|
|
||||||
NavigationMenu,
|
|
||||||
NavigationMenuContent,
|
|
||||||
NavigationMenuIndicator,
|
|
||||||
NavigationMenuItem,
|
|
||||||
NavigationMenuLink,
|
|
||||||
NavigationMenuList,
|
|
||||||
NavigationMenuTrigger,
|
|
||||||
NavigationMenuViewport,
|
|
||||||
} from "@/components/ui/navigation-menu";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { Button } from "./components/ui/button";
|
|
||||||
import { useContext } from "react";
|
|
||||||
import { AppContext } from "./pages/App";
|
|
||||||
|
|
||||||
export function ListToolsTempaltes() {
|
|
||||||
const ctx = useContext(AppContext);
|
|
||||||
if (!ctx) return <div>error</div>;
|
|
||||||
|
|
||||||
const { chatStore, setChatStore } = ctx;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NavigationMenuItem className="p-3">
|
|
||||||
<NavigationMenuTrigger>
|
|
||||||
<span>{Tr(`Saved tools templates`)}</span>
|
|
||||||
<Button
|
|
||||||
variant="link"
|
|
||||||
className="ml-2 text-sm"
|
|
||||||
onClick={() => {
|
|
||||||
chatStore.toolsString = "";
|
|
||||||
setChatStore({ ...chatStore });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{Tr(`Clear`)}
|
|
||||||
</Button>
|
|
||||||
</NavigationMenuTrigger>
|
|
||||||
<NavigationMenuContent>
|
|
||||||
<ul className="grid w-[400px] gap-3 p-4 md:w-[500px] md:grid-cols-2 lg:w-[600px]">
|
|
||||||
{ctx.templateTools.map((t, index) => (
|
|
||||||
<li key={index}>
|
|
||||||
<NavigationMenuLink asChild>
|
|
||||||
<a
|
|
||||||
onClick={() => {
|
|
||||||
chatStore.toolsString = t.toolsString;
|
|
||||||
setChatStore({ ...chatStore });
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"block select-none space-y-1 rounded-md p-3 leading-none no-underline outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground",
|
|
||||||
chatStore.toolsString === t.toolsString
|
|
||||||
? "bg-accent text-accent-foreground"
|
|
||||||
: ""
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="text-sm font-medium leading-none">
|
|
||||||
{t.name}
|
|
||||||
</div>
|
|
||||||
<p className="line-clamp-2 text-sm leading-snug text-muted-foreground">
|
|
||||||
{t.toolsString}
|
|
||||||
</p>
|
|
||||||
</a>
|
|
||||||
</NavigationMenuLink>
|
|
||||||
<div className="mt-2 flex justify-between">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
const name = prompt(`Give **tools** template a name`);
|
|
||||||
if (!name) return;
|
|
||||||
t.name = name;
|
|
||||||
ctx.setTemplateTools(structuredClone(ctx.templateTools));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
if (
|
|
||||||
!confirm(
|
|
||||||
`Are you sure to delete this **tools** template?`
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ctx.templateTools.splice(index, 1);
|
|
||||||
ctx.setTemplateTools(structuredClone(ctx.templateTools));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</NavigationMenuContent>
|
|
||||||
</NavigationMenuItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,9 @@ 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";
|
||||||
|
|
||||||
|
import "./registerSW"; // 添加此行
|
||||||
|
|
||||||
function Base() {
|
function Base() {
|
||||||
const [langCode, _setLangCode] = useState("en-US");
|
const [langCode, _setLangCode] = useState("en-US");
|
||||||
|
|||||||
291
src/message.tsx
291
src/message.tsx
@@ -1,291 +0,0 @@
|
|||||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
|
||||||
import Markdown from "react-markdown";
|
|
||||||
import { useContext, useState } from "react";
|
|
||||||
|
|
||||||
import { Tr, langCodeContext, LANG_OPTIONS } from "@/translate";
|
|
||||||
import { ChatStore, ChatStoreMessage } from "@/types/chatstore";
|
|
||||||
import { calculate_token_length, getMessageText } from "@/chatgpt";
|
|
||||||
import TTSButton, { TTSPlay } from "@/tts";
|
|
||||||
import { MessageHide } from "@/messageHide";
|
|
||||||
import { MessageDetail } from "@/messageDetail";
|
|
||||||
import { MessageToolCall } from "@/messageToolCall";
|
|
||||||
import { MessageToolResp } from "@/messageToolResp";
|
|
||||||
import { EditMessage } from "@/editMessage";
|
|
||||||
import logprobToColor from "@/logprob";
|
|
||||||
import {
|
|
||||||
ChatBubble,
|
|
||||||
ChatBubbleAvatar,
|
|
||||||
ChatBubbleMessage,
|
|
||||||
ChatBubbleAction,
|
|
||||||
ChatBubbleActionWrapper,
|
|
||||||
} from "@/components/ui/chat/chat-bubble";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
|
||||||
import {
|
|
||||||
ClipboardIcon,
|
|
||||||
PencilIcon,
|
|
||||||
MessageSquareOffIcon,
|
|
||||||
MessageSquarePlusIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { AppContext } from "./pages/App";
|
|
||||||
|
|
||||||
export const isVailedJSON = (str: string): boolean => {
|
|
||||||
try {
|
|
||||||
JSON.parse(str);
|
|
||||||
} catch (e) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Message(props: { messageIndex: number }) {
|
|
||||||
const ctx = useContext(AppContext);
|
|
||||||
if (ctx === null) return <></>;
|
|
||||||
const { messageIndex } = props;
|
|
||||||
const { chatStore, setChatStore } = ctx;
|
|
||||||
|
|
||||||
const chat = chatStore.history[messageIndex];
|
|
||||||
const [showEdit, setShowEdit] = useState(false);
|
|
||||||
const [showCopiedHint, setShowCopiedHint] = useState(false);
|
|
||||||
const [renderMarkdown, setRenderWorkdown] = useState(false);
|
|
||||||
const [renderColor, setRenderColor] = useState(false);
|
|
||||||
const DeleteIcon = () => (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
chatStore.history[messageIndex].hide =
|
|
||||||
!chatStore.history[messageIndex].hide;
|
|
||||||
|
|
||||||
//chatStore.totalTokens =
|
|
||||||
chatStore.totalTokens = 0;
|
|
||||||
for (const i of chatStore.history
|
|
||||||
.filter(({ hide }) => !hide)
|
|
||||||
.slice(chatStore.postBeginIndex)
|
|
||||||
.map(({ token }) => token)) {
|
|
||||||
chatStore.totalTokens += i;
|
|
||||||
}
|
|
||||||
setChatStore({ ...chatStore });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
const CopiedHint = () => (
|
|
||||||
<div role="alert" className="alert">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
className="stroke-info h-6 w-6 shrink-0"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
<span>{Tr("Message copied to clipboard!")}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
const copyToClipboard = async (text: string) => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(text);
|
|
||||||
toast({
|
|
||||||
description: Tr("Message copied to clipboard!"),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
const textArea = document.createElement("textarea");
|
|
||||||
textArea.value = text;
|
|
||||||
document.body.appendChild(textArea);
|
|
||||||
textArea.select();
|
|
||||||
try {
|
|
||||||
document.execCommand("copy");
|
|
||||||
toast({
|
|
||||||
description: Tr("Message copied to clipboard!"),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
toast({
|
|
||||||
description: Tr("Failed to copy to clipboard"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
document.body.removeChild(textArea);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const CopyIcon = ({ textToCopy }: { textToCopy: string }) => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
copyToClipboard(textToCopy);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Copy
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{chatStore.postBeginIndex !== 0 &&
|
|
||||||
!chatStore.history[messageIndex].hide &&
|
|
||||||
chatStore.postBeginIndex ===
|
|
||||||
chatStore.history.slice(0, messageIndex).filter(({ hide }) => !hide)
|
|
||||||
.length && (
|
|
||||||
<div className="flex items-center relative justify-center">
|
|
||||||
<hr className="w-full h-px my-4 border-0" />
|
|
||||||
<span className="absolute px-3 rounded p-1">
|
|
||||||
Above messages are "forgotten"
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<ChatBubble
|
|
||||||
variant={chat.role === "assistant" ? "received" : "sent"}
|
|
||||||
className={chat.role !== "assistant" ? "flex-row-reverse" : ""}
|
|
||||||
>
|
|
||||||
<ChatBubbleMessage isLoading={false}>
|
|
||||||
{chat.hide ? (
|
|
||||||
<MessageHide chat={chat} />
|
|
||||||
) : typeof chat.content !== "string" ? (
|
|
||||||
<MessageDetail chat={chat} renderMarkdown={renderMarkdown} />
|
|
||||||
) : chat.tool_calls ? (
|
|
||||||
<MessageToolCall chat={chat} copyToClipboard={copyToClipboard} />
|
|
||||||
) : chat.role === "tool" ? (
|
|
||||||
<MessageToolResp chat={chat} copyToClipboard={copyToClipboard} />
|
|
||||||
) : renderMarkdown ? (
|
|
||||||
<Markdown>{getMessageText(chat)}</Markdown>
|
|
||||||
) : (
|
|
||||||
<div className="message-content">
|
|
||||||
{chat.content &&
|
|
||||||
(chat.logprobs && renderColor
|
|
||||||
? chat.logprobs.content
|
|
||||||
.filter((c) => c.token)
|
|
||||||
.map((c) => (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
backgroundColor: logprobToColor(c.logprob),
|
|
||||||
display: "inline",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{c.token}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
: getMessageText(chat))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</ChatBubbleMessage>
|
|
||||||
<ChatBubbleActionWrapper>
|
|
||||||
<ChatBubbleAction
|
|
||||||
icon={
|
|
||||||
chat.hide ? (
|
|
||||||
<MessageSquarePlusIcon className="size-4" />
|
|
||||||
) : (
|
|
||||||
<MessageSquareOffIcon className="size-4" />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onClick={() => {
|
|
||||||
chatStore.history[messageIndex].hide =
|
|
||||||
!chatStore.history[messageIndex].hide;
|
|
||||||
chatStore.totalTokens = 0;
|
|
||||||
for (const i of chatStore.history
|
|
||||||
.filter(({ hide }) => !hide)
|
|
||||||
.slice(chatStore.postBeginIndex)
|
|
||||||
.map(({ token }) => token)) {
|
|
||||||
chatStore.totalTokens += i;
|
|
||||||
}
|
|
||||||
setChatStore({ ...chatStore });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ChatBubbleAction
|
|
||||||
icon={<PencilIcon className="size-4" />}
|
|
||||||
onClick={() => setShowEdit(true)}
|
|
||||||
/>
|
|
||||||
<ChatBubbleAction
|
|
||||||
icon={<ClipboardIcon className="size-4" />}
|
|
||||||
onClick={() => copyToClipboard(getMessageText(chat))}
|
|
||||||
/>
|
|
||||||
{chatStore.tts_api && chatStore.tts_key && <TTSButton chat={chat} />}
|
|
||||||
<TTSPlay chat={chat} />
|
|
||||||
</ChatBubbleActionWrapper>
|
|
||||||
</ChatBubble>
|
|
||||||
<EditMessage showEdit={showEdit} setShowEdit={setShowEdit} chat={chat} />
|
|
||||||
{chatStore.develop_mode && (
|
|
||||||
<div
|
|
||||||
className={`flex flex-wrap items-center gap-2 mt-2 ${
|
|
||||||
chat.role !== "assistant" ? "justify-end" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm">token</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={chat.token}
|
|
||||||
className="h-8 w-16 rounded-md border border-input bg-background px-2 text-sm"
|
|
||||||
readOnly
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex items-center justify-center rounded-sm opacity-70 hover:opacity-100 h-8 w-8"
|
|
||||||
onClick={() => {
|
|
||||||
chatStore.history.splice(messageIndex, 1);
|
|
||||||
chatStore.postBeginIndex = Math.max(
|
|
||||||
chatStore.postBeginIndex - 1,
|
|
||||||
0
|
|
||||||
);
|
|
||||||
chatStore.totalTokens = 0;
|
|
||||||
for (const i of chatStore.history
|
|
||||||
.filter(({ hide }) => !hide)
|
|
||||||
.slice(chatStore.postBeginIndex)
|
|
||||||
.map(({ token }) => token)) {
|
|
||||||
chatStore.totalTokens += i;
|
|
||||||
}
|
|
||||||
setChatStore({ ...chatStore });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<XMarkIcon className="size-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<label className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="h-4 w-4 rounded border-primary"
|
|
||||||
checked={chat.example}
|
|
||||||
onChange={() => {
|
|
||||||
chat.example = !chat.example;
|
|
||||||
setChatStore({ ...chatStore });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="text-sm font-medium">{Tr("example")}</span>
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="h-4 w-4 rounded border-primary"
|
|
||||||
checked={renderMarkdown}
|
|
||||||
onChange={() => setRenderWorkdown(!renderMarkdown)}
|
|
||||||
/>
|
|
||||||
<span className="text-sm font-medium">{Tr("render")}</span>
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="h-4 w-4 rounded border-primary"
|
|
||||||
checked={renderColor}
|
|
||||||
onChange={() => setRenderColor(!renderColor)}
|
|
||||||
/>
|
|
||||||
<span className="text-sm font-medium">{Tr("color")}</span>
|
|
||||||
</label>
|
|
||||||
{chat.response_model_name && (
|
|
||||||
<>
|
|
||||||
<span className="opacity-50">{chat.response_model_name}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { ChatStoreMessage } from "@/types/chatstore";
|
|
||||||
import Markdown from "react-markdown";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
chat: ChatStoreMessage;
|
|
||||||
renderMarkdown: boolean;
|
|
||||||
}
|
|
||||||
export function MessageDetail({ chat, renderMarkdown }: Props) {
|
|
||||||
if (typeof chat.content === "string") {
|
|
||||||
return <div></div>;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{chat.content.map((mdt) =>
|
|
||||||
mdt.type === "text" ? (
|
|
||||||
chat.hide ? (
|
|
||||||
mdt.text?.split("\n")[0].slice(0, 16) + " ..."
|
|
||||||
) : renderMarkdown ? (
|
|
||||||
<Markdown>{mdt.text}</Markdown>
|
|
||||||
) : (
|
|
||||||
mdt.text
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<img
|
|
||||||
className="my-2 rounded-md max-w-64 max-h-64"
|
|
||||||
src={mdt.image_url?.url}
|
|
||||||
onClick={() => {
|
|
||||||
window.open(mdt.image_url?.url, "_blank");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { ChatStoreMessage } from "@/types/chatstore";
|
|
||||||
import { getMessageText } from "@/chatgpt";
|
|
||||||
import { Badge } from "./components/ui/badge";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
chat: ChatStoreMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MessageHide({ chat }: Props) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<span>{getMessageText(chat).split("\n")[0].slice(0, 28)} ...</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex mt-2 justify-center">
|
|
||||||
<Badge variant="destructive">Removed from context</Badge>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { ChatStoreMessage } from "@/types/chatstore";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
chat: ChatStoreMessage;
|
|
||||||
copyToClipboard: (text: string) => void;
|
|
||||||
}
|
|
||||||
export function MessageToolCall({ chat, copyToClipboard }: Props) {
|
|
||||||
return (
|
|
||||||
<div className="message-content">
|
|
||||||
{chat.tool_calls?.map((tool_call) => (
|
|
||||||
<div className="bg-blue-300 dark:bg-blue-800 p-1 rounded my-1">
|
|
||||||
<strong>
|
|
||||||
Tool Call ID:{" "}
|
|
||||||
<span
|
|
||||||
className="p-1 m-1 rounded cursor-pointer hover:opacity-50 hover:underline"
|
|
||||||
onClick={() => copyToClipboard(String(tool_call.id))}
|
|
||||||
>
|
|
||||||
{tool_call?.id}
|
|
||||||
</span>
|
|
||||||
</strong>
|
|
||||||
<p>Type: {tool_call?.type}</p>
|
|
||||||
<p>
|
|
||||||
Function:
|
|
||||||
<span
|
|
||||||
className="p-1 m-1 rounded cursor-pointer hover:opacity-50 hover:underline"
|
|
||||||
onClick={() => copyToClipboard(tool_call.function.name)}
|
|
||||||
>
|
|
||||||
{tool_call.function.name}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Arguments:
|
|
||||||
<span
|
|
||||||
className="p-1 m-1 rounded cursor-pointer hover:opacity-50 hover:underline"
|
|
||||||
onClick={() => copyToClipboard(tool_call.function.arguments)}
|
|
||||||
>
|
|
||||||
{tool_call.function.arguments}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{/* [TODO] */}
|
|
||||||
{chat.content as string}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { ChatStoreMessage } from "@/types/chatstore";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
chat: ChatStoreMessage;
|
|
||||||
copyToClipboard: (text: string) => void;
|
|
||||||
}
|
|
||||||
export function MessageToolResp({ chat, copyToClipboard }: Props) {
|
|
||||||
return (
|
|
||||||
<div className="message-content">
|
|
||||||
<div className="bg-blue-300 dark:bg-blue-800 p-1 rounded my-1">
|
|
||||||
<strong>
|
|
||||||
Tool Response ID:{" "}
|
|
||||||
<span
|
|
||||||
className="p-1 m-1 rounded cursor-pointer hover:opacity-50 hover:underline"
|
|
||||||
onClick={() => copyToClipboard(String(chat.tool_call_id))}
|
|
||||||
>
|
|
||||||
{chat.tool_call_id}
|
|
||||||
</span>
|
|
||||||
</strong>
|
|
||||||
{/* [TODO] */}
|
|
||||||
<p>{chat.content as string}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -51,7 +51,7 @@ const AddToolMsg = (props: {
|
|||||||
className="btn btn-info m-1 p-1"
|
className="btn btn-info m-1 p-1"
|
||||||
onClick={() => setShowAddToolMsg(false)}
|
onClick={() => setShowAddToolMsg(false)}
|
||||||
>
|
>
|
||||||
{Tr("Cancle")}
|
<Tr>Cancle</Tr>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="rounded m-1 p-1 border-2 bg-cyan-400 hover:bg-cyan-600"
|
className="rounded m-1 p-1 border-2 bg-cyan-400 hover:bg-cyan-600"
|
||||||
@@ -74,6 +74,8 @@ const AddToolMsg = (props: {
|
|||||||
audio: null,
|
audio: null,
|
||||||
logprobs: null,
|
logprobs: null,
|
||||||
response_model_name: null,
|
response_model_name: null,
|
||||||
|
reasoning_content: null,
|
||||||
|
usage: null,
|
||||||
});
|
});
|
||||||
setChatStore({ ...chatStore });
|
setChatStore({ ...chatStore });
|
||||||
setNewToolCallID("");
|
setNewToolCallID("");
|
||||||
@@ -81,7 +83,7 @@ const AddToolMsg = (props: {
|
|||||||
setShowAddToolMsg(false);
|
setShowAddToolMsg(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{Tr("Add")}
|
<Tr>Add</Tr>
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { IDBPDatabase, openDB } from "idb";
|
import { IDBPDatabase, openDB } from "idb";
|
||||||
import { createContext, useEffect, useState } from "react";
|
import { createContext, useContext, useEffect, useState, useRef } from "react"; // 添加了useRef
|
||||||
import "@/global.css";
|
import "@/global.css";
|
||||||
|
|
||||||
import { calculate_token_length } from "@/chatgpt";
|
import { calculate_token_length } from "@/chatgpt";
|
||||||
@@ -31,8 +31,6 @@ import {
|
|||||||
|
|
||||||
interface AppContextType {
|
interface AppContextType {
|
||||||
db: Promise<IDBPDatabase<ChatStore>>;
|
db: Promise<IDBPDatabase<ChatStore>>;
|
||||||
chatStore: ChatStore;
|
|
||||||
setChatStore: (cs: ChatStore) => void;
|
|
||||||
selectedChatIndex: number;
|
selectedChatIndex: number;
|
||||||
setSelectedChatIndex: (i: number) => void;
|
setSelectedChatIndex: (i: number) => void;
|
||||||
templates: TemplateChatStore[];
|
templates: TemplateChatStore[];
|
||||||
@@ -47,9 +45,21 @@ interface AppContextType {
|
|||||||
setTemplateAPIsImageGen: (t: TemplateAPI[]) => void;
|
setTemplateAPIsImageGen: (t: TemplateAPI[]) => void;
|
||||||
templateTools: TemplateTools[];
|
templateTools: TemplateTools[];
|
||||||
setTemplateTools: (t: TemplateTools[]) => void;
|
setTemplateTools: (t: TemplateTools[]) => void;
|
||||||
|
defaultRenderMD: boolean;
|
||||||
|
setDefaultRenderMD: (b: boolean) => void;
|
||||||
|
handleNewChatStore: () => Promise<void>;
|
||||||
|
handleNewChatStoreWithOldOne: (chatStore: ChatStore) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AppContext = createContext<AppContextType | null>(null);
|
interface AppChatStoreContextType {
|
||||||
|
chatStore: ChatStore;
|
||||||
|
setChatStore: (cs: ChatStore) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AppContext = createContext<AppContextType>(null as any);
|
||||||
|
export const AppChatStoreContext = createContext<AppChatStoreContextType>(
|
||||||
|
null as any
|
||||||
|
);
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
@@ -80,40 +90,14 @@ import {
|
|||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
|
|
||||||
import {
|
|
||||||
Menubar,
|
|
||||||
MenubarContent,
|
|
||||||
MenubarItem,
|
|
||||||
MenubarMenu,
|
|
||||||
MenubarSeparator,
|
|
||||||
MenubarShortcut,
|
|
||||||
MenubarCheckboxItem,
|
|
||||||
MenubarTrigger,
|
|
||||||
} from "@/components/ui/menubar";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@/components/ui/popover";
|
|
||||||
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { ModeToggle } from "@/components/ModeToggle";
|
||||||
import {
|
|
||||||
BoxesIcon,
|
import Search from "@/components/Search";
|
||||||
ArrowUpDownIcon,
|
|
||||||
CircleDollarSignIcon,
|
import Navbar from "@/components/navbar";
|
||||||
ScissorsIcon,
|
import ConversationTitle from "@/components/ConversationTitle.";
|
||||||
WholeWordIcon,
|
import ImportDialog from "@/components/ImportDialog";
|
||||||
EllipsisIcon,
|
|
||||||
CogIcon,
|
|
||||||
Menu,
|
|
||||||
ReceiptIcon,
|
|
||||||
WalletIcon,
|
|
||||||
RulerIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { ModeToggle } from "@/components/mode-toggle";
|
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
// init selected index
|
// init selected index
|
||||||
@@ -134,7 +118,14 @@ export function App() {
|
|||||||
|
|
||||||
const getChatStoreByIndex = async (index: number): Promise<ChatStore> => {
|
const getChatStoreByIndex = async (index: number): Promise<ChatStore> => {
|
||||||
const ret: ChatStore = await (await db).get(STORAGE_NAME, index);
|
const ret: ChatStore = await (await db).get(STORAGE_NAME, index);
|
||||||
if (ret === null || ret === undefined) return newChatStore({});
|
if (ret === null || ret === undefined) {
|
||||||
|
const newStore = newChatStore({});
|
||||||
|
toast({
|
||||||
|
title: "New chat created",
|
||||||
|
description: `Current API Endpoint: ${newStore.apiEndpoint}`,
|
||||||
|
});
|
||||||
|
return newStore;
|
||||||
|
}
|
||||||
// handle read from old version chatstore
|
// handle read from old version chatstore
|
||||||
if (ret.maxGenTokens === undefined) ret.maxGenTokens = 2048;
|
if (ret.maxGenTokens === undefined) ret.maxGenTokens = 2048;
|
||||||
if (ret.maxGenTokens_enabled === undefined) ret.maxGenTokens_enabled = true;
|
if (ret.maxGenTokens_enabled === undefined) ret.maxGenTokens_enabled = true;
|
||||||
@@ -150,60 +141,10 @@ export function App() {
|
|||||||
message.token = calculate_token_length(message.content);
|
message.token = calculate_token_length(message.content);
|
||||||
}
|
}
|
||||||
if (ret.cost === undefined) ret.cost = 0;
|
if (ret.cost === undefined) ret.cost = 0;
|
||||||
|
|
||||||
return ret;
|
return ret;
|
||||||
};
|
};
|
||||||
|
|
||||||
const [chatStore, _setChatStore] = useState(newChatStore({}));
|
|
||||||
const setChatStore = async (chatStore: ChatStore) => {
|
|
||||||
console.log("recalculate postBeginIndex");
|
|
||||||
const max = chatStore.maxTokens - chatStore.tokenMargin;
|
|
||||||
let sum = 0;
|
|
||||||
chatStore.postBeginIndex = chatStore.history.filter(
|
|
||||||
({ hide }) => !hide
|
|
||||||
).length;
|
|
||||||
for (const msg of chatStore.history
|
|
||||||
.filter(({ hide }) => !hide)
|
|
||||||
.slice()
|
|
||||||
.reverse()) {
|
|
||||||
if (sum + msg.token > max) break;
|
|
||||||
sum += msg.token;
|
|
||||||
chatStore.postBeginIndex -= 1;
|
|
||||||
}
|
|
||||||
chatStore.postBeginIndex =
|
|
||||||
chatStore.postBeginIndex < 0 ? 0 : chatStore.postBeginIndex;
|
|
||||||
|
|
||||||
// manually estimate token
|
|
||||||
chatStore.totalTokens = calculate_token_length(
|
|
||||||
chatStore.systemMessageContent
|
|
||||||
);
|
|
||||||
for (const msg of chatStore.history
|
|
||||||
.filter(({ hide }) => !hide)
|
|
||||||
.slice(chatStore.postBeginIndex)) {
|
|
||||||
chatStore.totalTokens += msg.token;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("saved chat", selectedChatIndex, chatStore);
|
|
||||||
(await db).put(STORAGE_NAME, chatStore, selectedChatIndex);
|
|
||||||
|
|
||||||
// update total tokens
|
|
||||||
chatStore.totalTokens = calculate_token_length(
|
|
||||||
chatStore.systemMessageContent
|
|
||||||
);
|
|
||||||
for (const msg of chatStore.history
|
|
||||||
.filter(({ hide }) => !hide)
|
|
||||||
.slice(chatStore.postBeginIndex)) {
|
|
||||||
chatStore.totalTokens += msg.token;
|
|
||||||
}
|
|
||||||
|
|
||||||
_setChatStore(chatStore);
|
|
||||||
};
|
|
||||||
useEffect(() => {
|
|
||||||
const run = async () => {
|
|
||||||
_setChatStore(await getChatStoreByIndex(selectedChatIndex));
|
|
||||||
};
|
|
||||||
run();
|
|
||||||
}, [selectedChatIndex]);
|
|
||||||
|
|
||||||
// all chat store indexes
|
// all chat store indexes
|
||||||
const [allChatStoreIndexes, setAllChatStoreIndexes] = useState<IDBValidKey>(
|
const [allChatStoreIndexes, setAllChatStoreIndexes] = useState<IDBValidKey>(
|
||||||
[]
|
[]
|
||||||
@@ -213,9 +154,14 @@ 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 created",
|
||||||
|
description: `Current API Endpoint: ${chatStore.apiEndpoint}`,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
const handleNewChatStore = async () => {
|
const handleNewChatStore = async () => {
|
||||||
return handleNewChatStoreWithOldOne(chatStore);
|
let currentChatStore = await getChatStoreByIndex(selectedChatIndex);
|
||||||
|
return handleNewChatStoreWithOldOne(currentChatStore);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDEL = async () => {
|
const handleDEL = async () => {
|
||||||
@@ -224,7 +170,7 @@ export function App() {
|
|||||||
const newAllChatStoreIndexes = await (await db).getAllKeys(STORAGE_NAME);
|
const newAllChatStoreIndexes = await (await db).getAllKeys(STORAGE_NAME);
|
||||||
|
|
||||||
if (newAllChatStoreIndexes.length === 0) {
|
if (newAllChatStoreIndexes.length === 0) {
|
||||||
handleNewChatStore();
|
await handleNewChatStore();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,9 +196,34 @@ export function App() {
|
|||||||
window.location.reload();
|
window.location.reload();
|
||||||
};
|
};
|
||||||
|
|
||||||
// if there are any params in URL, create a new chatStore
|
const [showImportDialog, setShowImportDialog] = useState(false);
|
||||||
|
// if there are any params in URL, show the alert dialog to import configure
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
if (
|
||||||
|
params.get("api") ||
|
||||||
|
params.get("key") ||
|
||||||
|
params.get("sys") ||
|
||||||
|
params.get("mode") ||
|
||||||
|
params.get("model") ||
|
||||||
|
params.get("max") ||
|
||||||
|
params.get("temp") ||
|
||||||
|
params.get("dev") ||
|
||||||
|
params.get("whisper-api") ||
|
||||||
|
params.get("whisper-key") ||
|
||||||
|
params.get("tts-api") ||
|
||||||
|
params.get("tts-key")
|
||||||
|
) {
|
||||||
|
setShowImportDialog(true);
|
||||||
|
}
|
||||||
|
await db;
|
||||||
|
const allidx = await (await db).getAllKeys(STORAGE_NAME);
|
||||||
|
if (allidx.length === 0) {
|
||||||
|
handleNewChatStore();
|
||||||
|
}
|
||||||
|
setAllChatStoreIndexes(await (await db).getAllKeys(STORAGE_NAME));
|
||||||
|
/*
|
||||||
const chatStore = await getChatStoreByIndex(selectedChatIndex);
|
const chatStore = await getChatStoreByIndex(selectedChatIndex);
|
||||||
const api = getDefaultParams("api", "");
|
const api = getDefaultParams("api", "");
|
||||||
const key = getDefaultParams("key", "");
|
const key = getDefaultParams("key", "");
|
||||||
@@ -274,12 +245,7 @@ export function App() {
|
|||||||
console.log("create new chatStore because of params in URL");
|
console.log("create new chatStore because of params in URL");
|
||||||
handleNewChatStoreWithOldOne(chatStore);
|
handleNewChatStoreWithOldOne(chatStore);
|
||||||
}
|
}
|
||||||
await db;
|
*/
|
||||||
const allidx = await (await db).getAllKeys(STORAGE_NAME);
|
|
||||||
if (allidx.length === 0) {
|
|
||||||
handleNewChatStore();
|
|
||||||
}
|
|
||||||
setAllChatStoreIndexes(await (await db).getAllKeys(STORAGE_NAME));
|
|
||||||
};
|
};
|
||||||
run();
|
run();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -353,14 +319,46 @@ export function App() {
|
|||||||
);
|
);
|
||||||
_setTemplateTools(templateTools);
|
_setTemplateTools(templateTools);
|
||||||
};
|
};
|
||||||
|
const [defaultRenderMD, _setDefaultRenderMD] = useState(
|
||||||
|
localStorage.getItem("defaultRenderMD") === "true"
|
||||||
|
);
|
||||||
|
const setDefaultRenderMD = (defaultRenderMD: boolean) => {
|
||||||
|
localStorage.setItem("defaultRenderMD", `${defaultRenderMD}`);
|
||||||
|
_setDefaultRenderMD(defaultRenderMD);
|
||||||
|
};
|
||||||
|
|
||||||
console.log("[PERFORMANCE!] reading localStorage");
|
console.log("[PERFORMANCE!] reading localStorage");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<AppContext.Provider
|
||||||
|
value={{
|
||||||
|
db,
|
||||||
|
selectedChatIndex,
|
||||||
|
setSelectedChatIndex,
|
||||||
|
templates,
|
||||||
|
setTemplates,
|
||||||
|
templateAPIs,
|
||||||
|
setTemplateAPIs,
|
||||||
|
templateAPIsWhisper,
|
||||||
|
setTemplateAPIsWhisper,
|
||||||
|
templateAPIsTTS,
|
||||||
|
setTemplateAPIsTTS,
|
||||||
|
templateAPIsImageGen,
|
||||||
|
setTemplateAPIsImageGen,
|
||||||
|
templateTools,
|
||||||
|
setTemplateTools,
|
||||||
|
defaultRenderMD,
|
||||||
|
setDefaultRenderMD,
|
||||||
|
handleNewChatStore,
|
||||||
|
handleNewChatStoreWithOldOne,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Sidebar>
|
<Sidebar>
|
||||||
<SidebarHeader>
|
<SidebarHeader>
|
||||||
<Button onClick={handleNewChatStore}>
|
<Button onClick={handleNewChatStore}>
|
||||||
<span>{Tr("New")}</span>
|
<span>
|
||||||
|
<Tr>New Chat</Tr>
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
@@ -382,7 +380,12 @@ export function App() {
|
|||||||
asChild
|
asChild
|
||||||
isActive={i === selectedChatIndex}
|
isActive={i === selectedChatIndex}
|
||||||
>
|
>
|
||||||
<span>{i}</span>
|
<span>
|
||||||
|
<ConversationTitle
|
||||||
|
chatStoreIndex={i}
|
||||||
|
selectedChatStoreIndex={selectedChatIndex}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
);
|
);
|
||||||
@@ -392,9 +395,15 @@ export function App() {
|
|||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<ModeToggle />
|
||||||
|
<Search />
|
||||||
|
</div>
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="destructive">{Tr("DEL")}</Button>
|
<Button variant="destructive">
|
||||||
|
<Tr>Delete Chat</Tr>
|
||||||
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
@@ -412,136 +421,163 @@ export function App() {
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
{chatStore.develop_mode && (
|
|
||||||
<Button onClick={handleCLS} variant="destructive">
|
|
||||||
<span>{Tr("CLS")}</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</SidebarFooter>
|
</SidebarFooter>
|
||||||
<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">
|
<AppChatStoreProvider
|
||||||
<div className="flex items-center gap-2 px-3">
|
selectedChatIndex={selectedChatIndex}
|
||||||
<SidebarTrigger />
|
getChatStoreByIndex={getChatStoreByIndex}
|
||||||
<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,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
|
<ImportDialog open={showImportDialog} setOpen={setShowImportDialog} />
|
||||||
|
<Navbar />
|
||||||
<ChatBOX />
|
<ChatBOX />
|
||||||
</AppContext.Provider>
|
</AppChatStoreProvider>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
</>
|
</AppContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const AppChatStoreProvider = ({
|
||||||
|
children,
|
||||||
|
selectedChatIndex,
|
||||||
|
getChatStoreByIndex,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
selectedChatIndex: number;
|
||||||
|
getChatStoreByIndex: (index: number) => Promise<ChatStore>;
|
||||||
|
}) => {
|
||||||
|
const ctx = useContext(AppContext);
|
||||||
|
const { toast } = useToast();
|
||||||
|
const tabId = useRef<string>(Math.random().toString(36).substr(2, 9)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const channel = new BroadcastChannel("chat-store-access");
|
||||||
|
|
||||||
|
// 页面激活状态处理
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.visibilityState === "visible") {
|
||||||
|
channel.postMessage({
|
||||||
|
type: "open",
|
||||||
|
index: selectedChatIndex,
|
||||||
|
tabId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
|
||||||
|
// 消息处理逻辑
|
||||||
|
const handleMessage = (event: MessageEvent) => {
|
||||||
|
// 忽略自身消息和无关索引消息
|
||||||
|
if (event.data.tabId === tabId) return;
|
||||||
|
if (event.data.index !== selectedChatIndex) return;
|
||||||
|
|
||||||
|
// 根据消息类型处理
|
||||||
|
switch (event.data.type) {
|
||||||
|
case "open":
|
||||||
|
// 收到open消息时发送确认回复并显示警告
|
||||||
|
channel.postMessage({
|
||||||
|
type: "ack",
|
||||||
|
index: selectedChatIndex,
|
||||||
|
tabId,
|
||||||
|
});
|
||||||
|
showConflictWarning();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "ack":
|
||||||
|
// 收到确认回复时显示警告
|
||||||
|
showConflictWarning();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 立即发送初始查询
|
||||||
|
channel.postMessage({
|
||||||
|
type: "open",
|
||||||
|
index: selectedChatIndex,
|
||||||
|
tabId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 绑定事件监听器
|
||||||
|
channel.addEventListener("message", handleMessage);
|
||||||
|
|
||||||
|
// 清理函数
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
channel.removeEventListener("message", handleMessage);
|
||||||
|
channel.close();
|
||||||
|
};
|
||||||
|
}, [selectedChatIndex, toast, tabId]);
|
||||||
|
|
||||||
|
// 警告提示统一处理
|
||||||
|
const showConflictWarning = () => {
|
||||||
|
toast({
|
||||||
|
title: "访问冲突警告",
|
||||||
|
description: "当前会话已在其他浏览器标签打开, 请注意数据一致性!",
|
||||||
|
variant: "destructive",
|
||||||
|
duration: 8000,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const [chatStore, _setChatStore] = useState(newChatStore({}));
|
||||||
|
const setChatStore = async (chatStore: ChatStore) => {
|
||||||
|
console.log("recalculate postBeginIndex");
|
||||||
|
const max = chatStore.maxTokens - chatStore.tokenMargin;
|
||||||
|
let sum = 0;
|
||||||
|
chatStore.postBeginIndex = chatStore.history.filter(
|
||||||
|
({ hide }) => !hide
|
||||||
|
).length;
|
||||||
|
for (const msg of chatStore.history
|
||||||
|
.filter(({ hide }) => !hide)
|
||||||
|
.slice()
|
||||||
|
.reverse()) {
|
||||||
|
if (sum + msg.token > max) break;
|
||||||
|
sum += msg.token;
|
||||||
|
chatStore.postBeginIndex -= 1;
|
||||||
|
}
|
||||||
|
chatStore.postBeginIndex =
|
||||||
|
chatStore.postBeginIndex < 0 ? 0 : chatStore.postBeginIndex;
|
||||||
|
|
||||||
|
// manually estimate token
|
||||||
|
chatStore.totalTokens = calculate_token_length(
|
||||||
|
chatStore.systemMessageContent
|
||||||
|
);
|
||||||
|
for (const msg of chatStore.history
|
||||||
|
.filter(({ hide }) => !hide)
|
||||||
|
.slice(chatStore.postBeginIndex)) {
|
||||||
|
chatStore.totalTokens += msg.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("saved chat", selectedChatIndex, chatStore);
|
||||||
|
(await ctx.db).put(STORAGE_NAME, chatStore, selectedChatIndex);
|
||||||
|
|
||||||
|
// update total tokens
|
||||||
|
chatStore.totalTokens = calculate_token_length(
|
||||||
|
chatStore.systemMessageContent
|
||||||
|
);
|
||||||
|
for (const msg of chatStore.history
|
||||||
|
.filter(({ hide }) => !hide)
|
||||||
|
.slice(chatStore.postBeginIndex)) {
|
||||||
|
chatStore.totalTokens += msg.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
_setChatStore(chatStore);
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
const run = async () => {
|
||||||
|
_setChatStore(await getChatStoreByIndex(selectedChatIndex));
|
||||||
|
};
|
||||||
|
run();
|
||||||
|
}, [selectedChatIndex]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppChatStoreContext.Provider
|
||||||
|
value={{
|
||||||
|
chatStore,
|
||||||
|
setChatStore,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AppChatStoreContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -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 { langCodeContext, tr, 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,54 +29,57 @@ 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 { AppChatStoreContext, AppContext } from "./App";
|
||||||
import { addToRange } from "react-day-picker";
|
import { ImageGenDrawer } from "@/components/ImageGenDrawer";
|
||||||
|
|
||||||
|
const createMessageFromCurrentBuffer = (
|
||||||
|
chunkMessages: string[],
|
||||||
|
reasoningChunks: string[],
|
||||||
|
tools: ToolCall[],
|
||||||
|
response_count: number
|
||||||
|
): ChatStoreMessage => {
|
||||||
|
return {
|
||||||
|
role: "assistant",
|
||||||
|
content: chunkMessages.join(""),
|
||||||
|
reasoning_content: reasoningChunks.join(""),
|
||||||
|
tool_calls: tools.length > 0 ? tools : undefined,
|
||||||
|
// 补全其他必填字段的默认值(根据你的类型定义)
|
||||||
|
hide: false,
|
||||||
|
token: calculate_token_length(
|
||||||
|
chunkMessages.join("") + reasoningChunks.join("")
|
||||||
|
), // 需要实际的token计算逻辑
|
||||||
|
example: false,
|
||||||
|
audio: null,
|
||||||
|
logprobs: null,
|
||||||
|
response_model_name: null,
|
||||||
|
usage: null,
|
||||||
|
response_count,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export default function ChatBOX() {
|
export default function ChatBOX() {
|
||||||
const ctx = useContext(AppContext);
|
const { db, selectedChatIndex, setSelectedChatIndex, handleNewChatStore } =
|
||||||
if (ctx === null) return <></>;
|
useContext(AppContext);
|
||||||
const {
|
const { langCode, setLangCode } = useContext(langCodeContext);
|
||||||
db,
|
const { chatStore, setChatStore } = useContext(AppChatStoreContext);
|
||||||
chatStore,
|
|
||||||
setChatStore,
|
|
||||||
selectedChatIndex,
|
|
||||||
setSelectedChatIndex,
|
|
||||||
} = ctx;
|
|
||||||
// prevent error
|
// prevent error
|
||||||
if (chatStore === undefined) return <div></div>;
|
|
||||||
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";
|
||||||
@@ -112,15 +98,17 @@ export default function ChatBOX() {
|
|||||||
if (messagesEndRef.current === null) return;
|
if (messagesEndRef.current === null) return;
|
||||||
messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
|
messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
|
||||||
}
|
}
|
||||||
}, [showRetry, showGenerating, generatingMessage]);
|
}, [showRetry, showGenerating, generatingMessage, chatStore]);
|
||||||
|
|
||||||
const client = new ChatGPT(chatStore.apiKey);
|
const client = new ChatGPT(chatStore.apiKey);
|
||||||
|
|
||||||
const _completeWithStreamMode = async (
|
const _completeWithStreamMode = async (
|
||||||
response: Response
|
response: Response,
|
||||||
): Promise<Usage> => {
|
signal: AbortSignal
|
||||||
let responseTokenCount = 0;
|
): Promise<ChatStoreMessage> => {
|
||||||
|
let responseTokenCount = 0; // including reasoning content and normal content
|
||||||
const allChunkMessage: string[] = [];
|
const allChunkMessage: string[] = [];
|
||||||
|
const allReasoningContentChunk: string[] = [];
|
||||||
const allChunkTool: ToolCall[] = [];
|
const allChunkTool: ToolCall[] = [];
|
||||||
setShowGenerating(true);
|
setShowGenerating(true);
|
||||||
const logprobs: Logprobs = {
|
const logprobs: Logprobs = {
|
||||||
@@ -128,82 +116,110 @@ export default function ChatBOX() {
|
|||||||
};
|
};
|
||||||
let response_model_name: string | null = null;
|
let response_model_name: string | null = null;
|
||||||
let usage: Usage | null = null;
|
let usage: Usage | null = null;
|
||||||
for await (const i of client.processStreamResponse(response)) {
|
|
||||||
response_model_name = i.model;
|
|
||||||
responseTokenCount += 1;
|
|
||||||
if (i.usage) {
|
|
||||||
usage = i.usage;
|
|
||||||
}
|
|
||||||
|
|
||||||
const c = i.choices[0];
|
try {
|
||||||
|
for await (const i of client.processStreamResponse(response, signal)) {
|
||||||
// skip if choice is empty (e.g. azure)
|
if (signal?.aborted) break;
|
||||||
if (!c) continue;
|
response_model_name = i.model;
|
||||||
|
responseTokenCount += 1;
|
||||||
const logprob = c?.logprobs?.content[0]?.logprob;
|
if (i.usage) {
|
||||||
if (logprob !== undefined) {
|
usage = i.usage;
|
||||||
logprobs.content.push({
|
|
||||||
token: c?.delta?.content ?? "",
|
|
||||||
logprob,
|
|
||||||
});
|
|
||||||
console.log(c?.delta?.content, logprob);
|
|
||||||
}
|
|
||||||
|
|
||||||
allChunkMessage.push(c?.delta?.content ?? "");
|
|
||||||
const tool_calls = c?.delta?.tool_calls;
|
|
||||||
if (tool_calls) {
|
|
||||||
for (const tool_call of tool_calls) {
|
|
||||||
// init
|
|
||||||
if (tool_call.id) {
|
|
||||||
allChunkTool.push({
|
|
||||||
id: tool_call.id,
|
|
||||||
type: tool_call.type,
|
|
||||||
index: tool_call.index,
|
|
||||||
function: {
|
|
||||||
name: tool_call.function.name,
|
|
||||||
arguments: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// update tool call arguments
|
|
||||||
const tool = allChunkTool.find(
|
|
||||||
(tool) => tool.index === tool_call.index
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!tool) {
|
|
||||||
console.log("tool (by index) not found", tool_call.index);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
tool.function.arguments += tool_call.function.arguments;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const c = i.choices[0];
|
||||||
|
|
||||||
|
// skip if choice is empty (e.g. azure)
|
||||||
|
if (!c) continue;
|
||||||
|
|
||||||
|
const logprob = c?.logprobs?.content[0]?.logprob;
|
||||||
|
if (logprob !== undefined) {
|
||||||
|
logprobs.content.push({
|
||||||
|
token: c?.delta?.content ?? "",
|
||||||
|
logprob,
|
||||||
|
});
|
||||||
|
console.log(c?.delta?.content, logprob);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c?.delta?.content) {
|
||||||
|
allChunkMessage.push(c?.delta?.content ?? "");
|
||||||
|
}
|
||||||
|
if (c?.delta?.reasoning_content) {
|
||||||
|
allReasoningContentChunk.push(c?.delta?.reasoning_content ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const tool_calls = c?.delta?.tool_calls;
|
||||||
|
if (tool_calls) {
|
||||||
|
for (const tool_call of tool_calls) {
|
||||||
|
// init
|
||||||
|
if (tool_call.id) {
|
||||||
|
allChunkTool.push({
|
||||||
|
id: tool_call.id,
|
||||||
|
type: tool_call.type,
|
||||||
|
index: tool_call.index,
|
||||||
|
function: {
|
||||||
|
name: tool_call.function.name,
|
||||||
|
arguments: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// update tool call arguments
|
||||||
|
const tool = allChunkTool.find(
|
||||||
|
(tool) => tool.index === tool_call.index
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!tool) {
|
||||||
|
console.log("tool (by index) not found", tool_call.index);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
tool.function.arguments += tool_call.function.arguments;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setGeneratingMessage(
|
||||||
|
(allReasoningContentChunk.length
|
||||||
|
? "----------\nreasoning:\n" +
|
||||||
|
allReasoningContentChunk.join("") +
|
||||||
|
"\n----------\n"
|
||||||
|
: "") +
|
||||||
|
allChunkMessage.join("") +
|
||||||
|
allChunkTool.map((tool) => {
|
||||||
|
return `Tool Call ID: ${tool.id}\nType: ${tool.type}\nFunction: ${tool.function.name}\nArguments: ${tool.function.arguments}`;
|
||||||
|
}) +
|
||||||
|
"\n" +
|
||||||
|
responseTokenCount +
|
||||||
|
" response count"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
setGeneratingMessage(
|
} catch (e: any) {
|
||||||
allChunkMessage.join("") +
|
if (e.name === "AbortError") {
|
||||||
allChunkTool.map((tool) => {
|
// 1. 立即保存当前buffer中的内容
|
||||||
return `Tool Call ID: ${tool.id}\nType: ${tool.type}\nFunction: ${tool.function.name}\nArguments: ${tool.function.arguments}`;
|
if (allChunkMessage.length > 0 || allReasoningContentChunk.length > 0) {
|
||||||
})
|
const partialMsg = createMessageFromCurrentBuffer(
|
||||||
);
|
allChunkMessage,
|
||||||
|
allReasoningContentChunk,
|
||||||
|
allChunkTool,
|
||||||
|
responseTokenCount
|
||||||
|
);
|
||||||
|
chatStore.history.push(partialMsg);
|
||||||
|
setChatStore({ ...chatStore });
|
||||||
|
}
|
||||||
|
// 2. 不隐藏错误,重新抛出给上层
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
// 其他错误直接抛出
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
setShowGenerating(false);
|
||||||
|
setGeneratingMessage("");
|
||||||
}
|
}
|
||||||
setShowGenerating(false);
|
|
||||||
const content = allChunkMessage.join("");
|
const content = allChunkMessage.join("");
|
||||||
|
const reasoning_content = allReasoningContentChunk.join("");
|
||||||
|
|
||||||
console.log("save logprobs", logprobs);
|
console.log("save logprobs", logprobs);
|
||||||
const newMsg: ChatStoreMessage = {
|
|
||||||
role: "assistant",
|
|
||||||
content,
|
|
||||||
hide: false,
|
|
||||||
token: responseTokenCount,
|
|
||||||
example: false,
|
|
||||||
audio: null,
|
|
||||||
logprobs,
|
|
||||||
response_model_name,
|
|
||||||
};
|
|
||||||
if (allChunkTool.length > 0) newMsg.tool_calls = allChunkTool;
|
|
||||||
|
|
||||||
chatStore.history.push(newMsg);
|
|
||||||
// manually copy status from client to chatStore
|
// manually copy status from client to chatStore
|
||||||
chatStore.maxTokens = client.max_tokens;
|
chatStore.maxTokens = client.max_tokens;
|
||||||
chatStore.tokenMargin = client.tokens_margin;
|
chatStore.tokenMargin = client.tokens_margin;
|
||||||
@@ -232,28 +248,42 @@ export default function ChatBOX() {
|
|||||||
ret.completion_tokens_details = usage.completion_tokens_details ?? null;
|
ret.completion_tokens_details = usage.completion_tokens_details ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return ret;
|
const newMsg: ChatStoreMessage = {
|
||||||
|
role: "assistant",
|
||||||
|
content,
|
||||||
|
reasoning_content,
|
||||||
|
hide: false,
|
||||||
|
token:
|
||||||
|
responseTokenCount -
|
||||||
|
(usage?.completion_tokens_details?.reasoning_tokens ?? 0),
|
||||||
|
example: false,
|
||||||
|
audio: null,
|
||||||
|
logprobs,
|
||||||
|
response_model_name,
|
||||||
|
usage: usage ?? {
|
||||||
|
prompt_tokens: prompt_tokens,
|
||||||
|
completion_tokens: responseTokenCount,
|
||||||
|
total_tokens: prompt_tokens + responseTokenCount,
|
||||||
|
response_model_name: response_model_name,
|
||||||
|
prompt_tokens_details: null,
|
||||||
|
completion_tokens_details: null,
|
||||||
|
},
|
||||||
|
response_count: responseTokenCount,
|
||||||
|
};
|
||||||
|
if (allChunkTool.length > 0) newMsg.tool_calls = allChunkTool;
|
||||||
|
|
||||||
|
return newMsg;
|
||||||
};
|
};
|
||||||
|
|
||||||
const _completeWithFetchMode = async (response: Response): Promise<Usage> => {
|
const _completeWithFetchMode = async (
|
||||||
|
response: Response
|
||||||
|
): Promise<ChatStoreMessage> => {
|
||||||
const data = (await response.json()) as FetchResponse;
|
const data = (await response.json()) as FetchResponse;
|
||||||
const msg = client.processFetchResponse(data);
|
const msg = client.processFetchResponse(data);
|
||||||
|
|
||||||
chatStore.history.push({
|
|
||||||
role: "assistant",
|
|
||||||
content: msg.content,
|
|
||||||
tool_calls: msg.tool_calls,
|
|
||||||
hide: false,
|
|
||||||
token:
|
|
||||||
data.usage.completion_tokens ?? calculate_token_length(msg.content),
|
|
||||||
example: false,
|
|
||||||
audio: null,
|
|
||||||
logprobs: data.choices[0]?.logprobs,
|
|
||||||
response_model_name: data.model,
|
|
||||||
});
|
|
||||||
setShowGenerating(false);
|
setShowGenerating(false);
|
||||||
|
|
||||||
const ret: Usage = {
|
const usage: Usage = {
|
||||||
prompt_tokens: data.usage.prompt_tokens ?? 0,
|
prompt_tokens: data.usage.prompt_tokens ?? 0,
|
||||||
completion_tokens: data.usage.completion_tokens ?? 0,
|
completion_tokens: data.usage.completion_tokens ?? 0,
|
||||||
total_tokens: data.usage.total_tokens ?? 0,
|
total_tokens: data.usage.total_tokens ?? 0,
|
||||||
@@ -262,6 +292,23 @@ export default function ChatBOX() {
|
|||||||
completion_tokens_details: data.usage.completion_tokens_details ?? null,
|
completion_tokens_details: data.usage.completion_tokens_details ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ret: ChatStoreMessage = {
|
||||||
|
role: "assistant",
|
||||||
|
content: msg.content,
|
||||||
|
tool_calls: msg.tool_calls,
|
||||||
|
hide: false,
|
||||||
|
token: data.usage?.completion_tokens_details
|
||||||
|
? data.usage.completion_tokens -
|
||||||
|
data.usage.completion_tokens_details.reasoning_tokens
|
||||||
|
: (data.usage.completion_tokens ?? calculate_token_length(msg.content)),
|
||||||
|
example: false,
|
||||||
|
audio: null,
|
||||||
|
logprobs: data.choices[0]?.logprobs,
|
||||||
|
response_model_name: data.model,
|
||||||
|
reasoning_content: data.choices[0]?.message?.reasoning_content ?? null,
|
||||||
|
usage,
|
||||||
|
};
|
||||||
|
|
||||||
return ret;
|
return ret;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -277,7 +324,9 @@ export default function ChatBOX() {
|
|||||||
client.top_p = chatStore.top_p;
|
client.top_p = chatStore.top_p;
|
||||||
client.enable_top_p = chatStore.top_p_enabled;
|
client.enable_top_p = chatStore.top_p_enabled;
|
||||||
client.frequency_penalty = chatStore.frequency_penalty;
|
client.frequency_penalty = chatStore.frequency_penalty;
|
||||||
|
client.frequency_penalty_enabled = chatStore.frequency_penalty_enabled;
|
||||||
client.presence_penalty = chatStore.presence_penalty;
|
client.presence_penalty = chatStore.presence_penalty;
|
||||||
|
client.presence_penalty_enabled = chatStore.presence_penalty_enabled;
|
||||||
client.json_mode = chatStore.json_mode;
|
client.json_mode = chatStore.json_mode;
|
||||||
client.messages = chatStore.history
|
client.messages = chatStore.history
|
||||||
// only copy non hidden message
|
// only copy non hidden message
|
||||||
@@ -306,28 +355,47 @@ export default function ChatBOX() {
|
|||||||
client.max_gen_tokens = chatStore.maxGenTokens;
|
client.max_gen_tokens = chatStore.maxGenTokens;
|
||||||
client.enable_max_gen_tokens = chatStore.maxGenTokens_enabled;
|
client.enable_max_gen_tokens = chatStore.maxGenTokens_enabled;
|
||||||
|
|
||||||
|
const created_at = new Date();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setShowGenerating(true);
|
setShowGenerating(true);
|
||||||
|
abortControllerRef.current = new AbortController();
|
||||||
const response = await client._fetch(
|
const response = await client._fetch(
|
||||||
chatStore.streamMode,
|
chatStore.streamMode,
|
||||||
chatStore.logprobs
|
chatStore.logprobs,
|
||||||
|
abortControllerRef.current.signal
|
||||||
);
|
);
|
||||||
|
const responsed_at = new Date();
|
||||||
const contentType = response.headers.get("content-type");
|
const contentType = response.headers.get("content-type");
|
||||||
let usage: Usage;
|
let cs: ChatStoreMessage;
|
||||||
if (contentType?.startsWith("text/event-stream")) {
|
if (contentType?.startsWith("text/event-stream")) {
|
||||||
usage = await _completeWithStreamMode(response);
|
cs = await _completeWithStreamMode(
|
||||||
|
response,
|
||||||
|
abortControllerRef.current.signal
|
||||||
|
);
|
||||||
} else if (contentType?.startsWith("application/json")) {
|
} else if (contentType?.startsWith("application/json")) {
|
||||||
usage = await _completeWithFetchMode(response);
|
cs = await _completeWithFetchMode(response);
|
||||||
} else {
|
} else {
|
||||||
throw `unknown response content type ${contentType}`;
|
throw `unknown response content type ${contentType}`;
|
||||||
}
|
}
|
||||||
|
const usage = cs.usage;
|
||||||
|
if (!usage) {
|
||||||
|
throw "panic: usage is null";
|
||||||
|
}
|
||||||
|
|
||||||
|
const completed_at = new Date();
|
||||||
|
cs.created_at = created_at.toISOString();
|
||||||
|
cs.responsed_at = responsed_at.toISOString();
|
||||||
|
cs.completed_at = completed_at.toISOString();
|
||||||
|
|
||||||
|
chatStore.history.push(cs);
|
||||||
|
console.log("new chatStore", cs);
|
||||||
|
|
||||||
// manually copy status from client to chatStore
|
// manually copy status from client to chatStore
|
||||||
chatStore.maxTokens = client.max_tokens;
|
chatStore.maxTokens = client.max_tokens;
|
||||||
chatStore.tokenMargin = client.tokens_margin;
|
chatStore.tokenMargin = client.tokens_margin;
|
||||||
chatStore.totalTokens = client.total_tokens;
|
chatStore.totalTokens = client.total_tokens;
|
||||||
|
|
||||||
console.log("usage", usage);
|
|
||||||
// estimate user's input message token
|
// estimate user's input message token
|
||||||
const aboveTokens = chatStore.history
|
const aboveTokens = chatStore.history
|
||||||
.filter(({ hide }) => !hide)
|
.filter(({ hide }) => !hide)
|
||||||
@@ -375,7 +443,11 @@ export default function ChatBOX() {
|
|||||||
|
|
||||||
setShowRetry(false);
|
setShowRetry(false);
|
||||||
setChatStore({ ...chatStore });
|
setChatStore({ ...chatStore });
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
|
if (error.name === "AbortError") {
|
||||||
|
console.log("abort complete");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setShowRetry(true);
|
setShowRetry(true);
|
||||||
alert(error);
|
alert(error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -386,6 +458,10 @@ export default function ChatBOX() {
|
|||||||
|
|
||||||
// when user click the "send" button or ctrl+Enter in the textarea
|
// when user click the "send" button or ctrl+Enter in the textarea
|
||||||
const send = async (msg = "", call_complete = true) => {
|
const send = async (msg = "", call_complete = true) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}, 0);
|
||||||
|
|
||||||
const inputMsg = msg.trim();
|
const inputMsg = msg.trim();
|
||||||
if (!inputMsg && images.length === 0) {
|
if (!inputMsg && images.length === 0) {
|
||||||
console.log("empty message");
|
console.log("empty message");
|
||||||
@@ -408,6 +484,8 @@ export default function ChatBOX() {
|
|||||||
audio: null,
|
audio: null,
|
||||||
logprobs: null,
|
logprobs: null,
|
||||||
response_model_name: null,
|
response_model_name: null,
|
||||||
|
reasoning_content: null,
|
||||||
|
usage: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// manually calculate token length
|
// manually calculate token length
|
||||||
@@ -423,120 +501,45 @@ export default function ChatBOX() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
|
||||||
const userInputRef = useRef<HTMLInputElement>(null);
|
const userInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const abortControllerRef = useRef<AbortController>(new AbortController());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col p-2 gap-2 w-full">
|
<div className="grow flex flex-col w-full">
|
||||||
<div className="flex items-center gap-2 justify-between">
|
|
||||||
{true && <Settings setShow={setShowSettings} />}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => setShowSearch(true)}
|
|
||||||
>
|
|
||||||
<SearchIcon />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{showSearch && <Search show={showSearch} setShow={setShowSearch} />}
|
|
||||||
|
|
||||||
{!chatStore.apiKey && (
|
|
||||||
<Alert>
|
|
||||||
<KeyIcon className="h-4 w-4" />
|
|
||||||
<AlertTitle>Heads up!</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
{Tr("Please click above to set")} (OpenAI) API KEY
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
{!chatStore.apiEndpoint && (
|
|
||||||
<Alert>
|
|
||||||
<GlobeIcon className="h-4 w-4" />
|
|
||||||
<AlertTitle>Heads up!</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
{Tr("Please click above to set")} API Endpoint
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
<NavigationMenu>
|
|
||||||
<NavigationMenuList>
|
|
||||||
{ctx.templateAPIs.length > 0 && (
|
|
||||||
<ListAPIs label="API" apiField="apiEndpoint" keyField="apiKey" />
|
|
||||||
)}
|
|
||||||
{ctx.templateAPIsWhisper.length > 0 && (
|
|
||||||
<ListAPIs
|
|
||||||
label="Whisper API"
|
|
||||||
apiField="whisper_api"
|
|
||||||
keyField="whisper_key"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{ctx.templateAPIsTTS.length > 0 && (
|
|
||||||
<ListAPIs label="TTS API" apiField="tts_api" keyField="tts_key" />
|
|
||||||
)}
|
|
||||||
{ctx.templateAPIsImageGen.length > 0 && (
|
|
||||||
<ListAPIs
|
|
||||||
label="Image Gen API"
|
|
||||||
apiField="image_gen_api"
|
|
||||||
keyField="image_gen_key"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{ctx.templateTools.length > 0 && <ListToolsTempaltes />}
|
|
||||||
</NavigationMenuList>
|
|
||||||
</NavigationMenu>
|
|
||||||
</div>
|
|
||||||
<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</Tr>
|
||||||
|
</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
|
||||||
</div>
|
used to change the settings of this chat
|
||||||
<div className="flex items-center gap-2">
|
</Tr>
|
||||||
<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
|
||||||
|
</Tr>
|
||||||
|
</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
|
||||||
|
</Tr>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
@@ -549,7 +552,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 +562,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">
|
||||||
@@ -575,7 +584,7 @@ export default function ChatBOX() {
|
|||||||
</ChatBubble>
|
</ChatBubble>
|
||||||
)}
|
)}
|
||||||
<p className="text-center">
|
<p className="text-center">
|
||||||
{chatStore.history.length > 0 && (
|
{chatStore.history.length > 0 && !showGenerating && (
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -591,7 +600,20 @@ export default function ChatBOX() {
|
|||||||
await complete();
|
await complete();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{Tr("Re-Generate")}
|
<Tr>Re-Generate</Tr>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{chatStore.history.length > 0 && !showGenerating && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="m-2"
|
||||||
|
disabled={showGenerating}
|
||||||
|
onClick={() => {
|
||||||
|
handleNewChatStore();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tr>New Chat</Tr>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{chatStore.develop_mode && chatStore.history.length > 0 && (
|
{chatStore.develop_mode && chatStore.history.length > 0 && (
|
||||||
@@ -603,22 +625,24 @@ export default function ChatBOX() {
|
|||||||
await complete();
|
await complete();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{Tr("Completion")}
|
<Tr>Completion</Tr>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p className="p-2 my-2 text-center opacity-50 dark:text-white">
|
{chatStore.postBeginIndex !== 0 && (
|
||||||
{chatStore.postBeginIndex !== 0 && (
|
<p className="p-2 my-2 text-center opacity-50 dark:text-white">
|
||||||
<Alert variant="default">
|
<Alert variant="default">
|
||||||
<InfoIcon className="h-4 w-4" />
|
<InfoIcon className="h-4 w-4" />
|
||||||
<AlertTitle>{Tr("Chat History Notice")}</AlertTitle>
|
<AlertTitle>
|
||||||
|
<Tr>Chat History Notice</Tr>
|
||||||
|
</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
{Tr("Info: chat history is too long, forget messages")}:{" "}
|
<Tr>Info: chat history is too long, forget messages</Tr>:{" "}
|
||||||
{chatStore.postBeginIndex}
|
{chatStore.postBeginIndex}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
</p>
|
||||||
</p>
|
)}
|
||||||
<VersionHint />
|
<VersionHint />
|
||||||
{showRetry && (
|
{showRetry && (
|
||||||
<p className="text-right p-2 my-2 dark:text-white">
|
<p className="text-right p-2 my-2 dark:text-white">
|
||||||
@@ -629,12 +653,12 @@ export default function ChatBOX() {
|
|||||||
await complete();
|
await complete();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{Tr("Retry")}
|
<Tr>Retry</Tr>
|
||||||
</Button>
|
</Button>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div ref={messagesEndRef as any}></div>
|
|
||||||
</ChatMessageList>
|
</ChatMessageList>
|
||||||
|
<div id="message-end" ref={messagesEndRef as any}></div>
|
||||||
{images.length > 0 && (
|
{images.length > 0 && (
|
||||||
<div className="flex flex-wrap">
|
<div className="flex flex-wrap">
|
||||||
{images.map((image, index) => (
|
{images.map((image, index) => (
|
||||||
@@ -652,22 +676,39 @@ export default function ChatBOX() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="sticky bottom-0 w-full z-20 bg-background">
|
<div className="sticky bottom-0 w-full z-20 bg-background">
|
||||||
{generatingMessage && (
|
{generatingMessage && (
|
||||||
<div className="flex items-center justify-end gap-2 p-2 m-2 rounded bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
<div className="flex items-center justify-between gap-2 p-2 m-2 rounded bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
<div className="flex items-center gap-2">
|
||||||
Follow
|
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||||
</label>
|
<Tr>Follow</Tr>
|
||||||
<Switch
|
</label>
|
||||||
checked={follow}
|
<Switch
|
||||||
onCheckedChange={setFollow}
|
checked={follow}
|
||||||
aria-label="Toggle auto-scroll"
|
onCheckedChange={setFollow}
|
||||||
/>
|
aria-label="Toggle auto-scroll"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="ml-auto gap-1.5"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
setShowGenerating(false);
|
||||||
|
setGeneratingMessage("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tr>Stop Generating</Tr>
|
||||||
|
<ScissorsIcon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<form className="relative rounded-lg border bg-background focus-within:ring-1 focus-within:ring-ring p-1">
|
<form className="relative rounded-lg border bg-background focus-within:ring-1 focus-within:ring-ring p-1">
|
||||||
<ChatInput
|
<ChatInput
|
||||||
value={inputMsg}
|
value={inputMsg}
|
||||||
ref={userInputRef as any}
|
ref={userInputRef as any}
|
||||||
placeholder="Type your message here..."
|
placeholder={tr("Type your message here...", langCode)}
|
||||||
onChange={(event: any) => {
|
onChange={(event: any) => {
|
||||||
setInputMsg(event.target.value);
|
setInputMsg(event.target.value);
|
||||||
autoHeight(event.target);
|
autoHeight(event.target);
|
||||||
@@ -686,23 +727,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"
|
||||||
@@ -715,18 +747,11 @@ export default function ChatBOX() {
|
|||||||
autoHeight(userInputRef.current);
|
autoHeight(userInputRef.current);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Send Message
|
<Tr>Send</Tr>
|
||||||
<CornerDownLeftIcon className="size-3.5" />
|
<CornerDownLeftIcon className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<AddImage
|
|
||||||
setShowAddImage={setShowAddImage}
|
|
||||||
images={images}
|
|
||||||
showAddImage={showAddImage}
|
|
||||||
setImages={setImages}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
12
src/registerSW.ts
Normal file
12
src/registerSW.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
if ("serviceWorker" in navigator) {
|
||||||
|
window.addEventListener("load", () => {
|
||||||
|
navigator.serviceWorker
|
||||||
|
.register("/service-worker.js")
|
||||||
|
.then((registration) => {
|
||||||
|
console.log("SW registered:", registration);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.log("SW registration failed:", error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createContext } from "react";
|
import { createContext, useContext } from "react";
|
||||||
import MAP_zh_CN from "@/translate/zh_CN";
|
import MAP_zh_CN from "@/translate/zh_CN";
|
||||||
|
|
||||||
interface LangOption {
|
interface LangOption {
|
||||||
@@ -20,7 +20,26 @@ const LANG_OPTIONS: Record<string, LangOption> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const langCodeContext = createContext("en-US");
|
// lowercase all langMap keys
|
||||||
|
Object.keys(LANG_OPTIONS).forEach((langCode) => {
|
||||||
|
const langMap = LANG_OPTIONS[langCode].langMap;
|
||||||
|
const newLangMap: Record<string, string> = {};
|
||||||
|
Object.keys(langMap).forEach((key) => {
|
||||||
|
newLangMap[key.toLowerCase()] = langMap[key];
|
||||||
|
});
|
||||||
|
LANG_OPTIONS[langCode].langMap = newLangMap;
|
||||||
|
});
|
||||||
|
|
||||||
|
type LangCode = "en-US" | "zh-CN";
|
||||||
|
|
||||||
|
interface LangCodeContextSchema {
|
||||||
|
langCode: LangCode;
|
||||||
|
setLangCode: (langCode: LangCode) => void;
|
||||||
|
}
|
||||||
|
const langCodeContext = createContext<LangCodeContextSchema>({
|
||||||
|
langCode: "en-US",
|
||||||
|
setLangCode: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
function tr(text: string, langCode: "en-US" | "zh-CN") {
|
function tr(text: string, langCode: "en-US" | "zh-CN") {
|
||||||
const option = LANG_OPTIONS[langCode];
|
const option = LANG_OPTIONS[langCode];
|
||||||
@@ -31,18 +50,20 @@ function tr(text: string, langCode: "en-US" | "zh-CN") {
|
|||||||
|
|
||||||
const translatedText = langMap[text.toLowerCase()];
|
const translatedText = langMap[text.toLowerCase()];
|
||||||
if (translatedText === undefined) {
|
if (translatedText === undefined) {
|
||||||
|
if (langCode !== "en-US") {
|
||||||
|
console.log(`[Translation] not found for "${text}"`);
|
||||||
|
}
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
return translatedText;
|
return translatedText;
|
||||||
}
|
}
|
||||||
|
function Tr({ children }: { children: string }) {
|
||||||
function Tr(text: string) {
|
|
||||||
return (
|
return (
|
||||||
<langCodeContext.Consumer>
|
<langCodeContext.Consumer>
|
||||||
{/* @ts-ignore */}
|
{/* @ts-ignore */}
|
||||||
{({ langCode }) => {
|
{({ langCode }) => {
|
||||||
return tr(text, langCode);
|
return tr(children, langCode);
|
||||||
}}
|
}}
|
||||||
</langCodeContext.Consumer>
|
</langCodeContext.Consumer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 新建对话",
|
||||||
@@ -54,6 +62,92 @@ const LANG_MAP: Record<string, string> = {
|
|||||||
example: "示例",
|
example: "示例",
|
||||||
render: "渲染",
|
render: "渲染",
|
||||||
"reset current": "清空当前会话",
|
"reset current": "清空当前会话",
|
||||||
|
"send message": "发送",
|
||||||
|
"type your message here...": "在此输入消息...",
|
||||||
|
"total tokens": "总token数",
|
||||||
|
"session cost": "本会话消费",
|
||||||
|
"accumulated cost": "累计消费",
|
||||||
|
session: "会话",
|
||||||
|
"you can customize all the settings here": "您可以在此处自定义所有设置",
|
||||||
|
"Image Gen API": "图片生成 API",
|
||||||
|
"Image generation API endpoint. Service is enabled when this is set. Default: https://api.openai.com/v1/images/generations":
|
||||||
|
"图片生成 API 端点。设置后服务将启用。默认: https://api.openai.com/v1/images/generations",
|
||||||
|
"Image generation service API key. Defaults to the OpenAI key above, but can be configured separately here":
|
||||||
|
"图片生成服务 API 密钥。默认为上面的 OpenAI 密钥,但可以在此处单独配置",
|
||||||
|
"Adjust the playback speed of text-to-speech": "调整文本转语音的播放速度",
|
||||||
|
"TTS API endpoint. Service is enabled when this is set. Default: https://api.openai.com/v1/audio/speech":
|
||||||
|
"TTS API 端点。设置后服务将启用。默认: https://api.openai.com/v1/audio/speech",
|
||||||
|
"Text-to-speech service API key. Defaults to the OpenAI key above, but can be configured separately here":
|
||||||
|
"文本转语音服务 API 密钥。默认为上面的 OpenAI 密钥,但可以在此处单独配置",
|
||||||
|
"Whisper speech-to-text service. Service is enabled when this is set. Default: https://api.openai.com/v1/audio/transriptions":
|
||||||
|
"Whisper 语音转文本服务。设置后服务将启用。默认: https://api.openai.com/v1/audio/transriptions",
|
||||||
|
"Used for Whisper service. Defaults to the OpenAI key above, but can be configured separately here":
|
||||||
|
"用于 Whisper 服务。默认为上面的 OpenAI 密钥,但可以在此处单独配置",
|
||||||
|
"Frequency Penalty": "频率惩罚",
|
||||||
|
"Presence Penalty": "存在惩罚",
|
||||||
|
"Top P sampling method. It is recommended to choose one of the temperature sampling methods, do not enable both at the same time.":
|
||||||
|
"Top P 采样方法。建议选择其中一种温度采样方法,不要同时启用两种。",
|
||||||
|
"Total token count, this parameter will be updated every time you chat, in stream mode this parameter is an estimate":
|
||||||
|
"总 token 数,此参数将在每次对话时更新,在流式模式下此参数是一个估计",
|
||||||
|
"Indicates how many history messages to 'forget' when sending API requests":
|
||||||
|
"发送 API 请求时遗忘多少历史消息",
|
||||||
|
'When totalTokens > maxTokens - tokenMargin, the history message will be truncated, chatgpt will "forget" part of the messages in the conversation (but all history messages are still saved locally)':
|
||||||
|
"当 totalTokens > maxTokens - tokenMargin 时,历史消息将被截断,chatgpt 将“遗忘”对话中的部分消息(但所有历史消息仍然在本地保存)",
|
||||||
|
"maxGenTokens is the maximum number of tokens that can be generated in a scingle request.":
|
||||||
|
"maxGenTokens 是一次请求中可以生成的最大 token 数。",
|
||||||
|
"Logprobs, return the probability of each token":
|
||||||
|
"Logprobs,返回每个 token 的概率",
|
||||||
|
"Stream Mode, use stream mode to see the generated content dynamically, but the token count cannot be accurately calculated, which may cause too much or too little history messages to be truncated when the token count is too large.":
|
||||||
|
"流式模式,使用流式模式动态查看生成的内容,但无法准确计算 token 数,当 token 数过大时可能导致截断过多或过少的历史消息。",
|
||||||
|
"Temperature, the higher the value, the higher the randomness of the generated text.":
|
||||||
|
"温度,值越高,生成文本的随机性越高。",
|
||||||
|
"Model, Different models have different performance and pricing, please refer to the API documentation":
|
||||||
|
"模型,不同的模型具有不同的性能和定价,请参考 API 文档",
|
||||||
|
"Chat API": "对话 API",
|
||||||
|
"API endpoint, useful for using reverse proxy services in unsupported regions, default to https://api.openai.com/v1/chat/completions":
|
||||||
|
"API 端点,用于在不受支持的地区使用反向代理服务,默认为 https://api.openai.com/v1/chat/completions",
|
||||||
|
"OpenAI API key, do not leak this key": "OpenAI API 密钥,请勿泄漏",
|
||||||
|
"Select language": "选择语言",
|
||||||
|
"Develop Mode, enable to show more options and features":
|
||||||
|
"开发者模式,启用以显示更多选项和功能",
|
||||||
|
"Saved Template": "已保存模板",
|
||||||
|
"Image Generation": "图片生成",
|
||||||
|
TTS: "文本转语音",
|
||||||
|
"Speech Recognition": "语音识别",
|
||||||
|
System: "系统",
|
||||||
|
"Max context token count. This value will be set automatically based on the selected model.":
|
||||||
|
"最大上下文 token 数。此值将根据所选模型自动设置。",
|
||||||
|
chat: "对话",
|
||||||
|
save: "保存",
|
||||||
|
"Configure speech recognition settings": "配置语音识别设置",
|
||||||
|
"Whisper API": "Whisper API",
|
||||||
|
Custom: "自定义",
|
||||||
|
"Configure the LLM API settings": "配置 LLM API 设置",
|
||||||
|
"TTS Voice": "TTS 语音",
|
||||||
|
"TTS Format": "TTS 格式",
|
||||||
|
Formats: "格式",
|
||||||
|
"TTS API": "TTS API",
|
||||||
|
"Configure text-to-speech settings": "配置文本转语音设置",
|
||||||
|
Voices: "语音",
|
||||||
|
"in all sessions": "所有会话",
|
||||||
|
"Reset Total Cost": "重置总消费",
|
||||||
|
Language: "语言",
|
||||||
|
"Quick Actions": "快速操作",
|
||||||
|
Import: "导入",
|
||||||
|
Languages: "语言",
|
||||||
|
"maxGenTokens is the maximum number of tokens that can be generated in a single request.":
|
||||||
|
"maxGenTokens 是一次请求中可以生成的最大 token 数。",
|
||||||
|
"Image Generation API": "图片生成 API",
|
||||||
|
"Configure image generation settings": "配置图片生成设置",
|
||||||
|
"New Chat": "新对话",
|
||||||
|
"Delete Chat": "删除对话",
|
||||||
|
"removed from context": "已从上下文中移除",
|
||||||
|
follow: "跟随",
|
||||||
|
"stop generating": "停止生成",
|
||||||
|
"there are some configurations in the URL, import them?":
|
||||||
|
"URL 中有一些配置,是否导入?",
|
||||||
|
"Import Configuration": "导入配置",
|
||||||
|
cancel: "取消",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default LANG_MAP;
|
export default LANG_MAP;
|
||||||
|
|||||||
97
src/tts.tsx
97
src/tts.tsx
@@ -1,97 +0,0 @@
|
|||||||
import { SpeakerWaveIcon } from "@heroicons/react/24/outline";
|
|
||||||
import { useContext, useMemo, useState } from "react";
|
|
||||||
|
|
||||||
import { addTotalCost } from "@/utils/totalCost";
|
|
||||||
import { ChatStore, ChatStoreMessage } from "@/types/chatstore";
|
|
||||||
import { Message, getMessageText } from "@/chatgpt";
|
|
||||||
import { AudioLinesIcon, LoaderCircleIcon } from "lucide-react";
|
|
||||||
import { Button } from "./components/ui/button";
|
|
||||||
import { AppContext } from "./pages/App";
|
|
||||||
|
|
||||||
interface TTSProps {
|
|
||||||
chat: ChatStoreMessage;
|
|
||||||
}
|
|
||||||
interface TTSPlayProps {
|
|
||||||
chat: ChatStoreMessage;
|
|
||||||
}
|
|
||||||
export function TTSPlay(props: TTSPlayProps) {
|
|
||||||
const src = useMemo(() => {
|
|
||||||
if (props.chat.audio instanceof Blob) {
|
|
||||||
return URL.createObjectURL(props.chat.audio);
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}, [props.chat.audio]);
|
|
||||||
|
|
||||||
if (props.chat.hide) {
|
|
||||||
return <></>;
|
|
||||||
}
|
|
||||||
if (props.chat.audio instanceof Blob) {
|
|
||||||
return <audio className="w-64" src={src} controls />;
|
|
||||||
}
|
|
||||||
return <></>;
|
|
||||||
}
|
|
||||||
export default function TTSButton(props: TTSProps) {
|
|
||||||
const [generating, setGenerating] = useState(false);
|
|
||||||
const ctx = useContext(AppContext);
|
|
||||||
if (!ctx) return <div>error</div>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => {
|
|
||||||
const api = ctx.chatStore.tts_api;
|
|
||||||
const api_key = ctx.chatStore.tts_key;
|
|
||||||
const model = "tts-1";
|
|
||||||
const input = getMessageText(props.chat);
|
|
||||||
const voice = ctx.chatStore.tts_voice;
|
|
||||||
|
|
||||||
const body: Record<string, any> = {
|
|
||||||
model,
|
|
||||||
input,
|
|
||||||
voice,
|
|
||||||
response_format: ctx.chatStore.tts_format || "mp3",
|
|
||||||
};
|
|
||||||
if (ctx.chatStore.tts_speed_enabled) {
|
|
||||||
body["speed"] = ctx.chatStore.tts_speed;
|
|
||||||
}
|
|
||||||
|
|
||||||
setGenerating(true);
|
|
||||||
|
|
||||||
fetch(api, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${api_key}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
})
|
|
||||||
.then((response) => response.blob())
|
|
||||||
.then((blob) => {
|
|
||||||
// update price
|
|
||||||
const cost = (input.length * 0.015) / 1000;
|
|
||||||
ctx.chatStore.cost += cost;
|
|
||||||
addTotalCost(cost);
|
|
||||||
ctx.setChatStore({ ...ctx.chatStore });
|
|
||||||
|
|
||||||
// save blob
|
|
||||||
props.chat.audio = blob;
|
|
||||||
ctx.setChatStore({ ...ctx.chatStore });
|
|
||||||
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const audio = new Audio(url);
|
|
||||||
audio.play();
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setGenerating(false);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{generating ? (
|
|
||||||
<LoaderCircleIcon className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<AudioLinesIcon className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Logprobs, Message, MessageDetail, ToolCall } from "@/chatgpt";
|
import { Logprobs, Message, MessageDetail, ToolCall, Usage } from "@/chatgpt";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ChatStore is the main object of the chatgpt-api-web,
|
* ChatStore is the main object of the chatgpt-api-web,
|
||||||
@@ -26,7 +26,9 @@ export interface ChatStore {
|
|||||||
top_p: number;
|
top_p: number;
|
||||||
top_p_enabled: boolean;
|
top_p_enabled: boolean;
|
||||||
presence_penalty: number;
|
presence_penalty: number;
|
||||||
|
presence_penalty_enabled: boolean;
|
||||||
frequency_penalty: number;
|
frequency_penalty: number;
|
||||||
|
frequency_penalty_enabled: boolean;
|
||||||
develop_mode: boolean;
|
develop_mode: boolean;
|
||||||
whisper_api: string;
|
whisper_api: string;
|
||||||
whisper_key: string;
|
whisper_key: string;
|
||||||
@@ -69,9 +71,16 @@ export interface ChatStoreMessage {
|
|||||||
audio: Blob | null;
|
audio: Blob | null;
|
||||||
logprobs: Logprobs | null;
|
logprobs: Logprobs | null;
|
||||||
response_model_name: string | null;
|
response_model_name: string | null;
|
||||||
|
usage: Usage | null;
|
||||||
|
|
||||||
|
created_at?: string;
|
||||||
|
responsed_at?: string;
|
||||||
|
completed_at?: string;
|
||||||
|
response_count?: number;
|
||||||
|
|
||||||
role: "system" | "user" | "assistant" | "tool";
|
role: "system" | "user" | "assistant" | "tool";
|
||||||
content: string | MessageDetail[];
|
content: string | MessageDetail[];
|
||||||
|
reasoning_content: string | null;
|
||||||
name?: "example_user" | "example_assistant";
|
name?: "example_user" | "example_assistant";
|
||||||
tool_calls?: ToolCall[];
|
tool_calls?: ToolCall[];
|
||||||
tool_call_id?: string;
|
tool_call_id?: string;
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import {
|
|||||||
DefaultModel,
|
DefaultModel,
|
||||||
CHATGPT_API_WEB_VERSION,
|
CHATGPT_API_WEB_VERSION,
|
||||||
} from "@/const";
|
} from "@/const";
|
||||||
import { getDefaultParams } from "@/utils/getDefaultParam";
|
import { ChatStore, ChatStoreMessage } from "@/types/chatstore";
|
||||||
import { ChatStore } from "@/types/chatstore";
|
|
||||||
import { models } from "@/types/models";
|
import { models } from "@/types/models";
|
||||||
|
|
||||||
interface NewChatStoreOptions {
|
interface NewChatStoreOptions {
|
||||||
@@ -18,7 +17,9 @@ interface NewChatStoreOptions {
|
|||||||
top_p?: number;
|
top_p?: number;
|
||||||
top_p_enabled?: boolean;
|
top_p_enabled?: boolean;
|
||||||
presence_penalty?: number;
|
presence_penalty?: number;
|
||||||
|
presence_penalty_enabled?: boolean;
|
||||||
frequency_penalty?: number;
|
frequency_penalty?: number;
|
||||||
|
frequency_penalty_enabled?: boolean;
|
||||||
dev?: boolean;
|
dev?: boolean;
|
||||||
whisper_api?: string;
|
whisper_api?: string;
|
||||||
whisper_key?: string;
|
whisper_key?: string;
|
||||||
@@ -33,52 +34,44 @@ interface NewChatStoreOptions {
|
|||||||
image_gen_key?: string;
|
image_gen_key?: string;
|
||||||
json_mode?: boolean;
|
json_mode?: boolean;
|
||||||
logprobs?: boolean;
|
logprobs?: boolean;
|
||||||
|
maxTokens?: number;
|
||||||
|
use_this_history?: ChatStoreMessage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const newChatStore = (options: NewChatStoreOptions): ChatStore => {
|
export const newChatStore = (options: NewChatStoreOptions): ChatStore => {
|
||||||
return {
|
return {
|
||||||
chatgpt_api_web_version: CHATGPT_API_WEB_VERSION,
|
chatgpt_api_web_version: CHATGPT_API_WEB_VERSION,
|
||||||
systemMessageContent: getDefaultParams(
|
systemMessageContent: options.systemMessageContent ?? "",
|
||||||
"sys",
|
|
||||||
options.systemMessageContent ?? ""
|
|
||||||
),
|
|
||||||
toolsString: options.toolsString ?? "",
|
toolsString: options.toolsString ?? "",
|
||||||
history: [],
|
history: options.use_this_history ?? [],
|
||||||
postBeginIndex: 0,
|
postBeginIndex: 0,
|
||||||
tokenMargin: 1024,
|
tokenMargin: 1024,
|
||||||
totalTokens: 0,
|
totalTokens: 0,
|
||||||
maxTokens: getDefaultParams(
|
maxTokens:
|
||||||
"max",
|
models[options.model ?? DefaultModel]?.maxToken ??
|
||||||
models[getDefaultParams("model", options.model ?? DefaultModel)]
|
options.maxTokens ??
|
||||||
?.maxToken ?? 2048
|
2048,
|
||||||
),
|
|
||||||
maxGenTokens: 2048,
|
maxGenTokens: 2048,
|
||||||
maxGenTokens_enabled: false,
|
maxGenTokens_enabled: false,
|
||||||
apiKey: getDefaultParams("key", options.apiKey ?? ""),
|
apiKey: options.apiKey ?? "",
|
||||||
apiEndpoint: getDefaultParams(
|
apiEndpoint: options.apiEndpoint ?? DefaultAPIEndpoint,
|
||||||
"api",
|
streamMode: options.streamMode ?? true,
|
||||||
options.apiEndpoint ?? DefaultAPIEndpoint
|
model: options.model ?? DefaultModel,
|
||||||
),
|
|
||||||
streamMode: getDefaultParams("mode", options.streamMode ?? true),
|
|
||||||
model: getDefaultParams("model", options.model ?? DefaultModel),
|
|
||||||
cost: 0,
|
cost: 0,
|
||||||
temperature: getDefaultParams("temp", options.temperature ?? 1),
|
temperature: options.temperature ?? 1,
|
||||||
temperature_enabled: options.temperature_enabled ?? true,
|
temperature_enabled: options.temperature_enabled ?? true,
|
||||||
top_p: options.top_p ?? 1,
|
top_p: options.top_p ?? 1,
|
||||||
top_p_enabled: options.top_p_enabled ?? false,
|
top_p_enabled: options.top_p_enabled ?? false,
|
||||||
presence_penalty: options.presence_penalty ?? 0,
|
presence_penalty: options.presence_penalty ?? 0,
|
||||||
|
presence_penalty_enabled: options.presence_penalty_enabled ?? false,
|
||||||
frequency_penalty: options.frequency_penalty ?? 0,
|
frequency_penalty: options.frequency_penalty ?? 0,
|
||||||
develop_mode: getDefaultParams("dev", options.dev ?? false),
|
frequency_penalty_enabled: options.frequency_penalty_enabled ?? false,
|
||||||
whisper_api: getDefaultParams(
|
develop_mode: options.dev ?? false,
|
||||||
"whisper-api",
|
whisper_api:
|
||||||
options.whisper_api ?? "https://api.openai.com/v1/audio/transcriptions"
|
options.whisper_api ?? "https://api.openai.com/v1/audio/transcriptions",
|
||||||
),
|
whisper_key: options.whisper_key ?? "",
|
||||||
whisper_key: getDefaultParams("whisper-key", options.whisper_key ?? ""),
|
tts_api: options.tts_api ?? "https://api.openai.com/v1/audio/speech",
|
||||||
tts_api: getDefaultParams(
|
tts_key: options.tts_key ?? "",
|
||||||
"tts-api",
|
|
||||||
options.tts_api ?? "https://api.openai.com/v1/audio/speech"
|
|
||||||
),
|
|
||||||
tts_key: getDefaultParams("tts-key", options.tts_key ?? ""),
|
|
||||||
tts_voice: options.tts_voice ?? "alloy",
|
tts_voice: options.tts_voice ?? "alloy",
|
||||||
tts_speed: options.tts_speed ?? 1.0,
|
tts_speed: options.tts_speed ?? 1.0,
|
||||||
tts_speed_enabled: options.tts_speed_enabled ?? false,
|
tts_speed_enabled: options.tts_speed_enabled ?? false,
|
||||||
|
|||||||
8
src/utils/isVailedJSON.ts
Normal file
8
src/utils/isVailedJSON.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export const isVailedJSON = (str: string): boolean => {
|
||||||
|
try {
|
||||||
|
JSON.parse(str);
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
@@ -4,6 +4,13 @@ module.exports = {
|
|||||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
|
typography: (theme) => ({
|
||||||
|
DEFAULT: {
|
||||||
|
css: {
|
||||||
|
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
lg: 'var(--radius)',
|
lg: 'var(--radius)',
|
||||||
md: 'calc(var(--radius) - 2px)',
|
md: 'calc(var(--radius) - 2px)',
|
||||||
@@ -85,5 +92,5 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
plugins: [require("tailwindcss-animate")],
|
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,30 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { VitePWA } from "vite-plugin-pwa";
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [],
|
plugins: [
|
||||||
|
react(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: "autoUpdate",
|
||||||
|
includeAssets: ['favicon.png'],
|
||||||
|
manifest: {
|
||||||
|
name: "ChatGPT-API-WEB",
|
||||||
|
short_name: "CAW",
|
||||||
|
description: "ChatGPT API Web Interface",
|
||||||
|
theme_color: "#000000",
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: "favicon.png",
|
||||||
|
sizes: "256x256",
|
||||||
|
type: "image/png",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
base: "./",
|
base: "./",
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
|||||||
Reference in New Issue
Block a user