Compare commits

...

29 Commits

Author SHA1 Message Date
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
21 changed files with 14696 additions and 491 deletions

90
package-lock.json generated
View File

@@ -37,6 +37,7 @@
"@radix-ui/react-toggle": "^1.1.1",
"@radix-ui/react-toggle-group": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.6",
"@tailwindcss/typography": "^0.5.16",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"@types/ungap__structured-clone": "^1.2.0",
@@ -47,6 +48,7 @@
"cmdk": "1.0.4",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.5.1",
"highlight.js": "^11.11.1",
"idb": "^8.0.1",
"input-otp": "^1.4.1",
"lucide-react": "^0.469.0",
@@ -58,6 +60,7 @@
"react-markdown": "^9.0.3",
"react-resizable-panels": "^2.1.7",
"recharts": "^2.15.0",
"rehype-highlight": "^7.0.2",
"rehype-katex": "^7.0.1",
"remark-math": "^6.0.0",
"sakura.css": "^1.5.0",
@@ -3884,6 +3887,34 @@
"string.prototype.matchall": "^4.0.6"
}
},
"node_modules/@tailwindcss/typography": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.16.tgz",
"integrity": "sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA==",
"license": "MIT",
"dependencies": {
"lodash.castarray": "^4.4.0",
"lodash.isplainobject": "^4.0.6",
"lodash.merge": "^4.6.2",
"postcss-selector-parser": "6.0.10"
},
"peerDependencies": {
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
}
},
"node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": {
"version": "6.0.10",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
},
"engines": {
"node": ">=4"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -6103,6 +6134,15 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/highlight.js": {
"version": "11.11.1",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/html-url-attributes": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@@ -6875,6 +6915,12 @@
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/lodash.castarray": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz",
"integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==",
"license": "MIT"
},
"node_modules/lodash.debounce": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
@@ -6882,6 +6928,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
"license": "MIT"
},
"node_modules/lodash.sortby": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz",
@@ -6911,6 +6969,21 @@
"loose-envify": "cli.js"
}
},
"node_modules/lowlight": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz",
"integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"devlop": "^1.0.0",
"highlight.js": "~11.11.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
@@ -8534,6 +8607,23 @@
"node": ">=6"
}
},
"node_modules/rehype-highlight": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/rehype-highlight/-/rehype-highlight-7.0.2.tgz",
"integrity": "sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"hast-util-to-text": "^4.0.0",
"lowlight": "^3.0.0",
"unist-util-visit": "^5.0.0",
"vfile": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/rehype-katex": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz",

View File

@@ -11,6 +11,7 @@
"dependencies": {
"@heroicons/react": "^2.2.0",
"@hookform/resolvers": "^3.9.1",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-aspect-ratio": "^1.1.1",
@@ -38,6 +39,7 @@
"@radix-ui/react-toggle": "^1.1.1",
"@radix-ui/react-toggle-group": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.6",
"@tailwindcss/typography": "^0.5.16",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"@types/ungap__structured-clone": "^1.2.0",
@@ -48,6 +50,7 @@
"cmdk": "1.0.4",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.5.1",
"highlight.js": "^11.11.1",
"idb": "^8.0.1",
"input-otp": "^1.4.1",
"lucide-react": "^0.469.0",
@@ -59,6 +62,7 @@
"react-markdown": "^9.0.3",
"react-resizable-panels": "^2.1.7",
"recharts": "^2.15.0",
"rehype-highlight": "^7.0.2",
"rehype-katex": "^7.0.1",
"remark-math": "^6.0.0",
"sakura.css": "^1.5.0",

7654
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

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;

View File

@@ -1,16 +1,15 @@
import React from "react";
import { ChatStore, TemplateAPI, TemplateChatStore } from "@/types/chatstore";
import { Tr } from "@/translate";
import React, { useContext, useState, useRef } from "react";
import {
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuTrigger,
} from "@/components/ui/navigation-menu";
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 { useContext } from "react";
import { AppChatStoreContext, AppContext } from "@/pages/App";
import {
NavigationMenu,
@@ -37,14 +36,17 @@ import {
Dialog,
DialogContent,
DialogHeader,
DialogTrigger,
DialogTitle,
DialogFooter,
DialogClose,
} from "./ui/dialog";
import { DialogTitle } from "@radix-ui/react-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;
@@ -52,6 +54,81 @@ interface APITemplateDropdownProps {
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,
@@ -72,73 +149,147 @@ function APIsDropdownList({
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 (
<NavigationMenuItem>
<NavigationMenuTrigger>
<span className="lg:hidden">{shortLabel}</span>
<span className="hidden lg:inline">
{label}{" "}
{API.find(
(t: TemplateAPI) =>
chatStore[apiField as keyof ChatStore] === t.endpoint &&
chatStore[keyField as keyof ChatStore] === t.key
)?.name &&
`: ${
API.find(
(t: TemplateAPI) =>
chatStore[apiField as keyof ChatStore] === t.endpoint &&
chatStore[keyField as keyof ChatStore] === t.key
)?.name
}`}
</span>
</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="grid w-[400px] gap-3 p-4 md:w-[500px] md:grid-cols-2 lg:w-[600px] ">
{API.map((t: TemplateAPI, index: number) => (
<li key={index}>
<NavigationMenuLink asChild>
<a
onClick={() => {
// @ts-ignore
chatStore[apiField as keyof ChatStore] = t.endpoint;
// @ts-ignore
chatStore[keyField] = t.key;
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[apiField as keyof ChatStore] === t.endpoint &&
chatStore[keyField as keyof ChatStore] === t.key
? "bg-accent text-accent-foreground"
: ""
)}
>
<div className="text-sm font-medium leading-none">
{t.name}
</div>
<p className="line-clamp-2 text-sm leading-snug text-muted-foreground">
{new URL(t.endpoint).host}
</p>
</a>
</NavigationMenuLink>
</li>
))}
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
<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>
);
}
@@ -197,7 +348,7 @@ function ToolsDropdownList() {
<BrushIcon /> <Tr>Clear tools</Tr>
</CommandItem>
)}
{ctx.templateTools.map((t, index) => (
{ctx.templateTools.map((t: TemplateTools, index: number) => (
<CommandItem
key={index}
value={t.toolsString}
@@ -218,170 +369,278 @@ function ToolsDropdownList() {
);
}
function ChatTemplateDropdownList() {
const ctx = useContext(AppContext);
interface EditChatTemplateDialogProps {
template: TemplateChatStore;
onSave: (updatedTemplate: TemplateChatStore) => void;
onClose: () => void;
}
const { chatStore, setChatStore } = useContext(AppChatStoreContext);
const { templates, setTemplates } = useContext(AppContext);
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 (
<NavigationMenuItem>
<NavigationMenuTrigger>
<span className="lg:hidden">Chat Template</span>
<span className="hidden lg:inline">Chat Template</span>
</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="grid w-[400px] gap-3 p-4 md:w-[500px] md:grid-cols-2 lg:w-[600px]">
{templates.map((t: TemplateChatStore, index: number) => (
<ChatTemplateItem key={index} t={t} index={index} />
))}
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
<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>
);
}
const ChatTemplateItem = ({
t,
index,
}: {
t: TemplateChatStore;
index: number;
}) => {
const [dialogOpen, setDialogOpen] = React.useState(false);
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 (
<li
onClick={() => {
// Update chatStore with the selected template
if (chatStore.history.length > 0 || chatStore.systemMessageContent) {
console.log("you clicked", t.name);
const confirm = window.confirm(
"This will replace the current chat history. Are you sure?"
);
if (!confirm) return;
}
setChatStore({ ...newChatStore({ ...chatStore, ...t }) });
}}
>
<NavigationMenuLink asChild>
<a
className={cn(
"flex flex-row justify-between items-center 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"
)}
>
<div className="text-sm font-medium leading-non">{t.name}</div>
<div onClick={(e) => e.stopPropagation()}>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<EditIcon />
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Template</DialogTitle>
</DialogHeader>
<Label>Template Name</Label>
<Input
value={t.name}
onBlur={(e) => {
t.name = e.target.value;
templates[index] = t;
setTemplates([...templates]);
}}
/>
<p>
Raw JSON allows you to modify any content within the template.
You can remove unnecessary fields, and non-existent fields
will be inherited from the current session.
</p>
<Textarea
className="h-64"
value={JSON.stringify(t, null, 2)}
onBlur={(e) => {
try {
const json = JSON.parse(
e.target.value
) as TemplateChatStore;
json.name = t.name;
templates[index] = json;
setTemplates([...templates]);
} catch (e) {
console.error(e);
alert("Invalid JSON");
}
}}
/>
<Button
type="submit"
variant={"destructive"}
onClick={() => {
let confirm = window.confirm(
"Are you sure you want to delete this template?"
);
if (!confirm) return;
templates.splice(index, 1);
setTemplates([...templates]);
setDialogOpen(false);
}}
>
Delete
</Button>
<Button type="submit" onClick={() => setDialogOpen(false)}>
Close
</Button>
</DialogContent>
</Dialog>
</div>
</a>
</NavigationMenuLink>
</li>
<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(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 />}
<NavigationMenu>
<NavigationMenuList>
{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"
/>
)}
</NavigationMenuList>
</NavigationMenu>
{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>
);
};

View File

@@ -2,6 +2,7 @@ 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,
@@ -51,10 +52,12 @@ function MessageHide({ chat }: HideMessageProps) {
return (
<>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>{getMessageText(chat).split("\n")[0].slice(0, 28)} ...</span>
<span>{getMessageText(chat).trim().slice(0, 28)} ...</span>
</div>
<div className="flex mt-2 justify-center">
<Badge variant="destructive">Removed from context</Badge>
<Badge variant="destructive">
<Tr>Removed from context</Tr>
</Badge>
</div>
</>
);
@@ -73,7 +76,7 @@ function MessageDetail({ chat, renderMarkdown }: MessageDetailProps) {
{chat.content.map((mdt) =>
mdt.type === "text" ? (
chat.hide ? (
mdt.text?.split("\n")[0].slice(0, 16) + " ..."
mdt.text?.trim().slice(0, 16) + " ..."
) : renderMarkdown ? (
<Markdown>{mdt.text}</Markdown>
) : (
@@ -305,26 +308,28 @@ export default function Message(props: { messageIndex: number }) {
</div>
)}
{chat.role === "assistant" ? (
<div className="border-b border-border dark:border-border-dark pb-4">
<div className="pb-4">
{chat.reasoning_content ? (
<Collapsible className="mb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h4 className="text-sm font-semibold text-gray-500">
{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>
<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>
</div>
<CollapsibleContent className="ml-5 text-gray-500 message-content">
{chat.reasoning_content.trim()}
</CollapsibleContent>
</Collapsible>
<CollapsibleContent className="ml-5 text-gray-500 message-content p">
{chat.reasoning_content.trim()}
</CollapsibleContent>
</Collapsible>
</Card>
) : null}
<div>
{chat.hide ? (
@@ -334,37 +339,35 @@ export default function Message(props: { messageIndex: number }) {
) : chat.tool_calls ? (
<MessageToolCall chat={chat} copyToClipboard={copyToClipboard} />
) : renderMarkdown ? (
<div className="message-content max-w-full md:max-w-[100%]">
<Markdown
remarkPlugins={[remarkMath]}
rehypePlugins={[rehypeKatex]}
//break={true}
components={{
code: ({ children }) => (
<code className="bg-muted px-1 py-0.5 rounded">
{children}
</code>
),
pre: ({ children }) => (
<pre className="bg-muted p-4 rounded-lg overflow-auto">
{children}
</pre>
),
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{children}
</a>
),
}}
>
{getMessageText(chat)}
</Markdown>
</div>
<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 &&
@@ -433,7 +436,18 @@ export default function Message(props: { messageIndex: number }) {
) : chat.role === "tool" ? (
<MessageToolResp chat={chat} copyToClipboard={copyToClipboard} />
) : renderMarkdown ? (
<Markdown>{getMessageText(chat)}</Markdown>
<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 &&

View File

@@ -10,7 +10,6 @@ import { tr, Tr, langCodeContext, LANG_OPTIONS } from "@/translate";
import { isVailedJSON } from "@/utils/isVailedJSON";
import { SetAPIsTemplate } from "@/components/setAPIsTemplate";
import { autoHeight } from "@/utils/textAreaHelp";
import { getDefaultParams } from "@/utils/getDefaultParam";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -78,6 +77,7 @@ import { Slider } from "@/components/ui/slider";
import { NonOverflowScrollArea, ScrollArea } from "@/components/ui/scroll-area";
import { AppChatStoreContext, AppContext } from "@/pages/App";
import { toast } from "@/hooks/use-toast";
import { TemplateAttributeDialog } from "@/components/TemplateAttributeDialog";
const TTS_VOICES: string[] = [
"alloy",
@@ -153,10 +153,7 @@ const SelectModel = (props: { help: string }) => {
value={chatStore.model}
onValueChange={(model: string) => {
chatStore.model = model;
chatStore.maxTokens = getDefaultParams(
"max",
models[model].maxToken
);
chatStore.maxTokens = models[model].maxToken;
setChatStore({ ...chatStore });
}}
>
@@ -743,6 +740,7 @@ export default (props: {}) => {
// @ts-ignore
const { langCode, setLangCode } = useContext(langCodeContext);
const [open, setOpen] = useState<boolean>(false);
const [showTemplateDialog, setShowTemplateDialog] = useState(false);
useEffect(() => {
themeChange(false);
@@ -808,7 +806,7 @@ export default (props: {}) => {
<LongInput
label="System Prompt"
field="systemMessageContent"
help="系统消息用于指示ChatGPT的角色和一些前置条件例如“你是一个有帮助的人工智能助理”或者“你是一个专业英语翻译把我的话全部翻译成英语”详情参考 OPEAN AI API 文档"
help="System prompt, used to indicate the role of ChatGPT and some preconditions, such as 'You are a helpful AI assistant' or 'You are a professional English translator, translate my words into English', please refer to the OpenAI API documentation"
{...props}
/>
@@ -1055,21 +1053,7 @@ export default (props: {}) => {
<Button
variant="outline"
className="w-full"
onClick={() => {
const name = prompt(
tr("Give this template a name:", langCode)
);
if (!name) {
alert(tr("No template name specified", langCode));
return;
}
const tmp: ChatStore = structuredClone(chatStore);
tmp.history = tmp.history.filter((h) => h.example);
// @ts-ignore
tmp.name = name;
templates.push(tmp as TemplateChatStore);
setTemplates([...templates]);
}}
onClick={() => setShowTemplateDialog(true)}
>
<Tr>As template</Tr>
</Button>
@@ -1598,6 +1582,25 @@ export default (props: {}) => {
</div>
</NonOverflowScrollArea>
</SheetContent>
<TemplateAttributeDialog
open={showTemplateDialog}
chatStore={chatStore}
langCode={langCode}
onClose={() => setShowTemplateDialog(false)}
onSave={(name, selectedAttributes) => {
const tmp: ChatStore = {
...chatStore,
...selectedAttributes,
history: chatStore.history.filter((h) => h.example),
};
// @ts-ignore
tmp.name = name;
templates.push(tmp as TemplateChatStore);
setTemplates([...templates]);
setShowTemplateDialog(false);
}}
/>
</Sheet>
);
};

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, 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,7 +1,6 @@
import { AppChatStoreContext, AppContext } from "@/pages/App";
import { TemplateChatStore } from "@/types/chatstore";
import { ChatStore } from "@/types/chatstore";
import { getDefaultParams } from "@/utils/getDefaultParam";
import { useContext } from "react";
const Templates = () => {
@@ -18,51 +17,6 @@ const Templates = () => {
const newChatStore: ChatStore = structuredClone(t);
// @ts-ignore
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;
// manage undefined value because of version update

View File

@@ -14,6 +14,7 @@ import {
} from "@/components/ui/dialog";
import { Button } from "./ui/button";
import { AppChatStoreContext, AppContext } from "../pages/App";
import { ConfirmationDialog } from "./ui/confirmation-dialog";
interface EditMessageProps {
chat: ChatStoreMessage;
@@ -22,9 +23,19 @@ interface EditMessageProps {
}
export function EditMessage(props: EditMessageProps) {
const { chatStore, setChatStore } = useContext(AppChatStoreContext);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const { showEdit, setShowEdit, chat } = props;
const handleSwitchMessageType = () => {
if (typeof chat.content === "string") {
chat.content = [];
} else {
chat.content = "";
}
setChatStore({ ...chatStore });
};
return (
<Dialog open={showEdit} onOpenChange={setShowEdit}>
{/* <DialogTrigger>
@@ -46,19 +57,7 @@ export function EditMessage(props: EditMessageProps) {
<Button
variant="destructive"
className="w-full"
onClick={() => {
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 = "";
}
setChatStore({ ...chatStore });
}}
onClick={() => setShowConfirmDialog(true)}
>
Switch to{" "}
{typeof chat.content === "string"
@@ -68,6 +67,13 @@ export function EditMessage(props: EditMessageProps) {
)}
<Button onClick={() => setShowEdit(false)}>Close</Button>
</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>
);
}

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,3 +1,5 @@
@import "highlight.js/styles/monokai.css";
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -1,5 +1,5 @@
import { IDBPDatabase, openDB } from "idb";
import { createContext, useContext, useEffect, useState } from "react";
import { createContext, useContext, useEffect, useState, useRef } from "react"; // 添加了useRef
import "@/global.css";
import { calculate_token_length } from "@/chatgpt";
@@ -48,6 +48,7 @@ interface AppContextType {
defaultRenderMD: boolean;
setDefaultRenderMD: (b: boolean) => void;
handleNewChatStore: () => Promise<void>;
handleNewChatStoreWithOldOne: (chatStore: ChatStore) => Promise<void>;
}
interface AppChatStoreContextType {
@@ -96,6 +97,7 @@ import Search from "@/components/Search";
import Navbar from "@/components/navbar";
import ConversationTitle from "@/components/ConversationTitle.";
import ImportDialog from "@/components/ImportDialog";
export function App() {
// init selected index
@@ -140,10 +142,6 @@ export function App() {
}
if (ret.cost === undefined) ret.cost = 0;
toast({
title: "Chat ready",
description: `Current API Endpoint: ${ret.apiEndpoint}`,
});
return ret;
};
@@ -198,9 +196,34 @@ export function App() {
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(() => {
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 api = getDefaultParams("api", "");
const key = getDefaultParams("key", "");
@@ -222,12 +245,7 @@ export function App() {
console.log("create new chatStore because of params in URL");
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();
}, []);
@@ -332,6 +350,7 @@ export function App() {
defaultRenderMD,
setDefaultRenderMD,
handleNewChatStore,
handleNewChatStoreWithOldOne,
}}
>
<Sidebar>
@@ -410,6 +429,7 @@ export function App() {
selectedChatIndex={selectedChatIndex}
getChatStoreByIndex={getChatStoreByIndex}
>
<ImportDialog open={showImportDialog} setOpen={setShowImportDialog} />
<Navbar />
<ChatBOX />
</AppChatStoreProvider>
@@ -427,9 +447,78 @@ const AppChatStoreProvider = ({
selectedChatIndex: number;
getChatStoreByIndex: (index: number) => Promise<ChatStore>;
}) => {
console.log("[Render] AppChatStoreProvider");
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");

View File

@@ -40,9 +40,32 @@ import { Switch } from "@/components/ui/switch";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { AppChatStoreContext, AppContext } from "./App";
import APIListMenu from "@/components/ListAPI";
import { ImageGenDrawer } from "@/components/ImageGenDrawer";
import { abort } from "process";
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() {
const { db, selectedChatIndex, setSelectedChatIndex, handleNewChatStore } =
@@ -93,78 +116,105 @@ export default function ChatBOX() {
};
let response_model_name: string | null = null;
let usage: Usage | null = null;
for await (const i of client.processStreamResponse(response, signal)) {
if (signal?.aborted) break;
response_model_name = i.model;
responseTokenCount += 1;
if (i.usage) {
usage = i.usage;
}
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;
try {
for await (const i of client.processStreamResponse(response, signal)) {
if (signal?.aborted) break;
response_model_name = i.model;
responseTokenCount += 1;
if (i.usage) {
usage = i.usage;
}
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(
(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}`;
})
);
} catch (e: any) {
if (e.name === "AbortError") {
// 1. 立即保存当前buffer中的内容
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 reasoning_content = allReasoningContentChunk.join("");
@@ -210,7 +260,15 @@ export default function ChatBOX() {
audio: null,
logprobs,
response_model_name,
usage,
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;
@@ -558,21 +616,6 @@ export default function ChatBOX() {
<Tr>New Chat</Tr>
</Button>
)}
{showGenerating && (
<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>
)}
{chatStore.develop_mode && chatStore.history.length > 0 && (
<Button
variant="outline"
@@ -633,15 +676,32 @@ export default function ChatBOX() {
</div>
<div className="sticky bottom-0 w-full z-20 bg-background">
{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">
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
Follow
</label>
<Switch
checked={follow}
onCheckedChange={setFollow}
aria-label="Toggle auto-scroll"
/>
<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">
<div className="flex items-center gap-2">
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
<Tr>Follow</Tr>
</label>
<Switch
checked={follow}
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>
)}
<form className="relative rounded-lg border bg-background focus-within:ring-1 focus-within:ring-ring p-1">

View File

@@ -50,7 +50,9 @@ function tr(text: string, langCode: "en-US" | "zh-CN") {
const translatedText = langMap[text.toLowerCase()];
if (translatedText === undefined) {
console.log(`[Translation] not found for "${text}"`);
if (langCode !== "en-US") {
console.log(`[Translation] not found for "${text}"`);
}
return text;
}

View File

@@ -141,6 +141,13 @@ const LANG_MAP: Record<string, string> = {
"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;

View File

@@ -76,6 +76,7 @@ export interface ChatStoreMessage {
created_at?: string;
responsed_at?: string;
completed_at?: string;
response_count?: number;
role: "system" | "user" | "assistant" | "tool";
content: string | MessageDetail[];

View File

@@ -3,8 +3,7 @@ import {
DefaultModel,
CHATGPT_API_WEB_VERSION,
} from "@/const";
import { getDefaultParams } from "@/utils/getDefaultParam";
import { ChatStore } from "@/types/chatstore";
import { ChatStore, ChatStoreMessage } from "@/types/chatstore";
import { models } from "@/types/models";
interface NewChatStoreOptions {
@@ -36,38 +35,30 @@ interface NewChatStoreOptions {
json_mode?: boolean;
logprobs?: boolean;
maxTokens?: number;
use_this_history?: ChatStoreMessage[];
}
export const newChatStore = (options: NewChatStoreOptions): ChatStore => {
return {
chatgpt_api_web_version: CHATGPT_API_WEB_VERSION,
systemMessageContent: getDefaultParams(
"sys",
options.systemMessageContent ?? ""
),
systemMessageContent: options.systemMessageContent ?? "",
toolsString: options.toolsString ?? "",
history: [],
history: options.use_this_history ?? [],
postBeginIndex: 0,
tokenMargin: 1024,
totalTokens: 0,
maxTokens: getDefaultParams(
"max",
models[getDefaultParams("model", options.model ?? DefaultModel)]
?.maxToken ??
options.maxTokens ??
2048
),
maxTokens:
models[options.model ?? DefaultModel]?.maxToken ??
options.maxTokens ??
2048,
maxGenTokens: 2048,
maxGenTokens_enabled: false,
apiKey: getDefaultParams("key", options.apiKey ?? ""),
apiEndpoint: getDefaultParams(
"api",
options.apiEndpoint ?? DefaultAPIEndpoint
),
streamMode: getDefaultParams("mode", options.streamMode ?? true),
model: getDefaultParams("model", options.model ?? DefaultModel),
apiKey: options.apiKey ?? "",
apiEndpoint: options.apiEndpoint ?? DefaultAPIEndpoint,
streamMode: options.streamMode ?? true,
model: options.model ?? DefaultModel,
cost: 0,
temperature: getDefaultParams("temp", options.temperature ?? 1),
temperature: options.temperature ?? 1,
temperature_enabled: options.temperature_enabled ?? true,
top_p: options.top_p ?? 1,
top_p_enabled: options.top_p_enabled ?? false,
@@ -75,17 +66,12 @@ export const newChatStore = (options: NewChatStoreOptions): ChatStore => {
presence_penalty_enabled: options.presence_penalty_enabled ?? false,
frequency_penalty: options.frequency_penalty ?? 0,
frequency_penalty_enabled: options.frequency_penalty_enabled ?? false,
develop_mode: getDefaultParams("dev", options.dev ?? false),
whisper_api: getDefaultParams(
"whisper-api",
options.whisper_api ?? "https://api.openai.com/v1/audio/transcriptions"
),
whisper_key: getDefaultParams("whisper-key", options.whisper_key ?? ""),
tts_api: getDefaultParams(
"tts-api",
options.tts_api ?? "https://api.openai.com/v1/audio/speech"
),
tts_key: getDefaultParams("tts-key", options.tts_key ?? ""),
develop_mode: options.dev ?? false,
whisper_api:
options.whisper_api ?? "https://api.openai.com/v1/audio/transcriptions",
whisper_key: options.whisper_key ?? "",
tts_api: options.tts_api ?? "https://api.openai.com/v1/audio/speech",
tts_key: options.tts_key ?? "",
tts_voice: options.tts_voice ?? "alloy",
tts_speed: options.tts_speed ?? 1.0,
tts_speed_enabled: options.tts_speed_enabled ?? false,

View File

@@ -4,6 +4,13 @@ module.exports = {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {
typography: (theme) => ({
DEFAULT: {
css: {
},
},
}),
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
@@ -85,5 +92,5 @@ module.exports = {
}
}
},
plugins: [require("tailwindcss-animate")],
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
};

5682
yarn.lock Normal file

File diff suppressed because it is too large Load Diff