100 Commits

Author SHA1 Message Date
7793d94514 fix: deepseek rate limit keep-alive
Some checks failed
Deploy static content to Pages / deploy (push) Has been cancelled
2025-06-10 18:20:03 +08:00
24973eabfe fix: structuredClone template
Some checks are pending
Deploy static content to Pages / deploy (push) Waiting to run
2025-06-10 17:13:53 +08:00
6078d8a2c3 remove gitea action 2025-06-10 15:38:43 +08:00
d830b92fbf feat: Add tooltips to template attributes
Some checks failed
Build static content / build (push) Has been cancelled
2025-05-30 18:50:21 +08:00
7694ed6792 Merge branch 'cursor' 2025-05-30 18:36:57 +08:00
6b8426868a Fix: Template attribute dialog input and type issues 2025-05-30 18:35:40 +08:00
e18dd9b680 Merge pull request #25 from heimoshuiyu/cursor
feat: Add edit and delete functionality for templates and enhance JSON editor
2025-05-28 09:58:50 +08:00
13295bd24d Refactor: Replace window.confirm with custom dialog component 2025-05-28 09:57:46 +08:00
aa83f10657 feat: Add edit and delete functionality for templates and enhance JSON editor 2025-05-28 09:37:29 +08:00
95b319db7d Merge pull request #24 from heimoshuiyu/cursor
fix: chat template / api template select menu not working for the first click
2025-05-28 09:30:41 +08:00
8f24489959 refac: api template menu 2025-05-28 02:10:18 +08:00
39c3860c78 fix: chat template menu by cursor 2025-05-28 02:01:04 +08:00
812ce3cc1f fix: conversation list show in startup 2025-04-23 10:21:51 +08:00
667b334dfc feat: add URL parameter configuration import dialog and related functionality 2025-03-25 10:49:10 +08:00
9b32948cfa remove lg:w-[65%] 2025-03-24 16:09:39 +08:00
9fbd9b98c2 Merge branch 'dev' 2025-03-24 15:50:29 +08:00
14df7bebac Revert "fix overflow"
This reverts commit 2a39ff885a.
2025-03-24 15:49:29 +08:00
e4919bb91f fix: panic if usage is null in stream mode 2025-02-20 16:42:52 +08:00
2a39ff885a fix overflow 2025-02-20 11:59:36 +08:00
c03dbef798 add response count to ChatStoreMessage and update message creation 2025-02-08 14:25:12 +08:00
8cd43bec72 add Chinese translations for "follow" and "stop generating"; log translation misses only for non-English 2025-02-08 11:03:20 +08:00
ed5f561148 refactor Chatbox component layout and restore stop generating button 2025-02-08 11:03:00 +08:00
ecwu
8d4a9b840a Add syntax highlighting support to MessageBubble component with rehype-highlight and highlight.js 2025-02-07 21:52:09 +00:00
ecwu
8db892caf7 Update MessageBubble component to adjust Markdown styling and improve responsiveness 2025-02-07 21:37:53 +00:00
ecwu
d18040dca1 Add @tailwindcss/typography plugin and update MessageBubble component for improved Markdown rendering 2025-02-07 21:32:14 +00:00
ecwu
a5f7447f4f Refactor MessageBubble component to enhance Markdown rendering for list items and paragraphs 2025-02-07 17:42:49 +00:00
ecwu
332a645e34 Enhance MessageBubble component with improved styling and collapsible content 2025-02-07 17:30:52 +00:00
c37a99f06d fix haha 2025-02-08 00:54:32 +08:00
Zhenghao Wu
3e89e88c1d Merge pull request #23 from heimoshuiyu/master
sync 0207
2025-02-07 16:52:54 +00:00
5b4a0507ae stop generating store message 2025-02-08 00:09:49 +08:00
75bf4a419d 访问冲突警告 2025-02-07 23:14:12 +08:00
7dea556a56 fix history 2025-02-07 21:40:48 +08:00
79d5ded088 Update pages.yml
All checks were successful
Build static content / build (push) Successful in 8m5s
Update pages.yml

Update pages.yml

Update pages.yml

Update pages.yml

Update pages.yml

fix message bubble overflow on small screen

refactor ListAPI component to simplify click handler for template selection

chat store title

fix: adjust MessageBubble component to allow full-width rendering on medium screens

feat: enhance ConversationTitle component with full-width styling and click handler for title retrieval

feat: add abort signal support for fetch and stream response handling in Chat component

feat: add usage tracking and timestamps to ChatStoreMessage structure

pwa

feat: update theme colors to black in manifest and Vite config

display standlone

feat: add smooth scrolling to messages in Chatbox component

feat: add handleNewChatStore function to App context and integrate in Chatbox for new chat functionality

feat: refactor MessageBubble component to use ChatBubble and improve structure

refactor(MessageBubble): move TTSPlay component into message area and reorganize action buttons

ui(navbar): improve cost breakdown clarity and add accumulated cost tracking

Revert "feat: refactor MessageBubble component to use ChatBubble and improve structure"

This reverts commit d16984c7da896ee0d047dca0be3f4ad1703a5d2c.

display string mesasge trimed

fix typo

fix scroll after send

fix(MessageBubble): trim whitespace from reasoning content display

feat(sidebar): optimize mobile performance with CSS transitions

- Refactored mobile sidebar implementation to use direct CSS transforms instead of Sheet component
- Added static overlay mask with opacity transition for mobile experience
- Implemented custom close button with X icon to replace Sheet's default
- Improved z-index handling for sidebar elements (chat-bubble z-index reduced to 30)
- Preserved DOM structure during sidebar toggle to prevent unnecessary remounting
- Unified PC/mobile behavior using CSS animation rather than dynamic mounting
- Removed dependency on radix-ui Dialog components for mobile sidebar

fix scroll

fix sidebar style on mobile

apply default render to markdown

fix(ChatMessageList): set width to 100vw for full viewport coverage

fix small overflow

fix: overflow on PC

break model name anywhere

fix language
2025-02-07 18:56:13 +08:00
9e173b8955 add render md by default option, fix chat role type 2025-02-03 13:52:38 +08:00
ecwu
2193ce11df feat: update react-markdown to 9.0.3 and integrate KaTeX for math rendering in MessageBubble 2025-01-27 11:29:58 +00:00
ecwu
3b17ca791b feat: optimize LongInput component with memoization and local state management 2025-01-26 21:39:23 +00:00
ecwu
d51c283e55 feat: implement auto-resizing textarea in ChatInput component 2025-01-26 21:18:29 +00:00
ecwu
55e8186479 feat: enhance MessageBubble with custom Markdown rendering, update Navbar layout, and integrate Search component in App 2025-01-26 21:15:34 +00:00
ecwu
233397ba46 feat: add collapsible reasoning content to MessageBubble component, refactor message for response 2025-01-26 10:52:14 +00:00
Zhenghao Wu
c13bce63a9 Merge pull request #21 from heimoshuiyu/master
sync 0124
2025-01-24 12:28:03 +00:00
ecwu
25fcd1f685 feat: replace alert with toast notification for copied link in Settings component 2025-01-24 12:26:38 +00:00
0b3610935b fix: chatStore total_tokens count with reasoning 2025-01-22 19:19:54 +08:00
7aee52d5a2 save reasoning_content 2025-01-22 18:52:38 +08:00
6b78308bb5 feat: add maxTokens option to newChatStore 2025-01-21 09:51:39 +08:00
edcdc70a2b fix: enable/disable penalty in chatgpt.ts 2025-01-21 05:36:06 +08:00
3151fb8477 add options to enable/disable presence/frequency penalty 2025-01-21 05:31:37 +08:00
1146d514d3 Merge pull request #19 from heimoshuiyu/dev
Enhance current API display
2025-01-21 05:25:25 +08:00
ecwu
26cd5d1022 fix: remove unused import from App component 2025-01-20 15:02:02 +00:00
ecwu
cb1d25bbf6 fix: create a new toast when this is the first time visit 2025-01-20 15:01:34 +00:00
ecwu
02935d7a0f fix: update toast messages for clarity in chat session notifications 2025-01-20 14:54:08 +00:00
ecwu
0af9230c6e feat: enhance chat session notifications with API endpoint details 2025-01-20 14:14:24 +00:00
Zhenghao Wu
53f806ae3b Merge pull request #18 from heimoshuiyu/master
Sync progress
2025-01-14 01:46:21 +08:00
99d9e4d3f1 fix: delete chat template cause dialog render panic 2025-01-09 15:49:24 +08:00
e34cc7375d refac: edit chat template 2025-01-09 14:42:12 +08:00
ba64aec5b0 scroll to bottom at message sent 2025-01-08 22:49:52 +08:00
f0db9e6b03 fix: mockOnChange on user input textarea 2025-01-08 10:46:08 +08:00
74775b5265 fix: horizontal overflow, fix #17 2025-01-08 02:15:28 +08:00
394da2217c fix: save edit message on blur 2025-01-08 01:58:41 +08:00
137186e760 refactor: Input, Textarea maintain their own value, fix #8 2025-01-08 01:54:24 +08:00
d736c12ac1 Merge branch 'dev' 2025-01-08 01:26:44 +08:00
99e557c1a8 fix type: setChatSthore is async 2025-01-08 01:25:11 +08:00
b68224b13b refactor: seperate AppContext and AppChatStoreContext 2025-01-08 01:24:16 +08:00
20a152b899 ignore ctx is null early return 2025-01-08 00:17:01 +08:00
9cacc5c6d3 Merge pull request #15 from heimoshuiyu/dev
Feat: Add Chat Template Dropdown and Update Template Initialization Logic
2025-01-07 22:23:18 +08:00
001eca79f6 save api to chat template 2025-01-07 18:59:30 +08:00
a4b8ed441c bring Chat Template back 2025-01-07 18:58:23 +08:00
cdae105f3f Merge pull request #11 from heimoshuiyu/dev
refactor: hide API Endpoint overflow in Settings component for better display
2025-01-07 18:08:39 +08:00
ecwu
04cd1a36e1 refactor: prevent API overflow in Settings component for better display 2025-01-06 17:18:11 +08:00
d98ad885b2 Merge pull request #10 from heimoshuiyu/dev
Major Structure Refactor and Feature Enhancements
2025-01-06 14:15:07 +08:00
c43c24d3d5 fix: import typo 2025-01-06 14:13:21 +08:00
ecwu
765c2c446c refactor: move edit message components to components directory for better organization 2025-01-06 12:41:21 +08:00
ecwu
5effd0a3f4 refactor: remove unused buttons and enhance API and Tools display in Settings component 2025-01-06 12:37:29 +08:00
ecwu
af6ccad35b refactor: adjust PopoverContent position in ToolsDropdownList for better layout 2025-01-06 00:30:04 +08:00
ecwu
a7bbe1e000 refactor: enhance ToolsDropdownList with Popover and Command components for improved usability 2025-01-06 00:29:21 +08:00
ecwu
22e3760b7f refactor: add DrawerDescription to ImageGenDrawer and ImageUploadDrawer for improved accessibility 2025-01-05 23:32:22 +08:00
ecwu
78d40a8bf7 refactor: streamline ToolsDropdownList by removing unnecessary props and enhancing button functionality 2025-01-05 23:26:35 +08:00
ecwu
0e1529a4d2 refactor: add missing keys to list items and improve structure in APIListMenu and MessageBubble components 2025-01-05 20:42:36 +08:00
ecwu
709cad3138 refactor: simplify ImageUploadDrawer usage and integrate it into Chatbox for improved image handling 2025-01-05 20:32:19 +08:00
ecwu
c84cc7d9e8 remove unused addImage component 2025-01-05 20:01:27 +08:00
ecwu
c4dc89784d refactor: replace AddImage component with ImageUploadDrawer and add ImageGenDrawer for enhanced image handling 2025-01-05 20:00:17 +08:00
ecwu
40f61dd6f9 refactor: update button labels and improve dialog for saving tools templates 2025-01-05 19:43:57 +08:00
ecwu
c92b8f04cc refactor: rename ListAPI component to APIListMenu and update related references for improved clarity 2025-01-05 18:29:28 +08:00
ecwu
75a431360b refactor: reorganize SetAPIsTemplate component and update imports for improved structure 2025-01-05 00:11:31 +08:00
ecwu
76d50317e9 refactor: remove unused ListAPIs and ListToolsTemplates components for cleaner codebase 2025-01-05 00:01:13 +08:00
ecwu
9383cf045a refactor: replace ListAPIs component with ListAPI for improved structure and organization 2025-01-05 00:00:11 +08:00
ecwu
47f63364b1 refactor: rename message.tsx to MessageBubble.tsx and consolidate message-related components for better organization 2025-01-04 23:53:45 +08:00
ecwu
3663193f50 refactor: move isVailedJSON import to utils for better organization 2025-01-04 23:53:38 +08:00
ecwu
f3d08afcdd feat: add isVailedJSON utility function for JSON validation 2025-01-04 23:53:28 +08:00
ecwu
503bf6a9bb feat: replace DropdownMenu with Select component in ModeToggle for improved theme selection 2025-01-04 23:29:20 +08:00
ecwu
236d48e72d feat: enhance ListAPIs component with shortLabel prop for improved display and update translations for new chat session prompts 2025-01-04 23:13:58 +08:00
ecwu
1f9c75b91e refactor: rename tmps to temps in Settings and setAPIsTemplate components for consistency 2025-01-04 22:49:45 +08:00
ecwu
34360e5370 refactor: clean up imports and move logprobToColor function to utils 2025-01-03 11:34:46 +08:00
ecwu
3728766d7f refactor: rename components and update import paths for consistency 2025-01-03 11:30:12 +08:00
ecwu
3060543ee7 refactor: rename and reorganize Search component imports for better structure 2025-01-03 01:43:40 +08:00
ecwu
c421792b9f feat: add alert icon in Settings button for missing API key or endpoint 2025-01-03 01:35:45 +08:00
ecwu
45d405adf3 refactor: remove settings visibility logic and add TODO for future implementation 2025-01-03 01:19:50 +08:00
ecwu
30583a421d refactor: update Settings and Navbar components for improved state management and UI integration 2025-01-03 01:17:54 +08:00
ecwu
6fe1012270 add Navbar component and integrate into App layout 2025-01-03 00:33:02 +08:00
Zhenghao Wu
5b4c4bffe0 Merge pull request #9 from heimoshuiyu/master
Sync Progress
2025-01-02 23:44:24 +08:00
ecwu
a63502ae2b remove typo text from scroll area components 2024-12-31 14:40:33 +08:00
63 changed files with 24108 additions and 2714 deletions

View File

@@ -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/'

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

15
public/manifest.json Normal file
View 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"
}
]
}

View File

@@ -1,369 +0,0 @@
import { useContext, useState } from "react";
import { ChatStore } from "@/types/chatstore";
import { MessageDetail } from "@/chatgpt";
import { Tr } from "@/translate";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { Button } from "./components/ui/button";
import { PenIcon, XIcon } from "lucide-react";
import { Checkbox } from "./components/ui/checkbox";
import { Label } from "./components/ui/label";
import { Textarea } from "./components/ui/textarea";
import { Separator } from "./components/ui/separator";
import { AppContext } from "./pages/App";
interface Props {
images: MessageDetail[];
showAddImage: boolean;
setShowAddImage: (se: boolean) => void;
setImages: (images: MessageDetail[]) => void;
}
interface ImageResponse {
url?: string;
b64_json?: string;
revised_prompt: string;
}
export function AddImage({
showAddImage,
setShowAddImage,
setImages,
images,
}: Props) {
const ctx = useContext(AppContext);
if (ctx === null) return <></>;
const [enableHighResolution, setEnableHighResolution] = useState(true);
const [imageGenPrompt, setImageGenPrompt] = useState("");
const [imageGenModel, setImageGenModel] = useState("dall-e-3");
const [imageGenN, setImageGenN] = useState(1);
const [imageGenQuality, setImageGEnQuality] = useState("standard");
const [imageGenResponseFormat, setImageGenResponseFormat] =
useState("b64_json");
const [imageGenSize, setImageGenSize] = useState("1024x1024");
const [imageGenStyle, setImageGenStyle] = useState("vivid");
const [imageGenGenerating, setImageGenGenerating] = useState(false);
useState("b64_json");
return (
<Drawer open={showAddImage} onOpenChange={setShowAddImage}>
<DrawerContent>
<div className="mx-auto w-full max-w-lg">
<DrawerHeader>
<DrawerTitle>Add Images</DrawerTitle>
</DrawerHeader>
<div className="flex gap-2 items-center">
<Button
variant="secondary"
size="sm"
disabled={false}
onClick={() => {
const image_url = prompt("Image URL");
if (!image_url) {
return;
}
setImages([
...images,
{
type: "image_url",
image_url: {
url: image_url,
detail: enableHighResolution ? "high" : "low",
},
},
]);
}}
>
Add from URL
</Button>
<Button
variant="default"
size="sm"
disabled={false}
onClick={() => {
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.onchange = (event) => {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) {
return;
}
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onloadend = () => {
const base64data = reader.result;
setImages([
...images,
{
type: "image_url",
image_url: {
url: String(base64data),
detail: enableHighResolution ? "high" : "low",
},
},
]);
};
};
input.click();
}}
>
Add from local file
</Button>
<div className="flex items-center space-x-2">
<Checkbox
checked={enableHighResolution}
onCheckedChange={(checked) =>
setEnableHighResolution(checked === true)
}
/>
<label
htmlFor="terms"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
High resolution
</label>
</div>
</div>
<Separator className="my-2" />
{ctx.chatStore.image_gen_api && ctx.chatStore.image_gen_key && (
<div className="flex flex-col">
<h3>Generate Image</h3>
<span className="flex flex-col justify-between m-1 p-1">
<Label>Prompt: </Label>
<Textarea
className="textarea textarea-sm textarea-bordered"
value={imageGenPrompt}
onChange={(e: any) => {
setImageGenPrompt(e.target.value);
}}
/>
</span>
<span className="flex flex-row justify-between items-center m-1 p-1">
<label>Model: </label>
<select
className="select select-sm select-bordered"
value={imageGenModel}
onChange={(e: any) => {
setImageGenModel(e.target.value);
}}
>
<option value="dall-e-3">DALL-E 3</option>
<option value="dall-e-2">DALL-E 2</option>
</select>
</span>
<span className="flex flex-row justify-between items-center m-1 p-1">
<label>n: </label>
<input
className="input input-sm input-bordered"
value={imageGenN}
type="number"
min={1}
max={10}
onChange={(e: any) => setImageGenN(parseInt(e.target.value))}
/>
</span>
<span className="flex flex-row justify-between items-center m-1 p-1">
<label>Quality: </label>
<select
className="select select-sm select-bordered"
value={imageGenQuality}
onChange={(e: any) => setImageGEnQuality(e.target.value)}
>
<option value="hd">HD</option>
<option value="standard">Standard</option>
</select>
</span>
<span className="flex flex-row justify-between items-center m-1 p-1">
<label>Response Format: </label>
<select
className="select select-sm select-bordered"
value={imageGenResponseFormat}
onChange={(e: any) =>
setImageGenResponseFormat(e.target.value)
}
>
<option value="b64_json">b64_json</option>
<option value="url">url</option>
</select>
</span>
<span className="flex flex-row justify-between items-center m-1 p-1">
<label>Size: </label>
<select
className="select select-sm select-bordered"
value={imageGenSize}
onChange={(e: any) => setImageGenSize(e.target.value)}
>
<option value="256x256">256x256 (dall-e-2)</option>
<option value="512x512">512x512 (dall-e-2)</option>
<option value="1024x1024">1024x1024 (dall-e-2/3)</option>
<option value="1792x1024">1792x1024 (dall-e-3)</option>
<option value="1024x1792">1024x1792 (dall-e-3)</option>
</select>
</span>
<span className="flex flex-row justify-between items-center m-1 p-1">
<label>Style (only dall-e-3): </label>
<select
className="select select-sm select-bordered"
value={imageGenStyle}
onChange={(e: any) => setImageGenStyle(e.target.value)}
>
<option value="vivid">vivid</option>
<option value="natural">natural</option>
</select>
</span>
<span className="flex flex-row justify-between items-center m-1 p-1">
<Button
variant="default"
size="sm"
disabled={imageGenGenerating}
onClick={async () => {
try {
setImageGenGenerating(true);
const body: any = {
prompt: imageGenPrompt,
model: imageGenModel,
n: imageGenN,
quality: imageGenQuality,
response_format: imageGenResponseFormat,
size: imageGenSize,
};
if (imageGenModel === "dall-e-3") {
body.style = imageGenStyle;
}
const resp: ImageResponse[] = (
await fetch(ctx.chatStore.image_gen_api, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${ctx.chatStore.image_gen_key}`,
},
body: JSON.stringify(body),
}).then((resp) => resp.json())
).data;
console.log("image gen resp", resp);
for (const image of resp) {
let url = "";
if (image.url) url = image.url;
if (image.b64_json)
url = "data:image/png;base64," + image.b64_json;
if (!url) continue;
ctx.chatStore.history.push({
role: "assistant",
content: [
{
type: "image_url",
image_url: {
url,
detail: "low",
},
},
{
type: "text",
text: image.revised_prompt,
},
],
hide: false,
token: 65,
example: false,
audio: null,
logprobs: null,
response_model_name: imageGenModel,
});
ctx.setChatStore({ ...ctx.chatStore });
}
} catch (e) {
console.error(e);
alert("Failed to generate image: " + e);
} finally {
setImageGenGenerating(false);
}
}}
>
{Tr("Generate")}
</Button>
</span>
</div>
)}
<div className="flex flex-wrap">
{images.map((image, index) => (
<div className="flex flex-col">
{image.type === "image_url" && (
<img
className="rounded m-1 p-1 border-2 border-gray-400 w-32"
src={image.image_url?.url}
/>
)}
<span className="flex justify-between">
<Button
variant="ghost"
size="sm"
onClick={() => {
const image_url = prompt("Image URL");
if (!image_url) {
return;
}
images[index].image_url = {
url: image_url,
detail: enableHighResolution ? "high" : "low",
};
setImages([...images]);
}}
>
<PenIcon />
</Button>
<div className="flex items-center space-x-2">
<Checkbox
id={`hires-${index}`}
checked={image.image_url?.detail === "high"}
onCheckedChange={() => {
if (image.image_url === undefined) return;
image.image_url.detail =
image.image_url?.detail === "low" ? "high" : "low";
setImages([...images]);
}}
/>
<label
htmlFor={`hires-${index}`}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
HiRes
</label>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
if (!confirm("Are you sure to delete this image?")) {
return;
}
images.splice(index, 1);
setImages([...images]);
}}
>
<XIcon />
</Button>
</span>
</div>
))}
</div>
<DrawerFooter>
<Button onClick={() => setShowAddImage(false)}>Done</Button>
</DrawerFooter>
</div>
</DrawerContent>
</Drawer>
);
}

View File

@@ -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) {

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

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

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

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

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

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

View File

@@ -1,8 +1,7 @@
import { IDBPDatabase } from "idb";
import { useRef, useState, Dispatch, useContext } from "react"; import { useRef, useState, Dispatch, useContext } from "react";
import { ChatStore } from "@/types/chatstore"; import { ChatStore } from "@/types/chatstore";
import { MessageDetail } from "./chatgpt"; import { MessageDetail } from "../chatgpt";
import { import {
Dialog, Dialog,
@@ -16,15 +15,15 @@ import {
import { import {
Pagination, Pagination,
PaginationContent, PaginationContent,
PaginationEllipsis,
PaginationItem, PaginationItem,
PaginationLink,
PaginationNext, PaginationNext,
PaginationPrevious, PaginationPrevious,
} from "@/components/ui/pagination"; } from "@/components/ui/pagination";
import { Input } from "./components/ui/input"; import { Input } from "./ui/input";
import { AppContext } from "./pages/App"; import { 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

View File

@@ -1,204 +0,0 @@
import {
CubeIcon,
BanknotesIcon,
ChatBubbleLeftEllipsisIcon,
ScissorsIcon,
SwatchIcon,
SparklesIcon,
} from "@heroicons/react/24/outline";
import { ChatStore } from "@/types/chatstore";
import { models } from "@/types/models";
import { Tr } from "@/translate";
import { getTotalCost } from "@/utils/totalCost";
const StatusBar = (props: {
chatStore: ChatStore;
setShowSettings: (show: boolean) => void;
setShowSearch: (show: boolean) => void;
}) => {
const { chatStore, setShowSettings, setShowSearch } = props;
return (
<div className="navbar bg-base-100 p-0">
<div className="navbar-start">
<div className="dropdown lg:hidden">
<div tabIndex={0} role="button" className="btn btn-ghost btn-circle">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h7"
/>
</svg>
</div>
<ul
tabIndex={0}
className="menu menu-sm dropdown-content bg-base-100 rounded-box z-[1] mt-3 w-52 p-2 shadow"
>
<li>
<p>
<ChatBubbleLeftEllipsisIcon className="h-4 w-4" />
Tokens: {chatStore.totalTokens}/{chatStore.maxTokens}
</p>
</li>
<li>
<p>
<ScissorsIcon className="h-4 w-4" />
Cut:
{chatStore.postBeginIndex}/
{chatStore.history.filter(({ hide }) => !hide).length}
</p>
</li>
<li>
<p>
<BanknotesIcon className="h-4 w-4" />
Cost: ${chatStore.cost?.toFixed(4)}
</p>
</li>
</ul>
</div>
</div>
<div
className="navbar-center cursor-pointer py-1"
onClick={() => {
setShowSettings(true);
}}
>
{/* the long staus bar */}
<div className="stats shadow hidden lg:inline-grid">
<div className="stat">
<div className="stat-figure text-secondary">
<CubeIcon className="h-10 w-10" />
</div>
<div className="stat-title">Model</div>
<div className="stat-value text-base">{chatStore.model}</div>
<div className="stat-desc">
{models[chatStore.model]?.price?.prompt * 1000 * 1000} $/M tokens
</div>
</div>
<div className="stat">
<div className="stat-figure text-secondary">
<SwatchIcon className="h-10 w-10" />
</div>
<div className="stat-title">Mode</div>
<div className="stat-value text-base">
{chatStore.streamMode ? Tr("STREAM") : Tr("FETCH")}
</div>
<div className="stat-desc">STREAM/FETCH</div>
</div>
<div className="stat">
<div className="stat-figure text-secondary">
<ChatBubbleLeftEllipsisIcon className="h-10 w-10" />
</div>
<div className="stat-title">Tokens</div>
<div className="stat-value text-base">{chatStore.totalTokens}</div>
<div className="stat-desc">Max: {chatStore.maxTokens}</div>
</div>
<div className="stat">
<div className="stat-figure text-secondary">
<ScissorsIcon className="h-10 w-10" />
</div>
<div className="stat-title">Cut</div>
<div className="stat-value text-base">
{chatStore.postBeginIndex}
</div>
<div className="stat-desc">
Max: {chatStore.history.filter(({ hide }) => !hide).length}
</div>
</div>
<div className="stat">
<div className="stat-figure text-secondary">
<BanknotesIcon className="h-10 w-10" />
</div>
<div className="stat-title">Cost</div>
<div className="stat-value text-base">
${chatStore.cost?.toFixed(4)}
</div>
<div className="stat-desc">
Accumulated: ${getTotalCost().toFixed(2)}
</div>
</div>
</div>
{/* the short status bar */}
<div className="indicator lg:hidden">
{chatStore.totalTokens !== 0 && (
<span className="indicator-item badge badge-primary">
Tokens: {chatStore.totalTokens}
</span>
)}
<a className="btn btn-ghost text-base sm:text-xl p-0">
<SparklesIcon className="h-4 w-4 hidden sm:block" />
{chatStore.model}
</a>
</div>
</div>
<div className="navbar-end">
<button
className="btn btn-ghost btn-circle"
onClick={(event) => {
// stop propagation to parent
event.stopPropagation();
setShowSearch(true);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</button>
<button
className="btn btn-ghost btn-circle hidden sm:block"
onClick={() => setShowSettings(true)}
>
<div className="indicator">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
className="h-6 w-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
/>
</svg>
<span className="badge badge-xs badge-primary indicator-item"></span>
</div>
</button>
</div>
</div>
);
};
export default StatusBar;

View File

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

View File

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

View File

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

View File

@@ -1,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 />

View File

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

View File

@@ -1,8 +1,8 @@
import { useState, useEffect, Dispatch, useContext } from "react"; import { useState, useEffect, Dispatch, useContext } from "react";
import { Tr, langCodeContext, LANG_OPTIONS, tr } from "@/translate"; import { Tr, langCodeContext, LANG_OPTIONS, tr } from "@/translate";
import { ChatStore, ChatStoreMessage } from "@/types/chatstore"; import { ChatStore, ChatStoreMessage } from "@/types/chatstore";
import { EditMessageString } from "@/editMessageString"; import { EditMessageString } from "@/components/editMessageString";
import { EditMessageDetail } from "@/editMessageDetail"; import { EditMessageDetail } from "@/components/editMessageDetail";
import { import {
Dialog, Dialog,
@@ -12,8 +12,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>
); );
} }

View File

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

View File

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

View File

@@ -1,37 +0,0 @@
import { Moon, Sun } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useTheme } from "@/components/theme-provider";
export function ModeToggle() {
const { setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

93
src/components/navbar.tsx Normal file
View 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;

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

View File

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

View File

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

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

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

View File

@@ -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",

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
@import "highlight.js/styles/monokai.css";
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;

View File

@@ -1,113 +0,0 @@
import { ChatStore, TemplateAPI } from "@/types/chatstore";
import { Tr } from "@/translate";
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuIndicator,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
NavigationMenuViewport,
} from "@/components/ui/navigation-menu";
import { Button } from "./components/ui/button";
import { cn } from "@/lib/utils";
import { useContext } from "react";
import { AppContext } from "./pages/App";
interface Props {
label: string;
apiField: string;
keyField: string;
}
export function ListAPIs({ label, apiField, keyField }: Props) {
const ctx = useContext(AppContext);
if (ctx === null) return <></>;
return (
<NavigationMenuItem>
<NavigationMenuTrigger>
{label}{" "}
<span className="hidden lg:inline">
{ctx.templateAPIs.find(
(t) =>
ctx.chatStore[apiField as keyof ChatStore] === t.endpoint &&
ctx.chatStore[keyField as keyof ChatStore] === t.key
)?.name &&
`: ${
ctx.templateAPIs.find(
(t) =>
ctx.chatStore[apiField as keyof ChatStore] === t.endpoint &&
ctx.chatStore[keyField as keyof ChatStore] === t.key
)?.name
}`}
</span>
</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="grid w-[400px] gap-3 p-4 md:w-[500px] md:grid-cols-2 lg:w-[600px] ">
{ctx.templateAPIs.map((t, index) => (
<li>
<NavigationMenuLink asChild>
<a
onClick={() => {
// @ts-ignore
ctx.chatStore[apiField as keyof ChatStore] = t.endpoint;
// @ts-ignore
ctx.chatStore[keyField] = t.key;
ctx.setChatStore({ ...ctx.chatStore });
}}
className={cn(
"block select-none space-y-1 rounded-md p-3 leading-none no-underline outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground",
ctx.chatStore[apiField as keyof ChatStore] === t.endpoint &&
ctx.chatStore[keyField as keyof ChatStore] === t.key
? "bg-accent text-accent-foreground"
: ""
)}
>
<div className="text-sm font-medium leading-none">
{t.name}
</div>
<p className="line-clamp-2 text-sm leading-snug text-muted-foreground">
{new URL(t.endpoint).host}
</p>
</a>
</NavigationMenuLink>
<div className="mt-2 flex justify-between">
<Button
variant="ghost"
size="sm"
onClick={() => {
const name = prompt(`Give **${label}** template a name`);
if (!name) return;
t.name = name;
ctx.setTemplateAPIs(structuredClone(ctx.templateAPIs));
}}
>
Edit
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
if (
!confirm(
`Are you sure to delete this **${label}** template?`
)
) {
return;
}
ctx.templateAPIs.splice(index, 1);
ctx.setTemplateAPIs(structuredClone(ctx.templateAPIs));
}}
>
Delete
</Button>
</div>
</li>
))}
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
);
}

View File

@@ -1,101 +0,0 @@
import { ChatStore, TemplateTools } from "@/types/chatstore";
import { Tr } from "@/translate";
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuIndicator,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
NavigationMenuViewport,
} from "@/components/ui/navigation-menu";
import { cn } from "@/lib/utils";
import { Button } from "./components/ui/button";
import { useContext } from "react";
import { AppContext } from "./pages/App";
export function ListToolsTempaltes() {
const ctx = useContext(AppContext);
if (!ctx) return <div>error</div>;
const { chatStore, setChatStore } = ctx;
return (
<NavigationMenuItem className="p-3">
<NavigationMenuTrigger>
<span>{Tr(`Saved tools templates`)}</span>
<Button
variant="link"
className="ml-2 text-sm"
onClick={() => {
chatStore.toolsString = "";
setChatStore({ ...chatStore });
}}
>
{Tr(`Clear`)}
</Button>
</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="grid w-[400px] gap-3 p-4 md:w-[500px] md:grid-cols-2 lg:w-[600px]">
{ctx.templateTools.map((t, index) => (
<li key={index}>
<NavigationMenuLink asChild>
<a
onClick={() => {
chatStore.toolsString = t.toolsString;
setChatStore({ ...chatStore });
}}
className={cn(
"block select-none space-y-1 rounded-md p-3 leading-none no-underline outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground",
chatStore.toolsString === t.toolsString
? "bg-accent text-accent-foreground"
: ""
)}
>
<div className="text-sm font-medium leading-none">
{t.name}
</div>
<p className="line-clamp-2 text-sm leading-snug text-muted-foreground">
{t.toolsString}
</p>
</a>
</NavigationMenuLink>
<div className="mt-2 flex justify-between">
<Button
variant="ghost"
size="sm"
onClick={() => {
const name = prompt(`Give **tools** template a name`);
if (!name) return;
t.name = name;
ctx.setTemplateTools(structuredClone(ctx.templateTools));
}}
>
Edit
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
if (
!confirm(
`Are you sure to delete this **tools** template?`
)
) {
return;
}
ctx.templateTools.splice(index, 1);
ctx.setTemplateTools(structuredClone(ctx.templateTools));
}}
>
Delete
</Button>
</div>
</li>
))}
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
);
}

View File

@@ -5,7 +5,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");

View File

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

View File

@@ -1,35 +0,0 @@
import { ChatStoreMessage } from "@/types/chatstore";
import Markdown from "react-markdown";
interface Props {
chat: ChatStoreMessage;
renderMarkdown: boolean;
}
export function MessageDetail({ chat, renderMarkdown }: Props) {
if (typeof chat.content === "string") {
return <div></div>;
}
return (
<div>
{chat.content.map((mdt) =>
mdt.type === "text" ? (
chat.hide ? (
mdt.text?.split("\n")[0].slice(0, 16) + " ..."
) : renderMarkdown ? (
<Markdown>{mdt.text}</Markdown>
) : (
mdt.text
)
) : (
<img
className="my-2 rounded-md max-w-64 max-h-64"
src={mdt.image_url?.url}
onClick={() => {
window.open(mdt.image_url?.url, "_blank");
}}
/>
)
)}
</div>
);
}

View File

@@ -1,20 +0,0 @@
import { ChatStoreMessage } from "@/types/chatstore";
import { getMessageText } from "@/chatgpt";
import { Badge } from "./components/ui/badge";
interface Props {
chat: ChatStoreMessage;
}
export function MessageHide({ chat }: Props) {
return (
<>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>{getMessageText(chat).split("\n")[0].slice(0, 28)} ...</span>
</div>
<div className="flex mt-2 justify-center">
<Badge variant="destructive">Removed from context</Badge>
</div>
</>
);
}

View File

@@ -1,46 +0,0 @@
import { ChatStoreMessage } from "@/types/chatstore";
interface Props {
chat: ChatStoreMessage;
copyToClipboard: (text: string) => void;
}
export function MessageToolCall({ chat, copyToClipboard }: Props) {
return (
<div className="message-content">
{chat.tool_calls?.map((tool_call) => (
<div className="bg-blue-300 dark:bg-blue-800 p-1 rounded my-1">
<strong>
Tool Call ID:{" "}
<span
className="p-1 m-1 rounded cursor-pointer hover:opacity-50 hover:underline"
onClick={() => copyToClipboard(String(tool_call.id))}
>
{tool_call?.id}
</span>
</strong>
<p>Type: {tool_call?.type}</p>
<p>
Function:
<span
className="p-1 m-1 rounded cursor-pointer hover:opacity-50 hover:underline"
onClick={() => copyToClipboard(tool_call.function.name)}
>
{tool_call.function.name}
</span>
</p>
<p>
Arguments:
<span
className="p-1 m-1 rounded cursor-pointer hover:opacity-50 hover:underline"
onClick={() => copyToClipboard(tool_call.function.arguments)}
>
{tool_call.function.arguments}
</span>
</p>
</div>
))}
{/* [TODO] */}
{chat.content as string}
</div>
);
}

View File

@@ -1,25 +0,0 @@
import { ChatStoreMessage } from "@/types/chatstore";
interface Props {
chat: ChatStoreMessage;
copyToClipboard: (text: string) => void;
}
export function MessageToolResp({ chat, copyToClipboard }: Props) {
return (
<div className="message-content">
<div className="bg-blue-300 dark:bg-blue-800 p-1 rounded my-1">
<strong>
Tool Response ID:{" "}
<span
className="p-1 m-1 rounded cursor-pointer hover:opacity-50 hover:underline"
onClick={() => copyToClipboard(String(chat.tool_call_id))}
>
{chat.tool_call_id}
</span>
</strong>
{/* [TODO] */}
<p>{chat.content as string}</p>
</div>
</div>
);
}

View File

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

View File

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

View File

@@ -1,8 +1,7 @@
import { IDBPDatabase } from "idb";
import { useContext, useRef } from "react"; import { useContext, useRef } from "react";
import { useEffect, useState, Dispatch } from "react"; import { useEffect, useState } from "react";
import { Tr, langCodeContext, LANG_OPTIONS } from "@/translate"; import { 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
View 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);
});
});
}

View File

@@ -1,42 +0,0 @@
import { TemplateAPI } from "@/types/chatstore";
import { Tr } from "@/translate";
import { Button } from "./components/ui/button";
interface Props {
tmps: TemplateAPI[];
setTmps: (tmps: TemplateAPI[]) => void;
label: string;
endpoint: string;
APIkey: string;
}
export function SetAPIsTemplate({
endpoint,
APIkey,
tmps,
setTmps,
label,
}: Props) {
return (
<Button
variant="default"
size="sm"
className="mt-3"
onClick={() => {
const name = prompt(`Give this **${label}** template a name:`);
if (!name) {
alert("No template name specified");
return;
}
const tmp: TemplateAPI = {
name,
endpoint,
key: APIkey,
};
tmps.push(tmp);
setTmps([...tmps]);
}}
>
{Tr(`Save ${label}`)}
</Button>
);
}

View File

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

View File

@@ -11,9 +11,17 @@ const LANG_MAP: Record<string, string> = {
cost: "消费", cost: "消费",
stream: "流式返回", stream: "流式返回",
fetch: "一次获取", fetch: "一次获取",
Tools: "工具",
Clear: "清空",
"saved api templates": "已保存的 API 模板", "saved api templates": "已保存的 API 模板",
"saved prompt templates": "已保存的提示模板", "saved prompt templates": "已保存的提示模板",
"no chat history here": "暂无历史对话记录", "no chat history here": "暂无历史对话记录",
"This is a new chat session, start by typing a message":
"这是一个新对话,开始输入消息",
"Settings button located at the top right corner can be used to change the settings of this chat":
"右上角的设置按钮可用于更改此对话的设置",
"'New' button located at the top left corner can be used to create a new chat":
"左上角的 '新' 按钮可用于创建新对话",
"click above to change the settings of this chat": "click above to change the settings of this chat":
"点击上方更改此对话的参数(请勿泄漏)", "点击上方更改此对话的参数(请勿泄漏)",
"click the NEW to create a new chat": "点击左上角 NEW 新建对话", "click the NEW to create a new chat": "点击左上角 NEW 新建对话",
@@ -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;

View File

@@ -1,97 +0,0 @@
import { SpeakerWaveIcon } from "@heroicons/react/24/outline";
import { useContext, useMemo, useState } from "react";
import { addTotalCost } from "@/utils/totalCost";
import { ChatStore, ChatStoreMessage } from "@/types/chatstore";
import { Message, getMessageText } from "@/chatgpt";
import { AudioLinesIcon, LoaderCircleIcon } from "lucide-react";
import { Button } from "./components/ui/button";
import { AppContext } from "./pages/App";
interface TTSProps {
chat: ChatStoreMessage;
}
interface TTSPlayProps {
chat: ChatStoreMessage;
}
export function TTSPlay(props: TTSPlayProps) {
const src = useMemo(() => {
if (props.chat.audio instanceof Blob) {
return URL.createObjectURL(props.chat.audio);
}
return "";
}, [props.chat.audio]);
if (props.chat.hide) {
return <></>;
}
if (props.chat.audio instanceof Blob) {
return <audio className="w-64" src={src} controls />;
}
return <></>;
}
export default function TTSButton(props: TTSProps) {
const [generating, setGenerating] = useState(false);
const ctx = useContext(AppContext);
if (!ctx) return <div>error</div>;
return (
<Button
variant="ghost"
size="icon"
onClick={() => {
const api = ctx.chatStore.tts_api;
const api_key = ctx.chatStore.tts_key;
const model = "tts-1";
const input = getMessageText(props.chat);
const voice = ctx.chatStore.tts_voice;
const body: Record<string, any> = {
model,
input,
voice,
response_format: ctx.chatStore.tts_format || "mp3",
};
if (ctx.chatStore.tts_speed_enabled) {
body["speed"] = ctx.chatStore.tts_speed;
}
setGenerating(true);
fetch(api, {
method: "POST",
headers: {
Authorization: `Bearer ${api_key}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
})
.then((response) => response.blob())
.then((blob) => {
// update price
const cost = (input.length * 0.015) / 1000;
ctx.chatStore.cost += cost;
addTotalCost(cost);
ctx.setChatStore({ ...ctx.chatStore });
// save blob
props.chat.audio = blob;
ctx.setChatStore({ ...ctx.chatStore });
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
audio.play();
})
.finally(() => {
setGenerating(false);
});
}}
>
{generating ? (
<LoaderCircleIcon className="h-4 w-4 animate-spin" />
) : (
<AudioLinesIcon className="h-4 w-4" />
)}
</Button>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: {

5682
yarn.lock Normal file

File diff suppressed because it is too large Load Diff