├── .dockerignore ├── .env.example ├── .github └── workflows │ └── docker.yml ├── .gitignore ├── .npmrc ├── Dockerfile ├── LICENSE ├── README.md ├── app.vue ├── assets └── css │ └── style.css ├── components ├── ChatInput.vue ├── ChatList.vue ├── Footer.vue ├── Header.vue ├── IButton.vue ├── ModelSelect.vue ├── Pass.vue ├── Setting.vue └── Sidebar.vue ├── i18n.config.ts ├── layouts └── default.vue ├── nuxt.config.ts ├── package.json ├── pages └── index.vue ├── patches └── @google__generative-ai@0.12.0.patch ├── pnpm-lock.yaml ├── public ├── favicon.ico ├── manifest.json └── pwa.webp ├── server ├── api │ └── auth │ │ ├── gemini.post.ts │ │ ├── openai.post.ts │ │ └── workers │ │ ├── image.post.ts │ │ └── index.post.ts ├── middleware │ └── auth.ts └── tsconfig.json ├── tsconfig.json └── utils ├── api.ts ├── db.ts ├── helper.ts ├── store.ts ├── tools.ts └── types.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | # Nuxt dev/build outputs 2 | .output 3 | .data 4 | .nuxt 5 | .nitro 6 | .cache 7 | dist 8 | 9 | # Node dependencies 10 | node_modules 11 | 12 | # Logs 13 | logs 14 | *.log 15 | 16 | # Misc 17 | .DS_Store 18 | .fleet 19 | .idea 20 | 21 | # Local env files 22 | .env 23 | .env.* 24 | !.env.example 25 | 26 | .vercel 27 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | CF_TOKEN='A484BCC718B10B73022BFE2D9AB49776' 2 | CF_GATEWAY='https://gateway.ai.cloudflare.com/v1/A484BCC718B10B73022BFE2D9AB49776/myai' 3 | OPENAI_API_KEY='A484BCC718B10B73022BFE2D9AB49776' 4 | PASSWORD='000000' 5 | G_API_KEY='A484BCC718B10B73022BFE2D9AB49776' 6 | G_API_URL='https://generativelanguage.googleapis.com' -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: docker 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | jobs: 8 | docker: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | 13 | - name: Login to Docker Hub 14 | uses: docker/login-action@v3 15 | with: 16 | username: ${{ secrets.DOCKER_USERNAME }} 17 | password: ${{ secrets.DOCKER_PASSWORD }} 18 | 19 | - name: Build and push Docker image 20 | uses: docker/build-push-action@v5 21 | with: 22 | push: true 23 | tags: jazee6/cloudflare-ai-web:latest -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Nuxt dev/build outputs 2 | .output 3 | .data 4 | .nuxt 5 | .nitro 6 | .cache 7 | dist 8 | 9 | # Node dependencies 10 | node_modules 11 | 12 | # Logs 13 | logs 14 | *.log 15 | 16 | # Misc 17 | .DS_Store 18 | .fleet 19 | .idea 20 | 21 | # Local env files 22 | .env 23 | .env.* 24 | !.env.example 25 | 26 | .vercel 27 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-alpine 2 | 3 | WORKDIR /app 4 | 5 | EXPOSE 3000 6 | 7 | RUN npm i -g pnpm 8 | 9 | ADD package.json pnpm-lock.yaml ./ 10 | 11 | ADD patches ./patches 12 | 13 | RUN pnpm install 14 | 15 | COPY . . 16 | 17 | RUN pnpm build_node 18 | 19 | CMD ["node", ".output/server/index.mjs"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cloudflare-ai-web 2 | 3 | ## ‼️预计使用react + hono + shadcn/ui 重构项目,敬请期待 4 | 5 | ## AI 启动! 6 | 7 | ### 一键部署(推荐) 8 | 9 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FJazee6%2Fcloudflare-ai-web&env=CF_TOKEN,CF_GATEWAY&envDescription=%E7%8E%AF%E5%A2%83%E5%8F%98%E9%87%8F%E4%BF%A1%E6%81%AF%E8%AF%B7%E6%9F%A5%E7%9C%8B&envLink=https%3A%2F%2Fgithub.com%2FJazee6%2Fcloudflare-ai-web) 10 | 11 | 示例:https://ai.jaze.top 12 | 13 | ### Deno Deploy 14 | 15 | https://dash.deno.com 16 | 17 | - Fork 本仓库 18 | - Build Step改为`NITRO_PRESET=deno-deploy npm run build_node` 19 | - Deploy Project 20 | - 设置环境变量 21 | 22 | ### Docker 23 | 24 | ```bash 25 | docker run -d --name cloudflare-ai-web \ 26 | -e CF_TOKEN=YOUR_CF_TOKEN \ 27 | -e CF_GATEWAY=YOUR_CF_GATEWAY \ 28 | -p 3000:3000 \ 29 | --restart=always \ 30 | jazee6/cloudflare-ai-web 31 | ``` 32 | 33 | ## 特性 34 | 35 | - 利用 Cloudflare Workers AI 快速搭建多模态AI平台 36 | - 支持 Serverless 部署,无需服务器 37 | - 支持开启访问密码,聊天记录本地存储 38 | - 轻量化(~646 kB gzip) 39 | - 支持`ChatGPT` `Gemini Pro` `Stable Diffusion` `llama-3` `通义千问`等 40 | 41 | ### 模型支持 42 | 43 | https://developers.cloudflare.com/workers-ai/models/ 44 | 45 | 你可以在`./utils/db.ts`中增删模型 46 | 47 | ## 部署说明 48 | 49 | ### 环境变量列表 50 | 51 | | 名称 | 描述 | 52 | |----------------|------------------------------------| 53 | | CF_TOKEN | Cloudflare Workers AI Token | 54 | | CF_GATEWAY | Cloudflare AI Gateway URL | 55 | | OPENAI_API_KEY | OpenAI API Key (需要ChatGPT时填写) | 56 | | OPENAI_API_URL | 自定义OpenAI API请求地址 | 57 | | G_API_KEY | Google AI API Key (需要GeminiPro时填写) | 58 | | G_API_URL | Google AI 反代 (不支持地区填写,或参考以下配置) | 59 | | PASSWORD | 访问密码 (可选) | 60 | 61 | 示例: 查看`.env.example`文件 62 | 63 | #### CF_TOKEN 64 | 65 | https://dash.cloudflare.com/profile/api-tokens 66 | 67 | - 单击创建令牌 68 | - 使用Workers AI (Beta)模板 69 | - 单击继续以显示摘要 70 | - 单击创建令牌 71 | - 复制您的令牌,设置环境变量 72 | 73 | #### CF_GATEWAY 74 | 75 | https://dash.cloudflare.com/ 76 | 77 | - Cloudflare 侧栏 AI - AI Gateway 78 | - 添加新 AI Gateway 79 | - 填写名称和URL slug创建 80 | - 单击右上角API Endpoints 81 | - 复制您的Universal Endpoint(去掉末尾`/`),设置环境变量 82 | 83 | #### G_API_KEY 84 | 85 | https://ai.google.dev/tutorials/rest_quickstart#set_up_your_api_key 86 | 87 | #### G_API_URL 88 | 89 | 参考 https://github.com/Jazee6/gemini-proxy 搭建反代,末尾无需`/` 90 | 91 | 或者在`nuxt.config.ts`中添加以下配置 92 | 93 | ``` 94 | nitro: { 95 | vercel: { 96 | regions: ["sin1", "syd1", "sfo1", "iad1", "pdx1", "cle1"] 97 | } 98 | } 99 | ``` 100 | 101 | ## Star History 102 | 103 | [![Star History Chart](https://api.star-history.com/svg?repos=Jazee6/cloudflare-ai-web&type=Date)](https://star-history.com/#Jazee6/cloudflare-ai-web&Date) 104 | -------------------------------------------------------------------------------- /app.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /assets/css/style.css: -------------------------------------------------------------------------------- 1 | html, body, #__nuxt { 2 | @apply h-full 3 | } 4 | 5 | .blur-global { 6 | background: rgba(245, 245, 247, 0.72); 7 | backdrop-filter: saturate(180%) blur(20px); 8 | } 9 | 10 | .loading > dd:last-child::after, 11 | .loading > dl:last-child::after, 12 | .loading > dt:last-child::after, 13 | .loading > h1:last-child::after, 14 | .loading > h2:last-child::after, 15 | .loading > h3:last-child::after, 16 | .loading > h4:last-child::after, 17 | .loading > h5:last-child::after, 18 | .loading > h6:last-child::after, 19 | .loading > li:last-child::after, 20 | .loading > ol:last-child li:last-child::after, 21 | .loading > p:last-child::after, 22 | .loading > pre:last-child code::after, 23 | .loading > td:last-child::after, 24 | .loading > ul:last-child li:last-child::after { 25 | content: ' ● '; 26 | } 27 | 28 | .scrollbar-hide::-webkit-scrollbar { 29 | @apply hidden 30 | } 31 | -------------------------------------------------------------------------------- /components/ChatInput.vue: -------------------------------------------------------------------------------- 1 | 95 | 96 | -------------------------------------------------------------------------------- /components/ChatList.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 59 | 60 | -------------------------------------------------------------------------------- /components/Footer.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | -------------------------------------------------------------------------------- /components/Header.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | -------------------------------------------------------------------------------- /components/IButton.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | -------------------------------------------------------------------------------- /components/ModelSelect.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | -------------------------------------------------------------------------------- /components/Pass.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | -------------------------------------------------------------------------------- /components/Setting.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | -------------------------------------------------------------------------------- /components/Sidebar.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 39 | 40 | -------------------------------------------------------------------------------- /i18n.config.ts: -------------------------------------------------------------------------------- 1 | export default defineI18nConfig(() => ({ 2 | legacy: false, 3 | messages: { 4 | zh: { 5 | setting: '设置', 6 | use_own_key: '使用自己的key', 7 | new_chat: '新建对话', 8 | input_password: '请输入访问密码', 9 | confirm: '确定', 10 | with_history: '发送时携带历史记录', 11 | without_history: '发送时不携带历史记录', 12 | please_input_text: '请输入文本', 13 | add_image: '添加图片', 14 | support_paste: '支持粘贴', 15 | send: '发送', 16 | img_gen_steps: '图片生成步数', 17 | text_generation: '文本生成', 18 | image_generation: '图像生成', 19 | system_prompt: '系统提示', 20 | universal: '多模', 21 | }, 22 | en: { 23 | setting: 'Setting', 24 | use_own_key: 'Use own key', 25 | new_chat: 'New chat', 26 | input_password: 'Please enter access password', 27 | confirm: 'Confirm', 28 | with_history: 'Send with history', 29 | without_history: 'Send without history', 30 | please_input_text: 'Please input text', 31 | add_image: 'Add image', 32 | support_paste: 'Support paste', 33 | send: 'Send', 34 | img_gen_steps: 'Image generation steps', 35 | text_generation: 'Text generation', 36 | image_generation: 'Image generation', 37 | system_prompt: 'System prompt', 38 | universal: 'Universal', 39 | } 40 | } 41 | })) 42 | -------------------------------------------------------------------------------- /layouts/default.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | -------------------------------------------------------------------------------- /nuxt.config.ts: -------------------------------------------------------------------------------- 1 | // https://nuxt.com/docs/api/configuration/nuxt-config 2 | 3 | export default defineNuxtConfig({ 4 | devtools: {enabled: false}, 5 | modules: ['@nuxt/ui', '@nuxtjs/i18n'], 6 | css: ['~/assets/css/style.css'], 7 | devServer: { 8 | port: 3001, 9 | }, 10 | routeRules: { 11 | '/': { 12 | prerender: true, 13 | } 14 | }, 15 | app: { 16 | head: { 17 | title: 'CF AI Web', 18 | meta: [ 19 | { 20 | name: 'keywords', 21 | content: 'CF AI Web, AI, Cloudflare Workers, ChatGPT, GeminiPro, Google Generative AI' 22 | }, 23 | { 24 | name: 'description', 25 | content: 'Integrated web platform supporting Gemini Pro/Cloudflare Workers AI/ChatGPT by Jazee6' 26 | } 27 | ], 28 | link: [ 29 | { 30 | rel: 'manifest', 31 | href: '/manifest.json' 32 | } 33 | ], 34 | noscript: [ 35 | { 36 | innerHTML: 'This website requires JavaScript.' 37 | } 38 | ] 39 | } 40 | }, 41 | i18n: { 42 | vueI18n: './i18n.config.ts', 43 | strategy: 'no_prefix', 44 | defaultLocale: 'zh', 45 | } 46 | // nitro: { 47 | // vercel: { 48 | // regions: ["sin1", "syd1", "sfo1", "iad1", "pdx1", "cle1"] 49 | // } 50 | // } 51 | }) 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cloudflare-ai-web", 3 | "version": "3.1.1", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "build": "nuxt build --preset=vercel_edge", 8 | "build_node": "nuxt build", 9 | "dev": "nuxt dev", 10 | "generate": "nuxt generate", 11 | "preview": "nuxt preview", 12 | "postinstall": "nuxt prepare" 13 | }, 14 | "devDependencies": { 15 | "@nuxtjs/i18n": "^8.3.1", 16 | "@tailwindcss/typography": "^0.5.13", 17 | "@types/markdown-it": "^14.1.1", 18 | "nuxt": "^3.11.2", 19 | "vue": "^3.4.27", 20 | "vue-router": "^4.3.2" 21 | }, 22 | "dependencies": { 23 | "@google/generative-ai": "^0.12.0", 24 | "@nuxt/ui": "^2.16.0", 25 | "dexie": "^4.0.7", 26 | "eventsource-parser": "^1.1.2", 27 | "highlight.js": "^11.9.0", 28 | "markdown-it": "^14.1.0" 29 | }, 30 | "packageManager": "pnpm@9.1.4+sha512.9df9cf27c91715646c7d675d1c9c8e41f6fce88246f1318c1aa6a1ed1aeb3c4f032fcdf4ba63cc69c4fe6d634279176b5358727d8f2cc1e65b65f43ce2f8bfb0", 31 | "pnpm": { 32 | "patchedDependencies": { 33 | "@google/generative-ai@0.12.0": "patches/@google__generative-ai@0.12.0.patch" 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /pages/index.vue: -------------------------------------------------------------------------------- 1 | 242 | 243 | -------------------------------------------------------------------------------- /patches/@google__generative-ai@0.12.0.patch: -------------------------------------------------------------------------------- 1 | diff --git a/dist/index.js b/dist/index.js 2 | index 4290f816871d5e88e44e264fa47b2960cde864a7..64647480b2d600d8f163a15ef7ce49920b04bae5 100644 3 | --- a/dist/index.js 4 | +++ b/dist/index.js 5 | @@ -241,7 +241,7 @@ class GoogleGenerativeAIRequestInputError extends GoogleGenerativeAIError { 6 | * See the License for the specific language governing permissions and 7 | * limitations under the License. 8 | */ 9 | -const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com"; 10 | +const DEFAULT_BASE_URL = process.env.G_API_URL || "https://generativelanguage.googleapis.com"; 11 | const DEFAULT_API_VERSION = "v1beta"; 12 | /** 13 | * We can't `require` package.json if this runs on web. We will use rollup to 14 | diff --git a/dist/index.mjs b/dist/index.mjs 15 | index aabb1c97ad6ec5af1e8e736da9d3dc23c478cb09..b02db137b30e5f77e7a5d68657b1fbc19581c8f2 100644 16 | --- a/dist/index.mjs 17 | +++ b/dist/index.mjs 18 | @@ -239,7 +239,7 @@ class GoogleGenerativeAIRequestInputError extends GoogleGenerativeAIError { 19 | * See the License for the specific language governing permissions and 20 | * limitations under the License. 21 | */ 22 | -const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com"; 23 | +const DEFAULT_BASE_URL = process.env.G_API_URL || "https://generativelanguage.googleapis.com"; 24 | const DEFAULT_API_VERSION = "v1beta"; 25 | /** 26 | * We can't `require` package.json if this runs on web. We will use rollup to 27 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jazee6/cloudflare-ai-web/ac7d27b83ce3e111c25e8628ef24b44be98abbc6/public/favicon.ico -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Cloudflare AI Web", 3 | "short_name": "Cloudflare AI Web", 4 | "start_url": ".", 5 | "display": "standalone", 6 | "background_color": "#fff", 7 | "description": "Cloudflare AI Web App", 8 | "icons": [ 9 | { 10 | "src": "/pwa.webp", 11 | "sizes": "256x256", 12 | "type": "image/webp" 13 | } 14 | ], 15 | "related_applications": [ 16 | { 17 | "platform": "web", 18 | "url": "https://jaze.top" 19 | } 20 | ] 21 | } -------------------------------------------------------------------------------- /public/pwa.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jazee6/cloudflare-ai-web/ac7d27b83ce3e111c25e8628ef24b44be98abbc6/public/pwa.webp -------------------------------------------------------------------------------- /server/api/auth/gemini.post.ts: -------------------------------------------------------------------------------- 1 | import {GoogleGenerativeAI, HarmBlockThreshold, HarmCategory, SafetySetting} from '@google/generative-ai' 2 | import {headers} from '~/utils/helper'; 3 | import {OpenAIMessage} from "~/utils/types"; 4 | 5 | const genAI = new GoogleGenerativeAI(process.env.G_API_KEY!) 6 | 7 | export default defineEventHandler(async (event) => { 8 | const body = await readFormData(event) 9 | const model = body.get('model') as string 10 | const messages: OpenAIMessage[] = JSON.parse(body.get('messages')) 11 | const files = body.getAll('files') as File[] 12 | 13 | const m = genAI.getGenerativeModel({model, safetySettings}) 14 | let msg = messages.slice(1) 15 | 16 | let res 17 | if (files.length) { 18 | const imageParts = await Promise.all(files.map(fileToGenerativePart)) 19 | const prompt = msg.at(-1) 20 | if (prompt === undefined) { 21 | return new Response('对话失效,请重新开始对话', {status: 400}) 22 | } 23 | res = await m.generateContentStream([prompt.content, ...imageParts]) 24 | } else { 25 | const chat = m.startChat({ 26 | history: msg.slice(0, -1).map(m => ({ 27 | role: m.role === 'assistant' ? 'model' : m.role === 'user' ? 'user' : 'function', 28 | parts: [{text: m.content}] 29 | })) 30 | }) 31 | res = await chat.sendMessageStream(msg[msg.length - 1].content) 32 | } 33 | 34 | const textEncoder = new TextEncoder() 35 | const readableStream = new ReadableStream({ 36 | async start(controller) { 37 | for await (const chunk of res.stream) { 38 | try { 39 | controller.enqueue(textEncoder.encode(chunk.text())) 40 | } catch (e) { 41 | console.error(e) 42 | controller.enqueue(textEncoder.encode('已触发安全限制,请重新开始对话')) 43 | } 44 | } 45 | 46 | controller.close() 47 | } 48 | }) 49 | 50 | return new Response(readableStream, { 51 | headers, 52 | }) 53 | }) 54 | 55 | async function fileToGenerativePart(file: File) { 56 | return { 57 | inlineData: { 58 | data: Buffer.from(await file.arrayBuffer()).toString('base64'), 59 | mimeType: file.type, 60 | }, 61 | }; 62 | } 63 | 64 | const safetySettings: SafetySetting[] = [ 65 | { 66 | category: HarmCategory.HARM_CATEGORY_HARASSMENT, 67 | threshold: HarmBlockThreshold.BLOCK_NONE, 68 | }, 69 | { 70 | category: HarmCategory.HARM_CATEGORY_HATE_SPEECH, 71 | threshold: HarmBlockThreshold.BLOCK_NONE, 72 | }, 73 | { 74 | category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, 75 | threshold: HarmBlockThreshold.BLOCK_NONE, 76 | }, 77 | { 78 | category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, 79 | threshold: HarmBlockThreshold.BLOCK_NONE, 80 | }, 81 | ] 82 | -------------------------------------------------------------------------------- /server/api/auth/openai.post.ts: -------------------------------------------------------------------------------- 1 | import {handleErr, openaiParser, streamResponse} from "~/utils/helper"; 2 | import {OpenAIBody, OpenAIReq} from "~/utils/types"; 3 | 4 | export default defineEventHandler(async (event) => { 5 | const body: OpenAIReq = await readBody(event); 6 | const {model, messages, key, endpoint} = body; 7 | 8 | const openAIBody: OpenAIBody = { 9 | stream: true, 10 | model, 11 | messages, 12 | }; 13 | 14 | const apiUrl = process.env.OPENAI_API_URL ? 15 | `${process.env.OPENAI_API_URL}/v1/chat/completions` : 16 | `${process.env.CF_GATEWAY}/openai/${endpoint}`; 17 | 18 | const res = await fetch(apiUrl, { 19 | method: 'POST', 20 | headers: { 21 | Authorization: key === undefined ? `Bearer ${process.env.OPENAI_API_KEY}` : `Bearer ${key}`, 22 | 'Content-Type': 'application/json', 23 | }, 24 | body: JSON.stringify(openAIBody), 25 | }); 26 | 27 | if (!res.ok) { 28 | return handleErr(res); 29 | } 30 | 31 | return streamResponse(res, openaiParser); 32 | }); 33 | -------------------------------------------------------------------------------- /server/api/auth/workers/image.post.ts: -------------------------------------------------------------------------------- 1 | import {handleErr, imageResponse} from "~/utils/helper"; 2 | import {WorkersBodyImage, WorkersReqImage} from "~/utils/types"; 3 | 4 | export default defineEventHandler(async (event) => { 5 | const body: WorkersReqImage = await readBody(event) 6 | const {model, messages, num_steps} = body 7 | 8 | const workersBody: WorkersBodyImage = { 9 | prompt: messages[0].content, 10 | num_steps 11 | } 12 | 13 | const res = await fetch(`${process.env.CF_GATEWAY}/workers-ai/${model}`, { 14 | method: 'POST', 15 | headers: { 16 | Authorization: `Bearer ${process.env.CF_TOKEN}`, 17 | 'Content-Type': 'application/json', 18 | }, 19 | body: JSON.stringify(workersBody) 20 | }) 21 | 22 | if (!res.ok) { 23 | return handleErr(res) 24 | } 25 | 26 | return imageResponse(res) 27 | }) -------------------------------------------------------------------------------- /server/api/auth/workers/index.post.ts: -------------------------------------------------------------------------------- 1 | import {handleErr, streamResponse, workersTextParser} from "~/utils/helper"; 2 | import {WorkersBody, WorkersReq} from "~/utils/types"; 3 | 4 | export default defineEventHandler(async (event) => { 5 | const body: WorkersReq = await readBody(event) 6 | const {model, messages} = body 7 | 8 | const workersBody: WorkersBody = { 9 | stream: true, 10 | messages, 11 | } 12 | 13 | const res = await fetch(`${process.env.CF_GATEWAY}/workers-ai/${model}`, { 14 | method: 'POST', 15 | headers: { 16 | Authorization: `Bearer ${process.env.CF_TOKEN}`, 17 | 'Content-Type': 'application/json', 18 | }, 19 | body: JSON.stringify(workersBody) 20 | }) 21 | 22 | if (!res.ok) { 23 | return handleErr(res) 24 | } 25 | 26 | return streamResponse(res, workersTextParser) 27 | }) 28 | -------------------------------------------------------------------------------- /server/middleware/auth.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler((event) => { 2 | const pass = process.env.PASSWORD 3 | if (pass) { 4 | if (event.path.startsWith('/api/auth')) { 5 | if (event.headers.get('Authorization') !== pass) { 6 | return new Response('Password Incorrect', {status: 401}) 7 | } 8 | } 9 | } 10 | }) -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.nuxt/tsconfig.server.json" 3 | } 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://nuxt.com/docs/guide/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json" 4 | } 5 | -------------------------------------------------------------------------------- /utils/api.ts: -------------------------------------------------------------------------------- 1 | import {streamFetchWithFile} from "~/utils/helper"; 2 | 3 | export function openAIReq(req: OpenAIReq, onStream: (data: unknown) => void) { 4 | return streamFetch('/openai', req, onStream) 5 | } 6 | 7 | export function workersReq(req: WorkersReq, onStream: (data: unknown) => void) { 8 | return streamFetch('/workers', req, onStream) 9 | } 10 | 11 | export function workersImageReq(req: WorkersReqImage) { 12 | return postFetch('/workers/image', req) 13 | } 14 | 15 | export function geminiReq(req: FormData, onStream: (data: unknown) => void) { 16 | return streamFetchWithFile('/gemini', req, onStream) 17 | } -------------------------------------------------------------------------------- /utils/db.ts: -------------------------------------------------------------------------------- 1 | import Dexie, {type Table} from 'dexie'; 2 | 3 | export class Database extends Dexie { 4 | history!: Table 5 | tab!: Table 6 | 7 | constructor() { 8 | super('ai') 9 | this.version(4).stores({ 10 | history: '++id, session, type, role, content, src', 11 | tab: '++id, label' 12 | }) 13 | this.version(5).stores({ 14 | tab: '++id, label, created_at', 15 | history: '++id, session, type, role, content, src, created_at', 16 | }).upgrade(trans => { 17 | return trans.table('history').toCollection().modify(async i => { 18 | if (i.type === 'image') { 19 | i.content = '' 20 | i.src = [i.src] 21 | } 22 | }) 23 | }) 24 | } 25 | 26 | getLatestTab() { 27 | return DB.tab.orderBy('id').last(); 28 | } 29 | 30 | getTabs() { 31 | return DB.tab.limit(100).reverse().toArray() 32 | } 33 | 34 | async getHistory(session: number) { 35 | const arr = await DB.history.where('session').equals(session).limit(100).toArray() 36 | arr.forEach(i => { 37 | if (i.type === 'image') { 38 | i.src_url = [] 39 | i.src?.forEach(src => { 40 | i.src_url!.push(URL.createObjectURL(src)) 41 | }) 42 | i.content = 'image' 43 | } 44 | }) 45 | return arr 46 | } 47 | 48 | addTab(label: string) { 49 | return DB.tab.add({label, created_at: Date.now()}) 50 | } 51 | 52 | deleteTabAndHistory(id: number) { 53 | return DB.transaction('rw', DB.tab, DB.history, async () => { 54 | await DB.tab.delete(id) 55 | await DB.history.where('session').equals(id).delete() 56 | }) 57 | } 58 | } 59 | 60 | export const DB = new Database(); 61 | 62 | export const initialSettings = { 63 | openaiKey: '', 64 | image_steps: 20, 65 | system_prompt: 'You are ChatGPT, a large language model trained by OpenAI. Follow the user\'s instructions carefully. Respond using markdown.', 66 | } 67 | 68 | export type Settings = typeof initialSettings 69 | 70 | export const uniModals: Model[] = [ 71 | { 72 | id: 'gemini-2.0-flash', 73 | name: 'Gemini 2.0 flash', 74 | provider: 'google', 75 | type: 'universal' 76 | } 77 | ] 78 | 79 | export const textGenModels: Model[] = [{ 80 | id: 'gpt-3.5-turbo', 81 | name: 'ChatGPT-3.5-turbo', 82 | provider: 'openai', 83 | endpoint: 'chat/completions', 84 | type: 'chat' 85 | }, { 86 | id: '@cf/qwen/qwen1.5-14b-chat-awq', 87 | name: 'qwen1.5-14b-chat-awq', 88 | provider: 'workers-ai', 89 | type: 'chat' 90 | }, { 91 | id: '@cf/openchat/openchat-3.5-0106', 92 | name: 'openchat-3.5-0106', 93 | provider: 'workers-ai', 94 | type: 'chat' 95 | }, { 96 | id: '@cf/google/gemma-7b-it-lora', 97 | name: 'gemma-7b-it-lora', 98 | provider: 'workers-ai', 99 | type: 'chat' 100 | }, { 101 | id: '@hf/thebloke/openhermes-2.5-mistral-7b-awq', 102 | name: 'openhermes-2.5-mistral-7b-awq', 103 | provider: 'workers-ai', 104 | type: 'chat' 105 | }, { 106 | id: '@hf/thebloke/neural-chat-7b-v3-1-awq', 107 | name: 'neural-chat-7b-v3-1-awq', 108 | provider: 'workers-ai', 109 | type: 'chat' 110 | }, { 111 | id: '@hf/nexusflow/starling-lm-7b-beta', 112 | name: 'starling-lm-7b-beta', 113 | provider: 'workers-ai', 114 | type: 'chat' 115 | }, { 116 | id: '@cf/meta/llama-3-8b-instruct', 117 | name: 'llama-3-8b-instruct', 118 | provider: 'workers-ai', 119 | type: 'chat' 120 | }] 121 | 122 | export const imageGenModels: Model[] = [{ 123 | id: '@cf/lykon/dreamshaper-8-lcm', 124 | name: 'dreamshaper-8-lcm', 125 | provider: 'workers-ai-image', 126 | type: 'text-to-image' 127 | }, { 128 | id: '@cf/stabilityai/stable-diffusion-xl-base-1.0', 129 | name: 'stable-diffusion-xl-base-1.0', 130 | provider: 'workers-ai-image', 131 | type: 'text-to-image' 132 | }, { 133 | id: '@cf/bytedance/stable-diffusion-xl-lightning', 134 | name: 'stable-diffusion-xl-lightning', 135 | provider: 'workers-ai-image', 136 | type: 'text-to-image' 137 | }] 138 | 139 | export const models: Model[] = [...uniModals, ...textGenModels, ...imageGenModels] -------------------------------------------------------------------------------- /utils/helper.ts: -------------------------------------------------------------------------------- 1 | import {createParser} from "eventsource-parser"; 2 | import {getToken, isLogin} from "~/utils/tools"; 3 | import {useGlobalState} from "~/utils/store"; 4 | 5 | export const headers = { 6 | 'Content-Type': 'text/event-stream', 7 | } as const 8 | 9 | export function streamResponse(res: Response, parser: (chunk: string) => string) { 10 | const textDecoder = new TextDecoder() 11 | const textEncoder = new TextEncoder() 12 | 13 | const readableStream = new ReadableStream({ 14 | async start(controller) { 15 | const parserStream = createParser((event) => { 16 | if (event.type === "event") { 17 | if (event.data === '[DONE]') { 18 | parserStream.reset() 19 | return 20 | } 21 | const parsed = parser(event.data) 22 | controller.enqueue(textEncoder.encode(parsed)) 23 | } 24 | }) 25 | 26 | for await (const chunk of res.body as any) { 27 | parserStream.feed(textDecoder.decode(chunk)) 28 | } 29 | controller.close() 30 | }, 31 | }); 32 | 33 | return new Response(readableStream, { 34 | headers 35 | }) 36 | } 37 | 38 | export function openaiParser(chunk: string) { 39 | const data: OpenAIRes = JSON.parse(chunk) 40 | return data.choices[0].delta.content ?? '' 41 | } 42 | 43 | export function workersTextParser(chunk: string) { 44 | const data: WorkersRes = JSON.parse(chunk) 45 | return data.response 46 | } 47 | 48 | export function imageResponse(res: Response) { 49 | return new Response(res.body, { 50 | headers: { 51 | 'Content-Type': 'image/png', 52 | } 53 | }) 54 | } 55 | 56 | export async function handleErr(res: Response) { 57 | const text = await res.text() 58 | console.error(res.status, res.statusText, text) 59 | return new Response(text, { 60 | status: res.status, 61 | statusText: res.statusText, 62 | }) 63 | } 64 | 65 | const {passModal} = useGlobalState() 66 | 67 | async function handleStream(data: ReadableStream, onStream: (data: string) => void, resolve: (value: unknown) => void) { 68 | const reader = data.getReader() 69 | const decoder = new TextDecoder() 70 | while (true) { 71 | const {value, done} = await reader.read() 72 | if (done) { 73 | resolve(null) 74 | break 75 | } 76 | onStream(decoder.decode(value)) 77 | } 78 | } 79 | 80 | export async function basicFetch( 81 | path: string, 82 | options: RequestInit = {}, 83 | onStream?: (data: string) => void, 84 | ) { 85 | const headers = new Headers(options.headers || {}) 86 | if (isLogin()) { 87 | headers.set('Authorization', getToken()!) 88 | } 89 | const response = await fetch('/api/auth' + path, { 90 | ...options, 91 | headers, 92 | }) 93 | 94 | if (!response.ok) { 95 | const text = await response.text() 96 | if (response.status === 401 && text === 'Password Incorrect') { 97 | passModal.value = true 98 | } 99 | throw new Error(response.status + ' ' + response.statusText + ' ' + text) 100 | } 101 | 102 | if (response.headers.get('Content-Type')?.includes('text/event-stream')) { 103 | const body = response.body 104 | if (body === null) { 105 | throw new Error('Response body is null') 106 | } 107 | if (onStream) { 108 | return new Promise(resolve => { 109 | handleStream(body, onStream, resolve) 110 | }) 111 | } 112 | } 113 | 114 | if (response.headers.get('Content-Type')?.includes('image')) { 115 | return await response.blob() 116 | } 117 | } 118 | 119 | export function streamFetch(path: string, body: Object, onStream: (data: string) => void) { 120 | return basicFetch(path, { 121 | method: "POST", 122 | headers: { 123 | "Content-Type": "application/json", 124 | }, 125 | body: JSON.stringify(body), 126 | }, onStream) 127 | } 128 | 129 | export function postFetch(path: string, body: Object) { 130 | return basicFetch(path, { 131 | method: "POST", 132 | headers: { 133 | "Content-Type": "application/json", 134 | }, 135 | body: JSON.stringify(body), 136 | }) 137 | } 138 | 139 | export function streamFetchWithFile(path: string, body: FormData, onStream: (data: string) => void) { 140 | return basicFetch(path, { 141 | method: "POST", 142 | body, 143 | }, onStream) 144 | } -------------------------------------------------------------------------------- /utils/store.ts: -------------------------------------------------------------------------------- 1 | import {createGlobalState} from "@vueuse/shared" 2 | import {ref} from "vue" 3 | import {uniModals} from "./db" 4 | 5 | export const useGlobalState = createGlobalState(() => { 6 | const openModelSelect = ref(false) 7 | const passModal = ref(false) 8 | const openAside = ref(false) 9 | const openSettings = ref(false) 10 | const selectedModel = ref(uniModals[0]) 11 | 12 | return { 13 | openModelSelect, 14 | passModal, 15 | openAside, 16 | openSettings, 17 | selectedModel 18 | } 19 | }) -------------------------------------------------------------------------------- /utils/tools.ts: -------------------------------------------------------------------------------- 1 | import {useThrottleFn} from "@vueuse/shared"; 2 | 3 | const TOKEN_KEY = 'access_pass' 4 | 5 | export const isLogin = () => { 6 | return !!localStorage.getItem(TOKEN_KEY) 7 | } 8 | 9 | export const getToken = () => { 10 | return localStorage.getItem(TOKEN_KEY) 11 | } 12 | 13 | export const setToken = (token: string) => { 14 | localStorage.setItem(TOKEN_KEY, token); 15 | } 16 | 17 | export const clearToken = () => { 18 | localStorage.removeItem(TOKEN_KEY); 19 | } 20 | 21 | export const scrollStream = (el: HTMLElement, distance: number = 200) => { 22 | useThrottleFn(() => { 23 | (el.scrollTop + el.clientHeight >= el.scrollHeight - distance) && el.scrollTo({ 24 | top: el.scrollHeight, 25 | behavior: 'smooth' 26 | }) 27 | }, 500)() 28 | } 29 | 30 | export function scrollToTop(el: HTMLElement | null) { 31 | el?.scrollTo({ 32 | top: el.scrollHeight, 33 | behavior: 'smooth' 34 | }) 35 | } 36 | 37 | export function getSystemPrompt() { 38 | const content = JSON.parse(localStorage.getItem('settings') || '{}').system_prompt || 'You are ChatGPT, a large language model trained by OpenAI. Follow the user\'s instructions carefully. Respond using markdown.' 39 | const p: OpenAIMessage = { 40 | role: 'system', 41 | content 42 | } 43 | return p 44 | } 45 | 46 | export function getMessages(history: HistoryItem[], options?: { 47 | addHistory: boolean, 48 | type: Model['type'] 49 | }) { 50 | if (options?.type === 'text-to-image') { 51 | return [{ 52 | role: history[history.length - 2].role, 53 | content: history[history.length - 2].content 54 | }] 55 | } 56 | if (options?.addHistory) 57 | return [ 58 | getSystemPrompt() 59 | ].concat(history.slice(0, -1).filter(i => i.type === 'text').map((item) => { 60 | return { 61 | role: item.role, 62 | content: item.content 63 | } 64 | })) 65 | else 66 | return [ 67 | getSystemPrompt() 68 | ].concat({ 69 | role: history[history.length - 2].role, 70 | content: history[history.length - 2].content 71 | }) 72 | } 73 | 74 | export function handleImgZoom(img: HTMLImageElement) { 75 | const container = document.createElement('div') 76 | container.style.cssText = ` 77 | position: fixed; 78 | inset: 0; 79 | background-color: rgba(0, 0, 0, 0.8); 80 | display: flex; 81 | justify-content: center; 82 | align-items: center; 83 | transition: all 0.3s; 84 | opacity: 0; 85 | z-index: 9999; 86 | ` 87 | const imgZoom = document.createElement('img') 88 | imgZoom.src = img.src 89 | const screenW = screen.width 90 | imgZoom.style.cssText = ` 91 | width: ${screenW > 768 ? '85%' : '100%'}; 92 | height: ${screenW > 768 ? '85%' : '100%'}; 93 | object-fit: contain; 94 | ` 95 | container.appendChild(imgZoom) 96 | document.body.appendChild(container) 97 | container.addEventListener('click', () => { 98 | container.style.opacity = '0' 99 | setTimeout(() => { 100 | document.body.removeChild(container) 101 | }, 300) 102 | }) 103 | 104 | imgZoom.height 105 | container.style.opacity = '1' 106 | } 107 | 108 | const fileToDataURL = (file: Blob): Promise => { 109 | return new Promise((resolve) => { 110 | const reader = new FileReader() 111 | reader.onloadend = (e) => resolve((e.target as FileReader).result) 112 | reader.readAsDataURL(file) 113 | }) 114 | } 115 | 116 | const dataURLToImage = (dataURL: string): Promise => { 117 | return new Promise((resolve) => { 118 | const img = new Image() 119 | img.onload = () => resolve(img) 120 | img.src = dataURL 121 | }) 122 | } 123 | 124 | const canvastoFile = (canvas: HTMLCanvasElement, type: string, quality: number): Promise => { 125 | return new Promise((resolve) => canvas.toBlob((blob) => resolve(blob), type, quality)) 126 | } 127 | 128 | export const compressionFile = async (file: File, type: string, quality = 0.2) => { 129 | const fileName = file.name 130 | const canvas = document.createElement('canvas') 131 | const context = canvas.getContext('2d') as CanvasRenderingContext2D 132 | const base64 = await fileToDataURL(file) 133 | const img = await dataURLToImage(base64) 134 | canvas.width = img.width 135 | canvas.height = img.height 136 | context.clearRect(0, 0, img.width, img.height) 137 | 138 | context.fillStyle = '#fff' 139 | context.fillRect(0, 0, img.width, img.height) 140 | 141 | context.drawImage(img, 0, 0, img.width, img.height) 142 | const blob = (await canvastoFile(canvas, type, quality)) as Blob 143 | return new File([blob], fileName, { 144 | type: type 145 | }) 146 | } 147 | -------------------------------------------------------------------------------- /utils/types.ts: -------------------------------------------------------------------------------- 1 | export interface Model { 2 | id: string 3 | name: string 4 | provider: 'openai' | 'workers-ai' | 'google' | 'workers-ai-image' 5 | type: 'chat' | 'text-to-image' | 'universal' 6 | endpoint?: string 7 | } 8 | 9 | export interface HistoryItem { 10 | id?: number 11 | session: number 12 | type: 'text' | 'image' | 'image-prompt' | 'error' 13 | content: string 14 | role: 'user' | 'assistant' 15 | src?: Blob[] 16 | src_url?: string[] 17 | created_at: number 18 | } 19 | 20 | export interface TabItem { 21 | id?: number 22 | label: string 23 | created_at: number 24 | } 25 | 26 | export interface OpenAIMessage { 27 | role: 'system' | 'user' | 'assistant' 28 | content: string 29 | } 30 | 31 | export interface OpenAIBody { 32 | model: string 33 | stream: boolean 34 | messages: OpenAIMessage[] 35 | } 36 | 37 | export interface OpenAIReq { 38 | model: string 39 | endpoint: string 40 | messages: OpenAIMessage[] 41 | key?: string 42 | } 43 | 44 | export interface OpenAIRes { 45 | choices: { 46 | index: number 47 | delta: { 48 | content?: string 49 | } 50 | finish_reason: 'stop' | null 51 | }[] 52 | } 53 | 54 | export interface WorkersBody { 55 | stream: boolean 56 | messages: OpenAIMessage[] 57 | } 58 | 59 | export interface WorkersBodyImage { 60 | prompt: string 61 | num_steps?: number 62 | } 63 | 64 | export interface WorkersReq { 65 | model: string 66 | messages: OpenAIMessage[] 67 | } 68 | 69 | export interface WorkersReqImage { 70 | model: string 71 | messages: OpenAIMessage[] 72 | num_steps?: number 73 | } 74 | 75 | export interface WorkersRes { 76 | response: string 77 | } 78 | 79 | // export interface GeminiReq { 80 | // model: string 81 | // messages: OpenAIMessage[] 82 | // } --------------------------------------------------------------------------------