butterfly 1 rok pred
rodič
commit
69b079c86e

+ 198 - 0
app/api/anthropic/[...path]/route.ts

@@ -0,0 +1,198 @@
+import { type OpenAIListModelResponse } from "@/app/client/platforms/openai";
+import { getServerSideConfig } from "@/app/config/server";
+import {
+  ANTHROPIC_BASE_URL,
+  Anthropic,
+  ApiPath,
+  DEFAULT_MODELS,
+  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 { collectModelTable } from "@/app/utils/model";
+
+const ALLOWD_PATH = new Set([Anthropic.ChatPath, Anthropic.ChatPath1]);
+
+async function handle(
+  req: NextRequest,
+  { params }: { params: { path: string[] } },
+) {
+  console.log("[Anthropic Route] params ", params);
+
+  if (req.method === "OPTIONS") {
+    return NextResponse.json({ body: "OK" }, { status: 200 });
+  }
+
+  const subpath = params.path.join("/");
+
+  if (!ALLOWD_PATH.has(subpath)) {
+    console.log("[Anthropic Route] forbidden path ", subpath);
+    return NextResponse.json(
+      {
+        error: true,
+        msg: "you are not allowed to request " + subpath,
+      },
+      {
+        status: 403,
+      },
+    );
+  }
+
+  const authResult = auth(req, ModelProvider.Claude);
+  if (authResult.error) {
+    return NextResponse.json(authResult, {
+      status: 401,
+    });
+  }
+
+  try {
+    const response = await request(req);
+    return response;
+  } catch (e) {
+    console.error("[Anthropic] ", 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",
+];
+
+const serverConfig = getServerSideConfig();
+
+export async function request(req: NextRequest) {
+  const controller = new AbortController();
+
+  let authHeaderName = "x-api-key";
+  let authValue =
+    req.headers.get(authHeaderName) ||
+    req.headers.get("Authorization")?.replaceAll("Bearer ", "").trim() ||
+    serverConfig.anthropicApiKey ||
+    "";
+
+  let path = `${req.nextUrl.pathname}`.replaceAll(ApiPath.Anthropic, "");
+
+  let baseUrl =
+    serverConfig.anthropicUrl || serverConfig.baseUrl || ANTHROPIC_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",
+      // "Cache-Control": "no-store",
+      [authHeaderName]: authValue,
+      "anthropic-version":
+        req.headers.get("anthropic-version") ||
+        serverConfig.anthropicApiVersion ||
+        Anthropic.Vision,
+    },
+    method: req.method,
+    body: req.body,
+    redirect: "manual",
+    // @ts-ignore
+    duplex: "half",
+    signal: controller.signal,
+  };
+
+  // #1815 try to refuse gpt4 request
+  if (serverConfig.customModels && req.body) {
+    try {
+      const modelTable = collectModelTable(
+        DEFAULT_MODELS,
+        serverConfig.customModels,
+      );
+      const clonedBody = await req.text();
+      fetchOptions.body = clonedBody;
+
+      const jsonBody = JSON.parse(clonedBody) as { model?: string };
+
+      // not undefined and is false
+      if (modelTable[jsonBody?.model ?? ""].available === false) {
+        return NextResponse.json(
+          {
+            error: true,
+            message: `you are not allowed to use ${jsonBody?.model} model`,
+          },
+          {
+            status: 403,
+          },
+        );
+      }
+    } catch (e) {
+      console.error("[OpenAI] gpt4 filter", e);
+    }
+  }
+  console.log("[Anthropic request]", fetchOptions.headers, req.method);
+  try {
+    const res = await fetch(fetchUrl, fetchOptions);
+
+    console.log(
+      "[Anthropic response]",
+      res.status,
+      "   ",
+      res.headers,
+      res.url,
+    );
+    // to prevent browser prompt for credentials
+    const newHeaders = new Headers(res.headers);
+    newHeaders.delete("www-authenticate");
+    // to disable nginx buffering
+    newHeaders.set("X-Accel-Buffering", "no");
+
+    // The latest version of the OpenAI API forced the content-encoding to be "br" in json response
+    // So if the streaming is disabled, we need to remove the content-encoding header
+    // Because Vercel uses gzip to compress the response, if we don't remove the content-encoding header
+    // The browser will try to decode the response with brotli and fail
+    newHeaders.delete("content-encoding");
+
+    return new Response(res.body, {
+      status: res.status,
+      statusText: res.statusText,
+      headers: newHeaders,
+    });
+  } finally {
+    clearTimeout(timeoutId);
+  }
+}

+ 25 - 6
app/api/auth.ts

@@ -57,12 +57,31 @@ export function auth(req: NextRequest, modelProvider: ModelProvider) {
   if (!apiKey) {
     const serverConfig = getServerSideConfig();
 
-    const systemApiKey =
-      modelProvider === ModelProvider.GeminiPro
-        ? serverConfig.googleApiKey
-        : serverConfig.isAzure
-        ? serverConfig.azureApiKey
-        : serverConfig.apiKey;
+    // const systemApiKey =
+    //   modelProvider === ModelProvider.GeminiPro
+    //     ? serverConfig.googleApiKey
+    //     : serverConfig.isAzure
+    //     ? serverConfig.azureApiKey
+    //     : serverConfig.apiKey;
+
+    let systemApiKey: string | undefined;
+
+    switch (modelProvider) {
+      case ModelProvider.GeminiPro:
+        systemApiKey = serverConfig.googleApiKey;
+        break;
+      case ModelProvider.Claude:
+        systemApiKey = serverConfig.anthropicApiKey;
+        break;
+      case ModelProvider.GPT:
+      default:
+        if (serverConfig.isAzure) {
+          systemApiKey = serverConfig.azureApiKey;
+        } else {
+          systemApiKey = serverConfig.apiKey;
+        }
+    }
+
     if (systemApiKey) {
       console.log("[Auth] use system api key");
       req.headers.set("Authorization", `Bearer ${systemApiKey}`);

+ 10 - 4
app/client/api.ts

@@ -8,6 +8,7 @@ import {
 import { ChatMessage, ModelType, useAccessStore, useChatStore } from "../store";
 import { ChatGPTApi } from "./platforms/openai";
 import { GeminiProApi } from "./platforms/google";
+import { ClaudeApi } from "./platforms/anthropic";
 export const ROLES = ["system", "user", "assistant"] as const;
 export type MessageRole = (typeof ROLES)[number];
 
@@ -94,11 +95,16 @@ export class ClientApi {
   public llm: LLMApi;
 
   constructor(provider: ModelProvider = ModelProvider.GPT) {
-    if (provider === ModelProvider.GeminiPro) {
-      this.llm = new GeminiProApi();
-      return;
+    switch (provider) {
+      case ModelProvider.GeminiPro:
+        this.llm = new GeminiProApi();
+        break;
+      case ModelProvider.Claude:
+        this.llm = new ClaudeApi();
+        break;
+      default:
+        this.llm = new ChatGPTApi();
     }
-    this.llm = new ChatGPTApi();
   }
 
   config() {}

+ 515 - 0
app/client/platforms/anthropic.ts

@@ -0,0 +1,515 @@
+import { ACCESS_CODE_PREFIX, Anthropic, ApiPath } from "@/app/constant";
+import { ChatOptions, LLMApi, MultimodalContent } from "../api";
+import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
+import { getClientConfig } from "@/app/config/client";
+import { DEFAULT_API_HOST } from "@/app/constant";
+import { MessageRole, RequestMessage } from "@/app/typing";
+import {
+  EventStreamContentType,
+  fetchEventSource,
+} from "@fortaine/fetch-event-source";
+
+import Locale from "../../locales";
+import { prettyObject } from "@/app/utils/format";
+import { getMessageTextContent, isVisionModel } from "@/app/utils";
+
+export type MultiBlockContent = {
+  type: "image" | "text";
+  source?: {
+    type: string;
+    media_type: string;
+    data: string;
+  };
+  text?: string;
+};
+
+export type AnthropicMessage = {
+  role: (typeof ClaudeMapper)[keyof typeof ClaudeMapper];
+  content: string | MultiBlockContent[];
+};
+
+export interface AnthropicChatRequest {
+  model: string; // The model that will complete your prompt.
+  messages: AnthropicMessage[]; // The prompt that you want Claude to complete.
+  max_tokens: number; // The maximum number of tokens to generate before stopping.
+  stop_sequences?: string[]; // Sequences that will cause the model to stop generating completion text.
+  temperature?: number; // Amount of randomness injected into the response.
+  top_p?: number; // Use nucleus sampling.
+  top_k?: number; // Only sample from the top K options for each subsequent token.
+  metadata?: object; // An object describing metadata about the request.
+  stream?: boolean; // Whether to incrementally stream the response using server-sent events.
+}
+
+export interface ChatRequest {
+  model: string; // The model that will complete your prompt.
+  prompt: string; // The prompt that you want Claude to complete.
+  max_tokens_to_sample: number; // The maximum number of tokens to generate before stopping.
+  stop_sequences?: string[]; // Sequences that will cause the model to stop generating completion text.
+  temperature?: number; // Amount of randomness injected into the response.
+  top_p?: number; // Use nucleus sampling.
+  top_k?: number; // Only sample from the top K options for each subsequent token.
+  metadata?: object; // An object describing metadata about the request.
+  stream?: boolean; // Whether to incrementally stream the response using server-sent events.
+}
+
+export interface ChatResponse {
+  completion: string;
+  stop_reason: "stop_sequence" | "max_tokens";
+  model: string;
+}
+
+export type ChatStreamResponse = ChatResponse & {
+  stop?: string;
+  log_id: string;
+};
+
+const ClaudeMapper = {
+  assistant: "assistant",
+  user: "user",
+  system: "user",
+} as const;
+
+export class ClaudeApi implements LLMApi {
+  extractMessage(res: any) {
+    console.log("[Response] claude response: ", res);
+
+    return res.completion;
+  }
+  async chatComplete(options: ChatOptions): Promise<void> {
+    const ClaudeMapper: Record<RequestMessage["role"], string> = {
+      assistant: "Assistant",
+      user: "Human",
+      system: "Human",
+    };
+
+    const accessStore = useAccessStore.getState();
+
+    const shouldStream = !!options.config.stream;
+
+    const prompt = options.messages
+      .map((v) => ({
+        role: ClaudeMapper[v.role] ?? "Human",
+        content: v.content,
+      }))
+      .map((v) => `\n\n${v.role}: ${v.content}`)
+      .join("");
+
+    const modelConfig = {
+      ...useAppConfig.getState().modelConfig,
+      ...useChatStore.getState().currentSession().mask.modelConfig,
+      ...{
+        model: options.config.model,
+      },
+    };
+
+    const requestBody: ChatRequest = {
+      prompt,
+      stream: shouldStream,
+
+      model: modelConfig.model,
+      max_tokens_to_sample: modelConfig.max_tokens,
+      temperature: modelConfig.temperature,
+      top_p: modelConfig.top_p,
+      // top_k: modelConfig.top_k,
+      top_k: 5,
+    };
+
+    const path = this.path(Anthropic.ChatPath1);
+
+    const controller = new AbortController();
+    options.onController?.(controller);
+
+    const payload = {
+      method: "POST",
+      body: JSON.stringify(requestBody),
+      signal: controller.signal,
+      headers: {
+        "Content-Type": "application/json",
+        // Accept: "application/json",
+        "x-api-key": accessStore.anthropicApiKey,
+        "anthropic-version": accessStore.anthropicApiVersion,
+        Authorization: getAuthKey(accessStore.anthropicApiKey),
+      },
+      // mode: "no-cors" as RequestMode,
+    };
+
+    if (shouldStream) {
+      try {
+        const context = {
+          text: "",
+          finished: false,
+        };
+
+        const finish = () => {
+          if (!context.finished) {
+            options.onFinish(context.text);
+            context.finished = true;
+          }
+        };
+
+        controller.signal.onabort = finish;
+
+        fetchEventSource(path, {
+          ...payload,
+          async onopen(res) {
+            const contentType = res.headers.get("content-type");
+            console.log("response content type: ", contentType);
+
+            if (contentType?.startsWith("text/plain")) {
+              context.text = await res.clone().text();
+              return finish();
+            }
+
+            if (
+              !res.ok ||
+              !res.headers
+                .get("content-type")
+                ?.startsWith(EventStreamContentType) ||
+              res.status !== 200
+            ) {
+              const responseTexts = [context.text];
+              let extraInfo = await res.clone().text();
+              try {
+                const resJson = await res.clone().json();
+                extraInfo = prettyObject(resJson);
+              } catch {}
+
+              if (res.status === 401) {
+                responseTexts.push(Locale.Error.Unauthorized);
+              }
+
+              if (extraInfo) {
+                responseTexts.push(extraInfo);
+              }
+
+              context.text = responseTexts.join("\n\n");
+
+              return finish();
+            }
+          },
+          onmessage(msg) {
+            if (msg.data === "[DONE]" || context.finished) {
+              return finish();
+            }
+            const chunk = msg.data;
+            try {
+              const chunkJson = JSON.parse(chunk) as ChatStreamResponse;
+              const delta = chunkJson.completion;
+              if (delta) {
+                context.text += delta;
+                options.onUpdate?.(context.text, delta);
+              }
+            } catch (e) {
+              console.error("[Request] parse error", chunk, msg);
+            }
+          },
+          onclose() {
+            finish();
+          },
+          onerror(e) {
+            options.onError?.(e);
+          },
+          openWhenHidden: true,
+        });
+      } catch (e) {
+        console.error("failed to chat", e);
+        options.onError?.(e as Error);
+      }
+    } else {
+      try {
+        controller.signal.onabort = () => options.onFinish("");
+
+        const res = await fetch(path, payload);
+        const resJson = await res.json();
+
+        const message = this.extractMessage(resJson);
+        options.onFinish(message);
+      } catch (e) {
+        console.error("failed to chat", e);
+        options.onError?.(e as Error);
+      }
+    }
+  }
+  async chat(options: ChatOptions): Promise<void> {
+    const visionModel = isVisionModel(options.config.model);
+
+    const accessStore = useAccessStore.getState();
+
+    const shouldStream = !!options.config.stream;
+
+    const prompt = options.messages.map((v) => {
+      const { role, content } = v;
+      const insideRole = ClaudeMapper[role] ?? "user";
+
+      if (!visionModel || typeof content === "string") {
+        return {
+          role: insideRole,
+          content: getMessageTextContent(v),
+        };
+      }
+      return {
+        role: insideRole,
+        content: content.map(({ type, text, image_url }) => {
+          if (type === "text") {
+            return {
+              type,
+              text: text!,
+            };
+          }
+          const { url = "" } = image_url || {};
+          const colonIndex = url.indexOf(":");
+          const semicolonIndex = url.indexOf(";");
+          const comma = url.indexOf(",");
+
+          const mimeType = url.slice(colonIndex + 1, semicolonIndex);
+          const encodeType = url.slice(semicolonIndex + 1, comma);
+          const data = url.slice(comma + 1);
+
+          return {
+            type: "image" as const,
+            source: {
+              type: encodeType,
+              media_type: mimeType,
+              data,
+            },
+          };
+        }),
+      };
+    });
+
+    const modelConfig = {
+      ...useAppConfig.getState().modelConfig,
+      ...useChatStore.getState().currentSession().mask.modelConfig,
+      ...{
+        model: options.config.model,
+      },
+    };
+
+    const requestBody: AnthropicChatRequest = {
+      messages: prompt,
+      stream: shouldStream,
+
+      model: modelConfig.model,
+      max_tokens: modelConfig.max_tokens,
+      temperature: modelConfig.temperature,
+      top_p: modelConfig.top_p,
+      // top_k: modelConfig.top_k,
+      top_k: 5,
+    };
+
+    const path = this.path(Anthropic.ChatPath);
+
+    const controller = new AbortController();
+    options.onController?.(controller);
+
+    const payload = {
+      method: "POST",
+      body: JSON.stringify(requestBody),
+      signal: controller.signal,
+      headers: {
+        "Content-Type": "application/json",
+        Accept: "application/json",
+        "x-api-key": accessStore.anthropicApiKey,
+        "anthropic-version": accessStore.anthropicApiVersion,
+        Authorization: getAuthKey(accessStore.anthropicApiKey),
+      },
+      // mode: (!clientConfig?.isApp && pathObj.hostname === location.hostname ? "same-origin" : "cors") as RequestMode,
+      // mode: "no-cors" as RequestMode,
+      credentials: "include" as RequestCredentials,
+    };
+
+    if (shouldStream) {
+      try {
+        const context = {
+          text: "",
+          finished: false,
+        };
+
+        const finish = () => {
+          if (!context.finished) {
+            options.onFinish(context.text);
+            context.finished = true;
+          }
+        };
+
+        controller.signal.onabort = finish;
+        fetchEventSource(path, {
+          ...payload,
+          async onopen(res) {
+            const contentType = res.headers.get("content-type");
+            console.log("response content type: ", contentType);
+
+            if (contentType?.startsWith("text/plain")) {
+              context.text = await res.clone().text();
+              return finish();
+            }
+
+            if (
+              !res.ok ||
+              !res.headers
+                .get("content-type")
+                ?.startsWith(EventStreamContentType) ||
+              res.status !== 200
+            ) {
+              const responseTexts = [context.text];
+              let extraInfo = await res.clone().text();
+              try {
+                const resJson = await res.clone().json();
+                extraInfo = prettyObject(resJson);
+              } catch {}
+
+              if (res.status === 401) {
+                responseTexts.push(Locale.Error.Unauthorized);
+              }
+
+              if (extraInfo) {
+                responseTexts.push(extraInfo);
+              }
+
+              context.text = responseTexts.join("\n\n");
+
+              return finish();
+            }
+          },
+          onmessage(msg) {
+            if (msg.data === "[DONE]" || context.finished) {
+              return finish();
+            }
+            const chunk = msg.data;
+            try {
+              const chunkJson = JSON.parse(chunk) as ChatStreamResponse;
+              const delta = chunkJson.completion;
+              if (delta) {
+                context.text += delta;
+                options.onUpdate?.(context.text, delta);
+              }
+            } catch (e) {
+              console.error("[Request] parse error", chunk, msg);
+            }
+          },
+          onclose() {
+            finish();
+          },
+          onerror(e) {
+            options.onError?.(e);
+            throw e;
+          },
+          openWhenHidden: true,
+        });
+      } catch (e) {
+        console.error("failed to chat", e);
+        options.onError?.(e as Error);
+      }
+    } else {
+      try {
+        controller.signal.onabort = () => options.onFinish("");
+
+        const res = await fetch(path, payload);
+        const resJson = await res.json();
+
+        const message = this.extractMessage(resJson);
+        options.onFinish(message);
+      } catch (e) {
+        console.error("failed to chat", e);
+        options.onError?.(e as Error);
+      }
+    }
+  }
+  async usage() {
+    return {
+      used: 0,
+      total: 0,
+    };
+  }
+  async models() {
+    const provider = {
+      id: "anthropic",
+      providerName: "Anthropic",
+      providerType: "anthropic",
+    };
+
+    return [
+      {
+        name: "claude-instant-1",
+        available: true,
+        provider,
+      },
+      {
+        name: "claude-2",
+        available: true,
+        provider,
+      },
+      {
+        name: "claude-3-opus-20240229",
+        available: true,
+        provider,
+      },
+      {
+        name: "claude-3-sonnet-20240229",
+        available: true,
+        provider,
+      },
+      {
+        name: "claude-3-haiku-20240307",
+        available: true,
+        provider,
+      },
+    ];
+  }
+  path(path: string): string {
+    const accessStore = useAccessStore.getState();
+
+    let baseUrl: string = accessStore.anthropicUrl;
+
+    // if endpoint is empty, use default endpoint
+    if (baseUrl.trim().length === 0) {
+      const isApp = !!getClientConfig()?.isApp;
+
+      baseUrl = isApp
+        ? DEFAULT_API_HOST + "/api/proxy" + ApiPath.Anthropic
+        : ApiPath.Anthropic;
+    }
+
+    if (!baseUrl.startsWith("http") && !baseUrl.startsWith("/api")) {
+      baseUrl = "https://" + baseUrl;
+    }
+
+    baseUrl = trimEnd(baseUrl, "/");
+
+    return `${baseUrl}/${path}`;
+  }
+}
+
+function trimEnd(s: string, end = " ") {
+  if (end.length === 0) return s;
+
+  while (s.endsWith(end)) {
+    s = s.slice(0, -end.length);
+  }
+
+  return s;
+}
+
+function bearer(value: string) {
+  return `Bearer ${value.trim()}`;
+}
+
+function getAuthKey(apiKey = "") {
+  const accessStore = useAccessStore.getState();
+  const isApp = !!getClientConfig()?.isApp;
+  let authKey = "";
+
+  if (apiKey) {
+    // use user's api key first
+    authKey = bearer(apiKey);
+  } else if (
+    accessStore.enabledAccessControl() &&
+    !isApp &&
+    !!accessStore.accessCode
+  ) {
+    // or use access code
+    authKey = bearer(ACCESS_CODE_PREFIX + accessStore.accessCode);
+  }
+
+  return authKey;
+}

+ 2 - 0
app/components/exporter.tsx

@@ -315,6 +315,8 @@ export function PreviewActions(props: {
     var api: ClientApi;
     if (config.modelConfig.model.startsWith("gemini")) {
       api = new ClientApi(ModelProvider.GeminiPro);
+    } else if (config.modelConfig.model.startsWith("claude")) {
+      api = new ClientApi(ModelProvider.Claude);
     } else {
       api = new ClientApi(ModelProvider.GPT);
     }

+ 2 - 0
app/components/home.tsx

@@ -173,6 +173,8 @@ export function useLoadData() {
   var api: ClientApi;
   if (config.modelConfig.model.startsWith("gemini")) {
     api = new ClientApi(ModelProvider.GeminiPro);
+  } else if (config.modelConfig.model.startsWith("claude")) {
+    api = new ClientApi(ModelProvider.Claude);
   } else {
     api = new ClientApi(ModelProvider.GPT);
   }

+ 70 - 4
app/components/settings.tsx

@@ -51,6 +51,7 @@ import Locale, {
 import { copyToClipboard } from "../utils";
 import Link from "next/link";
 import {
+  Anthropic,
   Azure,
   Google,
   OPENAI_BASE_URL,
@@ -963,7 +964,7 @@ export function Settings() {
                     </Select>
                   </ListItem>
 
-                  {accessStore.provider === "OpenAI" ? (
+                  {accessStore.provider === ServiceProvider.OpenAI && (
                     <>
                       <ListItem
                         title={Locale.Settings.Access.OpenAI.Endpoint.Title}
@@ -1002,7 +1003,8 @@ export function Settings() {
                         />
                       </ListItem>
                     </>
-                  ) : accessStore.provider === "Azure" ? (
+                  )}
+                  {accessStore.provider === ServiceProvider.Azure && (
                     <>
                       <ListItem
                         title={Locale.Settings.Access.Azure.Endpoint.Title}
@@ -1061,7 +1063,8 @@ export function Settings() {
                         ></input>
                       </ListItem>
                     </>
-                  ) : accessStore.provider === "Google" ? (
+                  )}
+                  {accessStore.provider === ServiceProvider.Google && (
                     <>
                       <ListItem
                         title={Locale.Settings.Access.Google.Endpoint.Title}
@@ -1120,7 +1123,70 @@ export function Settings() {
                         ></input>
                       </ListItem>
                     </>
-                  ) : null}
+                  )}
+                  {accessStore.provider === ServiceProvider.Anthropic && (
+                    <>
+                      <ListItem
+                        title={Locale.Settings.Access.Anthropic.Endpoint.Title}
+                        subTitle={
+                          Locale.Settings.Access.Anthropic.Endpoint.SubTitle +
+                          Anthropic.ExampleEndpoint
+                        }
+                      >
+                        <input
+                          type="text"
+                          value={accessStore.anthropicUrl}
+                          placeholder={Anthropic.ExampleEndpoint}
+                          onChange={(e) =>
+                            accessStore.update(
+                              (access) =>
+                                (access.anthropicUrl = e.currentTarget.value),
+                            )
+                          }
+                        ></input>
+                      </ListItem>
+                      <ListItem
+                        title={Locale.Settings.Access.Anthropic.ApiKey.Title}
+                        subTitle={
+                          Locale.Settings.Access.Anthropic.ApiKey.SubTitle
+                        }
+                      >
+                        <PasswordInput
+                          value={accessStore.anthropicApiKey}
+                          type="text"
+                          placeholder={
+                            Locale.Settings.Access.Anthropic.ApiKey.Placeholder
+                          }
+                          onChange={(e) => {
+                            accessStore.update(
+                              (access) =>
+                                (access.anthropicApiKey =
+                                  e.currentTarget.value),
+                            );
+                          }}
+                        />
+                      </ListItem>
+                      <ListItem
+                        title={Locale.Settings.Access.Anthropic.ApiVerion.Title}
+                        subTitle={
+                          Locale.Settings.Access.Anthropic.ApiVerion.SubTitle
+                        }
+                      >
+                        <input
+                          type="text"
+                          value={accessStore.anthropicApiVersion}
+                          placeholder={Anthropic.Vision}
+                          onChange={(e) =>
+                            accessStore.update(
+                              (access) =>
+                                (access.anthropicApiVersion =
+                                  e.currentTarget.value),
+                            )
+                          }
+                        ></input>
+                      </ListItem>
+                    </>
+                  )}
                 </>
               )}
             </>

+ 6 - 0
app/config/server.ts

@@ -69,6 +69,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 apiKeyEnvVar = process.env.OPENAI_API_KEY ?? "";
   const apiKeys = apiKeyEnvVar.split(",").map((v) => v.trim());
@@ -92,6 +93,11 @@ export const getServerSideConfig = () => {
     googleApiKey: process.env.GOOGLE_API_KEY,
     googleUrl: process.env.GOOGLE_URL,
 
+    isAnthropic,
+    anthropicApiKey: process.env.ANTHROPIC_API_KEY,
+    anthropicApiVersion: process.env.ANTHROPIC_API_VERSION,
+    anthropicUrl: process.env.ANTHROPIC_URL,
+
     gtmId: process.env.GTM_ID,
 
     needCode: ACCESS_CODES.size > 0,

+ 65 - 0
app/constant.ts

@@ -10,6 +10,7 @@ export const RUNTIME_CONFIG_DOM = "danger-runtime-config";
 
 export const DEFAULT_API_HOST = "https://api.nextchat.dev";
 export const OPENAI_BASE_URL = "https://api.openai.com";
+export const ANTHROPIC_BASE_URL = "https://api.anthropic.com";
 
 export const GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/";
 
@@ -25,6 +26,7 @@ export enum Path {
 export enum ApiPath {
   Cors = "",
   OpenAI = "/api/openai",
+  Anthropic = "/api/anthropic",
 }
 
 export enum SlotID {
@@ -67,13 +69,22 @@ export enum ServiceProvider {
   OpenAI = "OpenAI",
   Azure = "Azure",
   Google = "Google",
+  Anthropic = "Anthropic",
 }
 
 export enum ModelProvider {
   GPT = "GPT",
   GeminiPro = "GeminiPro",
+  Claude = "Claude",
 }
 
+export const Anthropic = {
+  ChatPath: "v1/messages",
+  ChatPath1: "v1/complete",
+  ExampleEndpoint: "https://api.anthropic.com",
+  Vision: "2023-06-01",
+};
+
 export const OpenaiPath = {
   ChatPath: "v1/chat/completions",
   UsagePath: "dashboard/billing/usage",
@@ -289,6 +300,60 @@ export const DEFAULT_MODELS = [
       providerType: "google",
     },
   },
+  {
+    name: "claude-instant-1",
+    available: true,
+    provider: {
+      id: "anthropic",
+      providerName: "Anthropic",
+      providerType: "anthropic",
+    },
+  },
+  {
+    name: "claude-2",
+    available: true,
+    provider: {
+      id: "anthropic",
+      providerName: "Anthropic",
+      providerType: "anthropic",
+    },
+  },
+  {
+    name: "claude-3",
+    available: true,
+    provider: {
+      id: "anthropic",
+      providerName: "Anthropic",
+      providerType: "anthropic",
+    },
+  },
+  {
+    name: "claude-3-opus-20240229",
+    available: true,
+    provider: {
+      id: "anthropic",
+      providerName: "Anthropic",
+      providerType: "anthropic",
+    },
+  },
+  {
+    name: "claude-3-sonnet-20240229",
+    available: true,
+    provider: {
+      id: "anthropic",
+      providerName: "Anthropic",
+      providerType: "anthropic",
+    },
+  },
+  {
+    name: "claude-3-haiku-20240307",
+    available: true,
+    provider: {
+      id: "anthropic",
+      providerName: "Anthropic",
+      providerType: "anthropic",
+    },
+  },
 ] as const;
 
 export const CHAT_PAGE_SIZE = 15;

+ 17 - 0
app/locales/cn.ts

@@ -313,6 +313,23 @@ const cn = {
           SubTitle: "选择指定的部分版本",
         },
       },
+      Anthropic: {
+        ApiKey: {
+          Title: "接口密钥",
+          SubTitle: "使用自定义 Anthropic Key 绕过密码访问限制",
+          Placeholder: "Anthropic API Key",
+        },
+
+        Endpoint: {
+          Title: "接口地址",
+          SubTitle: "样例:",
+        },
+
+        ApiVerion: {
+          Title: "接口版本 (claude api version)",
+          SubTitle: "选择一个特定的 API 版本输入",
+        },
+      },
       Google: {
         ApiKey: {
           Title: "API 密钥",

+ 18 - 0
app/locales/en.ts

@@ -316,6 +316,24 @@ const en: LocaleType = {
           SubTitle: "Check your api version from azure console",
         },
       },
+      Anthropic: {
+        ApiKey: {
+          Title: "Anthropic API Key",
+          SubTitle:
+            "Use a custom Anthropic Key to bypass password access restrictions",
+          Placeholder: "Anthropic API Key",
+        },
+
+        Endpoint: {
+          Title: "Endpoint Address",
+          SubTitle: "Example:",
+        },
+
+        ApiVerion: {
+          Title: "API Version (claude api version)",
+          SubTitle: "Select and input a specific API version",
+        },
+      },
       CustomModel: {
         Title: "Custom Models",
         SubTitle: "Custom model options, seperated by comma",

+ 17 - 0
app/locales/pt.ts

@@ -316,6 +316,23 @@ const pt: PartialLocaleType = {
           SubTitle: "Verifique sua versão API do console Azure",
         },
       },
+      Anthropic: {
+        ApiKey: {
+          Title: "Chave API Anthropic",
+          SubTitle: "Verifique sua chave API do console Anthropic",
+          Placeholder: "Chave API Anthropic",
+        },
+
+        Endpoint: {
+          Title: "Endpoint Address",
+          SubTitle: "Exemplo: ",
+        },
+
+        ApiVerion: {
+          Title: "Versão API (Versão api claude)",
+          SubTitle: "Verifique sua versão API do console Anthropic",
+        },
+      },
       CustomModel: {
         Title: "Modelos Personalizados",
         SubTitle: "Opções de modelo personalizado, separados por vírgula",

+ 17 - 0
app/locales/sk.ts

@@ -317,6 +317,23 @@ const sk: PartialLocaleType = {
           SubTitle: "Skontrolujte svoju verziu API v Azure konzole",
         },
       },
+      Anthropic: {
+        ApiKey: {
+          Title: "API kľúč Anthropic",
+          SubTitle: "Skontrolujte svoj API kľúč v Anthropic konzole",
+          Placeholder: "API kľúč Anthropic",
+        },
+
+        Endpoint: {
+          Title: "Adresa koncového bodu",
+          SubTitle: "Príklad:",
+        },
+
+        ApiVerion: {
+          Title: "Verzia API (claude verzia API)",
+          SubTitle: "Vyberte špecifickú verziu časti",
+        },
+      },
       CustomModel: {
         Title: "Vlastné modely",
         SubTitle: "Možnosti vlastného modelu, oddelené čiarkou",

+ 20 - 3
app/locales/tw.ts

@@ -314,6 +314,23 @@ const tw = {
           SubTitle: "選擇指定的部分版本",
         },
       },
+      Anthropic: {
+        ApiKey: {
+          Title: "API 密鑰",
+          SubTitle: "從 Anthropic AI 獲取您的 API 密鑰",
+          Placeholder: "Anthropic API Key",
+        },
+
+        Endpoint: {
+          Title: "終端地址",
+          SubTitle: "示例:",
+        },
+
+        ApiVerion: {
+          Title: "API 版本 (claude api version)",
+          SubTitle: "選擇一個特定的 API 版本输入",
+        },
+      },
       Google: {
         ApiKey: {
           Title: "API 密鑰",
@@ -467,12 +484,12 @@ const tw = {
 
 type DeepPartial<T> = T extends object
   ? {
-    [P in keyof T]?: DeepPartial<T[P]>;
-  }
+      [P in keyof T]?: DeepPartial<T[P]>;
+    }
   : T;
 
 export type LocaleType = typeof tw;
 export type PartialLocaleType = DeepPartial<typeof tw>;
 
 export default tw;
-// Translated by @chunkiuuu, feel free the submit new pr if there are typo/incorrect translations :D
+// Translated by @chunkiuuu, feel free the submit new pr if there are typo/incorrect translations :D

+ 10 - 0
app/store/access.ts

@@ -36,6 +36,11 @@ const DEFAULT_ACCESS_STATE = {
   googleApiKey: "",
   googleApiVersion: "v1",
 
+  // anthropic
+  anthropicApiKey: "",
+  anthropicApiVersion: "2023-06-01",
+  anthropicUrl: "",
+
   // server config
   needCode: true,
   hideUserApiKey: false,
@@ -67,6 +72,10 @@ export const useAccessStore = createPersistStore(
       return ensure(get(), ["googleApiKey"]);
     },
 
+    isValidAnthropic() {
+      return ensure(get(), ["anthropicApiKey"]);
+    },
+
     isAuthorized() {
       this.fetch();
 
@@ -75,6 +84,7 @@ export const useAccessStore = createPersistStore(
         this.isValidOpenAI() ||
         this.isValidAzure() ||
         this.isValidGoogle() ||
+        this.isValidAnthropic() ||
         !this.enabledAccessControl() ||
         (this.enabledAccessControl() && ensure(get(), ["accessCode"]))
       );

+ 5 - 1
app/store/chat.ts

@@ -287,7 +287,7 @@ export const useChatStore = createPersistStore(
           session.lastUpdate = Date.now();
         });
         get().updateStat(message);
-        get().summarizeSession();
+        // get().summarizeSession();
       },
 
       async onUserInput(content: string, attachImages?: string[]) {
@@ -348,6 +348,8 @@ export const useChatStore = createPersistStore(
         var api: ClientApi;
         if (modelConfig.model.startsWith("gemini")) {
           api = new ClientApi(ModelProvider.GeminiPro);
+        } else if (modelConfig.model.startsWith("claude")) {
+          api = new ClientApi(ModelProvider.Claude);
         } else {
           api = new ClientApi(ModelProvider.GPT);
         }
@@ -533,6 +535,8 @@ export const useChatStore = createPersistStore(
         var api: ClientApi;
         if (modelConfig.model.startsWith("gemini")) {
           api = new ClientApi(ModelProvider.GeminiPro);
+        } else if (modelConfig.model.startsWith("claude")) {
+          api = new ClientApi(ModelProvider.Claude);
         } else {
           api = new ClientApi(ModelProvider.GPT);
         }

+ 8 - 0
app/typing.ts

@@ -1 +1,9 @@
 export type Updater<T> = (updater: (value: T) => void) => void;
+
+export const ROLES = ["system", "user", "assistant"] as const;
+export type MessageRole = (typeof ROLES)[number];
+
+export interface RequestMessage {
+  role: MessageRole;
+  content: string;
+}

+ 9 - 14
app/utils.ts

@@ -2,16 +2,17 @@ import { useEffect, useState } from "react";
 import { showToast } from "./components/ui-lib";
 import Locale from "./locales";
 import { RequestMessage } from "./client/api";
-import { DEFAULT_MODELS } from "./constant";
 
 export function trimTopic(topic: string) {
   // Fix an issue where double quotes still show in the Indonesian language
   // This will remove the specified punctuation from the end of the string
   // and also trim quotes from both the start and end if they exist.
-  return topic
-    // fix for gemini
-    .replace(/^["“”*]+|["“”*]+$/g, "")
-    .replace(/[,。!?”“"、,.!?*]*$/, "");
+  return (
+    topic
+      // fix for gemini
+      .replace(/^["“”*]+|["“”*]+$/g, "")
+      .replace(/[,。!?”“"、,.!?*]*$/, "")
+  );
 }
 
 export async function copyToClipboard(text: string) {
@@ -57,10 +58,7 @@ export async function downloadAs(text: string, filename: string) {
 
     if (result !== null) {
       try {
-        await window.__TAURI__.fs.writeTextFile(
-          result,
-          text
-        );
+        await window.__TAURI__.fs.writeTextFile(result, text);
         showToast(Locale.Download.Success);
       } catch (error) {
         showToast(Locale.Download.Failed);
@@ -293,10 +291,7 @@ export function getMessageImages(message: RequestMessage): string[] {
 
 export function isVisionModel(model: string) {
   // Note: This is a better way using the TypeScript feature instead of `&&` or `||` (ts v5.5.0-dev.20240314 I've been using)
-  const visionKeywords = [
-    "vision",
-    "claude-3",
-  ];
+  const visionKeywords = ["vision", "claude-3"];
 
-  return visionKeywords.some(keyword => model.includes(keyword));
+  return visionKeywords.some((keyword) => model.includes(keyword));
 }

+ 17 - 0
app/utils/object.ts

@@ -0,0 +1,17 @@
+export function omit<T extends object, U extends (keyof T)[]>(
+  obj: T,
+  ...keys: U
+): Omit<T, U[number]> {
+  const ret: any = { ...obj };
+  keys.forEach((key) => delete ret[key]);
+  return ret;
+}
+
+export function pick<T extends object, U extends (keyof T)[]>(
+  obj: T,
+  ...keys: U
+): Pick<T, U[number]> {
+  const ret: any = {};
+  keys.forEach((key) => (ret[key] = obj[key]));
+  return ret;
+}