diff --git a/.gitignore b/.gitignore index a24c6e047..2ff556f64 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,5 @@ dev *.key *.key.pub + +masks.json diff --git a/README.md b/README.md index 625e3cdc9..290a7f6ac 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@
-icon + + + icon +

NextChat (ChatGPT Next Web)

@@ -323,6 +326,14 @@ You can use this option if you want to increase the number of webdav service add Customize the default template used to initialize the User Input Preprocessing configuration item in Settings. +### `STABILITY_API_KEY` (optional) + +Stability API key. + +### `STABILITY_URL` (optional) + +Customize Stability API url. + ## Requirements NodeJS >= 18, Docker >= 20 diff --git a/README_CN.md b/README_CN.md index 40d473462..8c464dc09 100644 --- a/README_CN.md +++ b/README_CN.md @@ -1,5 +1,8 @@
-预览 + + + icon +

NextChat

@@ -215,6 +218,15 @@ ByteDance Api Url. 自定义默认的 template,用于初始化『设置』中的『用户输入预处理』配置项 +### `STABILITY_API_KEY` (optional) + +Stability API密钥 + +### `STABILITY_URL` (optional) + +自定义的Stability API请求地址 + + ## 开发 点击下方按钮,开始二次开发: diff --git a/README_JA.md b/README_JA.md new file mode 100644 index 000000000..6b8caadae --- /dev/null +++ b/README_JA.md @@ -0,0 +1,310 @@ +
+プレビュー + +

NextChat

+ +ワンクリックで無料であなた専用の ChatGPT ウェブアプリをデプロイ。GPT3、GPT4 & Gemini Pro モデルをサポート。 + +[企業版](#企業版) / [デモ](https://chat-gpt-next-web.vercel.app/) / [フィードバック](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [Discordに参加](https://discord.gg/zrhvHCr79N) + +[Zeaburでデプロイ](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FChatGPTNextWeb%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&project-name=nextchat&repository-name=NextChat) [Zeaburでデプロイ](https://zeabur.com/templates/ZBUEFA) [Gitpodで開く](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web) + + +
+ +## 企業版 + +あなたの会社のプライベートデプロイとカスタマイズのニーズに応える +- **ブランドカスタマイズ**:企業向けに特別に設計された VI/UI、企業ブランドイメージとシームレスにマッチ +- **リソース統合**:企業管理者が数十種類のAIリソースを統一管理、チームメンバーはすぐに使用可能 +- **権限管理**:メンバーの権限、リソースの権限、ナレッジベースの権限を明確にし、企業レベルのAdmin Panelで統一管理 +- **知識の統合**:企業内部のナレッジベースとAI機能を結びつけ、汎用AIよりも企業自身の業務ニーズに近づける +- **セキュリティ監査**:機密質問を自動的にブロックし、すべての履歴対話を追跡可能にし、AIも企業の情報セキュリティ基準に従わせる +- **プライベートデプロイ**:企業レベルのプライベートデプロイ、主要なプライベートクラウドデプロイをサポートし、データのセキュリティとプライバシーを保護 +- **継続的な更新**:マルチモーダル、エージェントなどの最先端機能を継続的に更新し、常に最新であり続ける + +企業版のお問い合わせ: **business@nextchat.dev** + + +## 始めに + +1. [OpenAI API Key](https://platform.openai.com/account/api-keys)を準備する; +2. 右側のボタンをクリックしてデプロイを開始: + [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&env=GOOGLE_API_KEY&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web) 、GitHubアカウントで直接ログインし、環境変数ページにAPI Keyと[ページアクセスパスワード](#設定ページアクセスパスワード) CODEを入力してください; +3. デプロイが完了したら、すぐに使用を開始できます; +4. (オプション)[カスタムドメインをバインド](https://vercel.com/docs/concepts/projects/domains/add-a-domain):Vercelが割り当てたドメインDNSは一部の地域で汚染されているため、カスタムドメインをバインドすると直接接続できます。 + +
+ +![メインインターフェース](./docs/images/cover.png) + +
+ + +## 更新を維持する + +もし上記の手順に従ってワンクリックでプロジェクトをデプロイした場合、「更新があります」というメッセージが常に表示されることがあります。これは、Vercel がデフォルトで新しいプロジェクトを作成するためで、本プロジェクトを fork していないことが原因です。そのため、正しく更新を検出できません。 + +以下の手順に従って再デプロイすることをお勧めします: + +- 元のリポジトリを削除する +- ページ右上の fork ボタンを使って、本プロジェクトを fork する +- Vercel で再度選択してデプロイする、[詳細な手順はこちらを参照してください](./docs/vercel-ja.md)。 + + +### 自動更新を開く + +> Upstream Sync の実行エラーが発生した場合は、手動で Sync Fork してください! + +プロジェクトを fork した後、GitHub の制限により、fork 後のプロジェクトの Actions ページで Workflows を手動で有効にし、Upstream Sync Action を有効にする必要があります。有効化後、毎時の定期自動更新が可能になります: + +![自動更新](./docs/images/enable-actions.jpg) + +![自動更新を有効にする](./docs/images/enable-actions-sync.jpg) + + +### 手動でコードを更新する + +手動で即座に更新したい場合は、[GitHub のドキュメント](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork)を参照して、fork したプロジェクトを上流のコードと同期する方法を確認してください。 + +このプロジェクトをスターまたはウォッチしたり、作者をフォローすることで、新機能の更新通知をすぐに受け取ることができます。 + + + +## ページアクセスパスワードを設定する + +> パスワードを設定すると、ユーザーは設定ページでアクセスコードを手動で入力しない限り、通常のチャットができず、未承認の状態であることを示すメッセージが表示されます。 + +> **警告**:パスワードの桁数は十分に長く設定してください。7桁以上が望ましいです。さもないと、[ブルートフォース攻撃を受ける可能性があります](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/518)。 + +このプロジェクトは限られた権限管理機能を提供しています。Vercel プロジェクトのコントロールパネルで、環境変数ページに `CODE` という名前の環境変数を追加し、値をカンマで区切ったカスタムパスワードに設定してください: + +``` +code1,code2,code3 +``` + +この環境変数を追加または変更した後、**プロジェクトを再デプロイ**して変更を有効にしてください。 + + +## 環境変数 + +> 本プロジェクトのほとんどの設定は環境変数で行います。チュートリアル:[Vercel の環境変数を変更する方法](./docs/vercel-ja.md)。 + +### `OPENAI_API_KEY` (必須) + +OpenAI の API キー。OpenAI アカウントページで申請したキーをカンマで区切って複数設定できます。これにより、ランダムにキーが選択されます。 + +### `CODE` (オプション) + +アクセスパスワード。カンマで区切って複数設定可能。 + +**警告**:この項目を設定しないと、誰でもデプロイしたウェブサイトを利用でき、トークンが急速に消耗する可能性があるため、設定をお勧めします。 + +### `BASE_URL` (オプション) + +> デフォルト: `https://api.openai.com` + +> 例: `http://your-openai-proxy.com` + +OpenAI API のプロキシ URL。手動で OpenAI API のプロキシを設定している場合はこのオプションを設定してください。 + +> SSL 証明書の問題がある場合は、`BASE_URL` のプロトコルを http に設定してください。 + +### `OPENAI_ORG_ID` (オプション) + +OpenAI の組織 ID を指定します。 + +### `AZURE_URL` (オプション) + +> 形式: https://{azure-resource-url}/openai/deployments/{deploy-name} +> `CUSTOM_MODELS` で `displayName` 形式で {deploy-name} を設定した場合、`AZURE_URL` から {deploy-name} を省略できます。 + +Azure のデプロイ URL。 + +### `AZURE_API_KEY` (オプション) + +Azure の API キー。 + +### `AZURE_API_VERSION` (オプション) + +Azure API バージョン。[Azure ドキュメント](https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#chat-completions)で確認できます。 + +### `GOOGLE_API_KEY` (オプション) + +Google Gemini Pro API キー。 + +### `GOOGLE_URL` (オプション) + +Google Gemini Pro API の URL。 + +### `ANTHROPIC_API_KEY` (オプション) + +Anthropic Claude API キー。 + +### `ANTHROPIC_API_VERSION` (オプション) + +Anthropic Claude API バージョン。 + +### `ANTHROPIC_URL` (オプション) + +Anthropic Claude API の URL。 + +### `BAIDU_API_KEY` (オプション) + +Baidu API キー。 + +### `BAIDU_SECRET_KEY` (オプション) + +Baidu シークレットキー。 + +### `BAIDU_URL` (オプション) + +Baidu API の URL。 + +### `BYTEDANCE_API_KEY` (オプション) + +ByteDance API キー。 + +### `BYTEDANCE_URL` (オプション) + +ByteDance API の URL。 + +### `ALIBABA_API_KEY` (オプション) + +アリババ(千问)API キー。 + +### `ALIBABA_URL` (オプション) + +アリババ(千问)API の URL。 + +### `HIDE_USER_API_KEY` (オプション) + +ユーザーが API キーを入力できないようにしたい場合は、この環境変数を 1 に設定します。 + +### `DISABLE_GPT4` (オプション) + +ユーザーが GPT-4 を使用できないようにしたい場合は、この環境変数を 1 に設定します。 + +### `ENABLE_BALANCE_QUERY` (オプション) + +バランスクエリ機能を有効にしたい場合は、この環境変数を 1 に設定します。 + +### `DISABLE_FAST_LINK` (オプション) + +リンクからのプリセット設定解析を無効にしたい場合は、この環境変数を 1 に設定します。 + +### `WHITE_WEBDEV_ENDPOINTS` (オプション) + +アクセス許可を与える WebDAV サービスのアドレスを追加したい場合、このオプションを使用します。フォーマット要件: +- 各アドレスは完全なエンドポイントでなければなりません。 +> `https://xxxx/xxx` +- 複数のアドレスは `,` で接続します。 + +### `CUSTOM_MODELS` (オプション) + +> 例:`+qwen-7b-chat,+glm-6b,-gpt-3.5-turbo,gpt-4-1106-preview=gpt-4-turbo` は `qwen-7b-chat` と `glm-6b` をモデルリストに追加し、`gpt-3.5-turbo` を削除し、`gpt-4-1106-preview` のモデル名を `gpt-4-turbo` として表示します。 +> すべてのモデルを無効にし、特定のモデルを有効にしたい場合は、`-all,+gpt-3.5-turbo` を使用します。これは `gpt-3.5-turbo` のみを有効にすることを意味します。 + +モデルリストを管理します。`+` でモデルを追加し、`-` でモデルを非表示にし、`モデル名=表示名` でモデルの表示名をカスタマイズし、カンマで区切ります。 + +Azure モードでは、`modelName@azure=deploymentName` 形式でモデル名とデプロイ名(deploy-name)を設定できます。 +> 例:`+gpt-3.5-turbo@azure=gpt35` この設定でモデルリストに `gpt35(Azure)` のオプションが表示されます。 + +ByteDance モードでは、`modelName@bytedance=deploymentName` 形式でモデル名とデプロイ名(deploy-name)を設定できます。 +> 例: `+Doubao-lite-4k@bytedance=ep-xxxxx-xxx` この設定でモデルリストに `Doubao-lite-4k(ByteDance)` のオプションが表示されます。 + +### `DEFAULT_MODEL` (オプション) + +デフォルトのモデルを変更します。 + +### `DEFAULT_INPUT_TEMPLATE` (オプション) + +『設定』の『ユーザー入力前処理』の初期設定に使用するテンプレートをカスタマイズします。 + + +## 開発 + +下のボタンをクリックして二次開発を開始してください: + +[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web) + +コードを書く前に、プロジェクトのルートディレクトリに `.env.local` ファイルを新規作成し、環境変数を記入します: + +``` +OPENAI_API_KEY= +``` + + +### ローカル開発 + +1. Node.js 18 と Yarn をインストールします。具体的な方法は ChatGPT にお尋ねください。 +2. `yarn install && yarn dev` を実行します。⚠️ 注意:このコマンドはローカル開発用であり、デプロイには使用しないでください。 +3. ローカルでデプロイしたい場合は、`yarn install && yarn build && yarn start` コマンドを使用してください。プロセスを守るために pm2 を使用することもできます。詳細は ChatGPT にお尋ねください。 + + +## デプロイ + +### コンテナデプロイ(推奨) + +> Docker バージョンは 20 以上が必要です。それ以下だとイメージが見つからないというエラーが出ます。 + +> ⚠️ 注意:Docker バージョンは最新バージョンより 1~2 日遅れることが多いため、デプロイ後に「更新があります」の通知が出続けることがありますが、正常です。 + +```shell +docker pull yidadaa/chatgpt-next-web + +docker run -d -p 3000:3000 \ + -e OPENAI_API_KEY=sk-xxxx \ + -e CODE=ページアクセスパスワード \ + yidadaa/chatgpt-next-web +``` + +プロキシを指定することもできます: + +```shell +docker run -d -p 3000:3000 \ + -e OPENAI_API_KEY=sk-xxxx \ + -e CODE=ページアクセスパスワード \ + --net=host \ + -e PROXY_URL=http://127.0.0.1:7890 \ + yidadaa/chatgpt-next-web +``` + +ローカルプロキシがアカウントとパスワードを必要とする場合は、以下を使用できます: + +```shell +-e PROXY_URL="http://127.0.0.1:7890 user password" +``` + +他の環境変数を指定する必要がある場合は、上記のコマンドに `-e 環境変数=環境変数値` を追加して指定してください。 + + +### ローカルデプロイ + +コンソールで以下のコマンドを実行します: + +```shell +bash <(curl -s https://raw.githubusercontent.com/Yidadaa/ChatGPT-Next-Web/main/scripts/setup.sh) +``` + +⚠️ 注意:インストール中に問題が発生した場合は、Docker を使用してデプロイしてください。 + + +## 謝辞 + +### 寄付者 + +> 英語版をご覧ください。 + +### 貢献者 + +[プロジェクトの貢献者リストはこちら](https://github.com/Yidadaa/ChatGPT-Next-Web/graphs/contributors) + +### 関連プロジェクト + +- [one-api](https://github.com/songquanpeng/one-api): 一つのプラットフォームで大規模モデルのクォータ管理を提供し、市場に出回っているすべての主要な大規模言語モデルをサポートします。 + + +## オープンソースライセンス + +[MIT](https://opensource.org/license/mit/) diff --git a/app/api/artifacts/route.ts b/app/api/artifacts/route.ts new file mode 100644 index 000000000..4707e795f --- /dev/null +++ b/app/api/artifacts/route.ts @@ -0,0 +1,73 @@ +import md5 from "spark-md5"; +import { NextRequest, NextResponse } from "next/server"; +import { getServerSideConfig } from "@/app/config/server"; + +async function handle(req: NextRequest, res: NextResponse) { + const serverConfig = getServerSideConfig(); + const storeUrl = () => + `https://api.cloudflare.com/client/v4/accounts/${serverConfig.cloudflareAccountId}/storage/kv/namespaces/${serverConfig.cloudflareKVNamespaceId}`; + const storeHeaders = () => ({ + Authorization: `Bearer ${serverConfig.cloudflareKVApiKey}`, + }); + if (req.method === "POST") { + const clonedBody = await req.text(); + const hashedCode = md5.hash(clonedBody).trim(); + const body: { + key: string; + value: string; + expiration_ttl?: number; + } = { + key: hashedCode, + value: clonedBody, + }; + try { + const ttl = parseInt(serverConfig.cloudflareKVTTL as string); + if (ttl > 60) { + body["expiration_ttl"] = ttl; + } + } catch (e) { + console.error(e); + } + const res = await fetch(`${storeUrl()}/bulk`, { + headers: { + ...storeHeaders(), + "Content-Type": "application/json", + }, + method: "PUT", + body: JSON.stringify([body]), + }); + const result = await res.json(); + console.log("save data", result); + if (result?.success) { + return NextResponse.json( + { code: 0, id: hashedCode, result }, + { status: res.status }, + ); + } + return NextResponse.json( + { error: true, msg: "Save data error" }, + { status: 400 }, + ); + } + if (req.method === "GET") { + const id = req?.nextUrl?.searchParams?.get("id"); + const res = await fetch(`${storeUrl()}/values/${id}`, { + headers: storeHeaders(), + method: "GET", + }); + return new Response(res.body, { + status: res.status, + statusText: res.statusText, + headers: res.headers, + }); + } + return NextResponse.json( + { error: true, msg: "Invalid request" }, + { status: 400 }, + ); +} + +export const POST = handle; +export const GET = handle; + +export const runtime = "edge"; diff --git a/app/api/auth.ts b/app/api/auth.ts index 8929fe8ad..54123e89c 100644 --- a/app/api/auth.ts +++ b/app/api/auth.ts @@ -70,6 +70,9 @@ export function auth(req: NextRequest, modelProvider: ModelProvider) { let systemApiKey: string | undefined; switch (modelProvider) { + case ModelProvider.Stability: + systemApiKey = serverConfig.stabilityApiKey; + break; case ModelProvider.GeminiPro: systemApiKey = serverConfig.googleApiKey; break; diff --git a/app/api/google/[...path]/route.ts b/app/api/google/[...path]/route.ts index 81e50538a..83a7ce794 100644 --- a/app/api/google/[...path]/route.ts +++ b/app/api/google/[...path]/route.ts @@ -1,7 +1,15 @@ import { NextRequest, NextResponse } from "next/server"; import { auth } from "../../auth"; import { getServerSideConfig } from "@/app/config/server"; -import { GEMINI_BASE_URL, Google, ModelProvider } from "@/app/constant"; +import { + ApiPath, + GEMINI_BASE_URL, + Google, + ModelProvider, +} from "@/app/constant"; +import { prettyObject } from "@/app/utils/format"; + +const serverConfig = getServerSideConfig(); async function handle( req: NextRequest, @@ -13,32 +21,6 @@ async function handle( return NextResponse.json({ body: "OK" }, { status: 200 }); } - const controller = new AbortController(); - - const serverConfig = getServerSideConfig(); - - let baseUrl = serverConfig.googleUrl || GEMINI_BASE_URL; - - if (!baseUrl.startsWith("http")) { - baseUrl = `https://${baseUrl}`; - } - - if (baseUrl.endsWith("/")) { - baseUrl = baseUrl.slice(0, -1); - } - - let path = `${req.nextUrl.pathname}`.replaceAll("/api/google/", ""); - - console.log("[Proxy] ", path); - console.log("[Base Url]", baseUrl); - - const timeoutId = setTimeout( - () => { - controller.abort(); - }, - 10 * 60 * 1000, - ); - const authResult = auth(req, ModelProvider.GeminiPro); if (authResult.error) { return NextResponse.json(authResult, { @@ -49,9 +31,9 @@ async function handle( const bearToken = req.headers.get("Authorization") ?? ""; const token = bearToken.trim().replaceAll("Bearer ", "").trim(); - const key = token ? token : serverConfig.googleApiKey; + const apiKey = token ? token : serverConfig.googleApiKey; - if (!key) { + if (!apiKey) { return NextResponse.json( { error: true, @@ -62,10 +44,63 @@ async function handle( }, ); } + try { + const response = await request(req, apiKey); + return response; + } catch (e) { + console.error("[Google] ", e); + return NextResponse.json(prettyObject(e)); + } +} - const fetchUrl = `${baseUrl}/${path}?key=${key}${ - req?.nextUrl?.searchParams?.get("alt") == "sse" ? "&alt=sse" : "" +export const GET = handle; +export const POST = handle; + +export const runtime = "edge"; +export const preferredRegion = [ + "bom1", + "cle1", + "cpt1", + "gru1", + "hnd1", + "iad1", + "icn1", + "kix1", + "pdx1", + "sfo1", + "sin1", + "syd1", +]; + +async function request(req: NextRequest, apiKey: string) { + const controller = new AbortController(); + + let baseUrl = serverConfig.googleUrl || GEMINI_BASE_URL; + + let path = `${req.nextUrl.pathname}`.replaceAll(ApiPath.Google, ""); + + 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}?key=${apiKey}${ + req?.nextUrl?.searchParams?.get("alt") === "sse" ? "&alt=sse" : "" }`; + + console.log("[Fetch Url] ", fetchUrl); const fetchOptions: RequestInit = { headers: { "Content-Type": "application/json", @@ -97,22 +132,3 @@ async function handle( clearTimeout(timeoutId); } } - -export const GET = handle; -export const POST = handle; - -export const runtime = "edge"; -export const preferredRegion = [ - "bom1", - "cle1", - "cpt1", - "gru1", - "hnd1", - "iad1", - "icn1", - "kix1", - "pdx1", - "sfo1", - "sin1", - "syd1", -]; diff --git a/app/api/stability/[...path]/route.ts b/app/api/stability/[...path]/route.ts new file mode 100644 index 000000000..4b2bcc305 --- /dev/null +++ b/app/api/stability/[...path]/route.ts @@ -0,0 +1,104 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSideConfig } from "@/app/config/server"; +import { ModelProvider, STABILITY_BASE_URL } from "@/app/constant"; +import { auth } from "@/app/api/auth"; + +async function handle( + req: NextRequest, + { params }: { params: { path: string[] } }, +) { + console.log("[Stability] params ", params); + + if (req.method === "OPTIONS") { + return NextResponse.json({ body: "OK" }, { status: 200 }); + } + + const controller = new AbortController(); + + const serverConfig = getServerSideConfig(); + + let baseUrl = serverConfig.stabilityUrl || STABILITY_BASE_URL; + + if (!baseUrl.startsWith("http")) { + baseUrl = `https://${baseUrl}`; + } + + if (baseUrl.endsWith("/")) { + baseUrl = baseUrl.slice(0, -1); + } + + let path = `${req.nextUrl.pathname}`.replaceAll("/api/stability/", ""); + + console.log("[Stability Proxy] ", path); + console.log("[Stability Base Url]", baseUrl); + + const timeoutId = setTimeout( + () => { + controller.abort(); + }, + 10 * 60 * 1000, + ); + + const authResult = auth(req, ModelProvider.Stability); + + if (authResult.error) { + return NextResponse.json(authResult, { + status: 401, + }); + } + + const bearToken = req.headers.get("Authorization") ?? ""; + const token = bearToken.trim().replaceAll("Bearer ", "").trim(); + + const key = token ? token : serverConfig.stabilityApiKey; + + if (!key) { + return NextResponse.json( + { + error: true, + message: `missing STABILITY_API_KEY in server env vars`, + }, + { + status: 401, + }, + ); + } + + const fetchUrl = `${baseUrl}/${path}`; + console.log("[Stability Url] ", fetchUrl); + const fetchOptions: RequestInit = { + headers: { + "Content-Type": req.headers.get("Content-Type") || "multipart/form-data", + Accept: req.headers.get("Accept") || "application/json", + Authorization: `Bearer ${key}`, + }, + method: req.method, + body: req.body, + // to fix #2485: https://stackoverflow.com/questions/55920957/cloudflare-worker-typeerror-one-time-use-body + redirect: "manual", + // @ts-ignore + duplex: "half", + signal: controller.signal, + }; + + try { + const res = await fetch(fetchUrl, fetchOptions); + // to prevent browser prompt for credentials + const newHeaders = new Headers(res.headers); + newHeaders.delete("www-authenticate"); + // to disable nginx buffering + newHeaders.set("X-Accel-Buffering", "no"); + return new Response(res.body, { + status: res.status, + statusText: res.statusText, + headers: newHeaders, + }); + } finally { + clearTimeout(timeoutId); + } +} + +export const GET = handle; +export const POST = handle; + +export const runtime = "edge"; diff --git a/app/api/webdav/[...path]/route.ts b/app/api/webdav/[...path]/route.ts index 01286fc1b..1f58a884f 100644 --- a/app/api/webdav/[...path]/route.ts +++ b/app/api/webdav/[...path]/route.ts @@ -37,9 +37,13 @@ async function handle( const normalizedAllowedEndpoint = normalizeUrl(allowedEndpoint); const normalizedEndpoint = normalizeUrl(endpoint as string); - return normalizedEndpoint && + return ( + normalizedEndpoint && normalizedEndpoint.hostname === normalizedAllowedEndpoint?.hostname && - normalizedEndpoint.pathname.startsWith(normalizedAllowedEndpoint.pathname); + normalizedEndpoint.pathname.startsWith( + normalizedAllowedEndpoint.pathname, + ) + ); }) ) { return NextResponse.json( diff --git a/app/client/api.ts b/app/client/api.ts index c0c71480c..102a4220f 100644 --- a/app/client/api.ts +++ b/app/client/api.ts @@ -168,6 +168,19 @@ export class ClientApi { } } +export function getBearerToken( + apiKey: string, + noBearer: boolean = false, +): string { + return validString(apiKey) + ? `${noBearer ? "" : "Bearer "}${apiKey.trim()}` + : ""; +} + +export function validString(x: string): boolean { + return x?.length > 0; +} + export function getHeaders() { const accessStore = useAccessStore.getState(); const chatStore = useChatStore.getState(); @@ -214,15 +227,6 @@ export function getHeaders() { return isAzure ? "api-key" : isAnthropic ? "x-api-key" : "Authorization"; } - function getBearerToken(apiKey: string, noBearer: boolean = false): string { - return validString(apiKey) - ? `${noBearer ? "" : "Bearer "}${apiKey.trim()}` - : ""; - } - - function validString(x: string): boolean { - return x?.length > 0; - } const { isGoogle, isAzure, diff --git a/app/client/platforms/alibaba.ts b/app/client/platforms/alibaba.ts index 723ba774b..d5fa3042f 100644 --- a/app/client/platforms/alibaba.ts +++ b/app/client/platforms/alibaba.ts @@ -21,7 +21,7 @@ import { } from "@fortaine/fetch-event-source"; import { prettyObject } from "@/app/utils/format"; import { getClientConfig } from "@/app/config/client"; -import { getMessageTextContent, isVisionModel } from "@/app/utils"; +import { getMessageTextContent } from "@/app/utils"; export interface OpenAIListModelResponse { object: string; diff --git a/app/client/platforms/anthropic.ts b/app/client/platforms/anthropic.ts index bf8faf837..b079ba1ad 100644 --- a/app/client/platforms/anthropic.ts +++ b/app/client/platforms/anthropic.ts @@ -3,7 +3,6 @@ import { ChatOptions, getHeaders, 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 { RequestMessage } from "@/app/typing"; import { EventStreamContentType, fetchEventSource, @@ -12,6 +11,7 @@ import { import Locale from "../../locales"; import { prettyObject } from "@/app/utils/format"; import { getMessageTextContent, isVisionModel } from "@/app/utils"; +import { preProcessImageContent } from "@/app/utils/chat"; import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare"; export type MultiBlockContent = { @@ -93,7 +93,12 @@ export class ClaudeApi implements LLMApi { }, }; - const messages = [...options.messages]; + // try get base64image from local cache image_url + const messages: ChatOptions["messages"] = []; + for (const v of options.messages) { + const content = await preProcessImageContent(v.content); + messages.push({ role: v.role, content }); + } const keys = ["system", "user"]; diff --git a/app/client/platforms/google.ts b/app/client/platforms/google.ts index 6054c7a47..1f55beebc 100644 --- a/app/client/platforms/google.ts +++ b/app/client/platforms/google.ts @@ -1,4 +1,4 @@ -import { Google, REQUEST_TIMEOUT_MS } from "@/app/constant"; +import { ApiPath, Google, REQUEST_TIMEOUT_MS } from "@/app/constant"; import { ChatOptions, getHeaders, LLMApi, LLMModel, LLMUsage } from "../api"; import { useAccessStore, useAppConfig, useChatStore } from "@/app/store"; import { getClientConfig } from "@/app/config/client"; @@ -14,8 +14,37 @@ import { getMessageImages, isVisionModel, } from "@/app/utils"; +import { preProcessImageContent } from "@/app/utils/chat"; export class GeminiProApi implements LLMApi { + path(path: string): string { + const accessStore = useAccessStore.getState(); + + let baseUrl = ""; + if (accessStore.useCustomConfig) { + baseUrl = accessStore.googleUrl; + } + + if (baseUrl.length === 0) { + const isApp = !!getClientConfig()?.isApp; + baseUrl = isApp + ? DEFAULT_API_HOST + `/api/proxy/google?key=${accessStore.googleApiKey}` + : ApiPath.Google; + } + if (baseUrl.endsWith("/")) { + baseUrl = baseUrl.slice(0, baseUrl.length - 1); + } + if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.Google)) { + baseUrl = "https://" + baseUrl; + } + + console.log("[Proxy Endpoint] ", baseUrl, path); + + let chatPath = [baseUrl, path].join("/"); + + chatPath += chatPath.includes("?") ? "&alt=sse" : "?alt=sse"; + return chatPath; + } extractMessage(res: any) { console.log("[Response] gemini-pro response: ", res); @@ -28,7 +57,14 @@ export class GeminiProApi implements LLMApi { async chat(options: ChatOptions): Promise { const apiClient = this; let multimodal = false; - const messages = options.messages.map((v) => { + + // try get base64image from local cache image_url + const _messages: ChatOptions["messages"] = []; + for (const v of options.messages) { + const content = await preProcessImageContent(v.content); + _messages.push({ role: v.role, content }); + } + const messages = _messages.map((v) => { let parts: any[] = [{ text: getMessageTextContent(v) }]; if (isVisionModel(options.config.model)) { const images = getMessageImages(v); @@ -70,6 +106,9 @@ export class GeminiProApi implements LLMApi { // if (visionModel && messages.length > 1) { // options.onError?.(new Error("Multiturn chat is not enabled for models/gemini-pro-vision")); // } + + const accessStore = useAccessStore.getState(); + const modelConfig = { ...useAppConfig.getState().modelConfig, ...useChatStore.getState().currentSession().mask.modelConfig, @@ -91,47 +130,30 @@ export class GeminiProApi implements LLMApi { safetySettings: [ { category: "HARM_CATEGORY_HARASSMENT", - threshold: "BLOCK_ONLY_HIGH", + threshold: accessStore.googleSafetySettings, }, { category: "HARM_CATEGORY_HATE_SPEECH", - threshold: "BLOCK_ONLY_HIGH", + threshold: accessStore.googleSafetySettings, }, { category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", - threshold: "BLOCK_ONLY_HIGH", + threshold: accessStore.googleSafetySettings, }, { category: "HARM_CATEGORY_DANGEROUS_CONTENT", - threshold: "BLOCK_ONLY_HIGH", + threshold: accessStore.googleSafetySettings, }, ], }; - const accessStore = useAccessStore.getState(); - - let baseUrl = ""; - - if (accessStore.useCustomConfig) { - baseUrl = accessStore.googleUrl; - } - - const isApp = !!getClientConfig()?.isApp; - let shouldStream = !!options.config.stream; const controller = new AbortController(); options.onController?.(controller); try { - if (!baseUrl && isApp) { - baseUrl = DEFAULT_API_HOST + "/api/proxy/google/"; - } - baseUrl = `${baseUrl}/${Google.ChatPath(modelConfig.model)}`.replaceAll( - "//", - "/", - ); - if (isApp) { - baseUrl += `?key=${accessStore.googleApiKey}`; - } + // https://github.com/google-gemini/cookbook/blob/main/quickstarts/rest/Streaming_REST.ipynb + const chatPath = this.path(Google.ChatPath(modelConfig.model)); + const chatPayload = { method: "POST", body: JSON.stringify(requestPayload), @@ -181,10 +203,6 @@ export class GeminiProApi implements LLMApi { controller.signal.onabort = finish; - // https://github.com/google-gemini/cookbook/blob/main/quickstarts/rest/Streaming_REST.ipynb - const chatPath = - baseUrl.replace("generateContent", "streamGenerateContent") + - (baseUrl.indexOf("?") > -1 ? "&alt=sse" : "?alt=sse"); fetchEventSource(chatPath, { ...chatPayload, async onopen(res) { @@ -259,7 +277,7 @@ export class GeminiProApi implements LLMApi { openWhenHidden: true, }); } else { - const res = await fetch(baseUrl, chatPayload); + const res = await fetch(chatPath, chatPayload); clearTimeout(requestTimeoutId); const resJson = await res.json(); if (resJson?.promptFeedback?.blockReason) { @@ -285,14 +303,4 @@ export class GeminiProApi implements LLMApi { async models(): Promise { return []; } - path(path: string): string { - return "/api/google/" + path; - } -} - -function ensureProperEnding(str: string) { - if (str.startsWith("[") && !str.endsWith("]")) { - return str + "]"; - } - return str; } diff --git a/app/client/platforms/openai.ts b/app/client/platforms/openai.ts index b1ccc2096..a60892c03 100644 --- a/app/client/platforms/openai.ts +++ b/app/client/platforms/openai.ts @@ -11,6 +11,7 @@ import { } 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 { @@ -123,10 +124,13 @@ export class ChatGPTApi implements LLMApi { 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 messages: ChatOptions["messages"] = []; + for (const v of options.messages) { + const content = visionModel + ? await preProcessImageContent(v.content) + : getMessageTextContent(v); + messages.push({ role: v.role, content }); + } const modelConfig = { ...useAppConfig.getState().modelConfig, diff --git a/app/components/artifacts.module.scss b/app/components/artifacts.module.scss new file mode 100644 index 000000000..6bd0fd9cf --- /dev/null +++ b/app/components/artifacts.module.scss @@ -0,0 +1,31 @@ +.artifacts { + display: flex; + width: 100%; + height: 100%; + flex-direction: column; + &-header { + display: flex; + align-items: center; + height: 36px; + padding: 20px; + background: var(--second); + } + &-title { + flex: 1; + text-align: center; + font-weight: bold; + font-size: 24px; + } + &-content { + flex-grow: 1; + padding: 0 20px 20px 20px; + background-color: var(--second); + } +} + +.artifacts-iframe { + width: 100%; + border: var(--border-in-light); + border-radius: 6px; + background-color: var(--gray); +} diff --git a/app/components/artifacts.tsx b/app/components/artifacts.tsx new file mode 100644 index 000000000..326891e73 --- /dev/null +++ b/app/components/artifacts.tsx @@ -0,0 +1,234 @@ +import { useEffect, useState, useRef, useMemo } from "react"; +import { useParams } from "react-router"; +import { useWindowSize } from "@/app/utils"; +import { IconButton } from "./button"; +import { nanoid } from "nanoid"; +import ExportIcon from "../icons/share.svg"; +import CopyIcon from "../icons/copy.svg"; +import DownloadIcon from "../icons/download.svg"; +import GithubIcon from "../icons/github.svg"; +import LoadingButtonIcon from "../icons/loading.svg"; +import Locale from "../locales"; +import { Modal, showToast } from "./ui-lib"; +import { copyToClipboard, downloadAs } from "../utils"; +import { Path, ApiPath, REPO_URL } from "@/app/constant"; +import { Loading } from "./home"; +import styles from "./artifacts.module.scss"; + +export function HTMLPreview(props: { + code: string; + autoHeight?: boolean; + height?: number | string; + onLoad?: (title?: string) => void; +}) { + const ref = useRef(null); + const frameId = useRef(nanoid()); + const [iframeHeight, setIframeHeight] = useState(600); + const [title, setTitle] = useState(""); + /* + * https://stackoverflow.com/questions/19739001/what-is-the-difference-between-srcdoc-and-src-datatext-html-in-an + * 1. using srcdoc + * 2. using src with dataurl: + * easy to share + * length limit (Data URIs cannot be larger than 32,768 characters.) + */ + + useEffect(() => { + const handleMessage = (e: any) => { + const { id, height, title } = e.data; + setTitle(title); + if (id == frameId.current) { + setIframeHeight(height); + } + }; + window.addEventListener("message", handleMessage); + return () => { + window.removeEventListener("message", handleMessage); + }; + }, []); + + const height = useMemo(() => { + if (!props.autoHeight) return props.height || 600; + if (typeof props.height === "string") { + return props.height; + } + const parentHeight = props.height || 600; + return iframeHeight + 40 > parentHeight ? parentHeight : iframeHeight + 40; + }, [props.autoHeight, props.height, iframeHeight]); + + const srcDoc = useMemo(() => { + const script = ``; + if (props.code.includes("")) { + props.code.replace("", "" + script); + } + return props.code + script; + }, [props.code]); + + const handleOnLoad = () => { + if (props?.onLoad) { + props.onLoad(title); + } + }; + + return ( +