diff --git a/.gitignore b/.gitignore index a24c6e047..2ff556f64 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,5 @@ dev *.key *.key.pub + +masks.json diff --git a/README.md b/README.md index 472102cdc..da1adb6a5 100644 --- a/README.md +++ b/README.md @@ -88,10 +88,15 @@ For enterprise inquiries, please contact: **business@nextchat.dev** - [x] Share as image, share to ShareGPT [#1741](https://github.com/Yidadaa/ChatGPT-Next-Web/pull/1741) - [x] 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. -- [ ] Plugins: support network search, calculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) +- [x] Artifacts: Easily preview, copy and share generated content/webpages through a separate window [#5092](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/pull/5092) +- [x] Plugins: support artifacts, network search, calculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) + - [x] artifacts + - [ ] network search, calculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) +- [ ] local knowledge base ## What's New +- 🚀 v2.14.0 Now supports Artifacts & SD - 🚀 v2.10.1 support Google Gemini Pro model. - 🚀 v2.9.11 you can use azure endpoint now. - 🚀 v2.8 now we have a client that runs across all platforms! @@ -120,15 +125,21 @@ For enterprise inquiries, please contact: **business@nextchat.dev** - [x] 分享为图片,分享到 ShareGPT 链接 [#1741](https://github.com/Yidadaa/ChatGPT-Next-Web/pull/1741) - [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) -- [ ] 插件机制,支持联网搜索、计算器、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) +- [x] Artifacts: 通过独立窗口,轻松预览、复制和分享生成的内容/可交互网页 [#5092](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/pull/5092) +- [x] 插件机制,支持 artifacts,联网搜索、计算器、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) + - [x] artifacts + - [ ] 支持联网搜索、计算器、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) + - [ ] 本地知识库 ## 最新动态 +- 🚀 v2.14.0 现在支持 Artifacts & SD 了。 +- 🚀 v2.10.1 现在支持 Gemini Pro 模型。 +- 🚀 v2.9.11 现在可以使用自定义 Azure 服务了。 +- 🚀 v2.8 发布了横跨 Linux/Windows/MacOS 的体积极小的客户端。 +- 🚀 v2.7 现在可以将会话分享为图片了,也可以分享到 ShareGPT 的在线链接。 - 🚀 v2.0 已经发布,现在你可以使用面具功能快速创建预制对话了! 了解更多: [ChatGPT 提示词高阶技能:零次、一次和少样本提示](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/138)。 - 💡 想要更方便地随时随地使用本项目?可以试下这款桌面插件:https://github.com/mushan0x0/AI0x0.com -- 🚀 v2.7 现在可以将会话分享为图片了,也可以分享到 ShareGPT 的在线链接。 -- 🚀 v2.8 发布了横跨 Linux/Windows/MacOS 的体积极小的客户端。 -- 🚀 v2.9.11 现在可以使用自定义 Azure 服务了。 ## Get Started @@ -271,6 +282,18 @@ Alibaba Cloud Api Key. Alibaba Cloud Api Url. +### `IFLYTEK_URL` (Optional) + +iflytek Api Url. + +### `IFLYTEK_API_KEY` (Optional) + +iflytek Api Key. + +### `IFLYTEK_API_SECRET` (Optional) + +iflytek Api Secret. + ### `HIDE_USER_API_KEY` (optional) > Default: Empty @@ -326,6 +349,14 @@ You can use this option if you want to increase the number of webdav service add Customize the default template used to initialize the User Input Preprocessing configuration item in Settings. +### `STABILITY_API_KEY` (optional) + +Stability API key. + +### `STABILITY_URL` (optional) + +Customize Stability API url. + ## Requirements NodeJS >= 18, Docker >= 20 diff --git a/README_CN.md b/README_CN.md index e42288bb5..beed396c5 100644 --- a/README_CN.md +++ b/README_CN.md @@ -172,6 +172,20 @@ ByteDance Api Url. 阿里云(千问)Api Url. +### `IFLYTEK_URL` (可选) + +讯飞星火Api Url. + +### `IFLYTEK_API_KEY` (可选) + +讯飞星火Api Key. + +### `IFLYTEK_API_SECRET` (可选) + +讯飞星火Api Secret. + + + ### `HIDE_USER_API_KEY` (可选) 如果你不想让用户自行填入 API Key,将此环境变量设置为 1 即可。 @@ -218,6 +232,15 @@ ByteDance Api Url. 自定义默认的 template,用于初始化『设置』中的『用户输入预处理』配置项 +### `STABILITY_API_KEY` (optional) + +Stability API密钥 + +### `STABILITY_URL` (optional) + +自定义的Stability API请求地址 + + ## 开发 点击下方按钮,开始二次开发: diff --git a/app/api/[provider]/[...path]/route.ts b/app/api/[provider]/[...path]/route.ts new file mode 100644 index 000000000..06e3e5160 --- /dev/null +++ b/app/api/[provider]/[...path]/route.ts @@ -0,0 +1,66 @@ +import { ApiPath } from "@/app/constant"; +import { NextRequest, NextResponse } from "next/server"; +import { handle as openaiHandler } from "../../openai"; +import { handle as azureHandler } from "../../azure"; +import { handle as googleHandler } from "../../google"; +import { handle as anthropicHandler } from "../../anthropic"; +import { handle as baiduHandler } from "../../baidu"; +import { handle as bytedanceHandler } from "../../bytedance"; +import { handle as alibabaHandler } from "../../alibaba"; +import { handle as moonshotHandler } from "../../moonshot"; +import { handle as stabilityHandler } from "../../stability"; +import { handle as iflytekHandler } from "../../iflytek"; +async function handle( + req: NextRequest, + { params }: { params: { provider: string; path: string[] } }, +) { + const apiPath = `/api/${params.provider}`; + console.log(`[${params.provider} Route] params `, params); + switch (apiPath) { + case ApiPath.Azure: + return azureHandler(req, { params }); + case ApiPath.Google: + return googleHandler(req, { params }); + case ApiPath.Anthropic: + return anthropicHandler(req, { params }); + case ApiPath.Baidu: + return baiduHandler(req, { params }); + case ApiPath.ByteDance: + return bytedanceHandler(req, { params }); + case ApiPath.Alibaba: + return alibabaHandler(req, { params }); + // case ApiPath.Tencent: using "/api/tencent" + case ApiPath.Moonshot: + return moonshotHandler(req, { params }); + case ApiPath.Stability: + return stabilityHandler(req, { params }); + case ApiPath.Iflytek: + return iflytekHandler(req, { params }); + default: + return openaiHandler(req, { params }); + } +} + +export const GET = handle; +export const POST = handle; + +export const runtime = "edge"; +export const preferredRegion = [ + "arn1", + "bom1", + "cdg1", + "cle1", + "cpt1", + "dub1", + "fra1", + "gru1", + "hnd1", + "iad1", + "icn1", + "kix1", + "lhr1", + "pdx1", + "sfo1", + "sin1", + "syd1", +]; diff --git a/app/api/alibaba/[...path]/route.ts b/app/api/alibaba.ts similarity index 91% rename from app/api/alibaba/[...path]/route.ts rename to app/api/alibaba.ts index c97ce5934..675d9f301 100644 --- a/app/api/alibaba/[...path]/route.ts +++ b/app/api/alibaba.ts @@ -14,7 +14,7 @@ import type { RequestPayload } from "@/app/client/platforms/openai"; const serverConfig = getServerSideConfig(); -async function handle( +export async function handle( req: NextRequest, { params }: { params: { path: string[] } }, ) { @@ -40,30 +40,6 @@ async function handle( } } -export const GET = handle; -export const POST = handle; - -export const runtime = "edge"; -export const preferredRegion = [ - "arn1", - "bom1", - "cdg1", - "cle1", - "cpt1", - "dub1", - "fra1", - "gru1", - "hnd1", - "iad1", - "icn1", - "kix1", - "lhr1", - "pdx1", - "sfo1", - "sin1", - "syd1", -]; - async function request(req: NextRequest) { const controller = new AbortController(); diff --git a/app/api/anthropic/[...path]/route.ts b/app/api/anthropic.ts similarity index 92% rename from app/api/anthropic/[...path]/route.ts rename to app/api/anthropic.ts index 20f8d52e0..3d49f4c88 100644 --- a/app/api/anthropic/[...path]/route.ts +++ b/app/api/anthropic.ts @@ -9,13 +9,13 @@ import { } from "@/app/constant"; import { prettyObject } from "@/app/utils/format"; import { NextRequest, NextResponse } from "next/server"; -import { auth } from "../../auth"; +import { auth } from "./auth"; import { isModelAvailableInServer } from "@/app/utils/model"; import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare"; const ALLOWD_PATH = new Set([Anthropic.ChatPath, Anthropic.ChatPath1]); -async function handle( +export async function handle( req: NextRequest, { params }: { params: { path: string[] } }, ) { @@ -56,30 +56,6 @@ async function handle( } } -export const GET = handle; -export const POST = handle; - -export const runtime = "edge"; -export const preferredRegion = [ - "arn1", - "bom1", - "cdg1", - "cle1", - "cpt1", - "dub1", - "fra1", - "gru1", - "hnd1", - "iad1", - "icn1", - "kix1", - "lhr1", - "pdx1", - "sfo1", - "sin1", - "syd1", -]; - const serverConfig = getServerSideConfig(); async function request(req: NextRequest) { diff --git a/app/api/artifacts/route.ts b/app/api/artifacts/route.ts new file mode 100644 index 000000000..4707e795f --- /dev/null +++ b/app/api/artifacts/route.ts @@ -0,0 +1,73 @@ +import md5 from "spark-md5"; +import { NextRequest, NextResponse } from "next/server"; +import { getServerSideConfig } from "@/app/config/server"; + +async function handle(req: NextRequest, res: NextResponse) { + const serverConfig = getServerSideConfig(); + const storeUrl = () => + `https://api.cloudflare.com/client/v4/accounts/${serverConfig.cloudflareAccountId}/storage/kv/namespaces/${serverConfig.cloudflareKVNamespaceId}`; + const storeHeaders = () => ({ + Authorization: `Bearer ${serverConfig.cloudflareKVApiKey}`, + }); + if (req.method === "POST") { + const clonedBody = await req.text(); + const hashedCode = md5.hash(clonedBody).trim(); + const body: { + key: string; + value: string; + expiration_ttl?: number; + } = { + key: hashedCode, + value: clonedBody, + }; + try { + const ttl = parseInt(serverConfig.cloudflareKVTTL as string); + if (ttl > 60) { + body["expiration_ttl"] = ttl; + } + } catch (e) { + console.error(e); + } + const res = await fetch(`${storeUrl()}/bulk`, { + headers: { + ...storeHeaders(), + "Content-Type": "application/json", + }, + method: "PUT", + body: JSON.stringify([body]), + }); + const result = await res.json(); + console.log("save data", result); + if (result?.success) { + return NextResponse.json( + { code: 0, id: hashedCode, result }, + { status: res.status }, + ); + } + return NextResponse.json( + { error: true, msg: "Save data error" }, + { status: 400 }, + ); + } + if (req.method === "GET") { + const id = req?.nextUrl?.searchParams?.get("id"); + const res = await fetch(`${storeUrl()}/values/${id}`, { + headers: storeHeaders(), + method: "GET", + }); + return new Response(res.body, { + status: res.status, + statusText: res.statusText, + headers: res.headers, + }); + } + return NextResponse.json( + { error: true, msg: "Invalid request" }, + { status: 400 }, + ); +} + +export const POST = handle; +export const GET = handle; + +export const runtime = "edge"; diff --git a/app/api/auth.ts b/app/api/auth.ts index e3b88702e..95965ceec 100644 --- a/app/api/auth.ts +++ b/app/api/auth.ts @@ -67,6 +67,9 @@ export function auth(req: NextRequest, modelProvider: ModelProvider) { let systemApiKey: string | undefined; switch (modelProvider) { + case ModelProvider.Stability: + systemApiKey = serverConfig.stabilityApiKey; + break; case ModelProvider.GeminiPro: systemApiKey = serverConfig.googleApiKey; break; @@ -82,6 +85,13 @@ export function auth(req: NextRequest, modelProvider: ModelProvider) { case ModelProvider.Qwen: systemApiKey = serverConfig.alibabaApiKey; break; + case ModelProvider.Moonshot: + systemApiKey = serverConfig.moonshotApiKey; + break; + case ModelProvider.Iflytek: + systemApiKey = + serverConfig.iflytekApiKey + ":" + serverConfig.iflytekApiSecret; + break; case ModelProvider.GPT: default: if (req.nextUrl.pathname.includes("azure/deployments")) { diff --git a/app/api/azure/[...path]/route.ts b/app/api/azure.ts similarity index 66% rename from app/api/azure/[...path]/route.ts rename to app/api/azure.ts index 4a17de0c8..e2cb0c7e6 100644 --- a/app/api/azure/[...path]/route.ts +++ b/app/api/azure.ts @@ -2,10 +2,10 @@ import { getServerSideConfig } from "@/app/config/server"; import { ModelProvider } from "@/app/constant"; import { prettyObject } from "@/app/utils/format"; import { NextRequest, NextResponse } from "next/server"; -import { auth } from "../../auth"; -import { requestOpenai } from "../../common"; +import { auth } from "./auth"; +import { requestOpenai } from "./common"; -async function handle( +export async function handle( req: NextRequest, { params }: { params: { path: string[] } }, ) { @@ -31,27 +31,3 @@ async function handle( return NextResponse.json(prettyObject(e)); } } - -export const GET = handle; -export const POST = handle; - -export const runtime = "edge"; -export const preferredRegion = [ - "arn1", - "bom1", - "cdg1", - "cle1", - "cpt1", - "dub1", - "fra1", - "gru1", - "hnd1", - "iad1", - "icn1", - "kix1", - "lhr1", - "pdx1", - "sfo1", - "sin1", - "syd1", -]; diff --git a/app/api/baidu/[...path]/route.ts b/app/api/baidu.ts similarity index 91% rename from app/api/baidu/[...path]/route.ts rename to app/api/baidu.ts index 94c9963c7..f4315d186 100644 --- a/app/api/baidu/[...path]/route.ts +++ b/app/api/baidu.ts @@ -14,7 +14,7 @@ import { getAccessToken } from "@/app/utils/baidu"; const serverConfig = getServerSideConfig(); -async function handle( +export async function handle( req: NextRequest, { params }: { params: { path: string[] } }, ) { @@ -52,30 +52,6 @@ async function handle( } } -export const GET = handle; -export const POST = handle; - -export const runtime = "edge"; -export const preferredRegion = [ - "arn1", - "bom1", - "cdg1", - "cle1", - "cpt1", - "dub1", - "fra1", - "gru1", - "hnd1", - "iad1", - "icn1", - "kix1", - "lhr1", - "pdx1", - "sfo1", - "sin1", - "syd1", -]; - async function request(req: NextRequest) { const controller = new AbortController(); diff --git a/app/api/bytedance/[...path]/route.ts b/app/api/bytedance.ts similarity index 90% rename from app/api/bytedance/[...path]/route.ts rename to app/api/bytedance.ts index 336c837f0..cb65b1061 100644 --- a/app/api/bytedance/[...path]/route.ts +++ b/app/api/bytedance.ts @@ -12,7 +12,7 @@ import { isModelAvailableInServer } from "@/app/utils/model"; const serverConfig = getServerSideConfig(); -async function handle( +export async function handle( req: NextRequest, { params }: { params: { path: string[] } }, ) { @@ -38,30 +38,6 @@ async function handle( } } -export const GET = handle; -export const POST = handle; - -export const runtime = "edge"; -export const preferredRegion = [ - "arn1", - "bom1", - "cdg1", - "cle1", - "cpt1", - "dub1", - "fra1", - "gru1", - "hnd1", - "iad1", - "icn1", - "kix1", - "lhr1", - "pdx1", - "sfo1", - "sin1", - "syd1", -]; - async function request(req: NextRequest) { const controller = new AbortController(); diff --git a/app/api/google/[...path]/route.ts b/app/api/google.ts similarity index 96% rename from app/api/google/[...path]/route.ts rename to app/api/google.ts index 83a7ce794..98fe469bf 100644 --- a/app/api/google/[...path]/route.ts +++ b/app/api/google.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { auth } from "../../auth"; +import { auth } from "./auth"; import { getServerSideConfig } from "@/app/config/server"; import { ApiPath, @@ -11,9 +11,9 @@ import { prettyObject } from "@/app/utils/format"; const serverConfig = getServerSideConfig(); -async function handle( +export async function handle( req: NextRequest, - { params }: { params: { path: string[] } }, + { params }: { params: { provider: string; path: string[] } }, ) { console.log("[Google Route] params ", params); diff --git a/app/api/iflytek.ts b/app/api/iflytek.ts new file mode 100644 index 000000000..eabdd9f4c --- /dev/null +++ b/app/api/iflytek.ts @@ -0,0 +1,131 @@ +import { getServerSideConfig } from "@/app/config/server"; +import { + Iflytek, + IFLYTEK_BASE_URL, + ApiPath, + ModelProvider, + ServiceProvider, +} from "@/app/constant"; +import { prettyObject } from "@/app/utils/format"; +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/app/api/auth"; +import { isModelAvailableInServer } from "@/app/utils/model"; +import type { RequestPayload } from "@/app/client/platforms/openai"; +// iflytek + +const serverConfig = getServerSideConfig(); + +export async function handle( + req: NextRequest, + { params }: { params: { path: string[] } }, +) { + console.log("[Iflytek Route] params ", params); + + if (req.method === "OPTIONS") { + return NextResponse.json({ body: "OK" }, { status: 200 }); + } + + const authResult = auth(req, ModelProvider.Iflytek); + if (authResult.error) { + return NextResponse.json(authResult, { + status: 401, + }); + } + + try { + const response = await request(req); + return response; + } catch (e) { + console.error("[Iflytek] ", e); + return NextResponse.json(prettyObject(e)); + } +} + +async function request(req: NextRequest) { + const controller = new AbortController(); + + // iflytek use base url or just remove the path + let path = `${req.nextUrl.pathname}`.replaceAll(ApiPath.Iflytek, ""); + + let baseUrl = serverConfig.iflytekUrl || IFLYTEK_BASE_URL; + + if (!baseUrl.startsWith("http")) { + baseUrl = `https://${baseUrl}`; + } + + if (baseUrl.endsWith("/")) { + baseUrl = baseUrl.slice(0, -1); + } + + console.log("[Proxy] ", path); + console.log("[Base Url]", baseUrl); + + const timeoutId = setTimeout( + () => { + controller.abort(); + }, + 10 * 60 * 1000, + ); + + const fetchUrl = `${baseUrl}${path}`; + const fetchOptions: RequestInit = { + headers: { + "Content-Type": "application/json", + Authorization: req.headers.get("Authorization") ?? "", + }, + method: req.method, + body: req.body, + redirect: "manual", + // @ts-ignore + duplex: "half", + signal: controller.signal, + }; + + // try to refuse some request to some models + if (serverConfig.customModels && req.body) { + try { + const clonedBody = await req.text(); + fetchOptions.body = clonedBody; + + const jsonBody = JSON.parse(clonedBody) as { model?: string }; + + // not undefined and is false + if ( + isModelAvailableInServer( + serverConfig.customModels, + jsonBody?.model as string, + ServiceProvider.Iflytek as string, + ) + ) { + return NextResponse.json( + { + error: true, + message: `you are not allowed to use ${jsonBody?.model} model`, + }, + { + status: 403, + }, + ); + } + } catch (e) { + console.error(`[Iflytek] filter`, e); + } + } + 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"); + + return new Response(res.body, { + status: res.status, + statusText: res.statusText, + headers: newHeaders, + }); + } finally { + clearTimeout(timeoutId); + } +} diff --git a/app/api/moonshot.ts b/app/api/moonshot.ts new file mode 100644 index 000000000..247dd6183 --- /dev/null +++ b/app/api/moonshot.ts @@ -0,0 +1,130 @@ +import { getServerSideConfig } from "@/app/config/server"; +import { + Moonshot, + MOONSHOT_BASE_URL, + ApiPath, + ModelProvider, + ServiceProvider, +} from "@/app/constant"; +import { prettyObject } from "@/app/utils/format"; +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/app/api/auth"; +import { isModelAvailableInServer } from "@/app/utils/model"; +import type { RequestPayload } from "@/app/client/platforms/openai"; + +const serverConfig = getServerSideConfig(); + +export async function handle( + req: NextRequest, + { params }: { params: { path: string[] } }, +) { + console.log("[Moonshot Route] params ", params); + + if (req.method === "OPTIONS") { + return NextResponse.json({ body: "OK" }, { status: 200 }); + } + + const authResult = auth(req, ModelProvider.Moonshot); + if (authResult.error) { + return NextResponse.json(authResult, { + status: 401, + }); + } + + try { + const response = await request(req); + return response; + } catch (e) { + console.error("[Moonshot] ", e); + return NextResponse.json(prettyObject(e)); + } +} + +async function request(req: NextRequest) { + const controller = new AbortController(); + + // alibaba use base url or just remove the path + let path = `${req.nextUrl.pathname}`.replaceAll(ApiPath.Moonshot, ""); + + let baseUrl = serverConfig.moonshotUrl || MOONSHOT_BASE_URL; + + if (!baseUrl.startsWith("http")) { + baseUrl = `https://${baseUrl}`; + } + + if (baseUrl.endsWith("/")) { + baseUrl = baseUrl.slice(0, -1); + } + + console.log("[Proxy] ", path); + console.log("[Base Url]", baseUrl); + + const timeoutId = setTimeout( + () => { + controller.abort(); + }, + 10 * 60 * 1000, + ); + + const fetchUrl = `${baseUrl}${path}`; + const fetchOptions: RequestInit = { + headers: { + "Content-Type": "application/json", + Authorization: req.headers.get("Authorization") ?? "", + }, + method: req.method, + body: req.body, + redirect: "manual", + // @ts-ignore + duplex: "half", + signal: controller.signal, + }; + + // #1815 try to refuse some request to some models + if (serverConfig.customModels && req.body) { + try { + const clonedBody = await req.text(); + fetchOptions.body = clonedBody; + + const jsonBody = JSON.parse(clonedBody) as { model?: string }; + + // not undefined and is false + if ( + isModelAvailableInServer( + serverConfig.customModels, + jsonBody?.model as string, + ServiceProvider.Moonshot as string, + ) + ) { + return NextResponse.json( + { + error: true, + message: `you are not allowed to use ${jsonBody?.model} model`, + }, + { + status: 403, + }, + ); + } + } catch (e) { + console.error(`[Moonshot] filter`, e); + } + } + 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"); + + return new Response(res.body, { + status: res.status, + statusText: res.statusText, + headers: newHeaders, + }); + } finally { + clearTimeout(timeoutId); + } +} diff --git a/app/api/openai/[...path]/route.ts b/app/api/openai.ts similarity index 80% rename from app/api/openai/[...path]/route.ts rename to app/api/openai.ts index 77059c151..0059ff8b4 100644 --- a/app/api/openai/[...path]/route.ts +++ b/app/api/openai.ts @@ -3,8 +3,8 @@ import { getServerSideConfig } from "@/app/config/server"; import { ModelProvider, OpenaiPath } from "@/app/constant"; import { prettyObject } from "@/app/utils/format"; import { NextRequest, NextResponse } from "next/server"; -import { auth } from "../../auth"; -import { requestOpenai } from "../../common"; +import { auth } from "./auth"; +import { requestOpenai } from "./common"; const ALLOWD_PATH = new Set(Object.values(OpenaiPath)); @@ -13,14 +13,14 @@ function getModels(remoteModelRes: OpenAIListModelResponse) { if (config.disableGPT4) { remoteModelRes.data = remoteModelRes.data.filter( - (m) => !m.id.startsWith("gpt-4"), + (m) => !m.id.startsWith("gpt-4") || m.id.startsWith("gpt-4o-mini"), ); } return remoteModelRes; } -async function handle( +export async function handle( req: NextRequest, { params }: { params: { path: string[] } }, ) { @@ -70,27 +70,3 @@ async function handle( return NextResponse.json(prettyObject(e)); } } - -export const GET = handle; -export const POST = handle; - -export const runtime = "edge"; -export const preferredRegion = [ - "arn1", - "bom1", - "cdg1", - "cle1", - "cpt1", - "dub1", - "fra1", - "gru1", - "hnd1", - "iad1", - "icn1", - "kix1", - "lhr1", - "pdx1", - "sfo1", - "sin1", - "syd1", -]; diff --git a/app/api/stability.ts b/app/api/stability.ts new file mode 100644 index 000000000..2646ace85 --- /dev/null +++ b/app/api/stability.ts @@ -0,0 +1,99 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSideConfig } from "@/app/config/server"; +import { ModelProvider, STABILITY_BASE_URL } from "@/app/constant"; +import { auth } from "@/app/api/auth"; + +export async function handle( + req: NextRequest, + { params }: { params: { path: string[] } }, +) { + console.log("[Stability] params ", params); + + if (req.method === "OPTIONS") { + return NextResponse.json({ body: "OK" }, { status: 200 }); + } + + const controller = new AbortController(); + + const serverConfig = getServerSideConfig(); + + let baseUrl = serverConfig.stabilityUrl || STABILITY_BASE_URL; + + if (!baseUrl.startsWith("http")) { + baseUrl = `https://${baseUrl}`; + } + + if (baseUrl.endsWith("/")) { + baseUrl = baseUrl.slice(0, -1); + } + + let path = `${req.nextUrl.pathname}`.replaceAll("/api/stability/", ""); + + console.log("[Stability Proxy] ", path); + console.log("[Stability Base Url]", baseUrl); + + const timeoutId = setTimeout( + () => { + controller.abort(); + }, + 10 * 60 * 1000, + ); + + const authResult = auth(req, ModelProvider.Stability); + + if (authResult.error) { + return NextResponse.json(authResult, { + status: 401, + }); + } + + const bearToken = req.headers.get("Authorization") ?? ""; + const token = bearToken.trim().replaceAll("Bearer ", "").trim(); + + const key = token ? token : serverConfig.stabilityApiKey; + + if (!key) { + return NextResponse.json( + { + error: true, + message: `missing STABILITY_API_KEY in server env vars`, + }, + { + status: 401, + }, + ); + } + + const fetchUrl = `${baseUrl}/${path}`; + console.log("[Stability Url] ", fetchUrl); + const fetchOptions: RequestInit = { + headers: { + "Content-Type": req.headers.get("Content-Type") || "multipart/form-data", + Accept: req.headers.get("Accept") || "application/json", + Authorization: `Bearer ${key}`, + }, + 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, + }; + + 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"); + return new Response(res.body, { + status: res.status, + statusText: res.statusText, + headers: newHeaders, + }); + } finally { + clearTimeout(timeoutId); + } +} diff --git a/app/api/tencent/route.ts b/app/api/tencent/route.ts new file mode 100644 index 000000000..885909e7a --- /dev/null +++ b/app/api/tencent/route.ts @@ -0,0 +1,124 @@ +import { getServerSideConfig } from "@/app/config/server"; +import { + TENCENT_BASE_URL, + ApiPath, + ModelProvider, + ServiceProvider, + Tencent, +} from "@/app/constant"; +import { prettyObject } from "@/app/utils/format"; +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/app/api/auth"; +import { isModelAvailableInServer } from "@/app/utils/model"; +import { getHeader } from "@/app/utils/tencent"; + +const serverConfig = getServerSideConfig(); + +async function handle( + req: NextRequest, + { params }: { params: { path: string[] } }, +) { + console.log("[Tencent Route] params ", params); + + if (req.method === "OPTIONS") { + return NextResponse.json({ body: "OK" }, { status: 200 }); + } + + const authResult = auth(req, ModelProvider.Hunyuan); + if (authResult.error) { + return NextResponse.json(authResult, { + status: 401, + }); + } + + try { + const response = await request(req); + return response; + } catch (e) { + console.error("[Tencent] ", e); + return NextResponse.json(prettyObject(e)); + } +} + +export const GET = handle; +export const POST = handle; + +export const runtime = "edge"; +export const preferredRegion = [ + "arn1", + "bom1", + "cdg1", + "cle1", + "cpt1", + "dub1", + "fra1", + "gru1", + "hnd1", + "iad1", + "icn1", + "kix1", + "lhr1", + "pdx1", + "sfo1", + "sin1", + "syd1", +]; + +async function request(req: NextRequest) { + const controller = new AbortController(); + + let baseUrl = serverConfig.tencentUrl || TENCENT_BASE_URL; + + if (!baseUrl.startsWith("http")) { + baseUrl = `https://${baseUrl}`; + } + + if (baseUrl.endsWith("/")) { + baseUrl = baseUrl.slice(0, -1); + } + + console.log("[Base Url]", baseUrl); + + const timeoutId = setTimeout( + () => { + controller.abort(); + }, + 10 * 60 * 1000, + ); + + const fetchUrl = baseUrl; + + const body = await req.text(); + const headers = await getHeader( + body, + serverConfig.tencentSecretId as string, + serverConfig.tencentSecretKey as string, + ); + const fetchOptions: RequestInit = { + headers, + method: req.method, + body, + redirect: "manual", + // @ts-ignore + duplex: "half", + signal: controller.signal, + }; + + 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"); + + return new Response(res.body, { + status: res.status, + statusText: res.statusText, + headers: newHeaders, + }); + } finally { + clearTimeout(timeoutId); + } +} diff --git a/app/api/webdav/[...path]/route.ts b/app/api/webdav/[...path]/route.ts index 01286fc1b..9f96cbfcf 100644 --- a/app/api/webdav/[...path]/route.ts +++ b/app/api/webdav/[...path]/route.ts @@ -29,6 +29,7 @@ async function handle( const requestUrl = new URL(req.url); let endpoint = requestUrl.searchParams.get("endpoint"); + let proxy_method = requestUrl.searchParams.get("proxy_method") || req.method; // Validate the endpoint to prevent potential SSRF attacks if ( @@ -37,9 +38,13 @@ async function handle( const normalizedAllowedEndpoint = normalizeUrl(allowedEndpoint); const normalizedEndpoint = normalizeUrl(endpoint as string); - return normalizedEndpoint && + return ( + normalizedEndpoint && normalizedEndpoint.hostname === normalizedAllowedEndpoint?.hostname && - normalizedEndpoint.pathname.startsWith(normalizedAllowedEndpoint.pathname); + normalizedEndpoint.pathname.startsWith( + normalizedAllowedEndpoint.pathname, + ) + ); }) ) { return NextResponse.json( @@ -61,7 +66,11 @@ async function handle( const targetPath = `${endpoint}${endpointPath}`; // only allow MKCOL, GET, PUT - if (req.method !== "MKCOL" && req.method !== "GET" && req.method !== "PUT") { + if ( + proxy_method !== "MKCOL" && + proxy_method !== "GET" && + proxy_method !== "PUT" + ) { return NextResponse.json( { error: true, @@ -74,7 +83,7 @@ async function handle( } // for MKCOL request, only allow request ${folder} - if (req.method === "MKCOL" && !targetPath.endsWith(folder)) { + if (proxy_method === "MKCOL" && !targetPath.endsWith(folder)) { return NextResponse.json( { error: true, @@ -87,7 +96,7 @@ async function handle( } // for GET request, only allow request ending with fileName - if (req.method === "GET" && !targetPath.endsWith(fileName)) { + if (proxy_method === "GET" && !targetPath.endsWith(fileName)) { return NextResponse.json( { error: true, @@ -100,7 +109,7 @@ async function handle( } // for PUT request, only allow request ending with fileName - if (req.method === "PUT" && !targetPath.endsWith(fileName)) { + if (proxy_method === "PUT" && !targetPath.endsWith(fileName)) { return NextResponse.json( { error: true, @@ -114,7 +123,7 @@ async function handle( const targetUrl = targetPath; - const method = req.method; + const method = proxy_method || req.method; const shouldNotHaveBody = ["get", "head"].includes( method?.toLowerCase() ?? "", ); @@ -139,7 +148,7 @@ async function handle( "[Any Proxy]", targetUrl, { - method: req.method, + method: method, }, { status: fetchResult?.status, diff --git a/app/client/api.ts b/app/client/api.ts index c0c71480c..d7fb023a2 100644 --- a/app/client/api.ts +++ b/app/client/api.ts @@ -6,12 +6,15 @@ import { ServiceProvider, } from "../constant"; import { ChatMessage, ModelType, useAccessStore, useChatStore } from "../store"; -import { ChatGPTApi } from "./platforms/openai"; +import { ChatGPTApi, DalleRequestPayload } from "./platforms/openai"; import { GeminiProApi } from "./platforms/google"; import { ClaudeApi } from "./platforms/anthropic"; import { ErnieApi } from "./platforms/baidu"; import { DoubaoApi } from "./platforms/bytedance"; import { QwenApi } from "./platforms/alibaba"; +import { HunyuanApi } from "./platforms/tencent"; +import { MoonshotApi } from "./platforms/moonshot"; +import { SparkApi } from "./platforms/iflytek"; export const ROLES = ["system", "user", "assistant"] as const; export type MessageRole = (typeof ROLES)[number]; @@ -40,6 +43,9 @@ export interface LLMConfig { stream?: boolean; presence_penalty?: number; frequency_penalty?: number; + size?: DalleRequestPayload["size"]; + quality?: DalleRequestPayload["quality"]; + style?: DalleRequestPayload["style"]; } export interface ChatOptions { @@ -62,12 +68,14 @@ export interface LLMModel { displayName?: string; available: boolean; provider: LLMModelProvider; + sorted: number; } export interface LLMModelProvider { id: string; providerName: string; providerType: string; + sorted: number; } export abstract class LLMApi { @@ -117,6 +125,15 @@ export class ClientApi { case ModelProvider.Qwen: this.llm = new QwenApi(); break; + case ModelProvider.Hunyuan: + this.llm = new HunyuanApi(); + break; + case ModelProvider.Moonshot: + this.llm = new MoonshotApi(); + break; + case ModelProvider.Iflytek: + this.llm = new SparkApi(); + break; default: this.llm = new ChatGPTApi(); } @@ -168,6 +185,19 @@ export class ClientApi { } } +export function getBearerToken( + apiKey: string, + noBearer: boolean = false, +): string { + return validString(apiKey) + ? `${noBearer ? "" : "Bearer "}${apiKey.trim()}` + : ""; +} + +export function validString(x: string): boolean { + return x?.length > 0; +} + export function getHeaders() { const accessStore = useAccessStore.getState(); const chatStore = useChatStore.getState(); @@ -186,6 +216,8 @@ export function getHeaders() { const isBaidu = modelConfig.providerName == ServiceProvider.Baidu; const isByteDance = modelConfig.providerName === ServiceProvider.ByteDance; const isAlibaba = modelConfig.providerName === ServiceProvider.Alibaba; + const isMoonshot = modelConfig.providerName === ServiceProvider.Moonshot; + const isIflytek = modelConfig.providerName === ServiceProvider.Iflytek; const isEnabledAccessControl = accessStore.enabledAccessControl(); const apiKey = isGoogle ? accessStore.googleApiKey @@ -197,6 +229,12 @@ export function getHeaders() { ? accessStore.bytedanceApiKey : isAlibaba ? accessStore.alibabaApiKey + : isMoonshot + ? accessStore.moonshotApiKey + : isIflytek + ? accessStore.iflytekApiKey && accessStore.iflytekApiSecret + ? accessStore.iflytekApiKey + ":" + accessStore.iflytekApiSecret + : "" : accessStore.openaiApiKey; return { isGoogle, @@ -205,6 +243,8 @@ export function getHeaders() { isBaidu, isByteDance, isAlibaba, + isMoonshot, + isIflytek, apiKey, isEnabledAccessControl, }; @@ -214,15 +254,6 @@ export function getHeaders() { return isAzure ? "api-key" : isAnthropic ? "x-api-key" : "Authorization"; } - function getBearerToken(apiKey: string, noBearer: boolean = false): string { - return validString(apiKey) - ? `${noBearer ? "" : "Bearer "}${apiKey.trim()}` - : ""; - } - - function validString(x: string): boolean { - return x?.length > 0; - } const { isGoogle, isAzure, @@ -263,6 +294,12 @@ export function getClientApi(provider: ServiceProvider): ClientApi { return new ClientApi(ModelProvider.Doubao); case ServiceProvider.Alibaba: return new ClientApi(ModelProvider.Qwen); + case ServiceProvider.Tencent: + return new ClientApi(ModelProvider.Hunyuan); + case ServiceProvider.Moonshot: + return new ClientApi(ModelProvider.Moonshot); + case ServiceProvider.Iflytek: + return new ClientApi(ModelProvider.Iflytek); default: return new ClientApi(ModelProvider.GPT); } diff --git a/app/client/platforms/baidu.ts b/app/client/platforms/baidu.ts index 188b78bf9..3be147f49 100644 --- a/app/client/platforms/baidu.ts +++ b/app/client/platforms/baidu.ts @@ -77,16 +77,24 @@ export class ErnieApi implements LLMApi { async chat(options: ChatOptions) { const messages = options.messages.map((v) => ({ - role: v.role, + // "error_code": 336006, "error_msg": "the role of message with even index in the messages must be user or function", + role: v.role === "system" ? "user" : v.role, content: getMessageTextContent(v), })); // "error_code": 336006, "error_msg": "the length of messages must be an odd number", if (messages.length % 2 === 0) { - messages.unshift({ - role: "user", - content: " ", - }); + if (messages.at(0)?.role === "user") { + messages.splice(1, 0, { + role: "assistant", + content: " ", + }); + } else { + messages.unshift({ + role: "user", + content: " ", + }); + } } const modelConfig = { diff --git a/app/client/platforms/google.ts b/app/client/platforms/google.ts index 1f55beebc..12d884635 100644 --- a/app/client/platforms/google.ts +++ b/app/client/platforms/google.ts @@ -25,11 +25,9 @@ export class GeminiProApi implements LLMApi { baseUrl = accessStore.googleUrl; } + const isApp = !!getClientConfig()?.isApp; if (baseUrl.length === 0) { - const isApp = !!getClientConfig()?.isApp; - baseUrl = isApp - ? DEFAULT_API_HOST + `/api/proxy/google?key=${accessStore.googleApiKey}` - : ApiPath.Google; + baseUrl = isApp ? DEFAULT_API_HOST + `/api/proxy/google` : ApiPath.Google; } if (baseUrl.endsWith("/")) { baseUrl = baseUrl.slice(0, baseUrl.length - 1); @@ -43,6 +41,10 @@ export class GeminiProApi implements LLMApi { let chatPath = [baseUrl, path].join("/"); chatPath += chatPath.includes("?") ? "&alt=sse" : "?alt=sse"; + // if chatPath.startsWith('http') then add key in query string + if (chatPath.startsWith("http") && accessStore.googleApiKey) { + chatPath += `&key=${accessStore.googleApiKey}`; + } return chatPath; } extractMessage(res: any) { diff --git a/app/client/platforms/iflytek.ts b/app/client/platforms/iflytek.ts new file mode 100644 index 000000000..73cea5ba0 --- /dev/null +++ b/app/client/platforms/iflytek.ts @@ -0,0 +1,240 @@ +"use client"; +import { + ApiPath, + DEFAULT_API_HOST, + Iflytek, + REQUEST_TIMEOUT_MS, +} from "@/app/constant"; +import { useAccessStore, useAppConfig, useChatStore } from "@/app/store"; + +import { ChatOptions, getHeaders, LLMApi, LLMModel } from "../api"; +import Locale from "../../locales"; +import { + EventStreamContentType, + fetchEventSource, +} from "@fortaine/fetch-event-source"; +import { prettyObject } from "@/app/utils/format"; +import { getClientConfig } from "@/app/config/client"; +import { getMessageTextContent } from "@/app/utils"; + +import { OpenAIListModelResponse, RequestPayload } from "./openai"; + +export class SparkApi implements LLMApi { + private disableListModels = true; + + path(path: string): string { + const accessStore = useAccessStore.getState(); + + let baseUrl = ""; + + if (accessStore.useCustomConfig) { + baseUrl = accessStore.iflytekUrl; + } + + if (baseUrl.length === 0) { + const isApp = !!getClientConfig()?.isApp; + const apiPath = ApiPath.Iflytek; + baseUrl = isApp ? DEFAULT_API_HOST + "/proxy" + apiPath : apiPath; + } + + if (baseUrl.endsWith("/")) { + baseUrl = baseUrl.slice(0, baseUrl.length - 1); + } + if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.Iflytek)) { + baseUrl = "https://" + baseUrl; + } + + console.log("[Proxy Endpoint] ", baseUrl, path); + + return [baseUrl, path].join("/"); + } + + extractMessage(res: any) { + return res.choices?.at(0)?.message?.content ?? ""; + } + + async chat(options: ChatOptions) { + const messages: ChatOptions["messages"] = []; + for (const v of options.messages) { + const content = getMessageTextContent(v); + messages.push({ role: v.role, content }); + } + + const modelConfig = { + ...useAppConfig.getState().modelConfig, + ...useChatStore.getState().currentSession().mask.modelConfig, + ...{ + model: options.config.model, + providerName: options.config.providerName, + }, + }; + + const requestPayload: RequestPayload = { + messages, + stream: options.config.stream, + model: modelConfig.model, + temperature: modelConfig.temperature, + presence_penalty: modelConfig.presence_penalty, + frequency_penalty: modelConfig.frequency_penalty, + top_p: modelConfig.top_p, + // max_tokens: Math.max(modelConfig.max_tokens, 1024), + // Please do not ask me why not send max_tokens, no reason, this param is just shit, I dont want to explain anymore. + }; + + console.log("[Request] Spark payload: ", requestPayload); + + const shouldStream = !!options.config.stream; + const controller = new AbortController(); + options.onController?.(controller); + + try { + const chatPath = this.path(Iflytek.ChatPath); + const chatPayload = { + method: "POST", + body: JSON.stringify(requestPayload), + signal: controller.signal, + headers: getHeaders(), + }; + + // Make a fetch request + const requestTimeoutId = setTimeout( + () => controller.abort(), + REQUEST_TIMEOUT_MS, + ); + + if (shouldStream) { + let responseText = ""; + let remainText = ""; + let finished = false; + + // Animate response text to make it look smooth + function animateResponseText() { + if (finished || controller.signal.aborted) { + responseText += remainText; + console.log("[Response Animation] finished"); + 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 animation + 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("[Spark] request response content type: ", contentType); + + if (contentType?.startsWith("text/plain")) { + responseText = await res.clone().text(); + return finish(); + } + + // Handle different error scenarios + if ( + !res.ok || + !res.headers + .get("content-type") + ?.startsWith(EventStreamContentType) || + res.status !== 200 + ) { + let extraInfo = await res.clone().text(); + try { + const resJson = await res.clone().json(); + extraInfo = prettyObject(resJson); + } catch {} + + if (res.status === 401) { + extraInfo = Locale.Error.Unauthorized; + } + + options.onError?.( + new Error( + `Request failed with status ${res.status}: ${extraInfo}`, + ), + ); + 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; + + if (delta) { + remainText += delta; + } + } catch (e) { + console.error("[Request] parse error", text); + options.onError?.(new Error(`Failed to parse response: ${text}`)); + } + }, + onclose() { + finish(); + }, + onerror(e) { + options.onError?.(e); + throw e; + }, + openWhenHidden: true, + }); + } else { + const res = await fetch(chatPath, chatPayload); + clearTimeout(requestTimeoutId); + + if (!res.ok) { + const errorText = await res.text(); + options.onError?.( + new Error(`Request failed with status ${res.status}: ${errorText}`), + ); + return; + } + + const resJson = await res.json(); + const message = this.extractMessage(resJson); + options.onFinish(message); + } + } catch (e) { + console.log("[Request] failed to make a chat request", e); + options.onError?.(e as Error); + } + } + + async usage() { + return { + used: 0, + total: 0, + }; + } + + async models(): Promise { + return []; + } +} diff --git a/app/client/platforms/moonshot.ts b/app/client/platforms/moonshot.ts new file mode 100644 index 000000000..7d257ccb2 --- /dev/null +++ b/app/client/platforms/moonshot.ts @@ -0,0 +1,251 @@ +"use client"; +// azure and openai, using same models. so using same LLMApi. +import { + ApiPath, + DEFAULT_API_HOST, + DEFAULT_MODELS, + Moonshot, + REQUEST_TIMEOUT_MS, + ServiceProvider, +} from "@/app/constant"; +import { useAccessStore, useAppConfig, useChatStore } from "@/app/store"; +import { collectModelsWithDefaultModel } from "@/app/utils/model"; +import { preProcessImageContent } from "@/app/utils/chat"; +import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare"; + +import { + ChatOptions, + getHeaders, + LLMApi, + LLMModel, + LLMUsage, + MultimodalContent, +} from "../api"; +import Locale from "../../locales"; +import { + EventStreamContentType, + fetchEventSource, +} from "@fortaine/fetch-event-source"; +import { prettyObject } from "@/app/utils/format"; +import { getClientConfig } from "@/app/config/client"; +import { getMessageTextContent } from "@/app/utils"; + +import { OpenAIListModelResponse, RequestPayload } from "./openai"; + +export class MoonshotApi implements LLMApi { + private disableListModels = true; + + path(path: string): string { + const accessStore = useAccessStore.getState(); + + let baseUrl = ""; + + if (accessStore.useCustomConfig) { + baseUrl = accessStore.moonshotUrl; + } + + if (baseUrl.length === 0) { + const isApp = !!getClientConfig()?.isApp; + const apiPath = ApiPath.Moonshot; + baseUrl = isApp ? DEFAULT_API_HOST + "/proxy" + apiPath : apiPath; + } + + if (baseUrl.endsWith("/")) { + baseUrl = baseUrl.slice(0, baseUrl.length - 1); + } + if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.Moonshot)) { + baseUrl = "https://" + baseUrl; + } + + console.log("[Proxy Endpoint] ", baseUrl, path); + + return [baseUrl, path].join("/"); + } + + extractMessage(res: any) { + return res.choices?.at(0)?.message?.content ?? ""; + } + + async chat(options: ChatOptions) { + const messages: ChatOptions["messages"] = []; + for (const v of options.messages) { + const content = getMessageTextContent(v); + messages.push({ role: v.role, content }); + } + + const modelConfig = { + ...useAppConfig.getState().modelConfig, + ...useChatStore.getState().currentSession().mask.modelConfig, + ...{ + model: options.config.model, + providerName: options.config.providerName, + }, + }; + + const requestPayload: RequestPayload = { + messages, + stream: options.config.stream, + model: modelConfig.model, + temperature: modelConfig.temperature, + presence_penalty: modelConfig.presence_penalty, + frequency_penalty: modelConfig.frequency_penalty, + top_p: modelConfig.top_p, + // max_tokens: Math.max(modelConfig.max_tokens, 1024), + // Please do not ask me why not send max_tokens, no reason, this param is just shit, I dont want to explain anymore. + }; + + console.log("[Request] openai payload: ", requestPayload); + + const shouldStream = !!options.config.stream; + const controller = new AbortController(); + options.onController?.(controller); + + try { + const chatPath = this.path(Moonshot.ChatPath); + const chatPayload = { + method: "POST", + body: JSON.stringify(requestPayload), + signal: controller.signal, + headers: getHeaders(), + }; + + // make a fetch request + const requestTimeoutId = setTimeout( + () => controller.abort(), + REQUEST_TIMEOUT_MS, + ); + + 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); + } + + 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, + }); + } else { + const res = await fetch(chatPath, chatPayload); + clearTimeout(requestTimeoutId); + + const resJson = await res.json(); + const message = this.extractMessage(resJson); + options.onFinish(message); + } + } catch (e) { + console.log("[Request] failed to make a chat request", e); + options.onError?.(e as Error); + } + } + async usage() { + return { + used: 0, + total: 0, + }; + } + + async models(): Promise { + return []; + } +} diff --git a/app/client/platforms/openai.ts b/app/client/platforms/openai.ts index 680125fe6..d4e262c16 100644 --- a/app/client/platforms/openai.ts +++ b/app/client/platforms/openai.ts @@ -11,8 +11,13 @@ import { } from "@/app/constant"; import { useAccessStore, useAppConfig, useChatStore } from "@/app/store"; import { collectModelsWithDefaultModel } from "@/app/utils/model"; -import { preProcessImageContent } from "@/app/utils/chat"; +import { + preProcessImageContent, + uploadImage, + base64Image2Blob, +} from "@/app/utils/chat"; import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare"; +import { DalleSize, DalleQuality, DalleStyle } from "@/app/typing"; import { ChatOptions, @@ -33,6 +38,7 @@ import { getMessageTextContent, getMessageImages, isVisionModel, + isDalle3 as _isDalle3, } from "@/app/utils"; export interface OpenAIListModelResponse { @@ -58,6 +64,16 @@ export interface RequestPayload { max_tokens?: number; } +export interface DalleRequestPayload { + model: string; + prompt: string; + response_format: "url" | "b64_json"; + n: number; + size: DalleSize; + quality: DalleQuality; + style: DalleStyle; +} + export class ChatGPTApi implements LLMApi { private disableListModels = true; @@ -100,20 +116,31 @@ export class ChatGPTApi implements LLMApi { return cloudflareAIGatewayUrl([baseUrl, path].join("/")); } - extractMessage(res: any) { - return res.choices?.at(0)?.message?.content ?? ""; + async extractMessage(res: any) { + if (res.error) { + return "```\n" + JSON.stringify(res, null, 4) + "\n```"; + } + // dalle3 model return url, using url create image message + if (res.data) { + let url = res.data?.at(0)?.url ?? ""; + const b64_json = res.data?.at(0)?.b64_json ?? ""; + if (!url && b64_json) { + // uploadImage + url = await uploadImage(base64Image2Blob(b64_json, "image/png")); + } + return [ + { + type: "image_url", + image_url: { + url, + }, + }, + ]; + } + return res.choices?.at(0)?.message?.content ?? res; } async chat(options: ChatOptions) { - const visionModel = isVisionModel(options.config.model); - const messages: ChatOptions["messages"] = []; - for (const v of options.messages) { - const content = visionModel - ? await preProcessImageContent(v.content) - : getMessageTextContent(v); - messages.push({ role: v.role, content }); - } - const modelConfig = { ...useAppConfig.getState().modelConfig, ...useChatStore.getState().currentSession().mask.modelConfig, @@ -123,26 +150,54 @@ export class ChatGPTApi implements LLMApi { }, }; - const requestPayload: RequestPayload = { - messages, - stream: options.config.stream, - model: modelConfig.model, - temperature: modelConfig.temperature, - presence_penalty: modelConfig.presence_penalty, - frequency_penalty: modelConfig.frequency_penalty, - top_p: modelConfig.top_p, - // max_tokens: Math.max(modelConfig.max_tokens, 1024), - // Please do not ask me why not send max_tokens, no reason, this param is just shit, I dont want to explain anymore. - }; + let requestPayload: RequestPayload | DalleRequestPayload; - // add max_tokens to vision model - if (visionModel && modelConfig.model.includes("preview")) { - requestPayload["max_tokens"] = Math.max(modelConfig.max_tokens, 4000); + const isDalle3 = _isDalle3(options.config.model); + if (isDalle3) { + const prompt = getMessageTextContent( + options.messages.slice(-1)?.pop() as any, + ); + requestPayload = { + model: options.config.model, + prompt, + // URLs are only valid for 60 minutes after the image has been generated. + response_format: "b64_json", // using b64_json, and save image in CacheStorage + n: 1, + size: options.config?.size ?? "1024x1024", + quality: options.config?.quality ?? "standard", + style: options.config?.style ?? "vivid", + }; + } else { + const visionModel = isVisionModel(options.config.model); + const messages: ChatOptions["messages"] = []; + for (const v of options.messages) { + const content = visionModel + ? await preProcessImageContent(v.content) + : getMessageTextContent(v); + messages.push({ role: v.role, content }); + } + + requestPayload = { + messages, + stream: options.config.stream, + model: modelConfig.model, + temperature: modelConfig.temperature, + presence_penalty: modelConfig.presence_penalty, + frequency_penalty: modelConfig.frequency_penalty, + top_p: modelConfig.top_p, + // max_tokens: Math.max(modelConfig.max_tokens, 1024), + // Please do not ask me why not send max_tokens, no reason, this param is just shit, I dont want to explain anymore. + }; + + // add max_tokens to vision model + if (visionModel && modelConfig.model.includes("preview")) { + requestPayload["max_tokens"] = Math.max(modelConfig.max_tokens, 4000); + } } console.log("[Request] openai payload: ", requestPayload); - const shouldStream = !!options.config.stream; + const shouldStream = !isDalle3 && !!options.config.stream; const controller = new AbortController(); options.onController?.(controller); @@ -168,13 +223,15 @@ export class ChatGPTApi implements LLMApi { model?.provider?.providerName === ServiceProvider.Azure, ); chatPath = this.path( - Azure.ChatPath( + (isDalle3 ? Azure.ImagePath : Azure.ChatPath)( (model?.displayName ?? model?.name) as string, useCustomConfig ? useAccessStore.getState().azureApiVersion : "", ), ); } else { - chatPath = this.path(OpenaiPath.ChatPath); + chatPath = this.path( + isDalle3 ? OpenaiPath.ImagePath : OpenaiPath.ChatPath, + ); } const chatPayload = { method: "POST", @@ -186,7 +243,7 @@ export class ChatGPTApi implements LLMApi { // make a fetch request const requestTimeoutId = setTimeout( () => controller.abort(), - REQUEST_TIMEOUT_MS, + isDalle3 ? REQUEST_TIMEOUT_MS * 2 : REQUEST_TIMEOUT_MS, // dalle3 using b64_json is slow. ); if (shouldStream) { @@ -317,7 +374,7 @@ export class ChatGPTApi implements LLMApi { clearTimeout(requestTimeoutId); const resJson = await res.json(); - const message = this.extractMessage(resJson); + const message = await this.extractMessage(resJson); options.onFinish(message); } } catch (e) { @@ -411,13 +468,17 @@ export class ChatGPTApi implements LLMApi { return []; } + //由于目前 OpenAI 的 disableListModels 默认为 true,所以当前实际不会运行到这场 + let seq = 1000; //同 Constant.ts 中的排序保持一致 return chatModels.map((m) => ({ name: m.id, available: true, + sorted: seq++, provider: { id: "openai", providerName: "OpenAI", providerType: "openai", + sorted: 1, }, })); } diff --git a/app/client/platforms/tencent.ts b/app/client/platforms/tencent.ts new file mode 100644 index 000000000..579008a9b --- /dev/null +++ b/app/client/platforms/tencent.ts @@ -0,0 +1,268 @@ +"use client"; +import { ApiPath, DEFAULT_API_HOST, REQUEST_TIMEOUT_MS } from "@/app/constant"; +import { useAccessStore, useAppConfig, useChatStore } from "@/app/store"; + +import { + ChatOptions, + getHeaders, + LLMApi, + LLMModel, + MultimodalContent, +} from "../api"; +import Locale from "../../locales"; +import { + EventStreamContentType, + fetchEventSource, +} from "@fortaine/fetch-event-source"; +import { prettyObject } from "@/app/utils/format"; +import { getClientConfig } from "@/app/config/client"; +import { getMessageTextContent, isVisionModel } from "@/app/utils"; +import mapKeys from "lodash-es/mapKeys"; +import mapValues from "lodash-es/mapValues"; +import isArray from "lodash-es/isArray"; +import isObject from "lodash-es/isObject"; + +export interface OpenAIListModelResponse { + object: string; + data: Array<{ + id: string; + object: string; + root: string; + }>; +} + +interface RequestPayload { + Messages: { + Role: "system" | "user" | "assistant"; + Content: string | MultimodalContent[]; + }[]; + Stream?: boolean; + Model: string; + Temperature: number; + TopP: number; +} + +function capitalizeKeys(obj: any): any { + if (isArray(obj)) { + return obj.map(capitalizeKeys); + } else if (isObject(obj)) { + return mapValues( + mapKeys(obj, (value: any, key: string) => + key.replace(/(^|_)(\w)/g, (m, $1, $2) => $2.toUpperCase()), + ), + capitalizeKeys, + ); + } else { + return obj; + } +} + +export class HunyuanApi implements LLMApi { + path(): string { + const accessStore = useAccessStore.getState(); + + let baseUrl = ""; + + if (accessStore.useCustomConfig) { + baseUrl = accessStore.tencentUrl; + } + + if (baseUrl.length === 0) { + const isApp = !!getClientConfig()?.isApp; + baseUrl = isApp + ? DEFAULT_API_HOST + "/api/proxy/tencent" + : ApiPath.Tencent; + } + + if (baseUrl.endsWith("/")) { + baseUrl = baseUrl.slice(0, baseUrl.length - 1); + } + if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.Tencent)) { + baseUrl = "https://" + baseUrl; + } + + console.log("[Proxy Endpoint] ", baseUrl); + return baseUrl; + } + + extractMessage(res: any) { + return res.Choices?.at(0)?.Message?.Content ?? ""; + } + + async chat(options: ChatOptions) { + const visionModel = isVisionModel(options.config.model); + const messages = options.messages.map((v, index) => ({ + // "Messages 中 system 角色必须位于列表的最开始" + role: index !== 0 && v.role === "system" ? "user" : v.role, + content: visionModel ? v.content : getMessageTextContent(v), + })); + + const modelConfig = { + ...useAppConfig.getState().modelConfig, + ...useChatStore.getState().currentSession().mask.modelConfig, + ...{ + model: options.config.model, + }, + }; + + const requestPayload: RequestPayload = capitalizeKeys({ + model: modelConfig.model, + messages, + temperature: modelConfig.temperature, + top_p: modelConfig.top_p, + stream: options.config.stream, + }); + + console.log("[Request] Tencent payload: ", requestPayload); + + const shouldStream = !!options.config.stream; + const controller = new AbortController(); + options.onController?.(controller); + + try { + const chatPath = this.path(); + const chatPayload = { + method: "POST", + body: JSON.stringify(requestPayload), + signal: controller.signal, + headers: getHeaders(), + }; + + // make a fetch request + const requestTimeoutId = setTimeout( + () => controller.abort(), + REQUEST_TIMEOUT_MS, + ); + + 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( + "[Tencent] 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 json = JSON.parse(text); + const choices = json.Choices as Array<{ + Delta: { Content: string }; + }>; + const delta = choices[0]?.Delta?.Content; + if (delta) { + remainText += delta; + } + } 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); + + const resJson = await res.json(); + const message = this.extractMessage(resJson); + options.onFinish(message); + } + } catch (e) { + console.log("[Request] failed to make a chat request", e); + options.onError?.(e as Error); + } + } + async usage() { + return { + used: 0, + total: 0, + }; + } + + async models(): Promise { + return []; + } +} diff --git a/app/command.ts b/app/command.ts index e515e5f0b..bea4e06f3 100644 --- a/app/command.ts +++ b/app/command.ts @@ -41,13 +41,16 @@ interface ChatCommands { del?: Command; } -export const ChatCommandPrefix = ":"; +// Compatible with Chinese colon character ":" +export const ChatCommandPrefix = /^[::]/; export function useChatCommand(commands: ChatCommands = {}) { function extract(userInput: string) { - return ( - userInput.startsWith(ChatCommandPrefix) ? userInput.slice(1) : userInput - ) as keyof ChatCommands; + const match = userInput.match(ChatCommandPrefix); + if (match) { + return userInput.slice(1) as keyof ChatCommands; + } + return userInput as keyof ChatCommands; } function search(userInput: string) { @@ -57,7 +60,7 @@ export function useChatCommand(commands: ChatCommands = {}) { .filter((c) => c.startsWith(input)) .map((c) => ({ title: desc[c as keyof ChatCommands], - content: ChatCommandPrefix + c, + content: ":" + c, })); } diff --git a/app/components/artifacts.module.scss b/app/components/artifacts.module.scss new file mode 100644 index 000000000..6bd0fd9cf --- /dev/null +++ b/app/components/artifacts.module.scss @@ -0,0 +1,31 @@ +.artifacts { + display: flex; + width: 100%; + height: 100%; + flex-direction: column; + &-header { + display: flex; + align-items: center; + height: 36px; + padding: 20px; + background: var(--second); + } + &-title { + flex: 1; + text-align: center; + font-weight: bold; + font-size: 24px; + } + &-content { + flex-grow: 1; + padding: 0 20px 20px 20px; + background-color: var(--second); + } +} + +.artifacts-iframe { + width: 100%; + border: var(--border-in-light); + border-radius: 6px; + background-color: var(--gray); +} diff --git a/app/components/artifacts.tsx b/app/components/artifacts.tsx new file mode 100644 index 000000000..326891e73 --- /dev/null +++ b/app/components/artifacts.tsx @@ -0,0 +1,234 @@ +import { useEffect, useState, useRef, useMemo } from "react"; +import { useParams } from "react-router"; +import { useWindowSize } from "@/app/utils"; +import { IconButton } from "./button"; +import { nanoid } from "nanoid"; +import ExportIcon from "../icons/share.svg"; +import CopyIcon from "../icons/copy.svg"; +import DownloadIcon from "../icons/download.svg"; +import GithubIcon from "../icons/github.svg"; +import LoadingButtonIcon from "../icons/loading.svg"; +import Locale from "../locales"; +import { Modal, showToast } from "./ui-lib"; +import { copyToClipboard, downloadAs } from "../utils"; +import { Path, ApiPath, REPO_URL } from "@/app/constant"; +import { Loading } from "./home"; +import styles from "./artifacts.module.scss"; + +export function HTMLPreview(props: { + code: string; + autoHeight?: boolean; + height?: number | string; + onLoad?: (title?: string) => void; +}) { + const ref = useRef(null); + const frameId = useRef(nanoid()); + const [iframeHeight, setIframeHeight] = useState(600); + const [title, setTitle] = useState(""); + /* + * https://stackoverflow.com/questions/19739001/what-is-the-difference-between-srcdoc-and-src-datatext-html-in-an + * 1. using srcdoc + * 2. using src with dataurl: + * easy to share + * length limit (Data URIs cannot be larger than 32,768 characters.) + */ + + useEffect(() => { + const handleMessage = (e: any) => { + const { id, height, title } = e.data; + setTitle(title); + if (id == frameId.current) { + setIframeHeight(height); + } + }; + window.addEventListener("message", handleMessage); + return () => { + window.removeEventListener("message", handleMessage); + }; + }, []); + + const height = useMemo(() => { + if (!props.autoHeight) return props.height || 600; + if (typeof props.height === "string") { + return props.height; + } + const parentHeight = props.height || 600; + return iframeHeight + 40 > parentHeight ? parentHeight : iframeHeight + 40; + }, [props.autoHeight, props.height, iframeHeight]); + + const srcDoc = useMemo(() => { + const script = ``; + if (props.code.includes("")) { + props.code.replace("", "" + script); + } + return props.code + script; + }, [props.code]); + + const handleOnLoad = () => { + if (props?.onLoad) { + props.onLoad(title); + } + }; + + return ( +