mirror of
				https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web.git
				synced 2025-11-04 16:23:41 +08:00 
			
		
		
		
	Merge pull request #5157 from ConnectAI-E/feature/tencent
Feature/tencent
This commit is contained in:
		
							
								
								
									
										124
									
								
								app/api/tencent/route.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										124
									
								
								app/api/tencent/route.ts
									
									
									
									
									
										Normal file
									
								
							@@ -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 = "nodejs";
 | 
			
		||||
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);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -12,6 +12,7 @@ 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";
 | 
			
		||||
 | 
			
		||||
export const ROLES = ["system", "user", "assistant"] as const;
 | 
			
		||||
@@ -117,6 +118,8 @@ export class ClientApi {
 | 
			
		||||
        break;
 | 
			
		||||
      case ModelProvider.Qwen:
 | 
			
		||||
        this.llm = new QwenApi();
 | 
			
		||||
      case ModelProvider.Hunyuan:
 | 
			
		||||
        this.llm = new HunyuanApi();
 | 
			
		||||
        break;
 | 
			
		||||
      case ModelProvider.Moonshot:
 | 
			
		||||
        this.llm = new MoonshotApi();
 | 
			
		||||
@@ -275,6 +278,8 @@ 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);
 | 
			
		||||
    default:
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										267
									
								
								app/client/platforms/tencent.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										267
									
								
								app/client/platforms/tencent.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,267 @@
 | 
			
		||||
"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) => ({
 | 
			
		||||
      role: 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<LLMModel[]> {
 | 
			
		||||
    return [];
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -54,6 +54,7 @@ import {
 | 
			
		||||
  Anthropic,
 | 
			
		||||
  Azure,
 | 
			
		||||
  Baidu,
 | 
			
		||||
  Tencent,
 | 
			
		||||
  ByteDance,
 | 
			
		||||
  Alibaba,
 | 
			
		||||
  Moonshot,
 | 
			
		||||
@@ -965,6 +966,57 @@ export function Settings() {
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const tencentConfigComponent = accessStore.provider ===
 | 
			
		||||
    ServiceProvider.Tencent && (
 | 
			
		||||
    <>
 | 
			
		||||
      <ListItem
 | 
			
		||||
        title={Locale.Settings.Access.Tencent.Endpoint.Title}
 | 
			
		||||
        subTitle={Locale.Settings.Access.Tencent.Endpoint.SubTitle}
 | 
			
		||||
      >
 | 
			
		||||
        <input
 | 
			
		||||
          type="text"
 | 
			
		||||
          value={accessStore.tencentUrl}
 | 
			
		||||
          placeholder={Tencent.ExampleEndpoint}
 | 
			
		||||
          onChange={(e) =>
 | 
			
		||||
            accessStore.update(
 | 
			
		||||
              (access) => (access.tencentUrl = e.currentTarget.value),
 | 
			
		||||
            )
 | 
			
		||||
          }
 | 
			
		||||
        ></input>
 | 
			
		||||
      </ListItem>
 | 
			
		||||
      <ListItem
 | 
			
		||||
        title={Locale.Settings.Access.Tencent.ApiKey.Title}
 | 
			
		||||
        subTitle={Locale.Settings.Access.Tencent.ApiKey.SubTitle}
 | 
			
		||||
      >
 | 
			
		||||
        <PasswordInput
 | 
			
		||||
          value={accessStore.tencentSecretId}
 | 
			
		||||
          type="text"
 | 
			
		||||
          placeholder={Locale.Settings.Access.Tencent.ApiKey.Placeholder}
 | 
			
		||||
          onChange={(e) => {
 | 
			
		||||
            accessStore.update(
 | 
			
		||||
              (access) => (access.tencentSecretId = e.currentTarget.value),
 | 
			
		||||
            );
 | 
			
		||||
          }}
 | 
			
		||||
        />
 | 
			
		||||
      </ListItem>
 | 
			
		||||
      <ListItem
 | 
			
		||||
        title={Locale.Settings.Access.Tencent.SecretKey.Title}
 | 
			
		||||
        subTitle={Locale.Settings.Access.Tencent.SecretKey.SubTitle}
 | 
			
		||||
      >
 | 
			
		||||
        <PasswordInput
 | 
			
		||||
          value={accessStore.tencentSecretKey}
 | 
			
		||||
          type="text"
 | 
			
		||||
          placeholder={Locale.Settings.Access.Tencent.SecretKey.Placeholder}
 | 
			
		||||
          onChange={(e) => {
 | 
			
		||||
            accessStore.update(
 | 
			
		||||
              (access) => (access.tencentSecretKey = e.currentTarget.value),
 | 
			
		||||
            );
 | 
			
		||||
          }}
 | 
			
		||||
        />
 | 
			
		||||
      </ListItem>
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const byteDanceConfigComponent = accessStore.provider ===
 | 
			
		||||
    ServiceProvider.ByteDance && (
 | 
			
		||||
    <>
 | 
			
		||||
@@ -1404,6 +1456,7 @@ export function Settings() {
 | 
			
		||||
                  {baiduConfigComponent}
 | 
			
		||||
                  {byteDanceConfigComponent}
 | 
			
		||||
                  {alibabaConfigComponent}
 | 
			
		||||
                  {tencentConfigComponent}
 | 
			
		||||
                  {moonshotConfigComponent}
 | 
			
		||||
                  {stabilityConfigComponent}
 | 
			
		||||
                </>
 | 
			
		||||
 
 | 
			
		||||
@@ -57,6 +57,11 @@ declare global {
 | 
			
		||||
      ALIBABA_URL?: string;
 | 
			
		||||
      ALIBABA_API_KEY?: string;
 | 
			
		||||
 | 
			
		||||
      // tencent only
 | 
			
		||||
      TENCENT_URL?: string;
 | 
			
		||||
      TENCENT_SECRET_KEY?: string;
 | 
			
		||||
      TENCENT_SECRET_ID?: string;
 | 
			
		||||
 | 
			
		||||
      // moonshot only
 | 
			
		||||
      MOONSHOT_URL?: string;
 | 
			
		||||
      MOONSHOT_API_KEY?: string;
 | 
			
		||||
@@ -120,6 +125,7 @@ export const getServerSideConfig = () => {
 | 
			
		||||
  const isAzure = !!process.env.AZURE_URL;
 | 
			
		||||
  const isGoogle = !!process.env.GOOGLE_API_KEY;
 | 
			
		||||
  const isAnthropic = !!process.env.ANTHROPIC_API_KEY;
 | 
			
		||||
  const isTencent = !!process.env.TENCENT_API_KEY;
 | 
			
		||||
 | 
			
		||||
  const isBaidu = !!process.env.BAIDU_API_KEY;
 | 
			
		||||
  const isBytedance = !!process.env.BYTEDANCE_API_KEY;
 | 
			
		||||
@@ -173,6 +179,11 @@ export const getServerSideConfig = () => {
 | 
			
		||||
    alibabaUrl: process.env.ALIBABA_URL,
 | 
			
		||||
    alibabaApiKey: getApiKey(process.env.ALIBABA_API_KEY),
 | 
			
		||||
 | 
			
		||||
    isTencent,
 | 
			
		||||
    tencentUrl: process.env.TENCENT_URL,
 | 
			
		||||
    tencentSecretKey: getApiKey(process.env.TENCENT_SECRET_KEY),
 | 
			
		||||
    tencentSecretId: process.env.TENCENT_SECRET_ID,
 | 
			
		||||
 | 
			
		||||
    isMoonshot,
 | 
			
		||||
    moonshotUrl: process.env.MOONSHOT_URL,
 | 
			
		||||
    moonshotApiKey: getApiKey(process.env.MOONSHOT_API_KEY),
 | 
			
		||||
 
 | 
			
		||||
@@ -22,6 +22,9 @@ 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 TENCENT_BASE_URL = "https://hunyuan.tencentcloudapi.com";
 | 
			
		||||
 | 
			
		||||
export const MOONSHOT_BASE_URL = "https://api.moonshot.cn";
 | 
			
		||||
 | 
			
		||||
export const CACHE_URL_PREFIX = "/api/cache";
 | 
			
		||||
@@ -48,6 +51,7 @@ export enum ApiPath {
 | 
			
		||||
  Baidu = "/api/baidu",
 | 
			
		||||
  ByteDance = "/api/bytedance",
 | 
			
		||||
  Alibaba = "/api/alibaba",
 | 
			
		||||
  Tencent = "/api/tencent",
 | 
			
		||||
  Moonshot = "/api/moonshot",
 | 
			
		||||
  Stability = "/api/stability",
 | 
			
		||||
  Artifacts = "/api/artifacts",
 | 
			
		||||
@@ -102,6 +106,7 @@ export enum ServiceProvider {
 | 
			
		||||
  Baidu = "Baidu",
 | 
			
		||||
  ByteDance = "ByteDance",
 | 
			
		||||
  Alibaba = "Alibaba",
 | 
			
		||||
  Tencent = "Tencent",
 | 
			
		||||
  Moonshot = "Moonshot",
 | 
			
		||||
  Stability = "Stability",
 | 
			
		||||
}
 | 
			
		||||
@@ -123,6 +128,7 @@ export enum ModelProvider {
 | 
			
		||||
  Ernie = "Ernie",
 | 
			
		||||
  Doubao = "Doubao",
 | 
			
		||||
  Qwen = "Qwen",
 | 
			
		||||
  Hunyuan = "Hunyuan",
 | 
			
		||||
  Moonshot = "Moonshot",
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -187,6 +193,10 @@ export const Alibaba = {
 | 
			
		||||
  ChatPath: "v1/services/aigc/text-generation/generation",
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const Tencent = {
 | 
			
		||||
  ExampleEndpoint: TENCENT_BASE_URL,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const Moonshot = {
 | 
			
		||||
  ExampleEndpoint: MOONSHOT_BASE_URL,
 | 
			
		||||
  ChatPath: "v1/chat/completions",
 | 
			
		||||
@@ -298,6 +308,16 @@ const alibabaModes = [
 | 
			
		||||
  "qwen-max-longcontext",
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
const tencentModels = [
 | 
			
		||||
  "hunyuan-pro",
 | 
			
		||||
  "hunyuan-standard",
 | 
			
		||||
  "hunyuan-lite",
 | 
			
		||||
  "hunyuan-role",
 | 
			
		||||
  "hunyuan-functioncall",
 | 
			
		||||
  "hunyuan-code",
 | 
			
		||||
  "hunyuan-vision",
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
const moonshotModes = ["moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k"];
 | 
			
		||||
 | 
			
		||||
export const DEFAULT_MODELS = [
 | 
			
		||||
@@ -364,6 +384,15 @@ export const DEFAULT_MODELS = [
 | 
			
		||||
      providerType: "alibaba",
 | 
			
		||||
    },
 | 
			
		||||
  })),
 | 
			
		||||
  ...tencentModels.map((name) => ({
 | 
			
		||||
    name,
 | 
			
		||||
    available: true,
 | 
			
		||||
    provider: {
 | 
			
		||||
      id: "tencent",
 | 
			
		||||
      providerName: "Tencent",
 | 
			
		||||
      providerType: "tencent",
 | 
			
		||||
    },
 | 
			
		||||
  })),
 | 
			
		||||
  ...moonshotModes.map((name) => ({
 | 
			
		||||
    name,
 | 
			
		||||
    available: true,
 | 
			
		||||
 
 | 
			
		||||
@@ -371,6 +371,22 @@ const cn = {
 | 
			
		||||
          SubTitle: "不支持自定义前往.env配置",
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      Tencent: {
 | 
			
		||||
        ApiKey: {
 | 
			
		||||
          Title: "API Key",
 | 
			
		||||
          SubTitle: "使用自定义腾讯云API Key",
 | 
			
		||||
          Placeholder: "Tencent API Key",
 | 
			
		||||
        },
 | 
			
		||||
        SecretKey: {
 | 
			
		||||
          Title: "Secret Key",
 | 
			
		||||
          SubTitle: "使用自定义腾讯云Secret Key",
 | 
			
		||||
          Placeholder: "Tencent Secret Key",
 | 
			
		||||
        },
 | 
			
		||||
        Endpoint: {
 | 
			
		||||
          Title: "接口地址",
 | 
			
		||||
          SubTitle: "不支持自定义前往.env配置",
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      ByteDance: {
 | 
			
		||||
        ApiKey: {
 | 
			
		||||
          Title: "接口密钥",
 | 
			
		||||
 
 | 
			
		||||
@@ -354,6 +354,22 @@ const en: LocaleType = {
 | 
			
		||||
          SubTitle: "not supported, configure in .env",
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      Tencent: {
 | 
			
		||||
        ApiKey: {
 | 
			
		||||
          Title: "Tencent API Key",
 | 
			
		||||
          SubTitle: "Use a custom Tencent API Key",
 | 
			
		||||
          Placeholder: "Tencent API Key",
 | 
			
		||||
        },
 | 
			
		||||
        SecretKey: {
 | 
			
		||||
          Title: "Tencent Secret Key",
 | 
			
		||||
          SubTitle: "Use a custom Tencent Secret Key",
 | 
			
		||||
          Placeholder: "Tencent Secret Key",
 | 
			
		||||
        },
 | 
			
		||||
        Endpoint: {
 | 
			
		||||
          Title: "Endpoint Address",
 | 
			
		||||
          SubTitle: "not supported, configure in .env",
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      ByteDance: {
 | 
			
		||||
        ApiKey: {
 | 
			
		||||
          Title: "ByteDance API Key",
 | 
			
		||||
 
 | 
			
		||||
@@ -39,6 +39,10 @@ const DEFAULT_ALIBABA_URL = isApp
 | 
			
		||||
  ? DEFAULT_API_HOST + "/api/proxy/alibaba"
 | 
			
		||||
  : ApiPath.Alibaba;
 | 
			
		||||
 | 
			
		||||
const DEFAULT_TENCENT_URL = isApp
 | 
			
		||||
  ? DEFAULT_API_HOST + "/api/proxy/tencent"
 | 
			
		||||
  : ApiPath.Tencent;
 | 
			
		||||
 | 
			
		||||
const DEFAULT_MOONSHOT_URL = isApp
 | 
			
		||||
  ? DEFAULT_API_HOST + "/api/proxy/moonshot"
 | 
			
		||||
  : ApiPath.Moonshot;
 | 
			
		||||
@@ -94,6 +98,11 @@ const DEFAULT_ACCESS_STATE = {
 | 
			
		||||
  stabilityUrl: DEFAULT_STABILITY_URL,
 | 
			
		||||
  stabilityApiKey: "",
 | 
			
		||||
 | 
			
		||||
  // tencent
 | 
			
		||||
  tencentUrl: DEFAULT_TENCENT_URL,
 | 
			
		||||
  tencentSecretKey: "",
 | 
			
		||||
  tencentSecretId: "",
 | 
			
		||||
 | 
			
		||||
  // server config
 | 
			
		||||
  needCode: true,
 | 
			
		||||
  hideUserApiKey: false,
 | 
			
		||||
@@ -142,6 +151,10 @@ export const useAccessStore = createPersistStore(
 | 
			
		||||
      return ensure(get(), ["alibabaApiKey"]);
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    isValidTencent() {
 | 
			
		||||
      return ensure(get(), ["tencentSecretKey", "tencentSecretId"]);
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    isValidMoonshot() {
 | 
			
		||||
      return ensure(get(), ["moonshotApiKey"]);
 | 
			
		||||
    },
 | 
			
		||||
@@ -158,6 +171,7 @@ export const useAccessStore = createPersistStore(
 | 
			
		||||
        this.isValidBaidu() ||
 | 
			
		||||
        this.isValidByteDance() ||
 | 
			
		||||
        this.isValidAlibaba() ||
 | 
			
		||||
        this.isValidTencent ||
 | 
			
		||||
        this.isValidMoonshot() ||
 | 
			
		||||
        !this.enabledAccessControl() ||
 | 
			
		||||
        (this.enabledAccessControl() && ensure(get(), ["accessCode"]))
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										109
									
								
								app/utils/tencent.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										109
									
								
								app/utils/tencent.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,109 @@
 | 
			
		||||
import { createHash, createHmac } from "node:crypto";
 | 
			
		||||
// 使用 SHA-256 和 secret 进行 HMAC 加密
 | 
			
		||||
function sha256(message: any, secret = "", encoding?: string) {
 | 
			
		||||
  return createHmac("sha256", secret)
 | 
			
		||||
    .update(message)
 | 
			
		||||
    .digest(encoding as any);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// 使用 SHA-256 进行哈希
 | 
			
		||||
function getHash(message: any, encoding = "hex") {
 | 
			
		||||
  return createHash("sha256")
 | 
			
		||||
    .update(message)
 | 
			
		||||
    .digest(encoding as any);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getDate(timestamp: number) {
 | 
			
		||||
  const date = new Date(timestamp * 1000);
 | 
			
		||||
  const year = date.getUTCFullYear();
 | 
			
		||||
  const month = ("0" + (date.getUTCMonth() + 1)).slice(-2);
 | 
			
		||||
  const day = ("0" + date.getUTCDate()).slice(-2);
 | 
			
		||||
  return `${year}-${month}-${day}`;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function getHeader(
 | 
			
		||||
  payload: any,
 | 
			
		||||
  SECRET_ID: string,
 | 
			
		||||
  SECRET_KEY: string,
 | 
			
		||||
) {
 | 
			
		||||
  // https://cloud.tencent.com/document/api/1729/105701
 | 
			
		||||
 | 
			
		||||
  const endpoint = "hunyuan.tencentcloudapi.com";
 | 
			
		||||
  const service = "hunyuan";
 | 
			
		||||
  const region = ""; // optional
 | 
			
		||||
  const action = "ChatCompletions";
 | 
			
		||||
  const version = "2023-09-01";
 | 
			
		||||
  const timestamp = Math.floor(Date.now() / 1000);
 | 
			
		||||
  //时间处理, 获取世界时间日期
 | 
			
		||||
  const date = getDate(timestamp);
 | 
			
		||||
 | 
			
		||||
  // ************* 步骤 1:拼接规范请求串 *************
 | 
			
		||||
 | 
			
		||||
  const hashedRequestPayload = getHash(payload);
 | 
			
		||||
  const httpRequestMethod = "POST";
 | 
			
		||||
  const contentType = "application/json";
 | 
			
		||||
  const canonicalUri = "/";
 | 
			
		||||
  const canonicalQueryString = "";
 | 
			
		||||
  const canonicalHeaders =
 | 
			
		||||
    `content-type:${contentType}\n` +
 | 
			
		||||
    "host:" +
 | 
			
		||||
    endpoint +
 | 
			
		||||
    "\n" +
 | 
			
		||||
    "x-tc-action:" +
 | 
			
		||||
    action.toLowerCase() +
 | 
			
		||||
    "\n";
 | 
			
		||||
  const signedHeaders = "content-type;host;x-tc-action";
 | 
			
		||||
 | 
			
		||||
  const canonicalRequest = [
 | 
			
		||||
    httpRequestMethod,
 | 
			
		||||
    canonicalUri,
 | 
			
		||||
    canonicalQueryString,
 | 
			
		||||
    canonicalHeaders,
 | 
			
		||||
    signedHeaders,
 | 
			
		||||
    hashedRequestPayload,
 | 
			
		||||
  ].join("\n");
 | 
			
		||||
 | 
			
		||||
  // ************* 步骤 2:拼接待签名字符串 *************
 | 
			
		||||
  const algorithm = "TC3-HMAC-SHA256";
 | 
			
		||||
  const hashedCanonicalRequest = getHash(canonicalRequest);
 | 
			
		||||
  const credentialScope = date + "/" + service + "/" + "tc3_request";
 | 
			
		||||
  const stringToSign =
 | 
			
		||||
    algorithm +
 | 
			
		||||
    "\n" +
 | 
			
		||||
    timestamp +
 | 
			
		||||
    "\n" +
 | 
			
		||||
    credentialScope +
 | 
			
		||||
    "\n" +
 | 
			
		||||
    hashedCanonicalRequest;
 | 
			
		||||
 | 
			
		||||
  // ************* 步骤 3:计算签名 *************
 | 
			
		||||
  const kDate = sha256(date, "TC3" + SECRET_KEY);
 | 
			
		||||
  const kService = sha256(service, kDate);
 | 
			
		||||
  const kSigning = sha256("tc3_request", kService);
 | 
			
		||||
  const signature = sha256(stringToSign, kSigning, "hex");
 | 
			
		||||
 | 
			
		||||
  // ************* 步骤 4:拼接 Authorization *************
 | 
			
		||||
  const authorization =
 | 
			
		||||
    algorithm +
 | 
			
		||||
    " " +
 | 
			
		||||
    "Credential=" +
 | 
			
		||||
    SECRET_ID +
 | 
			
		||||
    "/" +
 | 
			
		||||
    credentialScope +
 | 
			
		||||
    ", " +
 | 
			
		||||
    "SignedHeaders=" +
 | 
			
		||||
    signedHeaders +
 | 
			
		||||
    ", " +
 | 
			
		||||
    "Signature=" +
 | 
			
		||||
    signature;
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    Authorization: authorization,
 | 
			
		||||
    "Content-Type": contentType,
 | 
			
		||||
    Host: endpoint,
 | 
			
		||||
    "X-TC-Action": action,
 | 
			
		||||
    "X-TC-Timestamp": timestamp.toString(),
 | 
			
		||||
    "X-TC-Version": version,
 | 
			
		||||
    "X-TC-Region": region,
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
@@ -28,6 +28,7 @@
 | 
			
		||||
    "fuse.js": "^7.0.0",
 | 
			
		||||
    "heic2any": "^0.0.4",
 | 
			
		||||
    "html-to-image": "^1.11.11",
 | 
			
		||||
    "lodash-es": "^4.17.21",
 | 
			
		||||
    "mermaid": "^10.6.1",
 | 
			
		||||
    "nanoid": "^5.0.3",
 | 
			
		||||
    "next": "^14.1.1",
 | 
			
		||||
@@ -48,6 +49,7 @@
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@tauri-apps/cli": "1.5.11",
 | 
			
		||||
    "@types/lodash-es": "^4.17.12",
 | 
			
		||||
    "@types/node": "^20.11.30",
 | 
			
		||||
    "@types/react": "^18.2.70",
 | 
			
		||||
    "@types/react-dom": "^18.2.7",
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										12
									
								
								yarn.lock
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								yarn.lock
									
									
									
									
									
								
							@@ -1697,6 +1697,18 @@
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@types/katex/-/katex-0.14.0.tgz#b84c0afc3218069a5ad64fe2a95321881021b5fe"
 | 
			
		||||
  integrity sha512-+2FW2CcT0K3P+JMR8YG846bmDwplKUTsWgT2ENwdQ1UdVfRk3GQrh6Mi4sTopy30gI8Uau5CEqHTDZ6YvWIUPA==
 | 
			
		||||
 | 
			
		||||
"@types/lodash-es@^4.17.12":
 | 
			
		||||
  version "4.17.12"
 | 
			
		||||
  resolved "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz#65f6d1e5f80539aa7cfbfc962de5def0cf4f341b"
 | 
			
		||||
  integrity sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    "@types/lodash" "*"
 | 
			
		||||
 | 
			
		||||
"@types/lodash@*":
 | 
			
		||||
  version "4.17.7"
 | 
			
		||||
  resolved "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.7.tgz#2f776bcb53adc9e13b2c0dfd493dfcbd7de43612"
 | 
			
		||||
  integrity sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==
 | 
			
		||||
 | 
			
		||||
"@types/mdast@^3.0.0":
 | 
			
		||||
  version "3.0.11"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-3.0.11.tgz#dc130f7e7d9306124286f6d6cee40cf4d14a3dc0"
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user