diff --git a/README.md b/README.md index db62e0838..c8b158956 100644 --- a/README.md +++ b/README.md @@ -91,13 +91,13 @@ For enterprise inquiries, please contact: **business@nextchat.dev** - [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] 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, calculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) +- [x] Plugins: support network search, calculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) [#5353](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5353) + - [x] network search, calculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) [#5353](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5353) - [ ] local knowledge base ## What's New +- 🚀 v2.15.0 Now supports Plugins! Read this: [NextChat-Awesome-Plugins](https://github.com/ChatGPTNextWeb/NextChat-Awesome-Plugins) - 🚀 v2.14.0 Now supports Artifacts & SD - 🚀 v2.10.1 support Google Gemini Pro model. - 🚀 v2.9.11 you can use azure endpoint now. @@ -128,13 +128,13 @@ For enterprise inquiries, please contact: **business@nextchat.dev** - [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] 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) +- [x] 插件机制,支持`联网搜索`、`计算器`、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) [#5353](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5353) + - [x] 支持联网搜索、计算器、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) [#5353](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5353) - [ ] 本地知识库 ## 最新动态 +- 🚀 v2.15.0 现在支持插件功能了!了解更多:[NextChat-Awesome-Plugins](https://github.com/ChatGPTNextWeb/NextChat-Awesome-Plugins) - 🚀 v2.14.0 现在支持 Artifacts & SD 了。 - 🚀 v2.10.1 现在支持 Gemini Pro 模型。 - 🚀 v2.9.11 现在可以使用自定义 Azure 服务了。 diff --git a/app/api/[provider]/[...path]/route.ts b/app/api/[provider]/[...path]/route.ts index aaed6146a..9512e9b02 100644 --- a/app/api/[provider]/[...path]/route.ts +++ b/app/api/[provider]/[...path]/route.ts @@ -10,6 +10,8 @@ import { handle as alibabaHandler } from "../../alibaba"; import { handle as moonshotHandler } from "../../moonshot"; import { handle as stabilityHandler } from "../../stability"; import { handle as iflytekHandler } from "../../iflytek"; +import { handle as proxyHandler } from "../../proxy"; + async function handle( req: NextRequest, { params }: { params: { provider: string; path: string[] } }, @@ -36,8 +38,10 @@ async function handle( return stabilityHandler(req, { params }); case ApiPath.Iflytek: return iflytekHandler(req, { params }); - default: + case ApiPath.OpenAI: return openaiHandler(req, { params }); + default: + return proxyHandler(req, { params }); } } diff --git a/app/api/common.ts b/app/api/common.ts index 1975adf29..8e21f85c7 100644 --- a/app/api/common.ts +++ b/app/api/common.ts @@ -46,10 +46,7 @@ export async function requestOpenai( // const authValue = req.headers.get("Authorization") ?? ""; // const authHeaderName = isAzure ? "api-key" : "Authorization"; - let path = `${req.nextUrl.pathname}${req.nextUrl.search}`.replaceAll( - "/api/openai/", - "", - ); + let path = `${req.nextUrl.pathname}`.replaceAll("/api/openai/", ""); let baseUrl = (isAzure ? serverConfig.azureUrl : serverConfig.baseUrl) || OPENAI_BASE_URL; diff --git a/app/api/proxy.ts b/app/api/proxy.ts new file mode 100644 index 000000000..731003aa1 --- /dev/null +++ b/app/api/proxy.ts @@ -0,0 +1,75 @@ +import { NextRequest, NextResponse } from "next/server"; + +export async function handle( + req: NextRequest, + { params }: { params: { path: string[] } }, +) { + console.log("[Proxy Route] params ", params); + + if (req.method === "OPTIONS") { + return NextResponse.json({ body: "OK" }, { status: 200 }); + } + + // remove path params from searchParams + req.nextUrl.searchParams.delete("path"); + req.nextUrl.searchParams.delete("provider"); + + const subpath = params.path.join("/"); + const fetchUrl = `${req.headers.get( + "x-base-url", + )}/${subpath}?${req.nextUrl.searchParams.toString()}`; + const skipHeaders = ["connection", "host", "origin", "referer", "cookie"]; + const headers = new Headers( + Array.from(req.headers.entries()).filter((item) => { + if ( + item[0].indexOf("x-") > -1 || + item[0].indexOf("sec-") > -1 || + skipHeaders.includes(item[0]) + ) { + return false; + } + return true; + }), + ); + const controller = new AbortController(); + const fetchOptions: RequestInit = { + headers, + method: req.method, + body: req.body, + // to fix #2485: https://stackoverflow.com/questions/55920957/cloudflare-worker-typeerror-one-time-use-body + redirect: "manual", + // @ts-ignore + duplex: "half", + signal: controller.signal, + }; + + const timeoutId = setTimeout( + () => { + controller.abort(); + }, + 10 * 60 * 1000, + ); + + try { + const res = await fetch(fetchUrl, fetchOptions); + // to prevent browser prompt for credentials + const newHeaders = new Headers(res.headers); + newHeaders.delete("www-authenticate"); + // to disable nginx buffering + newHeaders.set("X-Accel-Buffering", "no"); + + // The latest version of the OpenAI API forced the content-encoding to be "br" in json response + // So if the streaming is disabled, we need to remove the content-encoding header + // Because Vercel uses gzip to compress the response, if we don't remove the content-encoding header + // The browser will try to decode the response with brotli and fail + newHeaders.delete("content-encoding"); + + return new Response(res.body, { + status: res.status, + statusText: res.statusText, + headers: newHeaders, + }); + } finally { + clearTimeout(timeoutId); + } +} diff --git a/app/client/api.ts b/app/client/api.ts index 06ab41c6c..a7d52cf1b 100644 --- a/app/client/api.ts +++ b/app/client/api.ts @@ -6,7 +6,13 @@ import { ModelProvider, ServiceProvider, } from "../constant"; -import { ChatMessage, ModelType, useAccessStore, useChatStore } from "../store"; +import { + ChatMessageTool, + ChatMessage, + ModelType, + useAccessStore, + useChatStore, +} from "../store"; import { ChatGPTApi, DalleRequestPayload } from "./platforms/openai"; import { GeminiProApi } from "./platforms/google"; import { ClaudeApi } from "./platforms/anthropic"; @@ -63,6 +69,8 @@ export interface ChatOptions { onFinish: (message: string) => void; onError?: (err: Error) => void; onController?: (controller: AbortController) => void; + onBeforeTool?: (tool: ChatMessageTool) => void; + onAfterTool?: (tool: ChatMessageTool) => void; } export interface LLMUsage { diff --git a/app/client/platforms/anthropic.ts b/app/client/platforms/anthropic.ts index b079ba1ad..fce675a16 100644 --- a/app/client/platforms/anthropic.ts +++ b/app/client/platforms/anthropic.ts @@ -1,6 +1,12 @@ import { ACCESS_CODE_PREFIX, Anthropic, ApiPath } from "@/app/constant"; import { ChatOptions, getHeaders, LLMApi, MultimodalContent } from "../api"; -import { useAccessStore, useAppConfig, useChatStore } from "@/app/store"; +import { + useAccessStore, + useAppConfig, + useChatStore, + usePluginStore, + ChatMessageTool, +} from "@/app/store"; import { getClientConfig } from "@/app/config/client"; import { DEFAULT_API_HOST } from "@/app/constant"; import { @@ -11,8 +17,9 @@ import { import Locale from "../../locales"; import { prettyObject } from "@/app/utils/format"; import { getMessageTextContent, isVisionModel } from "@/app/utils"; -import { preProcessImageContent } from "@/app/utils/chat"; +import { preProcessImageContent, stream } from "@/app/utils/chat"; import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare"; +import { RequestPayload } from "./openai"; export type MultiBlockContent = { type: "image" | "text"; @@ -191,112 +198,126 @@ export class ClaudeApi implements LLMApi { const controller = new AbortController(); options.onController?.(controller); - const payload = { - method: "POST", - body: JSON.stringify(requestBody), - signal: controller.signal, - headers: { - ...getHeaders(), // get common headers - "anthropic-version": accessStore.anthropicApiVersion, - // do not send `anthropicApiKey` in browser!!! - // Authorization: getAuthKey(accessStore.anthropicApiKey), - }, - }; - if (shouldStream) { - try { - const context = { - text: "", - finished: false, - }; - - const finish = () => { - if (!context.finished) { - options.onFinish(context.text); - context.finished = true; - } - }; - - controller.signal.onabort = finish; - fetchEventSource(path, { - ...payload, - async onopen(res) { - const contentType = res.headers.get("content-type"); - console.log("response content type: ", contentType); - - if (contentType?.startsWith("text/plain")) { - context.text = await res.clone().text(); - return finish(); - } - - if ( - !res.ok || - !res.headers - .get("content-type") - ?.startsWith(EventStreamContentType) || - res.status !== 200 - ) { - const responseTexts = [context.text]; - let extraInfo = await res.clone().text(); - try { - const resJson = await res.clone().json(); - extraInfo = prettyObject(resJson); - } catch {} - - if (res.status === 401) { - responseTexts.push(Locale.Error.Unauthorized); - } - - if (extraInfo) { - responseTexts.push(extraInfo); - } - - context.text = responseTexts.join("\n\n"); - - return finish(); - } - }, - onmessage(msg) { - let chunkJson: - | undefined - | { - type: "content_block_delta" | "content_block_stop"; - delta?: { - type: "text_delta"; - text: string; - }; - index: number; + let index = -1; + const [tools, funcs] = usePluginStore + .getState() + .getAsTools( + useChatStore.getState().currentSession().mask?.plugin as string[], + ); + return stream( + path, + requestBody, + { + ...getHeaders(), + "anthropic-version": accessStore.anthropicApiVersion, + }, + // @ts-ignore + tools.map((tool) => ({ + name: tool?.function?.name, + description: tool?.function?.description, + input_schema: tool?.function?.parameters, + })), + funcs, + controller, + // parseSSE + (text: string, runTools: ChatMessageTool[]) => { + // console.log("parseSSE", text, runTools); + let chunkJson: + | undefined + | { + type: "content_block_delta" | "content_block_stop"; + content_block?: { + type: "tool_use"; + id: string; + name: string; }; - try { - chunkJson = JSON.parse(msg.data); - } catch (e) { - console.error("[Response] parse error", msg.data); - } + delta?: { + type: "text_delta" | "input_json_delta"; + text?: string; + partial_json?: string; + }; + index: number; + }; + chunkJson = JSON.parse(text); - if (!chunkJson || chunkJson.type === "content_block_stop") { - return finish(); - } - - const { delta } = chunkJson; - if (delta?.text) { - context.text += delta.text; - options.onUpdate?.(context.text, delta.text); - } - }, - onclose() { - finish(); - }, - onerror(e) { - options.onError?.(e); - throw e; - }, - openWhenHidden: true, - }); - } catch (e) { - console.error("failed to chat", e); - options.onError?.(e as Error); - } + if (chunkJson?.content_block?.type == "tool_use") { + index += 1; + const id = chunkJson?.content_block.id; + const name = chunkJson?.content_block.name; + runTools.push({ + id, + type: "function", + function: { + name, + arguments: "", + }, + }); + } + if ( + chunkJson?.delta?.type == "input_json_delta" && + chunkJson?.delta?.partial_json + ) { + // @ts-ignore + runTools[index]["function"]["arguments"] += + chunkJson?.delta?.partial_json; + } + return chunkJson?.delta?.text; + }, + // processToolMessage, include tool_calls message and tool call results + ( + requestPayload: RequestPayload, + toolCallMessage: any, + toolCallResult: any[], + ) => { + // reset index value + index = -1; + // @ts-ignore + requestPayload?.messages?.splice( + // @ts-ignore + requestPayload?.messages?.length, + 0, + { + role: "assistant", + content: toolCallMessage.tool_calls.map( + (tool: ChatMessageTool) => ({ + type: "tool_use", + id: tool.id, + name: tool?.function?.name, + input: tool?.function?.arguments + ? JSON.parse(tool?.function?.arguments) + : {}, + }), + ), + }, + // @ts-ignore + ...toolCallResult.map((result) => ({ + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: result.tool_call_id, + content: result.content, + }, + ], + })), + ); + }, + options, + ); } else { + const payload = { + method: "POST", + body: JSON.stringify(requestBody), + signal: controller.signal, + headers: { + ...getHeaders(), // get common headers + "anthropic-version": accessStore.anthropicApiVersion, + // do not send `anthropicApiKey` in browser!!! + // Authorization: getAuthKey(accessStore.anthropicApiKey), + }, + }; + try { controller.signal.onabort = () => options.onFinish(""); diff --git a/app/client/platforms/moonshot.ts b/app/client/platforms/moonshot.ts index 7d257ccb2..c38d3317b 100644 --- a/app/client/platforms/moonshot.ts +++ b/app/client/platforms/moonshot.ts @@ -8,9 +8,15 @@ import { REQUEST_TIMEOUT_MS, ServiceProvider, } from "@/app/constant"; -import { useAccessStore, useAppConfig, useChatStore } from "@/app/store"; +import { + useAccessStore, + useAppConfig, + useChatStore, + ChatMessageTool, + usePluginStore, +} from "@/app/store"; import { collectModelsWithDefaultModel } from "@/app/utils/model"; -import { preProcessImageContent } from "@/app/utils/chat"; +import { preProcessImageContent, stream } from "@/app/utils/chat"; import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare"; import { @@ -116,115 +122,66 @@ export class MoonshotApi implements LLMApi { ); if (shouldStream) { - let responseText = ""; - let remainText = ""; - let finished = false; - - // animate response to make it looks smooth - function animateResponseText() { - if (finished || controller.signal.aborted) { - responseText += remainText; - console.log("[Response Animation] finished"); - if (responseText?.length === 0) { - options.onError?.(new Error("empty response from server")); + const [tools, funcs] = usePluginStore + .getState() + .getAsTools( + useChatStore.getState().currentSession().mask?.plugin as string[], + ); + return stream( + chatPath, + requestPayload, + getHeaders(), + tools as any, + funcs, + controller, + // parseSSE + (text: string, runTools: ChatMessageTool[]) => { + // console.log("parseSSE", text, runTools); + const json = JSON.parse(text); + const choices = json.choices as Array<{ + delta: { + content: string; + tool_calls: ChatMessageTool[]; + }; + }>; + const tool_calls = choices[0]?.delta?.tool_calls; + if (tool_calls?.length > 0) { + const index = tool_calls[0]?.index; + const id = tool_calls[0]?.id; + const args = tool_calls[0]?.function?.arguments; + if (id) { + runTools.push({ + id, + type: tool_calls[0]?.type, + function: { + name: tool_calls[0]?.function?.name as string, + arguments: args, + }, + }); + } else { + // @ts-ignore + runTools[index]["function"]["arguments"] += args; + } } - return; - } - - if (remainText.length > 0) { - const fetchCount = Math.max(1, Math.round(remainText.length / 60)); - const fetchText = remainText.slice(0, fetchCount); - responseText += fetchText; - remainText = remainText.slice(fetchCount); - options.onUpdate?.(responseText, fetchText); - } - - requestAnimationFrame(animateResponseText); - } - - // start animaion - animateResponseText(); - - const finish = () => { - if (!finished) { - finished = true; - options.onFinish(responseText + remainText); - } - }; - - controller.signal.onabort = finish; - - fetchEventSource(chatPath, { - ...chatPayload, - async onopen(res) { - clearTimeout(requestTimeoutId); - const contentType = res.headers.get("content-type"); - console.log( - "[OpenAI] request response content type: ", - contentType, + return choices[0]?.delta?.content; + }, + // processToolMessage, include tool_calls message and tool call results + ( + requestPayload: RequestPayload, + toolCallMessage: any, + toolCallResult: any[], + ) => { + // @ts-ignore + requestPayload?.messages?.splice( + // @ts-ignore + requestPayload?.messages?.length, + 0, + toolCallMessage, + ...toolCallResult, ); - - if (contentType?.startsWith("text/plain")) { - responseText = await res.clone().text(); - return finish(); - } - - if ( - !res.ok || - !res.headers - .get("content-type") - ?.startsWith(EventStreamContentType) || - res.status !== 200 - ) { - const responseTexts = [responseText]; - let extraInfo = await res.clone().text(); - try { - const resJson = await res.clone().json(); - extraInfo = prettyObject(resJson); - } catch {} - - if (res.status === 401) { - responseTexts.push(Locale.Error.Unauthorized); - } - - if (extraInfo) { - responseTexts.push(extraInfo); - } - - responseText = responseTexts.join("\n\n"); - - return finish(); - } }, - onmessage(msg) { - if (msg.data === "[DONE]" || finished) { - return finish(); - } - const text = msg.data; - try { - const json = JSON.parse(text); - const choices = json.choices as Array<{ - delta: { content: string }; - }>; - const delta = choices[0]?.delta?.content; - const textmoderation = json?.prompt_filter_results; - - if (delta) { - remainText += delta; - } - } catch (e) { - console.error("[Request] parse error", text, msg); - } - }, - onclose() { - finish(); - }, - onerror(e) { - options.onError?.(e); - throw e; - }, - openWhenHidden: true, - }); + options, + ); } else { const res = await fetch(chatPath, chatPayload); clearTimeout(requestTimeoutId); diff --git a/app/client/platforms/openai.ts b/app/client/platforms/openai.ts index ee1b0dc28..8e5da2711 100644 --- a/app/client/platforms/openai.ts +++ b/app/client/platforms/openai.ts @@ -10,12 +10,19 @@ import { REQUEST_TIMEOUT_MS, ServiceProvider, } from "@/app/constant"; -import { useAccessStore, useAppConfig, useChatStore } from "@/app/store"; +import { + ChatMessageTool, + useAccessStore, + useAppConfig, + useChatStore, + usePluginStore, +} from "@/app/store"; import { collectModelsWithDefaultModel } from "@/app/utils/model"; import { preProcessImageContent, uploadImage, base64Image2Blob, + stream, } from "@/app/utils/chat"; import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare"; import { DalleSize, DalleQuality, DalleStyle } from "@/app/typing"; @@ -246,137 +253,82 @@ export class ChatGPTApi implements LLMApi { () => controller.abort(), isDalle3 ? REQUEST_TIMEOUT_MS * 2 : REQUEST_TIMEOUT_MS, // dalle3 using b64_json is slow. ); - if (shouldStream) { - let responseText = ""; - let remainText = ""; - let finished = false; - - // animate response to make it looks smooth - function animateResponseText() { - if (finished || controller.signal.aborted) { - responseText += remainText; - console.log("[Response Animation] finished"); - if (responseText?.length === 0) { - options.onError?.(new Error("empty response from server")); + const [tools, funcs] = usePluginStore + .getState() + .getAsTools( + useChatStore.getState().currentSession().mask?.plugin as string[], + ); + // console.log("getAsTools", tools, funcs); + stream( + chatPath, + requestPayload, + getHeaders(), + tools as any, + funcs, + controller, + // parseSSE + (text: string, runTools: ChatMessageTool[]) => { + // console.log("parseSSE", text, runTools); + const json = JSON.parse(text); + const choices = json.choices as Array<{ + delta: { + content: string; + tool_calls: ChatMessageTool[]; + }; + }>; + const tool_calls = choices[0]?.delta?.tool_calls; + if (tool_calls?.length > 0) { + const index = tool_calls[0]?.index; + const id = tool_calls[0]?.id; + const args = tool_calls[0]?.function?.arguments; + if (id) { + runTools.push({ + id, + type: tool_calls[0]?.type, + function: { + name: tool_calls[0]?.function?.name as string, + arguments: args, + }, + }); + } else { + // @ts-ignore + runTools[index]["function"]["arguments"] += args; + } } - return; - } - - if (remainText.length > 0) { - const fetchCount = Math.max(1, Math.round(remainText.length / 60)); - const fetchText = remainText.slice(0, fetchCount); - responseText += fetchText; - remainText = remainText.slice(fetchCount); - options.onUpdate?.(responseText, fetchText); - } - - requestAnimationFrame(animateResponseText); - } - - // start animaion - animateResponseText(); - - const finish = () => { - if (!finished) { - finished = true; - options.onFinish(responseText + remainText); - } + return choices[0]?.delta?.content; + }, + // processToolMessage, include tool_calls message and tool call results + ( + requestPayload: RequestPayload, + toolCallMessage: any, + toolCallResult: any[], + ) => { + // @ts-ignore + requestPayload?.messages?.splice( + // @ts-ignore + requestPayload?.messages?.length, + 0, + toolCallMessage, + ...toolCallResult, + ); + }, + options, + ); + } else { + const chatPayload = { + method: "POST", + body: JSON.stringify(requestPayload), + signal: controller.signal, + headers: getHeaders(), }; - controller.signal.onabort = finish; + // make a fetch request + const requestTimeoutId = setTimeout( + () => controller.abort(), + isDalle3 ? REQUEST_TIMEOUT_MS * 2 : REQUEST_TIMEOUT_MS, // dalle3 using b64_json is slow. + ); - fetchEventSource(chatPath, { - ...chatPayload, - async onopen(res) { - clearTimeout(requestTimeoutId); - const contentType = res.headers.get("content-type"); - // console.log( - // "[OpenAI] request response content type: ", - // contentType, - // ); - - if (contentType?.startsWith("text/plain")) { - responseText = await res.clone().text(); - return finish(); - } - - if ( - !res.ok || - !res.headers - .get("content-type") - ?.startsWith(EventStreamContentType) || - res.status !== 200 - ) { - const responseTexts = [responseText]; - // let extraInfo = await res.clone().text(); - try { - const resJson = await res.clone().json(); - responseTexts.push(prettyObject(resJson)); - } catch { - responseTexts.push(Locale.Error.BACKEND_ERR); - } - - // if (res.status === 401) { - // responseTexts.push(Locale.Error.Unauthorized); - // } else if (res.status === 404) { - // responseTexts.push(Locale.Error.NOT_FOUND_ERR); - // } - // if (res.status > 400) { - // responseTexts.push(Locale.Error.BACKEND_ERR); - // } - // else if (extraInfo) { - // responseTexts.push(extraInfo); - // } - - responseText = responseTexts.join("\n\n"); - - return finish(); - } - }, - onmessage(msg) { - if (msg.data === "[DONE]" || finished) { - return finish(); - } - const text = msg.data; - try { - const json = JSON.parse(text); - const choices = json.choices as Array<{ - delta: { content: string }; - }>; - const delta = choices[0]?.delta?.content; - const textmoderation = json?.prompt_filter_results; - - if (delta) { - remainText += delta; - } - - if ( - textmoderation && - textmoderation.length > 0 && - ServiceProvider.Azure - ) { - const contentFilterResults = - textmoderation[0]?.content_filter_results; - console.log( - `[${ServiceProvider.Azure}] [Text Moderation] flagged categories result:`, - contentFilterResults, - ); - } - } catch (e) { - console.error("[Request] parse error", text, msg); - } - }, - onclose() { - finish(); - }, - onerror(e) { - options.onError?.(e); - throw e; - }, - openWhenHidden: true, - }); - } else { const res = await fetch(chatPath, chatPayload); clearTimeout(requestTimeoutId); diff --git a/app/components/chat.module.scss b/app/components/chat.module.scss index 286b9a286..c62439b76 100644 --- a/app/components/chat.module.scss +++ b/app/components/chat.module.scss @@ -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; diff --git a/app/components/chat.tsx b/app/components/chat.tsx index 1ac9fa347..ecc27b020 100644 --- a/app/components/chat.tsx +++ b/app/components/chat.tsx @@ -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: { /> )} - setShowPluginSelector(true)} - text={Locale.Plugin.Name} - icon={} - /> + {showPlugins(currentProviderName, currentModel) && ( + { + if (pluginStore.getAll().length == 0) { + navigate(Path.Plugins); + } else { + setShowPluginSelector(true); + } + }} + text={Locale.Plugin.Name} + icon={} + /> + )} {showPluginSelector && ( ({ + 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()} - {showTyping && ( + {message?.tools?.length == 0 && showTyping && (
{Locale.Chat.Typing}
)} + {/*@ts-ignore*/} + {message?.tools?.length > 0 && ( +
+ {message?.tools?.map((tool) => ( +
+ {tool.isError === false ? ( + + ) : tool.isError === true ? ( + + ) : ( + + )} + {tool?.function?.name} +
+ ))} +
+ )}
(await import("./mask")).MaskPage, { loading: () => , }); +const PluginPage = dynamic(async () => (await import("./plugin")).PluginPage, { + loading: () => , +}); + const SearchChat = dynamic( async () => (await import("./search-chat")).SearchChatPage, { @@ -185,6 +189,7 @@ function Screen() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/app/components/markdown.tsx b/app/components/markdown.tsx index 3d72c4e33..eb5a25157 100644 --- a/app/components/markdown.tsx +++ b/app/components/markdown.tsx @@ -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(null); const [collapsed, setCollapsed] = useState(true); const [showToggle, setShowToggle] = useState(false); @@ -188,6 +183,7 @@ function CustomCode(props: { children: any }) { return ( <> + + { + props.updateMask((mask) => { + mask.enableArtifacts = e.currentTarget.checked; + }); + }} + > + + {!props.shouldSyncFromGlobal ? ( ([]); + 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(); + 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(""); + 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 ( + +
+
+
+
+ {Locale.Plugin.Page.Title} +
+
+ {Locale.Plugin.Page.SubTitle(plugins.length)} +
+
+ +
+ +
+ } + bordered + onClick={() => navigate(-1)} + /> +
+
+
+ +
+
+ onSearch(e.currentTarget.value)} + /> + + } + text={Locale.Plugin.Page.Create} + bordered + onClick={() => { + const createdPlugin = pluginStore.create(); + setEditingPluginId(createdPlugin.id); + }} + /> +
+ +
+ {plugins.length == 0 && ( +
+ {Locale.Plugin.Page.Find} + + } bordered /> + +
+ )} + {plugins.map((m) => ( +
+
+
+
+
+ {m.title}@{m.version} +
+
+ {Locale.Plugin.Item.Info( + FunctionToolService.add(m).length, + )} +
+
+
+
+ {m.builtin ? ( + } + text={Locale.Plugin.Item.View} + onClick={() => setEditingPluginId(m.id)} + /> + ) : ( + } + text={Locale.Plugin.Item.Edit} + onClick={() => setEditingPluginId(m.id)} + /> + )} + {!m.builtin && ( + } + text={Locale.Plugin.Item.Delete} + onClick={async () => { + if ( + await showConfirm(Locale.Plugin.Item.DeleteConfirm) + ) { + pluginStore.delete(m.id); + } + }} + /> + )} +
+
+ ))} +
+
+
+ + {editingPlugin && ( +
+ } + text={Locale.UI.Confirm} + key="export" + bordered + onClick={() => setEditingPluginId("")} + />, + ]} + > + + + + + {["bearer", "basic", "custom"].includes( + editingPlugin.authType as string, + ) && ( + + + + )} + {editingPlugin.authType == "custom" && ( + + { + pluginStore.updatePlugin(editingPlugin.id, (plugin) => { + plugin.authHeader = e.target.value; + }); + }} + > + + )} + {["bearer", "basic", "custom"].includes( + editingPlugin.authType as string, + ) && ( + + { + pluginStore.updatePlugin(editingPlugin.id, (plugin) => { + plugin.authToken = e.currentTarget.value; + }); + }} + > + + )} + {!getClientConfig()?.isApp && ( + + { + pluginStore.updatePlugin(editingPlugin.id, (plugin) => { + plugin.usingProxy = e.currentTarget.checked; + }); + }} + > + + )} + + + +
+ setLoadUrl(e.currentTarget.value)} + > + } + text={Locale.Plugin.EditModal.Load} + bordered + onClick={() => loadFromUrl(loadUrl)} + /> +
+
+ +
+                      
+                    
+
+ } + >
+ {editingPluginTool?.tools.map((tool, index) => ( + + ))} + + +
+ )} + + ); +} diff --git a/app/components/ui-lib.tsx b/app/components/ui-lib.tsx index 1bc00d9bc..eb7b70629 100644 --- a/app/components/ui-lib.tsx +++ b/app/components/ui-lib.tsx @@ -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; diff --git a/app/constant.ts b/app/constant.ts index 05f048dd8..050993eae 100644 --- a/app/constant.ts +++ b/app/constant.ts @@ -3,6 +3,7 @@ import path from "path"; export const OWNER = "ChatGPTNextWeb"; export const REPO = "ChatGPT-Next-Web"; export const REPO_URL = `https://github.com/${OWNER}/${REPO}`; +export const PLUGINS_REPO_URL = `https://github.com/${OWNER}/NextChat-Awesome-Plugins`; export const ISSUE_URL = `https://github.com/${OWNER}/${REPO}/issues`; export const UPDATE_URL = `${REPO_URL}#keep-updated`; export const RELEASE_URL = `${REPO_URL}/releases`; @@ -39,6 +40,7 @@ export enum Path { Settings = "/settings", NewChat = "/new-chat", Masks = "/masks", + Plugins = "/plugins", Auth = "/auth", Sd = "/sd", SdNew = "/sd-new", @@ -73,12 +75,9 @@ export enum FileName { Prompts = "prompts.json", } -export enum Plugin { - Artifacts = "artifacts", -} - export enum StoreKey { Chat = "chat-next-web-store", + Plugin = "chat-next-web-plugin", Access = "access-control", Config = "app-config", Mask = "mask-store", @@ -479,6 +478,7 @@ export const internalAllowedWebDavEndpoints = [ export const DEFAULT_GA_ID = "G-89WN60ZK2E"; export const PLUGINS = [ + { name: "Plugins", path: Path.Plugins }, { name: "Stable Diffusion", path: Path.Sd }, { name: "Search Chat", path: Path.SearchChat }, ]; diff --git a/app/global.d.ts b/app/global.d.ts index e2daf9ba9..8ee636bcd 100644 --- a/app/global.d.ts +++ b/app/global.d.ts @@ -26,5 +26,11 @@ declare interface Window { isPermissionGranted(): Promise; sendNotification(options: string | Options): void; }; + http: { + fetch( + url: string, + options?: Record, + ): Promise>; + }; }; } diff --git a/app/layout.tsx b/app/layout.tsx index afd7345b9..82bc6e727 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -62,17 +62,21 @@ export default function RootLayout({ content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> + rel="manifest" + href="/site.webmanifest" + crossOrigin="use-credentials" + > + {/*