Merge remote-tracking branch 'upstream/main' into dev

# Conflicts:
#	app/client/platforms/openai.ts
#	app/components/chat.module.scss
#	app/components/chat.tsx
#	app/layout.tsx
#	app/store/chat.ts
#	package.json
#	yarn.lock
This commit is contained in:
sijinhui
2024-09-07 19:00:38 +08:00
32 changed files with 1546 additions and 470 deletions

View File

@@ -423,6 +423,21 @@
margin-top: 5px;
}
.chat-message-tools {
font-size: 12px;
color: #aaa;
line-height: 1.5;
margin-top: 5px;
.chat-message-tool {
display: flex;
align-items: end;
svg {
margin-left: 5px;
margin-right: 5px;
}
}
}
.chat-message-item {
box-sizing: border-box;
max-width: 100%;
@@ -692,6 +707,7 @@
}
}
.bottom-tip {
text-align: center;
margin-top: -25px;

View File

@@ -28,6 +28,7 @@ import DeleteIcon from "../icons/clear.svg";
import PinIcon from "../icons/pin.svg";
import EditIcon from "../icons/rename.svg";
import ConfirmIcon from "../icons/confirm.svg";
import CloseIcon from "../icons/close.svg";
import CancelIcon from "../icons/cancel.svg";
import ImageIcon from "../icons/image.svg";
@@ -54,6 +55,7 @@ import {
useAppConfig,
DEFAULT_TOPIC,
ModelType,
usePluginStore,
ChatSession,
} from "../store";
@@ -67,6 +69,7 @@ import {
getMessageImages,
isVisionModel,
isDalle3,
showPlugins,
} from "../utils";
import { uploadImage as uploadImageRemote } from "@/app/utils/chat";
@@ -99,7 +102,6 @@ import {
REQUEST_TIMEOUT_MS,
UNFINISHED_INPUT,
ServiceProvider,
Plugin,
} from "../constant";
import { Avatar } from "./emoji";
// import { ContextPrompts, MaskAvatar, MaskConfig } from "./mask";
@@ -462,6 +464,7 @@ export function ChatActions(props: {
const config = useAppConfig();
const navigate = useNavigate();
const chatStore = useChatStore();
const pluginStore = usePluginStore();
// switch themes
const theme = config.theme;
@@ -750,30 +753,32 @@ export function ChatActions(props: {
/>
)}
<ChatAction
onClick={() => setShowPluginSelector(true)}
text={Locale.Plugin.Name}
icon={<PluginIcon />}
/>
{showPlugins(currentProviderName, currentModel) && (
<ChatAction
onClick={() => {
if (pluginStore.getAll().length == 0) {
navigate(Path.Plugins);
} else {
setShowPluginSelector(true);
}
}}
text={Locale.Plugin.Name}
icon={<PluginIcon />}
/>
)}
{showPluginSelector && (
<Selector
multiple
defaultSelectedValue={chatStore.currentSession().mask?.plugin}
items={[
{
title: Locale.Plugin.Artifacts,
value: Plugin.Artifacts,
},
]}
items={pluginStore.getAll().map((item) => ({
title: `${item?.title}@${item?.version}`,
value: item?.id,
}))}
onClose={() => setShowPluginSelector(false)}
onSelection={(s) => {
const plugin = s[0];
chatStore.updateCurrentSession((session) => {
session.mask.plugin = s;
session.mask.plugin = s as string[];
});
if (plugin) {
showToast(plugin);
}
}}
/>
)}
@@ -1648,11 +1653,31 @@ function _Chat() {
: message.date.toLocaleString()}
</div>
</div>
{showTyping && (
{message?.tools?.length == 0 && showTyping && (
<div className={styles["chat-message-status"]}>
{Locale.Chat.Typing}
</div>
)}
{/*@ts-ignore*/}
{message?.tools?.length > 0 && (
<div className={styles["chat-message-tools"]}>
{message?.tools?.map((tool) => (
<div
key={tool.id}
className={styles["chat-message-tool"]}
>
{tool.isError === false ? (
<ConfirmIcon />
) : tool.isError === true ? (
<CloseIcon />
) : (
<LoadingButtonIcon />
)}
<span>{tool?.function?.name}</span>
</div>
))}
</div>
)}
<div className={styles["chat-message-item"]}>
<Markdown
key={message.streaming ? "loading" : "done"}

View File

@@ -59,6 +59,10 @@ const MaskPage = dynamic(async () => (await import("./mask")).MaskPage, {
loading: () => <Loading noLogo />,
});
const PluginPage = dynamic(async () => (await import("./plugin")).PluginPage, {
loading: () => <Loading noLogo />,
});
const SearchChat = dynamic(
async () => (await import("./search-chat")).SearchChatPage,
{
@@ -185,6 +189,7 @@ function Screen() {
<Route path={Path.Home} element={<Chat />} />
<Route path={Path.NewChat} element={<NewChat />} />
<Route path={Path.Masks} element={<MaskPage />} />
<Route path={Path.Plugins} element={<PluginPage />} />
<Route path={Path.SearchChat} element={<SearchChat />} />
<Route path={Path.Chat} element={<Chat />} />
<Route path={Path.Settings} element={<Settings />} />

View File

@@ -19,7 +19,6 @@ import {
HTMLPreview,
HTMLPreviewHander,
} from "./artifacts";
import { Plugin } from "../constant";
import { useChatStore } from "../store";
import { IconButton } from "./button";
@@ -77,7 +76,6 @@ export function PreCode(props: { children: any }) {
const { height } = useWindowSize();
const chatStore = useChatStore();
const session = chatStore.currentSession();
const plugins = session.mask?.plugin;
const renderArtifacts = useDebouncedCallback(() => {
if (!ref.current) return;
@@ -94,10 +92,7 @@ export function PreCode(props: { children: any }) {
}
}, 600);
const enableArtifacts = useMemo(
() => plugins?.includes(Plugin.Artifacts),
[plugins],
);
const enableArtifacts = session.mask?.enableArtifacts !== false;
//Wrap the paragraph for plain-text
useEffect(() => {
@@ -169,7 +164,7 @@ export function PreCode(props: { children: any }) {
);
}
function CustomCode(props: { children: any }) {
function CustomCode(props: { children: any; className?: string }) {
const ref = useRef<HTMLPreElement>(null);
const [collapsed, setCollapsed] = useState(true);
const [showToggle, setShowToggle] = useState(false);
@@ -188,6 +183,7 @@ function CustomCode(props: { children: any }) {
return (
<>
<code
className={props?.className}
ref={ref}
style={{
maxHeight: collapsed ? "400px" : "none",

View File

@@ -167,6 +167,22 @@ export function MaskConfig(props: {
></input>
</ListItem>
<ListItem
title={Locale.Mask.Config.Artifacts.Title}
subTitle={Locale.Mask.Config.Artifacts.SubTitle}
>
<input
aria-label={Locale.Mask.Config.Artifacts.Title}
type="checkbox"
checked={props.mask.enableArtifacts !== false}
onChange={(e) => {
props.updateMask((mask) => {
mask.enableArtifacts = e.currentTarget.checked;
});
}}
></input>
</ListItem>
{!props.shouldSyncFromGlobal ? (
<ListItem
title={Locale.Mask.Config.Share.Title}

View File

@@ -0,0 +1,16 @@
.plugin-title {
font-weight: bolder;
font-size: 16px;
margin: 10px 0;
}
.plugin-content {
font-size: 14px;
font-family: inherit;
pre code {
max-height: 240px;
overflow-y: auto;
white-space: pre-wrap;
min-width: 300px;
}
}

393
app/components/plugin.tsx Normal file
View File

@@ -0,0 +1,393 @@
import { useDebouncedCallback } from "use-debounce";
import OpenAPIClientAxios from "openapi-client-axios";
import yaml from "js-yaml";
import { PLUGINS_REPO_URL } from "../constant";
import { IconButton } from "./button";
import { ErrorBoundary } from "./error";
import styles from "./mask.module.scss";
import pluginStyles from "./plugin.module.scss";
import EditIcon from "../icons/edit.svg";
import AddIcon from "../icons/add.svg";
import CloseIcon from "../icons/close.svg";
import DeleteIcon from "../icons/delete.svg";
import EyeIcon from "../icons/eye.svg";
import ConfirmIcon from "../icons/confirm.svg";
import ReloadIcon from "../icons/reload.svg";
import GithubIcon from "../icons/github.svg";
import { Plugin, usePluginStore, FunctionToolService } from "../store/plugin";
import {
PasswordInput,
List,
ListItem,
Modal,
showConfirm,
showToast,
} from "./ui-lib";
import Locale from "../locales";
import { useNavigate } from "react-router-dom";
import { useEffect, useState } from "react";
import { getClientConfig } from "../config/client";
export function PluginPage() {
const navigate = useNavigate();
const pluginStore = usePluginStore();
const allPlugins = pluginStore.getAll();
const [searchPlugins, setSearchPlugins] = useState<Plugin[]>([]);
const [searchText, setSearchText] = useState("");
const plugins = searchText.length > 0 ? searchPlugins : allPlugins;
// refactored already, now it accurate
const onSearch = (text: string) => {
setSearchText(text);
if (text.length > 0) {
const result = allPlugins.filter((m) =>
m?.title.toLowerCase().includes(text.toLowerCase()),
);
setSearchPlugins(result);
} else {
setSearchPlugins(allPlugins);
}
};
const [editingPluginId, setEditingPluginId] = useState<string | undefined>();
const editingPlugin = pluginStore.get(editingPluginId);
const editingPluginTool = FunctionToolService.get(editingPlugin?.id);
const closePluginModal = () => setEditingPluginId(undefined);
const onChangePlugin = useDebouncedCallback((editingPlugin, e) => {
const content = e.target.innerText;
try {
const api = new OpenAPIClientAxios({
definition: yaml.load(content) as any,
});
api
.init()
.then(() => {
if (content != editingPlugin.content) {
pluginStore.updatePlugin(editingPlugin.id, (plugin) => {
plugin.content = content;
const tool = FunctionToolService.add(plugin, true);
plugin.title = tool.api.definition.info.title;
plugin.version = tool.api.definition.info.version;
});
}
})
.catch((e) => {
console.error(e);
showToast(Locale.Plugin.EditModal.Error);
});
} catch (e) {
console.error(e);
showToast(Locale.Plugin.EditModal.Error);
}
}, 100).bind(null, editingPlugin);
const [loadUrl, setLoadUrl] = useState<string>("");
const loadFromUrl = (loadUrl: string) =>
fetch(loadUrl)
.catch((e) => {
const p = new URL(loadUrl);
return fetch(`/api/proxy/${p.pathname}?${p.search}`, {
headers: {
"X-Base-URL": p.origin,
},
});
})
.then((res) => res.text())
.then((content) => {
try {
return JSON.stringify(JSON.parse(content), null, " ");
} catch (e) {
return content;
}
})
.then((content) => {
pluginStore.updatePlugin(editingPlugin.id, (plugin) => {
plugin.content = content;
const tool = FunctionToolService.add(plugin, true);
plugin.title = tool.api.definition.info.title;
plugin.version = tool.api.definition.info.version;
});
})
.catch((e) => {
showToast(Locale.Plugin.EditModal.Error);
});
return (
<ErrorBoundary>
<div className={styles["mask-page"]}>
<div className="window-header">
<div className="window-header-title">
<div className="window-header-main-title">
{Locale.Plugin.Page.Title}
</div>
<div className="window-header-submai-title">
{Locale.Plugin.Page.SubTitle(plugins.length)}
</div>
</div>
<div className="window-actions">
<div className="window-action-button">
<a
href={PLUGINS_REPO_URL}
target="_blank"
rel="noopener noreferrer"
>
<IconButton icon={<GithubIcon />} bordered />
</a>
</div>
<div className="window-action-button">
<IconButton
icon={<CloseIcon />}
bordered
onClick={() => navigate(-1)}
/>
</div>
</div>
</div>
<div className={styles["mask-page-body"]}>
<div className={styles["mask-filter"]}>
<input
type="text"
className={styles["search-bar"]}
placeholder={Locale.Plugin.Page.Search}
autoFocus
onInput={(e) => onSearch(e.currentTarget.value)}
/>
<IconButton
className={styles["mask-create"]}
icon={<AddIcon />}
text={Locale.Plugin.Page.Create}
bordered
onClick={() => {
const createdPlugin = pluginStore.create();
setEditingPluginId(createdPlugin.id);
}}
/>
</div>
<div>
{plugins.length == 0 && (
<div
style={{
display: "flex",
margin: "60px auto",
alignItems: "center",
justifyContent: "center",
}}
>
{Locale.Plugin.Page.Find}
<a
href={PLUGINS_REPO_URL}
target="_blank"
rel="noopener noreferrer"
style={{ marginLeft: 16 }}
>
<IconButton icon={<GithubIcon />} bordered />
</a>
</div>
)}
{plugins.map((m) => (
<div className={styles["mask-item"]} key={m.id}>
<div className={styles["mask-header"]}>
<div className={styles["mask-icon"]}></div>
<div className={styles["mask-title"]}>
<div className={styles["mask-name"]}>
{m.title}@<small>{m.version}</small>
</div>
<div className={styles["mask-info"] + " one-line"}>
{Locale.Plugin.Item.Info(
FunctionToolService.add(m).length,
)}
</div>
</div>
</div>
<div className={styles["mask-actions"]}>
{m.builtin ? (
<IconButton
icon={<EyeIcon />}
text={Locale.Plugin.Item.View}
onClick={() => setEditingPluginId(m.id)}
/>
) : (
<IconButton
icon={<EditIcon />}
text={Locale.Plugin.Item.Edit}
onClick={() => setEditingPluginId(m.id)}
/>
)}
{!m.builtin && (
<IconButton
icon={<DeleteIcon />}
text={Locale.Plugin.Item.Delete}
onClick={async () => {
if (
await showConfirm(Locale.Plugin.Item.DeleteConfirm)
) {
pluginStore.delete(m.id);
}
}}
/>
)}
</div>
</div>
))}
</div>
</div>
</div>
{editingPlugin && (
<div className="modal-mask">
<Modal
title={Locale.Plugin.EditModal.Title(editingPlugin?.builtin)}
onClose={closePluginModal}
actions={[
<IconButton
icon={<ConfirmIcon />}
text={Locale.UI.Confirm}
key="export"
bordered
onClick={() => setEditingPluginId("")}
/>,
]}
>
<List>
<ListItem title={Locale.Plugin.EditModal.Auth}>
<select
value={editingPlugin?.authType}
onChange={(e) => {
pluginStore.updatePlugin(editingPlugin.id, (plugin) => {
plugin.authType = e.target.value;
});
}}
>
<option value="">{Locale.Plugin.Auth.None}</option>
<option value="bearer">{Locale.Plugin.Auth.Bearer}</option>
<option value="basic">{Locale.Plugin.Auth.Basic}</option>
<option value="custom">{Locale.Plugin.Auth.Custom}</option>
</select>
</ListItem>
{["bearer", "basic", "custom"].includes(
editingPlugin.authType as string,
) && (
<ListItem title={Locale.Plugin.Auth.Location}>
<select
value={editingPlugin?.authLocation}
onChange={(e) => {
pluginStore.updatePlugin(editingPlugin.id, (plugin) => {
plugin.authLocation = e.target.value;
});
}}
>
<option value="header">
{Locale.Plugin.Auth.LocationHeader}
</option>
<option value="query">
{Locale.Plugin.Auth.LocationQuery}
</option>
<option value="body">
{Locale.Plugin.Auth.LocationBody}
</option>
</select>
</ListItem>
)}
{editingPlugin.authType == "custom" && (
<ListItem title={Locale.Plugin.Auth.CustomHeader}>
<input
type="text"
value={editingPlugin?.authHeader}
onChange={(e) => {
pluginStore.updatePlugin(editingPlugin.id, (plugin) => {
plugin.authHeader = e.target.value;
});
}}
></input>
</ListItem>
)}
{["bearer", "basic", "custom"].includes(
editingPlugin.authType as string,
) && (
<ListItem title={Locale.Plugin.Auth.Token}>
<PasswordInput
type="text"
value={editingPlugin?.authToken}
onChange={(e) => {
pluginStore.updatePlugin(editingPlugin.id, (plugin) => {
plugin.authToken = e.currentTarget.value;
});
}}
></PasswordInput>
</ListItem>
)}
{!getClientConfig()?.isApp && (
<ListItem
title={Locale.Plugin.Auth.Proxy}
subTitle={Locale.Plugin.Auth.ProxyDescription}
>
<input
type="checkbox"
checked={editingPlugin?.usingProxy}
style={{ minWidth: 16 }}
onChange={(e) => {
pluginStore.updatePlugin(editingPlugin.id, (plugin) => {
plugin.usingProxy = e.currentTarget.checked;
});
}}
></input>
</ListItem>
)}
</List>
<List>
<ListItem title={Locale.Plugin.EditModal.Content}>
<div style={{ display: "flex", justifyContent: "flex-end" }}>
<input
type="text"
style={{ minWidth: 200, marginRight: 20 }}
onInput={(e) => setLoadUrl(e.currentTarget.value)}
></input>
<IconButton
icon={<ReloadIcon />}
text={Locale.Plugin.EditModal.Load}
bordered
onClick={() => loadFromUrl(loadUrl)}
/>
</div>
</ListItem>
<ListItem
subTitle={
<div
className={`markdown-body ${pluginStyles["plugin-content"]}`}
dir="auto"
>
<pre>
<code
contentEditable={true}
dangerouslySetInnerHTML={{
__html: editingPlugin.content,
}}
onBlur={onChangePlugin}
></code>
</pre>
</div>
}
></ListItem>
{editingPluginTool?.tools.map((tool, index) => (
<ListItem
key={index}
title={tool?.function?.name}
subTitle={tool?.function?.description}
/>
))}
</List>
</Modal>
</div>
)}
</ErrorBoundary>
);
}

View File

@@ -64,8 +64,8 @@ export function Card(props: { children: JSX.Element[]; className?: string }) {
}
export function ListItem(props: {
title: string;
subTitle?: string;
title?: string;
subTitle?: string | JSX.Element;
children?: JSX.Element | JSX.Element[];
icon?: JSX.Element;
className?: string;