merge main
This commit is contained in:
commit
b8bbc37b8e
19
README.md
19
README.md
|
@ -88,10 +88,14 @@ For enterprise inquiries, please contact: **business@nextchat.dev**
|
||||||
- [x] Share as image, share to ShareGPT [#1741](https://github.com/Yidadaa/ChatGPT-Next-Web/pull/1741)
|
- [x] Share as image, share to ShareGPT [#1741](https://github.com/Yidadaa/ChatGPT-Next-Web/pull/1741)
|
||||||
- [x] Desktop App with tauri
|
- [x] Desktop App with tauri
|
||||||
- [x] Self-host Model: Fully compatible with [RWKV-Runner](https://github.com/josStorer/RWKV-Runner), as well as server deployment of [LocalAI](https://github.com/go-skynet/LocalAI): llama/gpt4all/rwkv/vicuna/koala/gpt4all-j/cerebras/falcon/dolly etc.
|
- [x] Self-host Model: Fully compatible with [RWKV-Runner](https://github.com/josStorer/RWKV-Runner), as well as server deployment of [LocalAI](https://github.com/go-skynet/LocalAI): llama/gpt4all/rwkv/vicuna/koala/gpt4all-j/cerebras/falcon/dolly etc.
|
||||||
- [ ] Plugins: support network search, calculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165)
|
- [x] Artifacts: Easily preview, copy and share generated content/webpages through a separate window [#5092](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/pull/5092)
|
||||||
|
- [x] Plugins: support artifacts, network search, calculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165)
|
||||||
|
- [x] artifacts
|
||||||
|
- [ ] network search, network search, calculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165)
|
||||||
|
|
||||||
## What's New
|
## What's New
|
||||||
|
|
||||||
|
- 🚀 v2.14.0 Now supports Artifacts & SD
|
||||||
- 🚀 v2.10.1 support Google Gemini Pro model.
|
- 🚀 v2.10.1 support Google Gemini Pro model.
|
||||||
- 🚀 v2.9.11 you can use azure endpoint now.
|
- 🚀 v2.9.11 you can use azure endpoint now.
|
||||||
- 🚀 v2.8 now we have a client that runs across all platforms!
|
- 🚀 v2.8 now we have a client that runs across all platforms!
|
||||||
|
@ -120,15 +124,20 @@ For enterprise inquiries, please contact: **business@nextchat.dev**
|
||||||
- [x] 分享为图片,分享到 ShareGPT 链接 [#1741](https://github.com/Yidadaa/ChatGPT-Next-Web/pull/1741)
|
- [x] 分享为图片,分享到 ShareGPT 链接 [#1741](https://github.com/Yidadaa/ChatGPT-Next-Web/pull/1741)
|
||||||
- [x] 使用 tauri 打包桌面应用
|
- [x] 使用 tauri 打包桌面应用
|
||||||
- [x] 支持自部署的大语言模型:开箱即用 [RWKV-Runner](https://github.com/josStorer/RWKV-Runner) ,服务端部署 [LocalAI 项目](https://github.com/go-skynet/LocalAI) llama / gpt4all / rwkv / vicuna / koala / gpt4all-j / cerebras / falcon / dolly 等等,或者使用 [api-for-open-llm](https://github.com/xusenlinzy/api-for-open-llm)
|
- [x] 支持自部署的大语言模型:开箱即用 [RWKV-Runner](https://github.com/josStorer/RWKV-Runner) ,服务端部署 [LocalAI 项目](https://github.com/go-skynet/LocalAI) llama / gpt4all / rwkv / vicuna / koala / gpt4all-j / cerebras / falcon / dolly 等等,或者使用 [api-for-open-llm](https://github.com/xusenlinzy/api-for-open-llm)
|
||||||
- [ ] 插件机制,支持联网搜索、计算器、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165)
|
- [x] Artifacts: 通过独立窗口,轻松预览、复制和分享生成的内容/可交互网页 [#5092](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/pull/5092)
|
||||||
|
- [x] 插件机制,支持 artifacts,联网搜索、计算器、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165)
|
||||||
|
- [x] artifacts
|
||||||
|
- [ ] 支持联网搜索、计算器、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165)
|
||||||
|
|
||||||
## 最新动态
|
## 最新动态
|
||||||
|
|
||||||
|
- 🚀 v2.14.0 现在支持 Artifacts & SD 了。
|
||||||
|
- 🚀 v2.10.1 现在支持 Gemini Pro 模型。
|
||||||
|
- 🚀 v2.9.11 现在可以使用自定义 Azure 服务了。
|
||||||
|
- 🚀 v2.8 发布了横跨 Linux/Windows/MacOS 的体积极小的客户端。
|
||||||
|
- 🚀 v2.7 现在可以将会话分享为图片了,也可以分享到 ShareGPT 的在线链接。
|
||||||
- 🚀 v2.0 已经发布,现在你可以使用面具功能快速创建预制对话了! 了解更多: [ChatGPT 提示词高阶技能:零次、一次和少样本提示](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/138)。
|
- 🚀 v2.0 已经发布,现在你可以使用面具功能快速创建预制对话了! 了解更多: [ChatGPT 提示词高阶技能:零次、一次和少样本提示](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/138)。
|
||||||
- 💡 想要更方便地随时随地使用本项目?可以试下这款桌面插件:https://github.com/mushan0x0/AI0x0.com
|
- 💡 想要更方便地随时随地使用本项目?可以试下这款桌面插件:https://github.com/mushan0x0/AI0x0.com
|
||||||
- 🚀 v2.7 现在可以将会话分享为图片了,也可以分享到 ShareGPT 的在线链接。
|
|
||||||
- 🚀 v2.8 发布了横跨 Linux/Windows/MacOS 的体积极小的客户端。
|
|
||||||
- 🚀 v2.9.11 现在可以使用自定义 Azure 服务了。
|
|
||||||
|
|
||||||
## Get Started
|
## Get Started
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,73 @@
|
||||||
|
import md5 from "spark-md5";
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getServerSideConfig } from "@/app/config/server";
|
||||||
|
|
||||||
|
async function handle(req: NextRequest, res: NextResponse) {
|
||||||
|
const serverConfig = getServerSideConfig();
|
||||||
|
const storeUrl = () =>
|
||||||
|
`https://api.cloudflare.com/client/v4/accounts/${serverConfig.cloudflareAccountId}/storage/kv/namespaces/${serverConfig.cloudflareKVNamespaceId}`;
|
||||||
|
const storeHeaders = () => ({
|
||||||
|
Authorization: `Bearer ${serverConfig.cloudflareKVApiKey}`,
|
||||||
|
});
|
||||||
|
if (req.method === "POST") {
|
||||||
|
const clonedBody = await req.text();
|
||||||
|
const hashedCode = md5.hash(clonedBody).trim();
|
||||||
|
const body: {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
expiration_ttl?: number;
|
||||||
|
} = {
|
||||||
|
key: hashedCode,
|
||||||
|
value: clonedBody,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const ttl = parseInt(serverConfig.cloudflareKVTTL as string);
|
||||||
|
if (ttl > 60) {
|
||||||
|
body["expiration_ttl"] = ttl;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
const res = await fetch(`${storeUrl()}/bulk`, {
|
||||||
|
headers: {
|
||||||
|
...storeHeaders(),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify([body]),
|
||||||
|
});
|
||||||
|
const result = await res.json();
|
||||||
|
console.log("save data", result);
|
||||||
|
if (result?.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ code: 0, id: hashedCode, result },
|
||||||
|
{ status: res.status },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: true, msg: "Save data error" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (req.method === "GET") {
|
||||||
|
const id = req?.nextUrl?.searchParams?.get("id");
|
||||||
|
const res = await fetch(`${storeUrl()}/values/${id}`, {
|
||||||
|
headers: storeHeaders(),
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
return new Response(res.body, {
|
||||||
|
status: res.status,
|
||||||
|
statusText: res.statusText,
|
||||||
|
headers: res.headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: true, msg: "Invalid request" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const POST = handle;
|
||||||
|
export const GET = handle;
|
||||||
|
|
||||||
|
export const runtime = "edge";
|
|
@ -25,11 +25,9 @@ export class GeminiProApi implements LLMApi {
|
||||||
baseUrl = accessStore.googleUrl;
|
baseUrl = accessStore.googleUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isApp = !!getClientConfig()?.isApp;
|
||||||
if (baseUrl.length === 0) {
|
if (baseUrl.length === 0) {
|
||||||
const isApp = !!getClientConfig()?.isApp;
|
baseUrl = isApp ? DEFAULT_API_HOST + `/api/proxy/google` : ApiPath.Google;
|
||||||
baseUrl = isApp
|
|
||||||
? DEFAULT_API_HOST + `/api/proxy/google?key=${accessStore.googleApiKey}`
|
|
||||||
: ApiPath.Google;
|
|
||||||
}
|
}
|
||||||
if (baseUrl.endsWith("/")) {
|
if (baseUrl.endsWith("/")) {
|
||||||
baseUrl = baseUrl.slice(0, baseUrl.length - 1);
|
baseUrl = baseUrl.slice(0, baseUrl.length - 1);
|
||||||
|
@ -43,6 +41,10 @@ export class GeminiProApi implements LLMApi {
|
||||||
let chatPath = [baseUrl, path].join("/");
|
let chatPath = [baseUrl, path].join("/");
|
||||||
|
|
||||||
chatPath += chatPath.includes("?") ? "&alt=sse" : "?alt=sse";
|
chatPath += chatPath.includes("?") ? "&alt=sse" : "?alt=sse";
|
||||||
|
// if chatPath.startsWith('http') then add key in query string
|
||||||
|
if (chatPath.startsWith("http") && accessStore.googleApiKey) {
|
||||||
|
chatPath += `&key=${accessStore.googleApiKey}`;
|
||||||
|
}
|
||||||
return chatPath;
|
return chatPath;
|
||||||
}
|
}
|
||||||
extractMessage(res: any) {
|
extractMessage(res: any) {
|
||||||
|
|
|
@ -0,0 +1,31 @@
|
||||||
|
.artifacts {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
&-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 36px;
|
||||||
|
padding: 20px;
|
||||||
|
background: var(--second);
|
||||||
|
}
|
||||||
|
&-title {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
&-content {
|
||||||
|
flex-grow: 1;
|
||||||
|
padding: 0 20px 20px 20px;
|
||||||
|
background-color: var(--second);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifacts-iframe {
|
||||||
|
width: 100%;
|
||||||
|
border: var(--border-in-light);
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: var(--gray);
|
||||||
|
}
|
|
@ -0,0 +1,234 @@
|
||||||
|
import { useEffect, useState, useRef, useMemo } from "react";
|
||||||
|
import { useParams } from "react-router";
|
||||||
|
import { useWindowSize } from "@/app/utils";
|
||||||
|
import { IconButton } from "./button";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
import ExportIcon from "../icons/share.svg";
|
||||||
|
import CopyIcon from "../icons/copy.svg";
|
||||||
|
import DownloadIcon from "../icons/download.svg";
|
||||||
|
import GithubIcon from "../icons/github.svg";
|
||||||
|
import LoadingButtonIcon from "../icons/loading.svg";
|
||||||
|
import Locale from "../locales";
|
||||||
|
import { Modal, showToast } from "./ui-lib";
|
||||||
|
import { copyToClipboard, downloadAs } from "../utils";
|
||||||
|
import { Path, ApiPath, REPO_URL } from "@/app/constant";
|
||||||
|
import { Loading } from "./home";
|
||||||
|
import styles from "./artifacts.module.scss";
|
||||||
|
|
||||||
|
export function HTMLPreview(props: {
|
||||||
|
code: string;
|
||||||
|
autoHeight?: boolean;
|
||||||
|
height?: number | string;
|
||||||
|
onLoad?: (title?: string) => void;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLIFrameElement>(null);
|
||||||
|
const frameId = useRef<string>(nanoid());
|
||||||
|
const [iframeHeight, setIframeHeight] = useState(600);
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
/*
|
||||||
|
* https://stackoverflow.com/questions/19739001/what-is-the-difference-between-srcdoc-and-src-datatext-html-in-an
|
||||||
|
* 1. using srcdoc
|
||||||
|
* 2. using src with dataurl:
|
||||||
|
* easy to share
|
||||||
|
* length limit (Data URIs cannot be larger than 32,768 characters.)
|
||||||
|
*/
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMessage = (e: any) => {
|
||||||
|
const { id, height, title } = e.data;
|
||||||
|
setTitle(title);
|
||||||
|
if (id == frameId.current) {
|
||||||
|
setIframeHeight(height);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("message", handleMessage);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("message", handleMessage);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const height = useMemo(() => {
|
||||||
|
if (!props.autoHeight) return props.height || 600;
|
||||||
|
if (typeof props.height === "string") {
|
||||||
|
return props.height;
|
||||||
|
}
|
||||||
|
const parentHeight = props.height || 600;
|
||||||
|
return iframeHeight + 40 > parentHeight ? parentHeight : iframeHeight + 40;
|
||||||
|
}, [props.autoHeight, props.height, iframeHeight]);
|
||||||
|
|
||||||
|
const srcDoc = useMemo(() => {
|
||||||
|
const script = `<script>new ResizeObserver((entries) => parent.postMessage({id: '${frameId.current}', height: entries[0].target.clientHeight}, '*')).observe(document.body)</script>`;
|
||||||
|
if (props.code.includes("</head>")) {
|
||||||
|
props.code.replace("</head>", "</head>" + script);
|
||||||
|
}
|
||||||
|
return props.code + script;
|
||||||
|
}, [props.code]);
|
||||||
|
|
||||||
|
const handleOnLoad = () => {
|
||||||
|
if (props?.onLoad) {
|
||||||
|
props.onLoad(title);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<iframe
|
||||||
|
className={styles["artifacts-iframe"]}
|
||||||
|
id={frameId.current}
|
||||||
|
ref={ref}
|
||||||
|
sandbox="allow-forms allow-modals allow-scripts"
|
||||||
|
style={{ height }}
|
||||||
|
srcDoc={srcDoc}
|
||||||
|
onLoad={handleOnLoad}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ArtifactsShareButton({
|
||||||
|
getCode,
|
||||||
|
id,
|
||||||
|
style,
|
||||||
|
fileName,
|
||||||
|
}: {
|
||||||
|
getCode: () => string;
|
||||||
|
id?: string;
|
||||||
|
style?: any;
|
||||||
|
fileName?: string;
|
||||||
|
}) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [name, setName] = useState(id);
|
||||||
|
const [show, setShow] = useState(false);
|
||||||
|
const shareUrl = useMemo(
|
||||||
|
() => [location.origin, "#", Path.Artifacts, "/", name].join(""),
|
||||||
|
[name],
|
||||||
|
);
|
||||||
|
const upload = (code: string) =>
|
||||||
|
id
|
||||||
|
? Promise.resolve({ id })
|
||||||
|
: fetch(ApiPath.Artifacts, {
|
||||||
|
method: "POST",
|
||||||
|
body: code,
|
||||||
|
})
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then(({ id }) => {
|
||||||
|
if (id) {
|
||||||
|
return { id };
|
||||||
|
}
|
||||||
|
throw Error();
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
showToast(Locale.Export.Artifacts.Error);
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="window-action-button" style={style}>
|
||||||
|
<IconButton
|
||||||
|
icon={loading ? <LoadingButtonIcon /> : <ExportIcon />}
|
||||||
|
bordered
|
||||||
|
title={Locale.Export.Artifacts.Title}
|
||||||
|
onClick={() => {
|
||||||
|
if (loading) return;
|
||||||
|
setLoading(true);
|
||||||
|
upload(getCode())
|
||||||
|
.then((res) => {
|
||||||
|
if (res?.id) {
|
||||||
|
setShow(true);
|
||||||
|
setName(res?.id);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{show && (
|
||||||
|
<div className="modal-mask">
|
||||||
|
<Modal
|
||||||
|
title={Locale.Export.Artifacts.Title}
|
||||||
|
onClose={() => setShow(false)}
|
||||||
|
actions={[
|
||||||
|
<IconButton
|
||||||
|
key="download"
|
||||||
|
icon={<DownloadIcon />}
|
||||||
|
bordered
|
||||||
|
text={Locale.Export.Download}
|
||||||
|
onClick={() => {
|
||||||
|
downloadAs(getCode(), `${fileName || name}.html`).then(() =>
|
||||||
|
setShow(false),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
<IconButton
|
||||||
|
key="copy"
|
||||||
|
icon={<CopyIcon />}
|
||||||
|
bordered
|
||||||
|
text={Locale.Chat.Actions.Copy}
|
||||||
|
onClick={() => {
|
||||||
|
copyToClipboard(shareUrl).then(() => setShow(false));
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<a target="_blank" href={shareUrl}>
|
||||||
|
{shareUrl}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Artifacts() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const [code, setCode] = useState("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [fileName, setFileName] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (id) {
|
||||||
|
fetch(`${ApiPath.Artifacts}?id=${id}`)
|
||||||
|
.then((res) => {
|
||||||
|
if (res.status > 300) {
|
||||||
|
throw Error("can not get content");
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
})
|
||||||
|
.then((res) => res.text())
|
||||||
|
.then(setCode)
|
||||||
|
.catch((e) => {
|
||||||
|
showToast(Locale.Export.Artifacts.Error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles["artifacts"]}>
|
||||||
|
<div className={styles["artifacts-header"]}>
|
||||||
|
<a href={REPO_URL} target="_blank" rel="noopener noreferrer">
|
||||||
|
<IconButton bordered icon={<GithubIcon />} shadow />
|
||||||
|
</a>
|
||||||
|
<div className={styles["artifacts-title"]}>NextChat Artifacts</div>
|
||||||
|
<ArtifactsShareButton
|
||||||
|
id={id}
|
||||||
|
getCode={() => code}
|
||||||
|
fileName={fileName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles["artifacts-content"]}>
|
||||||
|
{loading && <Loading />}
|
||||||
|
{code && (
|
||||||
|
<HTMLPreview
|
||||||
|
code={code}
|
||||||
|
autoHeight={false}
|
||||||
|
height={"100%"}
|
||||||
|
onLoad={(title) => {
|
||||||
|
setFileName(title as string);
|
||||||
|
setLoading(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -90,6 +90,7 @@ import {
|
||||||
REQUEST_TIMEOUT_MS,
|
REQUEST_TIMEOUT_MS,
|
||||||
UNFINISHED_INPUT,
|
UNFINISHED_INPUT,
|
||||||
ServiceProvider,
|
ServiceProvider,
|
||||||
|
Plugin,
|
||||||
} from "../constant";
|
} from "../constant";
|
||||||
import { Avatar } from "./emoji";
|
import { Avatar } from "./emoji";
|
||||||
import { ContextPrompts, MaskAvatar, MaskConfig } from "./mask";
|
import { ContextPrompts, MaskAvatar, MaskConfig } from "./mask";
|
||||||
|
@ -477,6 +478,7 @@ export function ChatActions(props: {
|
||||||
return model?.displayName ?? "";
|
return model?.displayName ?? "";
|
||||||
}, [models, currentModel, currentProviderName]);
|
}, [models, currentModel, currentProviderName]);
|
||||||
const [showModelSelector, setShowModelSelector] = useState(false);
|
const [showModelSelector, setShowModelSelector] = useState(false);
|
||||||
|
const [showPluginSelector, setShowPluginSelector] = useState(false);
|
||||||
const [showUploadImage, setShowUploadImage] = useState(false);
|
const [showUploadImage, setShowUploadImage] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -588,12 +590,6 @@ export function ChatActions(props: {
|
||||||
icon={<RobotIcon />}
|
icon={<RobotIcon />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ChatAction
|
|
||||||
onClick={() => showToast(Locale.WIP)}
|
|
||||||
text={Locale.Plugin.Name}
|
|
||||||
icon={<PluginIcon />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{showModelSelector && (
|
{showModelSelector && (
|
||||||
<Selector
|
<Selector
|
||||||
defaultSelectedValue={`${currentModel}@${currentProviderName}`}
|
defaultSelectedValue={`${currentModel}@${currentProviderName}`}
|
||||||
|
@ -627,6 +623,34 @@ export function ChatActions(props: {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ChatAction
|
||||||
|
onClick={() => setShowPluginSelector(true)}
|
||||||
|
text={Locale.Plugin.Name}
|
||||||
|
icon={<PluginIcon />}
|
||||||
|
/>
|
||||||
|
{showPluginSelector && (
|
||||||
|
<Selector
|
||||||
|
multiple
|
||||||
|
defaultSelectedValue={chatStore.currentSession().mask?.plugin}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
title: Locale.Plugin.Artifacts,
|
||||||
|
value: Plugin.Artifacts,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onClose={() => setShowPluginSelector(false)}
|
||||||
|
onSelection={(s) => {
|
||||||
|
const plugin = s[0];
|
||||||
|
chatStore.updateCurrentSession((session) => {
|
||||||
|
session.mask.plugin = s;
|
||||||
|
});
|
||||||
|
if (plugin) {
|
||||||
|
showToast(plugin);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -541,7 +541,7 @@ export function ImagePreviewer(props: {
|
||||||
<div>
|
<div>
|
||||||
<div className={styles["main-title"]}>NextChat</div>
|
<div className={styles["main-title"]}>NextChat</div>
|
||||||
<div className={styles["sub-title"]}>
|
<div className={styles["sub-title"]}>
|
||||||
github.com/Yidadaa/ChatGPT-Next-Web
|
github.com/ChatGPTNextWeb/ChatGPT-Next-Web
|
||||||
</div>
|
</div>
|
||||||
<div className={styles["icons"]}>
|
<div className={styles["icons"]}>
|
||||||
<ExportAvatar avatar={config.avatar} />
|
<ExportAvatar avatar={config.avatar} />
|
||||||
|
|
|
@ -39,6 +39,10 @@ export function Loading(props: { noLogo?: boolean }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const Artifacts = dynamic(async () => (await import("./artifacts")).Artifacts, {
|
||||||
|
loading: () => <Loading noLogo />,
|
||||||
|
});
|
||||||
|
|
||||||
const Settings = dynamic(async () => (await import("./settings")).Settings, {
|
const Settings = dynamic(async () => (await import("./settings")).Settings, {
|
||||||
loading: () => <Loading noLogo />,
|
loading: () => <Loading noLogo />,
|
||||||
});
|
});
|
||||||
|
@ -137,6 +141,7 @@ export function WindowContent(props: { children: React.ReactNode }) {
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const config = useAppConfig();
|
const config = useAppConfig();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const isArtifact = location.pathname.includes(Path.Artifacts);
|
||||||
const isHome = location.pathname === Path.Home;
|
const isHome = location.pathname === Path.Home;
|
||||||
const isAuth = location.pathname === Path.Auth;
|
const isAuth = location.pathname === Path.Auth;
|
||||||
const isSd = location.pathname === Path.Sd;
|
const isSd = location.pathname === Path.Sd;
|
||||||
|
@ -150,6 +155,13 @@ function Screen() {
|
||||||
loadAsyncGoogleFont();
|
loadAsyncGoogleFont();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
if (isArtifact) {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/artifacts/:id" element={<Artifacts />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
if (isAuth) return <AuthPage />;
|
if (isAuth) return <AuthPage />;
|
||||||
if (isSd) return <Sd />;
|
if (isSd) return <Sd />;
|
||||||
|
|
|
@ -6,14 +6,16 @@ import RehypeKatex from "rehype-katex";
|
||||||
import RemarkGfm from "remark-gfm";
|
import RemarkGfm from "remark-gfm";
|
||||||
import RehypeHighlight from "rehype-highlight";
|
import RehypeHighlight from "rehype-highlight";
|
||||||
import { useRef, useState, RefObject, useEffect, useMemo } from "react";
|
import { useRef, useState, RefObject, useEffect, useMemo } from "react";
|
||||||
import { copyToClipboard } from "../utils";
|
import { copyToClipboard, useWindowSize } from "../utils";
|
||||||
import mermaid from "mermaid";
|
import mermaid from "mermaid";
|
||||||
|
|
||||||
import LoadingIcon from "../icons/three-dots.svg";
|
import LoadingIcon from "../icons/three-dots.svg";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useDebouncedCallback } from "use-debounce";
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
import { showImageModal } from "./ui-lib";
|
import { showImageModal, FullScreen } from "./ui-lib";
|
||||||
|
import { ArtifactsShareButton, HTMLPreview } from "./artifacts";
|
||||||
|
import { Plugin } from "../constant";
|
||||||
|
import { useChatStore } from "../store";
|
||||||
export function Mermaid(props: { code: string }) {
|
export function Mermaid(props: { code: string }) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const [hasError, setHasError] = useState(false);
|
const [hasError, setHasError] = useState(false);
|
||||||
|
@ -64,25 +66,38 @@ export function PreCode(props: { children: any }) {
|
||||||
const ref = useRef<HTMLPreElement>(null);
|
const ref = useRef<HTMLPreElement>(null);
|
||||||
const refText = ref.current?.innerText;
|
const refText = ref.current?.innerText;
|
||||||
const [mermaidCode, setMermaidCode] = useState("");
|
const [mermaidCode, setMermaidCode] = useState("");
|
||||||
|
const [htmlCode, setHtmlCode] = useState("");
|
||||||
|
const { height } = useWindowSize();
|
||||||
|
const chatStore = useChatStore();
|
||||||
|
const session = chatStore.currentSession();
|
||||||
|
const plugins = session.mask?.plugin;
|
||||||
|
|
||||||
const renderMermaid = useDebouncedCallback(() => {
|
const renderArtifacts = useDebouncedCallback(() => {
|
||||||
if (!ref.current) return;
|
if (!ref.current) return;
|
||||||
const mermaidDom = ref.current.querySelector("code.language-mermaid");
|
const mermaidDom = ref.current.querySelector("code.language-mermaid");
|
||||||
if (mermaidDom) {
|
if (mermaidDom) {
|
||||||
setMermaidCode((mermaidDom as HTMLElement).innerText);
|
setMermaidCode((mermaidDom as HTMLElement).innerText);
|
||||||
}
|
}
|
||||||
|
const htmlDom = ref.current.querySelector("code.language-html");
|
||||||
|
if (htmlDom) {
|
||||||
|
setHtmlCode((htmlDom as HTMLElement).innerText);
|
||||||
|
} else if (refText?.startsWith("<!DOCTYPE")) {
|
||||||
|
setHtmlCode(refText);
|
||||||
|
}
|
||||||
}, 600);
|
}, 600);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTimeout(renderMermaid, 1);
|
setTimeout(renderArtifacts, 1);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [refText]);
|
}, [refText]);
|
||||||
|
|
||||||
|
const enableArtifacts = useMemo(
|
||||||
|
() => plugins?.includes(Plugin.Artifacts),
|
||||||
|
[plugins],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{mermaidCode.length > 0 && (
|
|
||||||
<Mermaid code={mermaidCode} key={mermaidCode} />
|
|
||||||
)}
|
|
||||||
<pre ref={ref}>
|
<pre ref={ref}>
|
||||||
<span
|
<span
|
||||||
className="copy-code-button"
|
className="copy-code-button"
|
||||||
|
@ -95,6 +110,22 @@ export function PreCode(props: { children: any }) {
|
||||||
></span>
|
></span>
|
||||||
{props.children}
|
{props.children}
|
||||||
</pre>
|
</pre>
|
||||||
|
{mermaidCode.length > 0 && (
|
||||||
|
<Mermaid code={mermaidCode} key={mermaidCode} />
|
||||||
|
)}
|
||||||
|
{htmlCode.length > 0 && enableArtifacts && (
|
||||||
|
<FullScreen className="no-dark html" right={70}>
|
||||||
|
<ArtifactsShareButton
|
||||||
|
style={{ position: "absolute", right: 20, top: 10 }}
|
||||||
|
getCode={() => htmlCode}
|
||||||
|
/>
|
||||||
|
<HTMLPreview
|
||||||
|
code={htmlCode}
|
||||||
|
autoHeight={!document.fullscreenElement}
|
||||||
|
height={!document.fullscreenElement ? 600 : height}
|
||||||
|
/>
|
||||||
|
</FullScreen>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -309,6 +309,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
&-content {
|
&-content {
|
||||||
|
min-width: 300px;
|
||||||
.list {
|
.list {
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
|
|
@ -19,6 +19,8 @@ import React, {
|
||||||
MouseEvent,
|
MouseEvent,
|
||||||
useEffect,
|
useEffect,
|
||||||
useState,
|
useState,
|
||||||
|
useCallback,
|
||||||
|
useRef,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { IconButton } from "./button";
|
import { IconButton } from "./button";
|
||||||
|
|
||||||
|
@ -53,7 +55,7 @@ export function ListItem(props: {
|
||||||
children?: JSX.Element | JSX.Element[];
|
children?: JSX.Element | JSX.Element[];
|
||||||
icon?: JSX.Element;
|
icon?: JSX.Element;
|
||||||
className?: string;
|
className?: string;
|
||||||
onClick?: (event: MouseEvent) => void;
|
onClick?: (e: MouseEvent) => void;
|
||||||
vertical?: boolean;
|
vertical?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
|
@ -463,17 +465,40 @@ export function Selector<T>(props: {
|
||||||
value: T;
|
value: T;
|
||||||
disable?: boolean;
|
disable?: boolean;
|
||||||
}>;
|
}>;
|
||||||
defaultSelectedValue?: T;
|
defaultSelectedValue?: T[] | T;
|
||||||
onSelection?: (selection: T[]) => void;
|
onSelection?: (selection: T[]) => void;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
multiple?: boolean;
|
multiple?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const [selectedValues, setSelectedValues] = useState<T[]>(
|
||||||
|
Array.isArray(props.defaultSelectedValue)
|
||||||
|
? props.defaultSelectedValue
|
||||||
|
: props.defaultSelectedValue !== undefined
|
||||||
|
? [props.defaultSelectedValue]
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelection = (e: MouseEvent, value: T) => {
|
||||||
|
if (props.multiple) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const newSelectedValues = selectedValues.includes(value)
|
||||||
|
? selectedValues.filter((v) => v !== value)
|
||||||
|
: [...selectedValues, value];
|
||||||
|
setSelectedValues(newSelectedValues);
|
||||||
|
props.onSelection?.(newSelectedValues);
|
||||||
|
} else {
|
||||||
|
setSelectedValues([value]);
|
||||||
|
props.onSelection?.([value]);
|
||||||
|
props.onClose?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles["selector"]} onClick={() => props.onClose?.()}>
|
<div className={styles["selector"]} onClick={() => props.onClose?.()}>
|
||||||
<div className={styles["selector-content"]}>
|
<div className={styles["selector-content"]}>
|
||||||
<List>
|
<List>
|
||||||
{props.items.map((item, i) => {
|
{props.items.map((item, i) => {
|
||||||
const selected = props.defaultSelectedValue === item.value;
|
const selected = selectedValues.includes(item.value);
|
||||||
return (
|
return (
|
||||||
<ListItem
|
<ListItem
|
||||||
className={`${styles["selector-item"]} ${
|
className={`${styles["selector-item"]} ${
|
||||||
|
@ -482,11 +507,11 @@ export function Selector<T>(props: {
|
||||||
key={i}
|
key={i}
|
||||||
title={item.title}
|
title={item.title}
|
||||||
subTitle={item.subTitle}
|
subTitle={item.subTitle}
|
||||||
onClick={(event) => {
|
onClick={(e) => {
|
||||||
event.stopPropagation();
|
if (item.disable) {
|
||||||
if (!item.disable) {
|
e.stopPropagation();
|
||||||
props.onSelection?.([item.value]);
|
} else {
|
||||||
props.onClose?.();
|
handleSelection(e, item.value);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
@ -510,3 +535,38 @@ export function Selector<T>(props: {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export function FullScreen(props: any) {
|
||||||
|
const { children, right = 10, top = 10, ...rest } = props;
|
||||||
|
const ref = useRef<HTMLDivElement>();
|
||||||
|
const [fullScreen, setFullScreen] = useState(false);
|
||||||
|
const toggleFullscreen = useCallback(() => {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
ref.current?.requestFullscreen();
|
||||||
|
} else {
|
||||||
|
document.exitFullscreen();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScreenChange = (e: any) => {
|
||||||
|
if (e.target === ref.current) {
|
||||||
|
setFullScreen(!!document.fullscreenElement);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("fullscreenchange", handleScreenChange);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("fullscreenchange", handleScreenChange);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<div ref={ref} style={{ position: "relative" }} {...rest}>
|
||||||
|
<div style={{ position: "absolute", right, top }}>
|
||||||
|
<IconButton
|
||||||
|
icon={fullScreen ? <MinIcon /> : <MaxIcon />}
|
||||||
|
onClick={toggleFullscreen}
|
||||||
|
bordered
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -177,6 +177,11 @@ export const getServerSideConfig = () => {
|
||||||
moonshotUrl: process.env.MOONSHOT_URL,
|
moonshotUrl: process.env.MOONSHOT_URL,
|
||||||
moonshotApiKey: getApiKey(process.env.MOONSHOT_API_KEY),
|
moonshotApiKey: getApiKey(process.env.MOONSHOT_API_KEY),
|
||||||
|
|
||||||
|
cloudflareAccountId: process.env.CLOUDFLARE_ACCOUNT_ID,
|
||||||
|
cloudflareKVNamespaceId: process.env.CLOUDFLARE_KV_NAMESPACE_ID,
|
||||||
|
cloudflareKVApiKey: getApiKey(process.env.CLOUDFLARE_KV_API_KEY),
|
||||||
|
cloudflareKVTTL: process.env.CLOUDFLARE_KV_TTL,
|
||||||
|
|
||||||
gtmId: process.env.GTM_ID,
|
gtmId: process.env.GTM_ID,
|
||||||
|
|
||||||
needCode: ACCESS_CODES.size > 0,
|
needCode: ACCESS_CODES.size > 0,
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
export const OWNER = "Yidadaa";
|
export const OWNER = "ChatGPTNextWeb";
|
||||||
export const REPO = "ChatGPT-Next-Web";
|
export const REPO = "ChatGPT-Next-Web";
|
||||||
export const REPO_URL = `https://github.com/${OWNER}/${REPO}`;
|
export const REPO_URL = `https://github.com/${OWNER}/${REPO}`;
|
||||||
export const ISSUE_URL = `https://github.com/${OWNER}/${REPO}/issues`;
|
export const ISSUE_URL = `https://github.com/${OWNER}/${REPO}/issues`;
|
||||||
|
@ -36,6 +36,7 @@ export enum Path {
|
||||||
Auth = "/auth",
|
Auth = "/auth",
|
||||||
Sd = "/sd",
|
Sd = "/sd",
|
||||||
SdNew = "/sd-new",
|
SdNew = "/sd-new",
|
||||||
|
Artifacts = "/artifacts",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ApiPath {
|
export enum ApiPath {
|
||||||
|
@ -49,6 +50,7 @@ export enum ApiPath {
|
||||||
Alibaba = "/api/alibaba",
|
Alibaba = "/api/alibaba",
|
||||||
Moonshot = "/api/moonshot",
|
Moonshot = "/api/moonshot",
|
||||||
Stability = "/api/stability",
|
Stability = "/api/stability",
|
||||||
|
Artifacts = "/api/artifacts",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum SlotID {
|
export enum SlotID {
|
||||||
|
@ -61,6 +63,10 @@ export enum FileName {
|
||||||
Prompts = "prompts.json",
|
Prompts = "prompts.json",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum Plugin {
|
||||||
|
Artifacts = "artifacts",
|
||||||
|
}
|
||||||
|
|
||||||
export enum StoreKey {
|
export enum StoreKey {
|
||||||
Chat = "chat-next-web-store",
|
Chat = "chat-next-web-store",
|
||||||
Access = "access-control",
|
Access = "access-control",
|
||||||
|
|
|
@ -104,6 +104,10 @@ const cn = {
|
||||||
Toast: "正在生成截图",
|
Toast: "正在生成截图",
|
||||||
Modal: "长按或右键保存图片",
|
Modal: "长按或右键保存图片",
|
||||||
},
|
},
|
||||||
|
Artifacts: {
|
||||||
|
Title: "分享页面",
|
||||||
|
Error: "分享失败",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Select: {
|
Select: {
|
||||||
Search: "搜索消息",
|
Search: "搜索消息",
|
||||||
|
@ -457,6 +461,7 @@ const cn = {
|
||||||
},
|
},
|
||||||
Plugin: {
|
Plugin: {
|
||||||
Name: "插件",
|
Name: "插件",
|
||||||
|
Artifacts: "Artifacts",
|
||||||
},
|
},
|
||||||
Discovery: {
|
Discovery: {
|
||||||
Name: "发现",
|
Name: "发现",
|
||||||
|
|
|
@ -106,6 +106,10 @@ const en: LocaleType = {
|
||||||
Toast: "Capturing Image...",
|
Toast: "Capturing Image...",
|
||||||
Modal: "Long press or right click to save image",
|
Modal: "Long press or right click to save image",
|
||||||
},
|
},
|
||||||
|
Artifacts: {
|
||||||
|
Title: "Share Artifacts",
|
||||||
|
Error: "Share Error",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Select: {
|
Select: {
|
||||||
Search: "Search",
|
Search: "Search",
|
||||||
|
@ -464,6 +468,7 @@ const en: LocaleType = {
|
||||||
},
|
},
|
||||||
Plugin: {
|
Plugin: {
|
||||||
Name: "Plugin",
|
Name: "Plugin",
|
||||||
|
Artifacts: "Artifacts",
|
||||||
},
|
},
|
||||||
Discovery: {
|
Discovery: {
|
||||||
Name: "Discovery",
|
Name: "Discovery",
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { BUILTIN_MASKS } from "../masks";
|
||||||
import { getLang, Lang } from "../locales";
|
import { getLang, Lang } from "../locales";
|
||||||
import { DEFAULT_TOPIC, ChatMessage } from "./chat";
|
import { DEFAULT_TOPIC, ChatMessage } from "./chat";
|
||||||
import { ModelConfig, useAppConfig } from "./config";
|
import { ModelConfig, useAppConfig } from "./config";
|
||||||
import { StoreKey } from "../constant";
|
import { StoreKey, Plugin } from "../constant";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { createPersistStore } from "../utils/store";
|
import { createPersistStore } from "../utils/store";
|
||||||
|
|
||||||
|
@ -17,6 +17,7 @@ export type Mask = {
|
||||||
modelConfig: ModelConfig;
|
modelConfig: ModelConfig;
|
||||||
lang: Lang;
|
lang: Lang;
|
||||||
builtin: boolean;
|
builtin: boolean;
|
||||||
|
plugin?: Plugin[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_MASK_STATE = {
|
export const DEFAULT_MASK_STATE = {
|
||||||
|
@ -37,6 +38,7 @@ export const createEmptyMask = () =>
|
||||||
lang: getLang(),
|
lang: getLang(),
|
||||||
builtin: false,
|
builtin: false,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
|
plugin: [Plugin.Artifacts],
|
||||||
}) as Mask;
|
}) as Mask;
|
||||||
|
|
||||||
export const useMaskStore = createPersistStore(
|
export const useMaskStore = createPersistStore(
|
||||||
|
|
|
@ -9,7 +9,7 @@
|
||||||
},
|
},
|
||||||
"package": {
|
"package": {
|
||||||
"productName": "NextChat",
|
"productName": "NextChat",
|
||||||
"version": "2.13.1"
|
"version": "2.14.0"
|
||||||
},
|
},
|
||||||
"tauri": {
|
"tauri": {
|
||||||
"allowlist": {
|
"allowlist": {
|
||||||
|
|
Loading…
Reference in New Issue