diff --git a/app/api/auth.ts b/app/api/auth.ts index 921d807ff..9b38729ca 100644 --- a/app/api/auth.ts +++ b/app/api/auth.ts @@ -90,6 +90,9 @@ export function auth( case ModelProvider.Qwen: systemApiKey = serverConfig.alibabaApiKey; break; + case ModelProvider.Moonshot: + systemApiKey = serverConfig.moonshotApiKey; + break; case ModelProvider.GPT: default: // isAzure diff --git a/app/api/moonshot/[...path]/route.ts b/app/api/moonshot/[...path]/route.ts new file mode 100644 index 000000000..14bc0a40d --- /dev/null +++ b/app/api/moonshot/[...path]/route.ts @@ -0,0 +1,154 @@ +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(); + +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)); + } +} + +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(); + + // 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/app/(auth)/login/user-login-core.tsx b/app/app/(auth)/login/user-login-core.tsx index 8ba0cb50e..a14d615a0 100644 --- a/app/app/(auth)/login/user-login-core.tsx +++ b/app/app/(auth)/login/user-login-core.tsx @@ -21,7 +21,6 @@ import { import type { FormProps, TabsProps } from "antd"; import { SignInOptions } from "next-auth/react"; import { getSession } from "next-auth/react"; -import { FieldData } from "rc-field-form/es/interface"; export default function UserLoginCore() { const [loading, setLoading] = useState(false); @@ -154,7 +153,7 @@ export default function UserLoginCore() { 无权限,请确认用户名正确并等待审批
- 或联系管理员 小司 + 或联系管理员
), }); @@ -173,7 +172,7 @@ export default function UserLoginCore() { name: "password", errors: [result.error], }, - ] as FieldData[]); + ]); } if (loginProvider === "email") { loginForm.setFields([ @@ -181,7 +180,7 @@ export default function UserLoginCore() { name: "email", errors: [result.error], }, - ] as FieldData[]); + ]); } } console.log("response,", result); diff --git a/app/client/api.ts b/app/client/api.ts index db004024f..e4fbea500 100644 --- a/app/client/api.ts +++ b/app/client/api.ts @@ -13,6 +13,7 @@ import { ClaudeApi } from "./platforms/anthropic"; import { ErnieApi } from "./platforms/baidu"; import { DoubaoApi } from "./platforms/bytedance"; import { QwenApi } from "./platforms/alibaba"; +import { MoonshotApi } from "./platforms/moonshot"; export const ROLES = ["system", "user", "assistant"] as const; export type MessageRole = (typeof ROLES)[number]; @@ -125,6 +126,9 @@ export class ClientApi { case ModelProvider.Qwen: this.llm = new QwenApi(); break; + case ModelProvider.Moonshot: + this.llm = new MoonshotApi(); + break; default: this.llm = new ChatGPTApi(); } @@ -207,6 +211,7 @@ 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 isEnabledAccessControl = accessStore.enabledAccessControl(); const apiKey = isGoogle ? accessStore.googleApiKey @@ -218,7 +223,9 @@ export function getHeaders() { ? accessStore.bytedanceApiKey : isAlibaba ? accessStore.alibabaApiKey - : accessStore.openaiApiKey; + : isMoonshot + ? accessStore.moonshotApiKey + : accessStore.openaiApiKey; return { isGoogle, isAzure, @@ -226,6 +233,7 @@ export function getHeaders() { isBaidu, isByteDance, isAlibaba, + isMoonshot, apiKey, isEnabledAccessControl, }; @@ -278,6 +286,8 @@ export function getClientApi(provider: ServiceProvider): ClientApi { return new ClientApi(ModelProvider.Doubao); case ServiceProvider.Alibaba: return new ClientApi(ModelProvider.Qwen); + case ServiceProvider.Moonshot: + return new ClientApi(ModelProvider.Moonshot); default: return new ClientApi(ModelProvider.GPT); } 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/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/components/chat.tsx b/app/components/chat.tsx index 69ae01881..2d9d75b07 100644 --- a/app/components/chat.tsx +++ b/app/components/chat.tsx @@ -651,11 +651,11 @@ export function ChatActions(props: { /> )} - {/* setShowPluginSelector(true)}*/} - {/* text={Locale.Plugin.Name}*/} - {/* icon={}*/} - {/*/>*/} + setShowPluginSelector(true)} + text={Locale.Plugin.Name} + icon={} + /> {showPluginSelector && (
NextChat
- github.com/ChatGPTNextWeb/ChatGPT-Next-Web + github.com/Yidadaa/ChatGPT-Next-Web
diff --git a/app/components/settings.tsx b/app/components/settings.tsx index 235e0e187..1c5a1e5ce 100644 --- a/app/components/settings.tsx +++ b/app/components/settings.tsx @@ -56,6 +56,7 @@ import { Baidu, ByteDance, Alibaba, + Moonshot, Google, GoogleSafetySettingsThreshold, OPENAI_BASE_URL, @@ -1042,6 +1043,45 @@ export function Settings() { ); + const moonshotConfigComponent = accessStore.provider === + ServiceProvider.Moonshot && ( + <> + + + accessStore.update( + (access) => (access.moonshotUrl = e.currentTarget.value), + ) + } + > + + + { + accessStore.update( + (access) => (access.moonshotApiKey = e.currentTarget.value), + ); + }} + /> + + + ); + const stabilityConfigComponent = accessStore.provider === ServiceProvider.Stability && ( <> @@ -1364,6 +1404,7 @@ export function Settings() { {/* {baiduConfigComponent}*/} {/* {byteDanceConfigComponent}*/} {/* {alibabaConfigComponent}*/} + {/* {moonshotConfigComponent}*/} {/* {stabilityConfigComponent}*/} {/* */} {/* )}*/} diff --git a/app/config/server.ts b/app/config/server.ts index 450305251..3e94999e3 100644 --- a/app/config/server.ts +++ b/app/config/server.ts @@ -59,6 +59,10 @@ declare global { ALIBABA_URL?: string; ALIBABA_API_KEY?: string; + // moonshot only + MOONSHOT_URL?: string; + MOONSHOT_API_KEY?: string; + // custom template for preprocessing user input DEFAULT_INPUT_TEMPLATE?: string; } @@ -126,6 +130,7 @@ export const getServerSideConfig = () => { const isBaidu = !!process.env.BAIDU_API_KEY; const isBytedance = !!process.env.BYTEDANCE_API_KEY; const isAlibaba = !!process.env.ALIBABA_API_KEY; + const isMoonshot = !!process.env.MOONSHOT_API_KEY; // const apiKeyEnvVar = process.env.OPENAI_API_KEY ?? ""; // const apiKeys = apiKeyEnvVar.split(",").map((v) => v.trim()); // const randomIndex = Math.floor(Math.random() * apiKeys.length); @@ -176,6 +181,10 @@ export const getServerSideConfig = () => { alibabaUrl: process.env.ALIBABA_URL, alibabaApiKey: getApiKey(process.env.ALIBABA_API_KEY), + isMoonshot, + moonshotUrl: process.env.MOONSHOT_URL, + moonshotApiKey: getApiKey(process.env.MOONSHOT_API_KEY), + cloudflareAccountId: process.env.CLOUDFLARE_ACCOUNT_ID, cloudflareKVNamespaceId: process.env.CLOUDFLARE_KV_NAMESPACE_ID, cloudflareKVApiKey: getApiKey(process.env.CLOUDFLARE_KV_API_KEY), diff --git a/app/constant.ts b/app/constant.ts index 84aaea200..ee8799345 100644 --- a/app/constant.ts +++ b/app/constant.ts @@ -1,4 +1,4 @@ -export const OWNER = "ChatGPTNextWeb"; +export const OWNER = "Yidadaa"; export const REPO = "ChatGPT-Next-Web"; export const REPO_URL = `https://github.com/${OWNER}/${REPO}`; export const ISSUE_URL = `https://github.com/${OWNER}/${REPO}/issues`; @@ -22,6 +22,7 @@ export const BAIDU_OATUH_URL = `${BAIDU_BASE_URL}/oauth/2.0/token`; export const BYTEDANCE_BASE_URL = "https://ark.cn-beijing.volces.com"; export const ALIBABA_BASE_URL = "https://dashscope.aliyuncs.com/api/"; +export const MOONSHOT_BASE_URL = "https://api.moonshot.cn"; export const CACHE_URL_PREFIX = "/api/cache"; export const UPLOAD_URL = `${CACHE_URL_PREFIX}/upload`; @@ -48,6 +49,7 @@ export enum ApiPath { Baidu = "/api/baidu", ByteDance = "/api/bytedance", Alibaba = "/api/alibaba", + Moonshot = "/api/moonshot", Stability = "/api/stability", Artifacts = "/api/artifacts", } @@ -101,6 +103,7 @@ export enum ServiceProvider { Baidu = "Baidu", ByteDance = "ByteDance", Alibaba = "Alibaba", + Moonshot = "Moonshot", Stability = "Stability", } @@ -121,6 +124,7 @@ export enum ModelProvider { Ernie = "Ernie", Doubao = "Doubao", Qwen = "Qwen", + Moonshot = "Moonshot", } export const Stability = { @@ -187,6 +191,11 @@ export const Alibaba = { ChatPath: "v1/services/aigc/text-generation/generation", }; +export const Moonshot = { + ExampleEndpoint: MOONSHOT_BASE_URL, + ChatPath: "v1/chat/completions", +}; + export const DEFAULT_INPUT_TEMPLATE = `{{input}}`; // input / time / model / lang // export const DEFAULT_SYSTEM_TEMPLATE = ` // You are ChatGPT, a large language model trained by {{ServiceProvider}}. @@ -294,6 +303,8 @@ const alibabaModes = [ "qwen-max-longcontext", ]; +const moonshotModes = ["moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k"]; + export const DEFAULT_MODELS = [ { name: "gpt-3.5-turbo", @@ -385,6 +396,15 @@ export const DEFAULT_MODELS = [ providerType: "openai", }, }, + // ...moonshotModes.map((name) => ({ + // name, + // available: true, + // provider: { + // id: "moonshot", + // providerName: "Moonshot", + // providerType: "moonshot", + // }, + // })), ] as const; // export const AZURE_MODELS: string[] = [ diff --git a/app/locales/cn.ts b/app/locales/cn.ts index fa4578d26..4eae2c32f 100644 --- a/app/locales/cn.ts +++ b/app/locales/cn.ts @@ -433,6 +433,17 @@ const cn = { SubTitle: "样例:", }, }, + Moonshot: { + ApiKey: { + Title: "接口密钥", + SubTitle: "使用自定义月之暗面API Key", + Placeholder: "Moonshot API Key", + }, + Endpoint: { + Title: "接口地址", + SubTitle: "样例:", + }, + }, Stability: { ApiKey: { Title: "接口密钥", diff --git a/app/locales/en.ts b/app/locales/en.ts index 281b2c4b7..a29e5067f 100644 --- a/app/locales/en.ts +++ b/app/locales/en.ts @@ -416,6 +416,17 @@ const en: LocaleType = { SubTitle: "Example: ", }, }, + Moonshot: { + ApiKey: { + Title: "Moonshot API Key", + SubTitle: "Use a custom Moonshot API Key", + Placeholder: "Moonshot API Key", + }, + Endpoint: { + Title: "Endpoint Address", + SubTitle: "Example: ", + }, + }, Stability: { ApiKey: { Title: "Stability API Key", diff --git a/app/store/access.ts b/app/store/access.ts index 7f7cd6588..5dd069a43 100644 --- a/app/store/access.ts +++ b/app/store/access.ts @@ -39,6 +39,10 @@ const DEFAULT_ALIBABA_URL = isApp ? DEFAULT_API_HOST + "/api/proxy/alibaba" : ApiPath.Alibaba; +const DEFAULT_MOONSHOT_URL = isApp + ? DEFAULT_API_HOST + "/api/proxy/moonshot" + : ApiPath.Moonshot; + const DEFAULT_STABILITY_URL = isApp ? DEFAULT_API_HOST + "/api/proxy/stability" : ApiPath.Stability; @@ -83,6 +87,10 @@ const DEFAULT_ACCESS_STATE = { alibabaUrl: DEFAULT_ALIBABA_URL, alibabaApiKey: "", + // moonshot + moonshotUrl: DEFAULT_MOONSHOT_URL, + moonshotApiKey: "", + //stability stabilityUrl: DEFAULT_STABILITY_URL, stabilityApiKey: "", @@ -138,6 +146,10 @@ export const useAccessStore = createPersistStore( return ensure(get(), ["alibabaApiKey"]); }, + isValidMoonshot() { + return ensure(get(), ["moonshotApiKey"]); + }, + isAuthorized() { this.fetch(); @@ -150,6 +162,7 @@ export const useAccessStore = createPersistStore( this.isValidBaidu() || this.isValidByteDance() || this.isValidAlibaba() || + this.isValidMoonshot() || !this.enabledAccessControl() || (this.enabledAccessControl() && ensure(get(), ["accessCode"])) ); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index feef57d16..86faf4c3c 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -9,7 +9,7 @@ }, "package": { "productName": "NextChat", - "version": "2.14.0" + "version": "2.13.1" }, "tauri": { "allowlist": {