diff --git a/README.md b/README.md index db62e083801..c8b158956b3 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 06e3e51603c..24aa5ec040f 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 24453dd9635..25decbf620e 100644 --- a/app/api/common.ts +++ b/app/api/common.ts @@ -32,10 +32,7 @@ export async function requestOpenai(req: NextRequest) { authHeaderName = "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 00000000000..731003aa1ea --- /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 d7fb023a226..cecc453baa2 100644 --- a/app/client/api.ts +++ b/app/client/api.ts @@ -5,7 +5,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"; @@ -56,6 +62,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 b079ba1ada2..fce675a1671 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); - } - - 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); - } + delta?: { + type: "text_delta" | "input_json_delta"; + text?: string; + partial_json?: string; + }; + index: number; + }; + chunkJson = JSON.parse(text); + + 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 7d257ccb2e6..c38d3317bd0 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")); - } - 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, - ); - - 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); + 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; } - - 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); } + return choices[0]?.delta?.content; }, - onclose() { - finish(); - }, - onerror(e) { - options.onError?.(e); - throw e; + // 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, + ); }, - 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 d4e262c16b4..b3b306d1d11 100644 --- a/app/client/platforms/openai.ts +++ b/app/client/platforms/openai.ts @@ -9,12 +9,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"; @@ -233,143 +240,82 @@ export class ChatGPTApi implements LLMApi { isDalle3 ? OpenaiPath.ImagePath : OpenaiPath.ChatPath, ); } - const chatPayload = { - method: "POST", - body: JSON.stringify(requestPayload), - signal: controller.signal, - headers: getHeaders(), - }; - - // make a fetch request - const requestTimeoutId = setTimeout( - () => 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")); - } - 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, - ); - - 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); + 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; } - - 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); } + return choices[0]?.delta?.content; }, - onclose() { - finish(); - }, - onerror(e) { - options.onError?.(e); - throw e; + // 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, + ); }, - openWhenHidden: true, - }); + options, + ); } else { + const chatPayload = { + method: "POST", + body: JSON.stringify(requestPayload), + signal: controller.signal, + headers: getHeaders(), + }; + + // make a fetch request + const requestTimeoutId = setTimeout( + () => controller.abort(), + isDalle3 ? REQUEST_TIMEOUT_MS * 2 : REQUEST_TIMEOUT_MS, // dalle3 using b64_json is slow. + ); + const res = await fetch(chatPath, chatPayload); clearTimeout(requestTimeoutId); diff --git a/app/components/chat.module.scss b/app/components/chat.module.scss index 3b5c143b9d9..7176399cc36 100644 --- a/app/components/chat.module.scss +++ b/app/components/chat.module.scss @@ -413,6 +413,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%; @@ -630,4 +645,4 @@ .chat-input-send { bottom: 30px; } -} \ No newline at end of file +} diff --git a/app/components/chat.tsx b/app/components/chat.tsx index 5be8b4d3d4e..dad1933ace9 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"; @@ -53,6 +54,7 @@ import { useAppConfig, DEFAULT_TOPIC, ModelType, + usePluginStore, } from "../store"; import { @@ -64,6 +66,7 @@ import { getMessageImages, isVisionModel, isDalle3, + showPlugins, } from "../utils"; import { uploadImage as uploadImageRemote } from "@/app/utils/chat"; @@ -95,7 +98,6 @@ import { REQUEST_TIMEOUT_MS, UNFINISHED_INPUT, ServiceProvider, - Plugin, } from "../constant"; import { Avatar } from "./emoji"; import { ContextPrompts, MaskAvatar, MaskConfig } from "./mask"; @@ -439,6 +441,7 @@ export function ChatActions(props: { const config = useAppConfig(); const navigate = useNavigate(); const chatStore = useChatStore(); + const pluginStore = usePluginStore(); // switch themes const theme = config.theme; @@ -723,30 +726,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); - } }} /> )} @@ -1573,11 +1578,31 @@ function _Chat() { )} - {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, { @@ -181,6 +185,7 @@ function Screen() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/app/components/markdown.tsx b/app/components/markdown.tsx index 500af71752f..4b9e608c9a9 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(() => { diff --git a/app/components/mask.tsx b/app/components/mask.tsx index 8c17a544a26..62503c37a85 100644 --- a/app/components/mask.tsx +++ b/app/components/mask.tsx @@ -167,6 +167,22 @@ export function MaskConfig(props: { > + + { + 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 fd78f9c4765..85693b98616 100644 --- a/app/components/ui-lib.tsx +++ b/app/components/ui-lib.tsx @@ -50,8 +50,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 e88d497ca94..90557c16c72 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", @@ -72,12 +74,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 31e2b6e8a84..8ee636bcd3c 100644 --- a/app/global.d.ts +++ b/app/global.d.ts @@ -21,10 +21,16 @@ declare interface Window { writeBinaryFile(path: string, data: Uint8Array): Promise; writeTextFile(path: string, data: string): Promise; }; - notification:{ + notification: { requestPermission(): Promise; isPermissionGranted(): Promise; sendNotification(options: string | Options): void; }; + http: { + fetch( + url: string, + options?: Record, + ): Promise>; + }; }; } diff --git a/app/locales/cn.ts b/app/locales/cn.ts index 9a3227d68a5..33e368f69f4 100644 --- a/app/locales/cn.ts +++ b/app/locales/cn.ts @@ -509,10 +509,6 @@ const cn = { Clear: "上下文已清除", Revert: "恢复上下文", }, - Plugin: { - Name: "插件", - Artifacts: "Artifacts", - }, Discovery: { Name: "发现", }, @@ -534,6 +530,46 @@ const cn = { View: "查看", }, }, + Plugin: { + Name: "插件", + Page: { + Title: "插件", + SubTitle: (count: number) => `${count} 个插件`, + Search: "搜索插件", + Create: "新建", + Find: "您可以在Github上找到优秀的插件:", + }, + Item: { + Info: (count: number) => `${count} 方法`, + View: "查看", + Edit: "编辑", + Delete: "删除", + DeleteConfirm: "确认删除?", + }, + Auth: { + None: "不需要授权", + Basic: "Basic", + Bearer: "Bearer", + Custom: "自定义", + CustomHeader: "自定义参数名称", + Token: "Token", + Proxy: "使用代理", + ProxyDescription: "使用代理解决 CORS 错误", + Location: "位置", + LocationHeader: "Header", + LocationQuery: "Query", + LocationBody: "Body", + }, + EditModal: { + Title: (readonly: boolean) => `编辑插件 ${readonly ? "(只读)" : ""}`, + Download: "下载", + Auth: "授权方式", + Content: "OpenAPI Schema", + Load: "从网页加载", + Method: "方法", + Error: "格式错误", + }, + }, Mask: { Name: "面具", Page: { @@ -568,6 +604,10 @@ const cn = { Title: "隐藏预设对话", SubTitle: "隐藏后预设对话不会出现在聊天界面", }, + Artifacts: { + Title: "启用Artifacts", + SubTitle: "启用之后可以直接渲染HTML页面", + }, Share: { Title: "分享此面具", SubTitle: "生成此面具的直达链接", diff --git a/app/locales/en.ts b/app/locales/en.ts index 77f3a700ae1..403b9b687e7 100644 --- a/app/locales/en.ts +++ b/app/locales/en.ts @@ -517,10 +517,6 @@ const en: LocaleType = { Clear: "Context Cleared", Revert: "Revert", }, - Plugin: { - Name: "Plugin", - Artifacts: "Artifacts", - }, Discovery: { Name: "Discovery", }, @@ -542,6 +538,47 @@ const en: LocaleType = { View: "View", }, }, + Plugin: { + Name: "Plugin", + Page: { + Title: "Plugins", + SubTitle: (count: number) => `${count} plugins`, + Search: "Search Plugin", + Create: "Create", + Find: "You can find awesome plugins on github: ", + }, + Item: { + Info: (count: number) => `${count} method`, + View: "View", + Edit: "Edit", + Delete: "Delete", + DeleteConfirm: "Confirm to delete?", + }, + Auth: { + None: "None", + Basic: "Basic", + Bearer: "Bearer", + Custom: "Custom", + CustomHeader: "Parameter Name", + Token: "Token", + Proxy: "Using Proxy", + ProxyDescription: "Using proxies to solve CORS error", + Location: "Location", + LocationHeader: "Header", + LocationQuery: "Query", + LocationBody: "Body", + }, + EditModal: { + Title: (readonly: boolean) => + `Edit Plugin ${readonly ? "(readonly)" : ""}`, + Download: "Download", + Auth: "Authentication Type", + Content: "OpenAPI Schema", + Load: "Load From URL", + Method: "Method", + Error: "OpenAPI Schema Error", + }, + }, Mask: { Name: "Mask", Page: { @@ -576,6 +613,10 @@ const en: LocaleType = { Title: "Hide Context Prompts", SubTitle: "Do not show in-context prompts in chat", }, + Artifacts: { + Title: "Enable Artifacts", + SubTitle: "Can render HTML page when enable artifacts.", + }, Share: { Title: "Share This Mask", SubTitle: "Generate a link to this mask", diff --git a/app/store/chat.ts b/app/store/chat.ts index de2a6307850..8b0cc39eb62 100644 --- a/app/store/chat.ts +++ b/app/store/chat.ts @@ -29,12 +29,25 @@ import { useAccessStore } from "./access"; import { isDalle3 } from "../utils"; import { indexedDBStorage } from "@/app/utils/indexedDB-storage"; +export type ChatMessageTool = { + id: string; + index?: number; + type?: string; + function?: { + name: string; + arguments?: string; + }; + content?: string; + isError?: boolean; +}; + export type ChatMessage = RequestMessage & { date: string; streaming?: boolean; isError?: boolean; id: string; model?: ModelType; + tools?: ChatMessageTool[]; }; export function createMessage(override: Partial): ChatMessage { @@ -390,8 +403,24 @@ export const useChatStore = createPersistStore( } ChatControllerPool.remove(session.id, botMessage.id); }, + onBeforeTool(tool: ChatMessageTool) { + (botMessage.tools = botMessage?.tools || []).push(tool); + get().updateCurrentSession((session) => { + session.messages = session.messages.concat(); + }); + }, + onAfterTool(tool: ChatMessageTool) { + botMessage?.tools?.forEach((t, i, tools) => { + if (tool.id == t.id) { + tools[i] = { ...tool }; + } + }); + get().updateCurrentSession((session) => { + session.messages = session.messages.concat(); + }); + }, onError(error) { - const isAborted = error.message.includes("aborted"); + const isAborted = error.message?.includes?.("aborted"); botMessage.content += "\n\n" + prettyObject({ diff --git a/app/store/index.ts b/app/store/index.ts index 0760f48ca26..122afd5d3cb 100644 --- a/app/store/index.ts +++ b/app/store/index.ts @@ -2,3 +2,4 @@ export * from "./chat"; export * from "./update"; export * from "./access"; export * from "./config"; +export * from "./plugin"; diff --git a/app/store/mask.ts b/app/store/mask.ts index a790f89f833..083121b65cc 100644 --- a/app/store/mask.ts +++ b/app/store/mask.ts @@ -2,7 +2,7 @@ import { BUILTIN_MASKS } from "../masks"; import { getLang, Lang } from "../locales"; import { DEFAULT_TOPIC, ChatMessage } from "./chat"; import { ModelConfig, useAppConfig } from "./config"; -import { StoreKey, Plugin } from "../constant"; +import { StoreKey } from "../constant"; import { nanoid } from "nanoid"; import { createPersistStore } from "../utils/store"; @@ -17,7 +17,8 @@ export type Mask = { modelConfig: ModelConfig; lang: Lang; builtin: boolean; - plugin?: Plugin[]; + plugin?: string[]; + enableArtifacts?: boolean; }; export const DEFAULT_MASK_STATE = { @@ -38,7 +39,7 @@ export const createEmptyMask = () => lang: getLang(), builtin: false, createdAt: Date.now(), - plugin: [Plugin.Artifacts], + plugin: [], }) as Mask; export const useMaskStore = createPersistStore( diff --git a/app/store/plugin.ts b/app/store/plugin.ts new file mode 100644 index 00000000000..74f0fbe17a4 --- /dev/null +++ b/app/store/plugin.ts @@ -0,0 +1,225 @@ +import OpenAPIClientAxios from "openapi-client-axios"; +import { getLang, Lang } from "../locales"; +import { StoreKey } from "../constant"; +import { nanoid } from "nanoid"; +import { createPersistStore } from "../utils/store"; +import yaml from "js-yaml"; +import { adapter } from "../utils"; + +export type Plugin = { + id: string; + createdAt: number; + title: string; + version: string; + content: string; + builtin: boolean; + authType?: string; + authLocation?: string; + authHeader?: string; + authToken?: string; + usingProxy?: boolean; +}; + +export type FunctionToolItem = { + type: string; + function: { + name: string; + description?: string; + parameters: Object; + }; +}; + +type FunctionToolServiceItem = { + api: OpenAPIClientAxios; + length: number; + tools: FunctionToolItem[]; + funcs: Record; +}; + +export const FunctionToolService = { + tools: {} as Record, + add(plugin: Plugin, replace = false) { + if (!replace && this.tools[plugin.id]) return this.tools[plugin.id]; + const headerName = ( + plugin?.authType == "custom" ? plugin?.authHeader : "Authorization" + ) as string; + const tokenValue = + plugin?.authType == "basic" + ? `Basic ${plugin?.authToken}` + : plugin?.authType == "bearer" + ? ` Bearer ${plugin?.authToken}` + : plugin?.authToken; + const authLocation = plugin?.authLocation || "header"; + const definition = yaml.load(plugin.content) as any; + const serverURL = definition?.servers?.[0]?.url; + const baseURL = !!plugin?.usingProxy ? "/api/proxy" : serverURL; + const headers: Record = { + "X-Base-URL": !!plugin?.usingProxy ? serverURL : undefined, + }; + if (authLocation == "header") { + headers[headerName] = tokenValue; + } + const api = new OpenAPIClientAxios({ + definition: yaml.load(plugin.content) as any, + axiosConfigDefaults: { + adapter: (window.__TAURI__ ? adapter : ["xhr"]) as any, + baseURL, + headers, + }, + }); + try { + api.initSync(); + } catch (e) {} + const operations = api.getOperations(); + return (this.tools[plugin.id] = { + api, + length: operations.length, + tools: operations.map((o) => { + // @ts-ignore + const parameters = o?.requestBody?.content["application/json"] + ?.schema || { + type: "object", + properties: {}, + }; + if (!parameters["required"]) { + parameters["required"] = []; + } + if (o.parameters instanceof Array) { + o.parameters.forEach((p) => { + // @ts-ignore + if (p?.in == "query" || p?.in == "path") { + // const name = `${p.in}__${p.name}` + // @ts-ignore + const name = p?.name; + parameters["properties"][name] = { + // @ts-ignore + type: p.schema.type, + // @ts-ignore + description: p.description, + }; + // @ts-ignore + if (p.required) { + parameters["required"].push(name); + } + } + }); + } + return { + type: "function", + function: { + name: o.operationId, + description: o.description || o.summary, + parameters: parameters, + }, + } as FunctionToolItem; + }), + funcs: operations.reduce((s, o) => { + // @ts-ignore + s[o.operationId] = function (args) { + const parameters: Record = {}; + if (o.parameters instanceof Array) { + o.parameters.forEach((p) => { + // @ts-ignore + parameters[p?.name] = args[p?.name]; + // @ts-ignore + delete args[p?.name]; + }); + } + if (authLocation == "query") { + parameters[headerName] = tokenValue; + } else if (authLocation == "body") { + args[headerName] = tokenValue; + } + // @ts-ignore + return api.client[o.operationId]( + parameters, + args, + api.axiosConfigDefaults, + ); + }; + return s; + }, {}), + }); + }, + get(id: string) { + return this.tools[id]; + }, +}; + +export const createEmptyPlugin = () => + ({ + id: nanoid(), + title: "", + version: "1.0.0", + content: "", + builtin: false, + createdAt: Date.now(), + }) as Plugin; + +export const DEFAULT_PLUGIN_STATE = { + plugins: {} as Record, +}; + +export const usePluginStore = createPersistStore( + { ...DEFAULT_PLUGIN_STATE }, + + (set, get) => ({ + create(plugin?: Partial) { + const plugins = get().plugins; + const id = nanoid(); + plugins[id] = { + ...createEmptyPlugin(), + ...plugin, + id, + builtin: false, + }; + + set(() => ({ plugins })); + get().markUpdate(); + + return plugins[id]; + }, + updatePlugin(id: string, updater: (plugin: Plugin) => void) { + const plugins = get().plugins; + const plugin = plugins[id]; + if (!plugin) return; + const updatePlugin = { ...plugin }; + updater(updatePlugin); + plugins[id] = updatePlugin; + FunctionToolService.add(updatePlugin, true); + set(() => ({ plugins })); + get().markUpdate(); + }, + delete(id: string) { + const plugins = get().plugins; + delete plugins[id]; + set(() => ({ plugins })); + get().markUpdate(); + }, + + getAsTools(ids: string[]) { + const plugins = get().plugins; + const selected = ids + .map((id) => plugins[id]) + .filter((i) => i) + .map((p) => FunctionToolService.add(p)); + return [ + // @ts-ignore + selected.reduce((s, i) => s.concat(i.tools), []), + selected.reduce((s, i) => Object.assign(s, i.funcs), {}), + ]; + }, + get(id?: string) { + return get().plugins[id ?? 1145141919810]; + }, + getAll() { + return Object.values(get().plugins).sort( + (a, b) => b.createdAt - a.createdAt, + ); + }, + }), + { + name: StoreKey.Plugin, + version: 1, + }, +); diff --git a/app/utils.ts b/app/utils.ts index 2a292290755..60041ba060f 100644 --- a/app/utils.ts +++ b/app/utils.ts @@ -2,6 +2,9 @@ import { useEffect, useState } from "react"; import { showToast } from "./components/ui-lib"; import Locale from "./locales"; import { RequestMessage } from "./client/api"; +import { ServiceProvider, REQUEST_TIMEOUT_MS } from "./constant"; +import isObject from "lodash-es/isObject"; +import { fetch as tauriFetch, Body, ResponseType } from "@tauri-apps/api/http"; export function trimTopic(topic: string) { // Fix an issue where double quotes still show in the Indonesian language @@ -270,3 +273,48 @@ export function isVisionModel(model: string) { export function isDalle3(model: string) { return "dall-e-3" === model; } + +export function showPlugins(provider: ServiceProvider, model: string) { + if ( + provider == ServiceProvider.OpenAI || + provider == ServiceProvider.Azure || + provider == ServiceProvider.Moonshot + ) { + return true; + } + if (provider == ServiceProvider.Anthropic && !model.includes("claude-2")) { + return true; + } + return false; +} + +export function fetch( + url: string, + options?: Record, +): Promise { + if (window.__TAURI__) { + const payload = options?.body || options?.data; + return tauriFetch(url, { + ...options, + body: + payload && + ({ + type: "Text", + payload, + } as any), + timeout: ((options?.timeout as number) || REQUEST_TIMEOUT_MS) / 1000, + responseType: + options?.responseType == "text" ? ResponseType.Text : ResponseType.JSON, + } as any); + } + return window.fetch(url, options); +} + +export function adapter(config: Record) { + const { baseURL, url, params, ...rest } = config; + const path = baseURL ? `${baseURL}${url}` : url; + const fetchUrl = params + ? `${path}?${new URLSearchParams(params as any).toString()}` + : path; + return fetch(fetchUrl as string, { ...rest, responseType: "text" }); +} diff --git a/app/utils/chat.ts b/app/utils/chat.ts index 6a296e5765d..7f3bb23c58e 100644 --- a/app/utils/chat.ts +++ b/app/utils/chat.ts @@ -1,5 +1,15 @@ -import { CACHE_URL_PREFIX, UPLOAD_URL } from "@/app/constant"; +import { + CACHE_URL_PREFIX, + UPLOAD_URL, + REQUEST_TIMEOUT_MS, +} from "@/app/constant"; import { RequestMessage } from "@/app/client/api"; +import Locale from "@/app/locales"; +import { + EventStreamContentType, + fetchEventSource, +} from "@fortaine/fetch-event-source"; +import { prettyObject } from "./format"; export function compressImage(file: Blob, maxSize: number): Promise { return new Promise((resolve, reject) => { @@ -142,3 +152,203 @@ export function removeImage(imageUrl: string) { credentials: "include", }); } + +export function stream( + chatPath: string, + requestPayload: any, + headers: any, + tools: any[], + funcs: Record, + controller: AbortController, + parseSSE: (text: string, runTools: any[]) => string | undefined, + processToolMessage: ( + requestPayload: any, + toolCallMessage: any, + toolCallResult: any[], + ) => void, + options: any, +) { + let responseText = ""; + let remainText = ""; + let finished = false; + let running = false; + let runTools: any[] = []; + + // 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")); + } + 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) { + if (!running && runTools.length > 0) { + const toolCallMessage = { + role: "assistant", + tool_calls: [...runTools], + }; + running = true; + runTools.splice(0, runTools.length); // empty runTools + return Promise.all( + toolCallMessage.tool_calls.map((tool) => { + options?.onBeforeTool?.(tool); + return Promise.resolve( + // @ts-ignore + funcs[tool.function.name]( + // @ts-ignore + tool?.function?.arguments + ? JSON.parse(tool?.function?.arguments) + : {}, + ), + ) + .then((res) => { + const content = JSON.stringify(res.data); + if (res.status >= 300) { + return Promise.reject(content); + } + return content; + }) + .then((content) => { + options?.onAfterTool?.({ + ...tool, + content, + isError: false, + }); + return content; + }) + .catch((e) => { + options?.onAfterTool?.({ ...tool, isError: true }); + return e.toString(); + }) + .then((content) => ({ + role: "tool", + content, + tool_call_id: tool.id, + })); + }), + ).then((toolCallResult) => { + processToolMessage(requestPayload, toolCallMessage, toolCallResult); + setTimeout(() => { + // call again + console.debug("[ChatAPI] restart"); + running = false; + chatApi(chatPath, headers, requestPayload, tools); // call fetchEventSource + }, 60); + }); + return; + } + if (running) { + return; + } + console.debug("[ChatAPI] end"); + finished = true; + options.onFinish(responseText + remainText); + } + }; + + controller.signal.onabort = finish; + + function chatApi( + chatPath: string, + headers: any, + requestPayload: any, + tools: any, + ) { + const chatPayload = { + method: "POST", + body: JSON.stringify({ + ...requestPayload, + tools: tools && tools.length ? tools : undefined, + }), + signal: controller.signal, + headers, + }; + const requestTimeoutId = setTimeout( + () => controller.abort(), + REQUEST_TIMEOUT_MS, + ); + fetchEventSource(chatPath, { + ...chatPayload, + async onopen(res) { + clearTimeout(requestTimeoutId); + const contentType = res.headers.get("content-type"); + console.log("[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(); + 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 chunk = parseSSE(msg.data, runTools); + if (chunk) { + remainText += chunk; + } + } catch (e) { + console.error("[Request] parse error", text, msg, e); + } + }, + onclose() { + finish(); + }, + onerror(e) { + options?.onError?.(e); + throw e; + }, + openWhenHidden: true, + }); + } + console.debug("[ChatAPI] start"); + chatApi(chatPath, headers, requestPayload, tools); // call fetchEventSource +} diff --git a/next.config.mjs b/next.config.mjs index 27c60dd2997..26dadca4c9e 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -65,10 +65,10 @@ if (mode !== "export") { nextConfig.rewrites = async () => { const ret = [ // adjust for previous version directly using "/api/proxy/" as proxy base route - { - source: "/api/proxy/v1/:path*", - destination: "https://api.openai.com/v1/:path*", - }, + // { + // source: "/api/proxy/v1/:path*", + // destination: "https://api.openai.com/v1/:path*", + // }, { // https://{resource_name}.openai.azure.com/openai/deployments/{deploy_name}/chat/completions source: "/api/proxy/azure/:resource_name/deployments/:deploy_name/:path*", diff --git a/package.json b/package.json index 1c6d78c208e..ca5fcc0f5df 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@svgr/webpack": "^6.5.1", "@vercel/analytics": "^0.1.11", "@vercel/speed-insights": "^1.0.2", + "axios": "^1.7.5", "emoji-picker-react": "^4.9.2", "fuse.js": "^7.0.0", "heic2any": "^0.0.4", @@ -34,6 +35,7 @@ "nanoid": "^5.0.3", "next": "^14.1.1", "node-fetch": "^3.3.1", + "openapi-client-axios": "^7.5.5", "react": "^18.2.0", "react-dom": "^18.2.0", "react-markdown": "^8.0.7", @@ -49,7 +51,9 @@ "zustand": "^4.3.8" }, "devDependencies": { + "@tauri-apps/api": "^1.6.0", "@tauri-apps/cli": "1.5.11", + "@types/js-yaml": "4.0.9", "@types/lodash-es": "^4.17.12", "@types/node": "^20.11.30", "@types/react": "^18.2.70", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e0892590223..387584491ba 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -17,7 +17,7 @@ tauri-build = { version = "1.5.1", features = [] } [dependencies] serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } -tauri = { version = "1.5.4", features = [ +tauri = { version = "1.5.4", features = [ "http-all", "notification-all", "fs-all", "clipboard-all", diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 120ab9b5aee..78807a2c5fe 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -50,6 +50,11 @@ }, "notification": { "all": true + }, + "http": { + "all": true, + "request": true, + "scope": ["https://*", "http://*"] } }, "bundle": { diff --git a/yarn.lock b/yarn.lock index 1c7f834e876..4979e4d995e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1553,6 +1553,11 @@ dependencies: tslib "^2.4.0" +"@tauri-apps/api@^1.6.0": + version "1.6.0" + resolved "https://registry.npmjs.org/@tauri-apps/api/-/api-1.6.0.tgz#745b7e4e26782c3b2ad9510d558fa5bb2cf29186" + integrity sha512-rqI++FWClU5I2UBp4HXFvl+sBWkdigBkxnpJDQUWttNyG7IZP4FwQGhTNL5EOw0vI8i6eSAJ5frLqO7n7jbJdg== + "@tauri-apps/cli-darwin-arm64@1.5.11": version "1.5.11" resolved "https://registry.yarnpkg.com/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-1.5.11.tgz#a831f98f685148e46e8050dbdddbf4bcdda9ddc6" @@ -1684,6 +1689,11 @@ "@types/react" "*" hoist-non-react-statics "^3.3.0" +"@types/js-yaml@4.0.9": + version "4.0.9" + resolved "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz#cd82382c4f902fed9691a2ed79ec68c5898af4c2" + integrity sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg== + "@types/json-schema@*", "@types/json-schema@^7.0.8": version "7.0.12" resolved "https://registry.npmmirror.com/@types/json-schema/-/json-schema-7.0.12.tgz#d70faba7039d5fca54c83c7dbab41051d2b6f6cb" @@ -2138,6 +2148,11 @@ astral-regex@^2.0.0: resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31" integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ== +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== + available-typed-arrays@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz#92f95616501069d07d10edb2fc37d3e1c65123b7" @@ -2148,6 +2163,15 @@ axe-core@^4.6.2: resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.6.3.tgz#fc0db6fdb65cc7a80ccf85286d91d64ababa3ece" integrity sha512-/BQzOX780JhsxDnPpH4ZiyrJAzcd8AfzFPkv+89veFSr1rcMjuq2JDCwypKaPeB6ljHp9KjXhPpjgCvQlWYuqg== +axios@^1.7.5: + version "1.7.5" + resolved "https://registry.npmjs.org/axios/-/axios-1.7.5.tgz#21eed340eb5daf47d29b6e002424b3e88c8c54b1" + integrity sha512-fZu86yCo+svH3uqJ/yTdQ0QHpQu5oL+/QE+QPSv6BZSkDAoky9vytxp7u5qk83OJFS3kEBcesWni9WTZAv3tSw== + dependencies: + follow-redirects "^1.15.6" + form-data "^4.0.0" + proxy-from-env "^1.1.0" + axobject-query@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-3.1.1.tgz#3b6e5c6d4e43ca7ba51c5babf99d22a9c68485e1" @@ -2189,6 +2213,11 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== +bath-es5@^3.0.3: + version "3.0.3" + resolved "https://registry.npmjs.org/bath-es5/-/bath-es5-3.0.3.tgz#4e2808e8b33b4a5e3328ec1e9032f370f042193d" + integrity sha512-PdCioDToH3t84lP40kUFCKWCOCH389Dl1kbC8FGoqOwamxsmqxxnJSXdkTOsPoNHXjem4+sJ+bbNoQm5zeCqxg== + binary-extensions@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" @@ -2392,6 +2421,13 @@ colorette@^2.0.19: resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.19.tgz#cdf044f47ad41a0f4b56b3a0d5b4e6e1a2d5a798" integrity sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ== +combined-stream@^1.0.8: + version "1.0.8" + resolved "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + comma-separated-tokens@^2.0.0: version "2.0.3" resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz#4e89c9458acb61bc8fef19f4529973b2392839ee" @@ -2925,11 +2961,21 @@ delaunator@5: dependencies: robust-predicates "^3.0.0" +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== + dequal@^2.0.0: version "2.0.3" resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== +dereference-json-schema@^0.2.1: + version "0.2.1" + resolved "https://registry.npmjs.org/dereference-json-schema/-/dereference-json-schema-0.2.1.tgz#fcad3c98e0116f7124b0989d39d947fa318cae09" + integrity sha512-uzJsrg225owJyRQ8FNTPHIuBOdSzIZlHhss9u6W8mp7jJldHqGuLv9cULagP/E26QVJDnjtG8U7Dw139mM1ydA== + diff@^5.0.0: version "5.1.0" resolved "https://registry.yarnpkg.com/diff/-/diff-5.1.0.tgz#bc52d298c5ea8df9194800224445ed43ffc87e40" @@ -3548,6 +3594,11 @@ flatted@^3.1.0: resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.7.tgz#609f39207cb614b89d0765b477cb2d437fbf9787" integrity sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ== +follow-redirects@^1.15.6: + version "1.15.6" + resolved "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b" + integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA== + for-each@^0.3.3: version "0.3.3" resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e" @@ -3555,6 +3606,15 @@ for-each@^0.3.3: dependencies: is-callable "^1.1.3" +form-data@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" + integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + format@^0.2.0: version "0.2.2" resolved "https://registry.yarnpkg.com/format/-/format-0.2.2.tgz#d6170107e9efdc4ed30c9dc39016df942b5cb58b" @@ -4942,7 +5002,7 @@ mime-db@1.52.0: resolved "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== -mime-types@^2.1.27: +mime-types@^2.1.12, mime-types@^2.1.27: version "2.1.35" resolved "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== @@ -5166,6 +5226,20 @@ onetime@^6.0.0: dependencies: mimic-fn "^4.0.0" +openapi-client-axios@^7.5.5: + version "7.5.5" + resolved "https://registry.npmjs.org/openapi-client-axios/-/openapi-client-axios-7.5.5.tgz#4cb2bb7484ff9d1c92d9ff509db235cc35d64f38" + integrity sha512-pgCo1z+rxtYmGQXzB+N5DiXvRurTP6JqV+Ao/wtaGUMIIIM+znh3nTztps+FZS8mZgWnDHpdEzL9bWtZuWuvoA== + dependencies: + bath-es5 "^3.0.3" + dereference-json-schema "^0.2.1" + openapi-types "^12.1.3" + +openapi-types@^12.1.3: + version "12.1.3" + resolved "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz#471995eb26c4b97b7bd356aacf7b91b73e777dd3" + integrity sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw== + optionator@^0.9.3: version "0.9.3" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.3.tgz#007397d44ed1872fdc6ed31360190f81814e2c64" @@ -5308,6 +5382,11 @@ property-information@^6.0.0: resolved "https://registry.yarnpkg.com/property-information/-/property-information-6.2.0.tgz#b74f522c31c097b5149e3c3cb8d7f3defd986a1d" integrity sha512-kma4U7AFCTwpqq5twzC1YVIDXSqg6qQK6JN0smOw8fgRy1OkMi0CYSzFmsy6dnqSenamAtj0CyXMUJ1Mf6oROg== +proxy-from-env@^1.1.0: + version "1.1.0" + resolved "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" + integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== + punycode@^2.1.0: version "2.3.0" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.0.tgz#f67fa67c94da8f4d0cfff981aee4118064199b8f"