├── .github └── workflows │ └── release.yml ├── .gitignore ├── .husky ├── .gitignore └── pre-commit ├── .prettierrc ├── DEMO.md ├── LICENSE ├── README-CN.md ├── README.md ├── assets ├── config.png ├── demo.gif ├── logo.png ├── notion-demo.gif └── screen-shot.md ├── index.html ├── manifest.firefox.json ├── manifest.json ├── package-lock.json ├── package.json ├── patches ├── antd+5.24.0.patch └── openai+4.85.3.patch ├── pnpm-lock.yaml ├── postcss.config.js ├── src ├── assets │ ├── icon.png │ └── icon.svg ├── background │ ├── chatgpt-web.ts │ ├── index.ts │ └── openai-request.ts ├── common │ ├── antd-theme.ts │ ├── api │ │ ├── chatgpt-web.ts │ │ ├── instructions.ts │ │ ├── openai-request.ts │ │ ├── openai.ts │ │ └── writely.ts │ ├── browser.ts │ ├── debug.ts │ ├── event-name.ts │ ├── file.tsx │ ├── i18n.ts │ ├── langs.tsx │ ├── locale │ │ ├── en-US.json │ │ └── zh-CN.json │ ├── parse-stream.ts │ ├── store │ │ └── settings.ts │ └── types.d.ts ├── components │ ├── icon-btn.tsx │ └── icon │ │ ├── back.tsx │ │ ├── chatgpt.tsx │ │ ├── checked.tsx │ │ ├── close.tsx │ │ ├── copy.tsx │ │ ├── delete.tsx │ │ ├── drag.tsx │ │ ├── edit.tsx │ │ ├── feedback.tsx │ │ ├── github.tsx │ │ ├── heart.tsx │ │ ├── index.ts │ │ ├── insert.tsx │ │ ├── link.tsx │ │ ├── logo.tsx │ │ ├── more.tsx │ │ ├── open-ai.tsx │ │ ├── prompt-icons.tsx │ │ ├── replace.tsx │ │ ├── replay.tsx │ │ ├── return.tsx │ │ ├── right.tsx │ │ ├── send.tsx │ │ ├── setting.tsx │ │ ├── stop.tsx │ │ ├── up.tsx │ │ ├── update.tsx │ │ ├── write.tsx │ │ └── writely.tsx ├── content │ ├── app.tsx │ ├── container │ │ ├── ask-writely │ │ │ ├── content │ │ │ │ ├── index.tsx │ │ │ │ ├── list.tsx │ │ │ │ └── quick-prompt.tsx │ │ │ ├── index.tsx │ │ │ ├── prompts.tsx │ │ │ └── result-panel │ │ │ │ ├── actions │ │ │ │ ├── base-action.tsx │ │ │ │ ├── copy.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── replace.tsx │ │ │ │ ├── replay.tsx │ │ │ │ └── update.tsx │ │ │ │ ├── content.tsx │ │ │ │ ├── header.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── login-instruction.tsx │ │ ├── index.tsx │ │ └── store │ │ │ ├── instruction.ts │ │ │ ├── result-panel.ts │ │ │ ├── selection.ts │ │ │ └── view.ts │ ├── index.css │ ├── index.tsx │ ├── shadow-dom.ts │ └── utils │ │ ├── edit-detector.ts │ │ └── selection │ │ ├── highlight.ts │ │ └── index.ts ├── global.d.ts ├── options │ ├── app.tsx │ ├── index.css │ ├── index.html │ ├── index.tsx │ ├── setting-form │ │ ├── block.tsx │ │ ├── custom-list.tsx │ │ ├── index.tsx │ │ ├── instructions │ │ │ ├── actions │ │ │ │ ├── add.tsx │ │ │ │ ├── export.tsx │ │ │ │ └── import.tsx │ │ │ ├── emoji.tsx │ │ │ ├── index.tsx │ │ │ ├── instruction-modal.tsx │ │ │ ├── list.tsx │ │ │ └── modal-state.tsx │ │ ├── open-api.tsx │ │ ├── provider.tsx │ │ └── system.tsx │ └── types │ │ ├── index.ts │ │ └── settings.ts └── popup │ ├── app.tsx │ ├── index.css │ ├── index.html │ └── index.tsx ├── tailwind.config.cjs ├── tsconfig.json ├── tsup.config.ts └── yarn.lock /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-22.04 10 | 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-node@v3 14 | with: 15 | node-version: 18 16 | 17 | - name: Get version 18 | id: get_version 19 | uses: battila7/get-version-action@v2 20 | 21 | - run: yarn install 22 | - run: yarn build 23 | 24 | - name: Change version 25 | run: | 26 | sed -i -e "s/\"version\": \".*\"/\"version\": \"${{ steps.get_version.outputs.version-without-v }}\"/" manifest.json 27 | sed -i -e "s/\"version\": \".*\"/\"version\": \"${{ steps.get_version.outputs.version-without-v }}\"/" manifest.firefox.json 28 | - name: Package chrome plugin 29 | run: | 30 | mkdir -p release/writely 31 | mv dist release/writely 32 | mv manifest.json release/writely 33 | cd release/writely 34 | zip -r ../writely-chrome-${{ steps.get_version.outputs.version-without-v }}.zip * 35 | - name: Upload plugin to release 36 | uses: svenstaro/upload-release-action@v2 37 | with: 38 | release_name: ${{ steps.get_version.outputs.version }} 39 | repo_token: ${{ secrets.GITHUB_TOKEN }} 40 | file: release/writely-chrome-${{ steps.get_version.outputs.version-without-v }}.zip 41 | asset_name: writely-chrome-${{ steps.get_version.outputs.version-without-v }}.zip 42 | tag: ${{ github.ref }} 43 | overwrite: true 44 | body: ${{ steps.tag.outputs.message }} 45 | 46 | - name: Package firefox plugin 47 | run: | 48 | rm -rf release/writely/manifest.json 49 | mv manifest.firefox.json release/writely/manifest.json 50 | cd release/writely 51 | zip -r ../writely-firefox-${{ steps.get_version.outputs.version-without-v }}.zip * 52 | - name: Upload plugin to release 53 | uses: svenstaro/upload-release-action@v2 54 | with: 55 | release_name: ${{ steps.get_version.outputs.version }} 56 | repo_token: ${{ secrets.GITHUB_TOKEN }} 57 | file: release/writely-firefox-${{ steps.get_version.outputs.version-without-v }}.zip 58 | asset_name: writely-firefox-${{ steps.get_version.outputs.version-without-v }}.zip 59 | tag: ${{ github.ref }} 60 | overwrite: true 61 | body: ${{ steps.tag.outputs.message }} 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | *.log 4 | *.lock -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": false, 5 | "singleQuote": true 6 | } -------------------------------------------------------------------------------- /DEMO.md: -------------------------------------------------------------------------------- 1 | 2 | ## Demo 3 | **Support all website editor** 4 | 5 | ### Feishu 6 | https://user-images.githubusercontent.com/13167934/223999481-62c20e4a-97c1-4cfe-8648-84c96535d4a0.mov 7 | 8 | ### Zhihu 9 | https://user-images.githubusercontent.com/13167934/223768368-99711deb-db46-4c5e-9d26-26c7cb2b7276.mov 10 | 11 | ### Gmail 12 | https://user-images.githubusercontent.com/13167934/224238265-69d19f7e-266a-48da-96e1-9554dee3ace3.mov 13 | 14 | ### Bilibli Collection 15 | https://www.zhihu.com/zvideo/1618328598485667840 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 小安 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README-CN.md: -------------------------------------------------------------------------------- 1 | # Writely (Beta) 2 | > 将 Notion AI 的能力带到任何地方 3 | 4 | ![](./assets/logo.png) 5 | 6 |

7 | 8 | 9 | 10 | Mozilla Add-on 11 |

12 | 13 | 14 | [English Version](README.md) 15 | 16 | 17 | ## 特性 18 | 1.🔥 基于 Open AI GPT 模型,带来了全新的智能写作体验。 19 | 20 | 2.✍️ 支持在互联网上的任何编辑器网页上进行写作辅助,有效提高用户的写作效率和质量。 21 | 22 | 3.📖 该产品可以执行查询翻译和阅读辅助功能,大大减少用户的阅读时间并提高理解能力。 23 | 24 | 25 | ## 使用方法 26 | ### 安装 27 | 28 | Chrome 插件: 29 | 30 | 31 | 32 | Firefox 扩展:Mozilla Add-on 33 | 34 | 35 | ### 配置 36 | 1. 获取 Open AI API Key。 如果您没有,请在 https://platform.openai.com/account/api-keys 上进行申请。 37 | 2. 单击插件图标,然后单击“设置”图标。 38 | 39 | image 40 | 41 | 42 | 3. 进行配置。 43 | 44 | image 45 | 46 | 4. 将鼠标滑动到任何网页上的单词上,一个“W”图标将出现在鼠标附近,单击以使用。 47 | 48 | ![demo](https://user-images.githubusercontent.com/13167934/224236822-eb1cc963-77e5-4820-aa6d-63088989c0cf.gif) 49 | 50 | ## 更多演示 51 | 52 | [演示文档](./DEMO.md) 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Writely (Beta) 2 | 3 | > Let's bring the power of Notion AI to everywhere! 4 | 5 | ![](./assets/logo.png) 6 | 7 |

8 | 9 | 10 | 11 | Mozilla Add-on 12 | Edge Add-on 13 |

14 | 15 | [中文文档](README-CN.md) 16 | 17 | Join US On Discord 18 | 19 | ## Features 20 | 21 | 1.🔥 Based on Open AI GPT model, brings a new intelligent writing experience. 22 | 23 | 2.✍️ Support writing assistant on any editor webpage on the Internet, effectively improving users' writing efficiency and quality. 24 | 25 | 3.📖 The product can perform query translation and assist reading, greatly reducing users' reading time and improving comprehension. 26 | 27 | ## What it can do for you 28 | 29 | 1. 📧 Write an email 30 | 2. 📖 Write an article 31 | 3. 📺 Browse social media platforms such as Twitter and Weibo and reply to netizens 32 | 4. 😯 Write stories on Zhihu 33 | ... 34 | 35 | ## Usage 36 | 37 | ### Installation 38 | 39 | Chrome Web Store: 40 | 41 | 42 | 43 | Firefox Add-on: Mozilla Add-on 44 | 45 | Edge Add-on: Edge Add-on 46 | 47 | ### Configuration 48 | 49 | 1. Obtain an Open AI API Key. If you don't have one, apply for it at https://platform.openai.com/account/api-keys. 50 | 2. Click the plugin icon and click the `Settings` icon. 51 | 52 | image 53 | 54 | 3. Perform configuration. 55 | 56 | image 57 | 58 | 4. After sliding the word on any webpage, a "W" icon will appear near the mouse, click to use. 59 | 60 | ![ezgif com-video-to-gif (7)](https://user-images.githubusercontent.com/13167934/224320617-b8ba473b-6250-470c-92ac-aa206adbb5a8.gif) 61 | ![demo](https://user-images.githubusercontent.com/13167934/224236822-eb1cc963-77e5-4820-aa6d-63088989c0cf.gif) 62 | 63 | ## More demos [Demos](./DEMO.md) 64 | 65 | ## Star History 66 | 67 | [![Star History Chart](https://api.star-history.com/svg?repos=anc95/writely&type=Date)](https://star-history.com/#anc95/writely&Date) 68 | -------------------------------------------------------------------------------- /assets/config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anc95/writely/36b9ffd3bdbe5f05161e950d1a1354ae8c2b7fab/assets/config.png -------------------------------------------------------------------------------- /assets/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anc95/writely/36b9ffd3bdbe5f05161e950d1a1354ae8c2b7fab/assets/demo.gif -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anc95/writely/36b9ffd3bdbe5f05161e950d1a1354ae8c2b7fab/assets/logo.png -------------------------------------------------------------------------------- /assets/notion-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anc95/writely/36b9ffd3bdbe5f05161e950d1a1354ae8c2b7fab/assets/notion-demo.gif -------------------------------------------------------------------------------- /assets/screen-shot.md: -------------------------------------------------------------------------------- 1 | image 2 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Covid-19 Stats- UK 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /manifest.firefox.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Writely", 3 | "version": "1.0.0", 4 | "description": "A GPT Powered Extension helping your writing and reading", 5 | "manifest_version": 3, 6 | "author": "https://github.com/anc95", 7 | "action": { 8 | "default_title": "Writely", 9 | "default_popup": "dist/popup/index.html" 10 | }, 11 | "options_ui": { 12 | "page": "dist/options/index.html", 13 | "open_in_tab": true 14 | }, 15 | "icons": { 16 | "16": "dist/assets/icon.png", 17 | "32": "dist/assets/icon.png", 18 | "48": "dist/assets/icon.png", 19 | "128": "dist/assets/icon.png" 20 | }, 21 | "content_scripts": [ 22 | { 23 | "js": [ 24 | "dist/content/index.js" 25 | ], 26 | "css": [ 27 | "dist/content/animate.css" 28 | ], 29 | "matches": [ 30 | "*://*/*" 31 | ], 32 | "all_frames": true 33 | } 34 | ], 35 | "host_permissions": [ 36 | "https://*.miao-ya.com/" 37 | ], 38 | "background": { 39 | "scripts": [ 40 | "dist/background/index.js" 41 | ] 42 | }, 43 | "permissions": [ 44 | "storage", 45 | "clipboardRead", 46 | "contextMenus", 47 | "cookies" 48 | ], 49 | "browser_specific_settings": { 50 | "gecko": { 51 | "id": "anchao951220@gmail.com" 52 | } 53 | } 54 | } -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Writely", 3 | "version": "1.0.0", 4 | "description": "A GPT Powered Extension helping your writing and reading", 5 | "manifest_version": 3, 6 | "author": "https://github.com/anc95", 7 | "action": { 8 | "default_title": "Writely", 9 | "default_popup": "dist/popup/index.html", 10 | "defult_icon": "dist/assets/icon.png" 11 | }, 12 | "options_ui": { 13 | "page": "dist/options/index.html", 14 | "open_in_tab": true 15 | }, 16 | "icons": { 17 | "16": "dist/assets/icon.png", 18 | "32": "dist/assets/icon.png", 19 | "48": "dist/assets/icon.png", 20 | "128": "dist/assets/icon.png" 21 | }, 22 | "content_scripts": [ 23 | { 24 | "js": [ 25 | "dist/content/index.js" 26 | ], 27 | "css": [ 28 | "dist/content/animate.css" 29 | ], 30 | "matches": [ 31 | "*://*/*" 32 | ], 33 | "all_frames": true 34 | } 35 | ], 36 | "background": { 37 | "service_worker": "dist/background/index.js" 38 | }, 39 | "host_permissions": [ 40 | "https://*.miao-ya.com/" 41 | ], 42 | "permissions": [ 43 | "storage", 44 | "clipboardRead", 45 | "contextMenus", 46 | "cookies" 47 | ] 48 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "writely", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "author": "chao.an", 6 | "license": "MIT", 7 | "devDependencies": { 8 | "@esbuild-plugins/node-modules-polyfill": "^0.2.2", 9 | "@types/chrome": "^0.0.212", 10 | "@types/lodash-es": "^4.17.6", 11 | "@types/marked": "^4.0.8", 12 | "@types/rangy": "^0.0.34", 13 | "@types/react": "^18.0.28", 14 | "@types/react-dom": "^18.0.11", 15 | "@types/webextension-polyfill": "^0.10.0", 16 | "@webcomponents/webcomponentsjs": "^2.7.0", 17 | "ahooks": "^3.7.5", 18 | "animate.css": "^4.1.1", 19 | "antd": "^5.24.0", 20 | "autoprefixer": "^10.4.13", 21 | "chokidar": "^3.5.3", 22 | "classnames": "^2.3.2", 23 | "cross-env": "^7.0.3", 24 | "dotenv": "^16.0.3", 25 | "emoji-picker-react": "^4.4.8", 26 | "esbuild": "^0.17.5", 27 | "esbuild-plugin-copy": "^2.1.0", 28 | "esbuild-style-plugin": "^1.6.1", 29 | "husky": ">=6", 30 | "i18next": "^22.4.10", 31 | "i18next-browser-languagedetector": "^7.0.1", 32 | "lint-staged": ">=10", 33 | "lodash-es": "^4.17.21", 34 | "markdown-it": "^13.0.1", 35 | "markdown-it-highlightjs": "^4.0.1", 36 | "nodemon": "^2.0.20", 37 | "openai": "^4.2.1", 38 | "patch-package": "^6.5.1", 39 | "prettier": "^2.8.7", 40 | "react": "^18.2.0", 41 | "react-dom": "^18.2.0", 42 | "react-draggable": "^4.4.5", 43 | "react-i18next": "^12.2.0", 44 | "swr": "^2.0.4", 45 | "tailwindcss": "^3.2.7", 46 | "tslib": "^2.5.0", 47 | "tsup": "^6.7.0", 48 | "typescript": "^4.9.5", 49 | "unstated-next": "^1.1.0", 50 | "uuid": "^8.0.0", 51 | "webextension-polyfill": "^0.10.0" 52 | }, 53 | "scripts": { 54 | "dev": "cross-env NODE_ENV=development NODE_OPTIONS=--max-old-space-size=8192 tsup --watch", 55 | "build": "cross-env NODE_ENV=production NODE_OPTIONS=--max-old-space-size=8192 tsup", 56 | "postinstall": "patch-package", 57 | "prepare": "husky install" 58 | }, 59 | "lint-staged": { 60 | "*.{js,css,md,ts,tsx}": "prettier --write" 61 | }, 62 | "dependencies": { 63 | "@types/uuid": "^9.0.2" 64 | }, 65 | "publishConfig": { 66 | "registry": "https://registry.npmjs.org/" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /patches/antd+5.24.0.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/antd/es/tooltip/index.js b/node_modules/antd/es/tooltip/index.js 2 | index 5a2ae24..ae79c99 100644 3 | --- a/node_modules/antd/es/tooltip/index.js 4 | +++ b/node_modules/antd/es/tooltip/index.js 5 | @@ -181,7 +181,7 @@ const InternalTooltip = /*#__PURE__*/React.forwardRef((props, ref) => { 6 | }), 7 | motion: { 8 | motionName: getTransitionName(rootPrefixCls, 'zoom-big-fast', props.transitionName), 9 | - motionDeadline: 1000 10 | + motionDeadline: 10 11 | }, 12 | destroyTooltipOnHide: !!destroyTooltipOnHide 13 | }), tempOpen ? cloneElement(child, { 14 | -------------------------------------------------------------------------------- /patches/openai+4.85.3.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/openai/package.json b/node_modules/openai/package.json 2 | index 0d5c3f7..6010965 100644 3 | --- a/node_modules/openai/package.json 4 | +++ b/node_modules/openai/package.json 5 | @@ -44,36 +44,11 @@ 6 | }, 7 | "exports": { 8 | "./_shims/auto/*": { 9 | - "deno": { 10 | - "types": "./_shims/auto/*.d.ts", 11 | - "require": "./_shims/auto/*.js", 12 | - "default": "./_shims/auto/*.mjs" 13 | - }, 14 | - "bun": { 15 | - "types": "./_shims/auto/*.d.ts", 16 | - "require": "./_shims/auto/*-bun.js", 17 | - "default": "./_shims/auto/*-bun.mjs" 18 | - }, 19 | "browser": { 20 | "types": "./_shims/auto/*.d.ts", 21 | "require": "./_shims/auto/*.js", 22 | "default": "./_shims/auto/*.mjs" 23 | }, 24 | - "worker": { 25 | - "types": "./_shims/auto/*.d.ts", 26 | - "require": "./_shims/auto/*.js", 27 | - "default": "./_shims/auto/*.mjs" 28 | - }, 29 | - "workerd": { 30 | - "types": "./_shims/auto/*.d.ts", 31 | - "require": "./_shims/auto/*.js", 32 | - "default": "./_shims/auto/*.mjs" 33 | - }, 34 | - "node": { 35 | - "types": "./_shims/auto/*-node.d.ts", 36 | - "require": "./_shims/auto/*-node.js", 37 | - "default": "./_shims/auto/*-node.mjs" 38 | - }, 39 | "types": "./_shims/auto/*.d.ts", 40 | "require": "./_shims/auto/*.js", 41 | "default": "./_shims/auto/*.mjs" 42 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [require('tailwindcss')(), require('autoprefixer')()], 3 | } -------------------------------------------------------------------------------- /src/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anc95/writely/36b9ffd3bdbe5f05161e950d1a1354ae8c2b7fab/src/assets/icon.png -------------------------------------------------------------------------------- /src/assets/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | 10 | 18 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/background/chatgpt-web.ts: -------------------------------------------------------------------------------- 1 | import { EventName, PortName } from '@/common/event-name' 2 | import browser from 'webextension-polyfill' 3 | import { v4 as uuidv4 } from '../../node_modules/uuid/dist/esm-browser/index' 4 | import { MessagePayload } from '@/common/types' 5 | 6 | browser.runtime.onConnect.addListener((port) => { 7 | if (port.name !== PortName.chatgptWeb) { 8 | return 9 | } 10 | 11 | port.onMessage.addListener((msg) => { 12 | if (msg.type === EventName.chat) { 13 | sendMessageOnChatGPTWeb(msg.data, port) 14 | } else if (msg.type === EventName.stopChatGPTChat) { 15 | abortController.abort() 16 | } 17 | }) 18 | }) 19 | 20 | browser.runtime.onMessage.addListener( 21 | (message: MessagePayload, _, sendResponse) => { 22 | if (message.type === EventName.getChatGPTToken) { 23 | getAccessToken().then(sendResponse) 24 | 25 | return true 26 | } 27 | } 28 | ) 29 | 30 | let conversation_id = undefined 31 | let parent_message_id = uuidv4() 32 | let abortController = new AbortController() 33 | 34 | const sendMessageOnChatGPTWeb = async (prompt: string, port) => { 35 | const token = await getAccessToken() 36 | const msgUUID = uuidv4() 37 | abortController.abort() 38 | abortController = new AbortController() 39 | 40 | const res = await fetch('https://chat.openai.com/backend-api/conversation', { 41 | method: 'POST', 42 | body: JSON.stringify({ 43 | action: 'next', 44 | messages: [ 45 | { 46 | id: msgUUID, 47 | author: { role: 'user' }, 48 | content: { content_type: 'text', parts: [prompt] }, 49 | metadata: {}, 50 | }, 51 | ], 52 | model: 'text-davinci-002-render-sha', 53 | parent_message_id: parent_message_id, 54 | conversation_id: conversation_id, 55 | }), 56 | headers: { 57 | Accept: 'text/event-stream', 58 | Authorization: `Bearer ${token}`, 59 | 'content-type': 'application/json', 60 | }, 61 | signal: abortController.signal, 62 | }) 63 | 64 | parent_message_id = msgUUID 65 | 66 | const reader = res.body.getReader() 67 | return new ReadableStream({ 68 | start(controller) { 69 | return pump() 70 | function pump() { 71 | return reader.read().then(({ done, value }) => { 72 | const strValue = new TextDecoder().decode(value) 73 | const cId = /"conversation_id":\s+"([^"]+)+/.exec(strValue)?.[1] 74 | 75 | if (cId) { 76 | conversation_id = cId 77 | } 78 | 79 | console.log('发送消息', port) 80 | 81 | port?.postMessage({ 82 | type: EventName.chatgptResponse, 83 | data: strValue, 84 | }) 85 | 86 | if (done) { 87 | controller.close() 88 | return 89 | } 90 | 91 | controller.enqueue(value) 92 | return pump() 93 | }) 94 | } 95 | }, 96 | }) 97 | } 98 | 99 | const getAccessToken = async () => { 100 | try { 101 | const res = (await ( 102 | await fetch('https://chat.openai.com/api/auth/session') 103 | ).json()) as { accessToken: string } 104 | return res.accessToken 105 | } catch { 106 | return '' 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/background/index.ts: -------------------------------------------------------------------------------- 1 | import { EventName } from '@/common/event-name' 2 | import { getSetting } from '@/common/store/settings' 3 | import type { MessagePayload } from '@/common/types' 4 | import browser from 'webextension-polyfill' 5 | import './chatgpt-web' 6 | import './openai-request' 7 | 8 | browser.runtime.onMessage.addListener( 9 | (message: MessagePayload) => { 10 | if (message.type === 'open-options-page') { 11 | browser.runtime.openOptionsPage() 12 | } 13 | } 14 | ) 15 | 16 | browser.runtime.onMessage.addListener( 17 | (message: MessagePayload, sender, sendResponse) => { 18 | if (message.type === EventName.getToken) { 19 | getToken().then((v) => (sendResponse as any)(v)) 20 | return true 21 | } 22 | } 23 | ) 24 | 25 | browser.contextMenus.create({ 26 | title: 'Launch writely', 27 | id: 'writely', 28 | // contexts: ['selection'], 29 | }) 30 | 31 | browser.contextMenus.create({ 32 | title: 'Writely instructions', 33 | id: 'writely-instructions', 34 | // contexts: ['selection'], 35 | }) 36 | 37 | const createSubMenu = async () => { 38 | const settings = await getSetting() 39 | 40 | settings.customInstructions?.map((instruction) => { 41 | browser.contextMenus.create({ 42 | title: instruction.name, 43 | id: instruction.id, 44 | contexts: ['selection'], 45 | parentId: 'writely-instructions', 46 | }) 47 | }) 48 | } 49 | 50 | createSubMenu() 51 | 52 | browser.contextMenus.onClicked.addListener((info, tab) => { 53 | if (info.menuItemId === 'writely' && tab.id) { 54 | browser.tabs.sendMessage(tab.id, { 55 | type: EventName.launchWritely, 56 | }) 57 | } 58 | 59 | if (info.parentMenuItemId === 'writely-instructions') { 60 | browser.tabs.sendMessage(tab.id, { 61 | type: EventName.launchWritelyResultPanel, 62 | data: { 63 | instruction: info.menuItemId, 64 | }, 65 | }) 66 | } 67 | }) 68 | 69 | const getToken = async () => { 70 | try { 71 | return JSON.parse( 72 | decodeURIComponent( 73 | ( 74 | await browser.cookies.get({ 75 | name: 'supabase-auth-token', 76 | url: 'https://writely.miao-ya.com', 77 | }) 78 | ).value || '' 79 | ) 80 | )[0] 81 | } catch { 82 | return '' 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/background/openai-request.ts: -------------------------------------------------------------------------------- 1 | import { EventName } from '@/common/event-name' 2 | import { MessagePayload } from '@/common/types' 3 | import { omit } from 'lodash-es' 4 | import OpenAI, { OpenAIError } from 'openai' 5 | import { ChatCompletionChunk } from 'openai/resources' 6 | import { Stream } from 'openai/streaming' 7 | import Browser from 'webextension-polyfill' 8 | 9 | const openai = new OpenAI({ apiKey: 'tt' }) 10 | let stream: Stream 11 | 12 | Browser.runtime.onMessage.addListener( 13 | (message: MessagePayload, sender) => { 14 | if (message.type === EventName.openAIChat) { 15 | openai.apiKey = message.data.apiKey || 'tt' 16 | openai.baseURL = message.data.baseURL 17 | stream = null 18 | 19 | const doRequest = async () => { 20 | try { 21 | stream = (await openai.chat.completions.create({ 22 | ...(omit(message.data, ['apiKey', 'baseURL']) as any), 23 | stream: true, 24 | })) as any 25 | let text = '' 26 | for await (const chunk of stream) { 27 | if (chunk.choices[0]?.delta?.content) { 28 | text += chunk.choices[0]?.delta?.content 29 | Browser.tabs.sendMessage(sender.tab.id, { 30 | type: EventName.openAIResponse, 31 | data: text, 32 | } satisfies MessagePayload) 33 | } 34 | } 35 | 36 | Browser.tabs.sendMessage(sender.tab.id, { 37 | type: EventName.openAIResponseEnd, 38 | data: text, 39 | }) 40 | } catch (e) { 41 | Browser.tabs.sendMessage(sender.tab.id, { 42 | type: EventName.openAIResponseError, 43 | data: e?.error?.message || 'failed', 44 | }) 45 | } 46 | } 47 | 48 | doRequest() 49 | } else if (message.type === EventName.stopOpenAIChat) { 50 | if (stream) { 51 | stream.controller.abort() 52 | } 53 | } 54 | } 55 | ) 56 | -------------------------------------------------------------------------------- /src/common/antd-theme.ts: -------------------------------------------------------------------------------- 1 | import { ThemeConfig, theme as atdTheme } from 'antd'; 2 | 3 | export const theme: ThemeConfig = { 4 | token: { 5 | colorPrimary: 'rgb(3,3,3)', 6 | }, 7 | algorithm: atdTheme.defaultAlgorithm, 8 | }; 9 | -------------------------------------------------------------------------------- /src/common/api/chatgpt-web.ts: -------------------------------------------------------------------------------- 1 | import Browser from 'webextension-polyfill' 2 | import { EventName, PortName } from '../event-name' 3 | import { MessagePayload } from '../types' 4 | import { parseStream } from '../parse-stream' 5 | import useSWR from 'swr' 6 | 7 | class ChatGPTWeb { 8 | port = Browser.runtime.connect({ name: PortName.chatgptWeb }) 9 | dataCallback: (text: string, error?: Error, ended?: boolean) => void 10 | accessToken: string 11 | 12 | constructor() { 13 | this.port.onMessage.addListener( 14 | (message: MessagePayload) => { 15 | if (message.type === EventName.chatgptResponse) { 16 | const { data, ended } = parseStream(message.data) 17 | this.dataCallback(data, undefined, ended) 18 | } 19 | } 20 | ) 21 | 22 | this.initToken() 23 | } 24 | 25 | public sendMsg = (prompt: string, onData: any) => { 26 | this.dataCallback = onData 27 | this.port?.postMessage({ 28 | type: EventName.chat, 29 | data: prompt, 30 | }) 31 | } 32 | 33 | public abort = () => { 34 | this.port.postMessage({ 35 | type: EventName.stopChatGPTChat, 36 | }) 37 | } 38 | 39 | private initToken = async () => { 40 | this.accessToken = await Browser.runtime.sendMessage({ 41 | type: EventName.getChatGPTToken, 42 | }) 43 | } 44 | } 45 | 46 | export const chatgptWeb = new ChatGPTWeb() 47 | 48 | export const useChatGPTWebInfo = () => { 49 | return useSWR(['chatgpt-web-info'], async () => { 50 | return (await ( 51 | await fetch('https://chat.openai.com/api/auth/session') 52 | ).json()) as { accessToken: string; user: { email: string; name: string } } 53 | }) 54 | } 55 | -------------------------------------------------------------------------------- /src/common/api/instructions.ts: -------------------------------------------------------------------------------- 1 | import { Instruction } from '@/options/types' 2 | import { uniqueId } from 'lodash-es' 3 | import { getSetting, saveSetting } from '../store/settings' 4 | 5 | export const addOne = async (instruction: Instruction) => { 6 | const settings = await getSetting() 7 | 8 | settings.customInstructions.push({ 9 | id: uniqueId(Date.now() + ''), 10 | ...instruction, 11 | }) 12 | 13 | await saveSetting(settings) 14 | } 15 | 16 | export const update = async (instruction: Instruction) => { 17 | const settings = await getSetting() 18 | 19 | settings.customInstructions = settings.customInstructions.map((item) => { 20 | if (item.id === instruction.id) { 21 | return instruction 22 | } 23 | 24 | return item 25 | }) 26 | 27 | await saveSetting(settings) 28 | } 29 | 30 | export const remove = async (id: string) => { 31 | const settings = await getSetting() 32 | 33 | settings.customInstructions = settings.customInstructions.filter((item) => { 34 | return item.id !== id 35 | }) 36 | 37 | await saveSetting(settings) 38 | } 39 | 40 | export const setTopPinned = async (id: string) => { 41 | const settings = await getSetting() 42 | let pinnedInsturction = null 43 | 44 | settings.customInstructions = settings.customInstructions.filter((item) => { 45 | if (item.id === id) { 46 | pinnedInsturction = item 47 | } 48 | 49 | return item.id !== id 50 | }) 51 | 52 | if (pinnedInsturction) { 53 | settings.customInstructions = [ 54 | pinnedInsturction, 55 | ...settings.customInstructions, 56 | ] 57 | 58 | await saveSetting(settings) 59 | } 60 | } 61 | 62 | export const batchAdd = async (instructions: Instruction[]) => { 63 | const settings = await getSetting() 64 | 65 | settings.customInstructions.push( 66 | ...instructions 67 | .map((i) => ({ 68 | id: uniqueId(Date.now() + ''), 69 | name: i.name, 70 | instruction: i.instruction, 71 | icon: i.icon, 72 | })) 73 | .filter((i) => i.name && i.instruction) 74 | ) 75 | 76 | await saveSetting(settings) 77 | } 78 | -------------------------------------------------------------------------------- /src/common/api/openai-request.ts: -------------------------------------------------------------------------------- 1 | import Browser from 'webextension-polyfill' 2 | import { EventName, PortName } from '../event-name' 3 | import { MessagePayload } from '../types' 4 | import { ChatCompletionCreateParamsBase } from 'openai/resources/chat/completions' 5 | 6 | export class OpenAIRequest { 7 | dataCallback: (text: string, error?: Error, ended?: boolean) => void 8 | baseURL: string 9 | apiKey: string 10 | 11 | constructor(baseURL: string, apiKey: string) { 12 | this.baseURL = baseURL 13 | this.apiKey = apiKey 14 | 15 | Browser.runtime.onMessage.addListener( 16 | (message: MessagePayload) => { 17 | if (message.type === EventName.openAIResponse) { 18 | this.dataCallback(message.data, undefined, false) 19 | } else if (message.type === EventName.openAIResponseEnd) { 20 | this.dataCallback(message.data, undefined, true) 21 | } else if (message.type === EventName.openAIResponseError) { 22 | this.dataCallback(undefined, new Error(message.data), true) 23 | } 24 | } 25 | ) 26 | } 27 | 28 | public sendMsg = (data: ChatCompletionCreateParamsBase, onData: any) => { 29 | this.dataCallback = onData 30 | 31 | Browser.runtime.sendMessage({ 32 | type: EventName.openAIChat, 33 | data: { 34 | ...data, 35 | baseURL: this.baseURL, 36 | apiKey: this.apiKey, 37 | }, 38 | }) 39 | } 40 | 41 | public abort = () => { 42 | Browser.runtime.sendMessage({ type: EventName.stopOpenAIChat }) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/common/api/openai.ts: -------------------------------------------------------------------------------- 1 | import i18next from 'i18next' 2 | import OpenAI from 'openai' 3 | import { useEffect, useMemo, useRef, useState } from 'react' 4 | import { logger } from '../debug' 5 | import { defaultSetting, useSettings } from '../store/settings' 6 | import { ServiceProvider } from '@/options/types' 7 | import { getToken } from './writely' 8 | import { parseStream } from '../parse-stream' 9 | import { chatgptWeb } from './chatgpt-web' 10 | import { OpenAIRequest } from './openai-request' 11 | import { stopOpenAICHat } from '../browser' 12 | 13 | const useOpenAPI = () => { 14 | const { settings } = useSettings() 15 | const openAIRef = useRef() 16 | 17 | useEffect(() => { 18 | const isWritelyProvider = 19 | settings.serviceProvider === ServiceProvider.Writely 20 | 21 | openAIRef.current = new OpenAI({ 22 | apiKey: isWritelyProvider ? getToken() : settings?.apiKey, 23 | baseURL: isWritelyProvider 24 | ? 'https://writely-proxy.miao-ya.com/v1' 25 | : settings.url, 26 | }) 27 | }, [settings?.apiKey, settings.url, settings.serviceProvider, getToken()]) 28 | 29 | return openAIRef 30 | } 31 | 32 | const useOpenAIRequest = () => { 33 | const { settings } = useSettings() 34 | 35 | return useMemo(() => { 36 | const isWritelyProvider = 37 | settings.serviceProvider === ServiceProvider.Writely 38 | 39 | const client = new OpenAIRequest( 40 | isWritelyProvider ? 'https://writely-proxy.miao-ya.com/v1' : settings.url, 41 | isWritelyProvider ? getToken() : settings?.apiKey 42 | ) 43 | 44 | return client 45 | }, [settings.apiKey, settings.url]) 46 | } 47 | 48 | const axiosOptionForOpenAI = ( 49 | onData: (text: string, err?: any, end?: boolean) => void 50 | ) => ({ 51 | responseType: 'stream' as ResponseType, 52 | onDownloadProgress: (e) => { 53 | if (e.currentTarget.status !== 200) { 54 | onData('', new Error(e.currentTarget.responseText), false) 55 | return 56 | } 57 | 58 | try { 59 | const { data, ended } = parseStream(e.currentTarget.response) 60 | 61 | if (ended) { 62 | onData(data, '', true) 63 | } else { 64 | onData?.(data) 65 | } 66 | } catch (e) { 67 | // expose current response for error display 68 | console.log(e) 69 | onData?.('', e.currentTarget) 70 | } 71 | }, 72 | }) 73 | 74 | export const useQueryOpenAIPrompt = () => { 75 | const openAI = useOpenAIRequest() 76 | const { settings } = useSettings() 77 | 78 | return ( 79 | prompt: string, 80 | onData?: (text: string, error?: Error, end?: boolean) => void 81 | ) => { 82 | const isWritelyService = 83 | settings.serviceProvider === ServiceProvider.Writely 84 | 85 | const commonOption = { 86 | // max_tokens: 4000 - prompt.replace(/[\u4e00-\u9fa5]/g, 'aa').length, 87 | stream: true, 88 | model: isWritelyService ? defaultSetting.model : settings.model, 89 | temperature: parseFloat(settings.temperature), 90 | } 91 | 92 | if (settings.serviceProvider === ServiceProvider.ChatGPT) { 93 | chatgptWeb.sendMsg(prompt, onData) 94 | 95 | return chatgptWeb.abort 96 | } else { 97 | openAI.sendMsg( 98 | { 99 | ...commonOption, 100 | messages: [{ role: 'user', content: prompt }], 101 | }, 102 | onData 103 | ) 104 | 105 | return () => { 106 | stopOpenAICHat() 107 | } 108 | } 109 | } 110 | } 111 | 112 | export const useOpenAIEditPrompt = () => { 113 | const queryPrompt = useQueryOpenAIPrompt() 114 | 115 | return ( 116 | input: string, 117 | instruction: string, 118 | onData?: (text: string, error?: Error, end?: boolean) => void 119 | ) => { 120 | return queryPrompt( 121 | !instruction 122 | ? input 123 | : i18next.t('Prompt template', { content: input, task: instruction }), 124 | onData 125 | ) 126 | } 127 | } 128 | 129 | export const useModels = () => { 130 | // const api = useOpenAPI(); 131 | // return useSWR('models', async () => { 132 | // return (await (await api?.current?.listModels()).data?.data) || []; 133 | // }); 134 | 135 | return useMemo(() => { 136 | return ['gpt-4o', 'gpt-4o-mini', '4o', 'deepseek-r1', 'grok-2-latest'] 137 | }, []) 138 | } 139 | -------------------------------------------------------------------------------- /src/common/api/writely.ts: -------------------------------------------------------------------------------- 1 | import useSWR from 'swr' 2 | import browser from 'webextension-polyfill' 3 | import { EventName } from '../event-name' 4 | 5 | export const useUser = () => { 6 | return useSWR(['user'], async () => { 7 | const result = await fetch('https://writely.miao-ya.com/api/user') 8 | const value = (await result.json()) as { 9 | data: { 10 | user: { 11 | email: string 12 | } 13 | } 14 | } 15 | 16 | return value 17 | }) 18 | } 19 | 20 | let writely_token = '' 21 | ;(async function _getToken() { 22 | const setToken = async () => { 23 | const result = await browser.runtime.sendMessage({ 24 | type: EventName.getToken, 25 | }) 26 | writely_token = decodeURI(result) 27 | } 28 | 29 | setToken() 30 | 31 | setInterval(setToken, 5000) 32 | })() 33 | 34 | export const getToken = () => writely_token 35 | -------------------------------------------------------------------------------- /src/common/browser.ts: -------------------------------------------------------------------------------- 1 | import { EventName } from './event-name' 2 | 3 | import browser from 'webextension-polyfill' 4 | 5 | export const openOptionPage = () => { 6 | browser.runtime.sendMessage({ 7 | type: EventName.openOptionsPage, 8 | }) 9 | } 10 | 11 | export const stopOpenAICHat = () => { 12 | browser.runtime.sendMessage({ 13 | type: EventName.stopOpenAIChat, 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /src/common/debug.ts: -------------------------------------------------------------------------------- 1 | import { getSetting } from './store/settings' 2 | 3 | class Logger { 4 | private _debug = false 5 | 6 | constructor() { 7 | this.init() 8 | } 9 | 10 | private async init() { 11 | const settings = await getSetting() 12 | 13 | if (settings.debug) { 14 | this._debug = true 15 | } 16 | } 17 | 18 | public debug = (...args) => { 19 | if (this._debug) { 20 | console.debug(...args) 21 | } 22 | } 23 | } 24 | 25 | const logger = new Logger() 26 | 27 | export { logger } 28 | -------------------------------------------------------------------------------- /src/common/event-name.ts: -------------------------------------------------------------------------------- 1 | export const launch_writely = 'launch-writely' 2 | 3 | export enum EventName { 4 | launchWritely = 'launch-writely', 5 | launchWritelyResultPanel = 'launchWritelyResultPanel', 6 | openOptionsPage = 'open-options-page', 7 | getToken = 'get-token', 8 | token = 'token', 9 | chat = 'chat', 10 | chatgptResponse = 'chatgpt-response', 11 | getChatGPTToken = 'get-chatgpt-token', 12 | stopChatGPTChat = 'stop-chatgpt-chat', 13 | 14 | openAIChat = 'openai-chat', 15 | openAIResponse = 'openai-response', 16 | openAIResponseEnd = 'openai-end', 17 | openAIResponseError = 'openai-error', 18 | stopOpenAIChat = 'stop-openai-chat', 19 | } 20 | 21 | export enum PortName { 22 | chatgptWeb = 'chatgpt web', 23 | } 24 | -------------------------------------------------------------------------------- /src/common/file.tsx: -------------------------------------------------------------------------------- 1 | export const download = (url: string, filename: string) => { 2 | const a = document.createElement('a') as HTMLAnchorElement 3 | a.href = url 4 | a.download = filename 5 | 6 | a.click() 7 | } 8 | -------------------------------------------------------------------------------- /src/common/i18n.ts: -------------------------------------------------------------------------------- 1 | import i18n from 'i18next'; 2 | import LanguageDetector from 'i18next-browser-languagedetector'; 3 | import zhJSON from './locale/zh-CN.json'; 4 | import enJSON from './locale/en-US.json'; 5 | import { getSetting } from './store/settings'; 6 | 7 | export const initI18n = async () => { 8 | const settings = await getSetting(); 9 | 10 | return i18n.use(LanguageDetector).init({ 11 | resources: { 12 | 'en-US': { 13 | translation: enJSON, 14 | }, 15 | 'zh-CN': { 16 | translation: zhJSON, 17 | }, 18 | }, 19 | fallbackLng: 'en-US', 20 | lng: settings.lang || '', 21 | }); 22 | }; 23 | -------------------------------------------------------------------------------- /src/common/langs.tsx: -------------------------------------------------------------------------------- 1 | export const langs = [ 2 | { 3 | label: 'English', 4 | value: 'en-US', 5 | }, 6 | { 7 | label: '中文', 8 | value: 'zh-CN', 9 | }, 10 | ]; 11 | -------------------------------------------------------------------------------- /src/common/locale/en-US.json: -------------------------------------------------------------------------------- 1 | { 2 | "Improve writing": "Improve writing", 3 | "Fix spell and grammar": "Fix spell and grammar", 4 | "Make shorter": "Make shorter", 5 | "Make Longer": "Make Longer", 6 | "Translate to": "Translate to", 7 | "English": "English", 8 | "Chinese": "Chinese", 9 | "Append": "Append", 10 | "Replace": "Replace", 11 | "Prompt template": "{{task}}:\n {{content}}", 12 | "Senior Writer": "Senior Writer", 13 | "Send to writely": "Send to writely" 14 | } -------------------------------------------------------------------------------- /src/common/locale/zh-CN.json: -------------------------------------------------------------------------------- 1 | { 2 | "Improve writing": "提升写作", 3 | "Fix spell and grammar": "修复错别字和语法问题", 4 | "Make shorter": "使内容更精简", 5 | "Make Longer": "使内容更丰富", 6 | "Translate to": "翻译成", 7 | "English": "英语", 8 | "Chinese": "中文", 9 | "Append": "添加", 10 | "Replace": "替换内容", 11 | "Prompt template": "{{task}}:\n {{content}}", 12 | "Senior Writer": "写作高手", 13 | "Send to writely": "发送给 Writely", 14 | "Change tone": "改变语调", 15 | "Professional": "专业", 16 | "Casual": "随意", 17 | "Straightforward": "简单明了", 18 | "Confident": "自信", 19 | "Friendly": "友好", 20 | "Change tone to": "语气变为", 21 | "Insert content": "插入内容", 22 | "Replace content": "替换内容", 23 | "Edit or review selection": "修改或审查选中区域", 24 | "Generate from selection": "从选中区域生成", 25 | "Summarize": "总结", 26 | "Explain this": "解释一下", 27 | "Find action items": "找到行动事项", 28 | "Draft with AI": "用 AI 起草", 29 | "Brain storm ideas": "头脑风暴想法", 30 | "Blog post": "博客文章", 31 | "Social media post": "社交媒体帖子", 32 | "Press release": "新闻发布", 33 | "Creative story": "创意故事", 34 | "Essay": "文章", 35 | "Poem": "诗歌", 36 | "Job description": "工作描述", 37 | "Pros and cons list": "利弊清单", 38 | "Write a": "写一个", 39 | "Custom instructions": "自定义指令", 40 | "More": "更多", 41 | "Create new instruction": "✨创建新指令", 42 | "Icon": "图标", 43 | "Name": "指令名", 44 | "Instruction": "指令内容", 45 | "Enter the instruction name...": "输入指令名称", 46 | "Example: Write an email to my boss": "举例: 给老板写一封邮件", 47 | "Ok": "确定", 48 | "Cancel": "取消", 49 | "Delete instruction": "删除指令", 50 | "Edit instruction": "编辑指令", 51 | "Actions": "操作", 52 | "Add": "新增", 53 | "Model": "模型", 54 | "Models Instruction": "模型说明", 55 | "Temperature": "温度", 56 | "Temperature Instruction": "温度说明", 57 | "URL": "代理网址", 58 | "System": "系统", 59 | "Language": "语言", 60 | "Debug": "调试", 61 | "accurate": "精确", 62 | "balance": "平衡", 63 | "creative": "创造力", 64 | "Test": "测试", 65 | "Test connection": "测试连接", 66 | "Send message": "发送消息", 67 | "Settings": "设置", 68 | "Add new instruction": "添加新指令", 69 | "Import": "导入", 70 | "Export": "导出", 71 | "Export instructions as JSON": "导出指令为JSON文件", 72 | "Import instructions": "导入指令", 73 | "😄 Imported successfully": "😄 导入成功", 74 | "😭 Error format": "😭 格式错误", 75 | "Stop Generate": "停止生成", 76 | "Service Provider": "服务提供方", 77 | "Connect your writely account": "关联你的 Writely 账号", 78 | "No Writely account detected": "未检测到 Writely 账号", 79 | "Connect your Chatgpt account": "关联你的 Chatgpt 账号", 80 | "please Go to": "请前往", 81 | "Extension Settings": "插件设置", 82 | "to connect": "关联", 83 | "Using the services provided by Writely, there are 10 free times per day": "使用 Writely 提供的服务,每天有 10 次的免费次数", 84 | "By using the services provided by OpenAI API Key, you can permanently use Writely software for free": "使用 OpenAI API Key 提供的服务,您可以永久免费使用 Writely 软件", 85 | "Using the ChatGPT Web service is not recommended as it may carry the risk of being banned by OpenAI. Please consider this carefully. In case of account suspension, it is unrelated to Writely.": "使用ChatGPT Web的服务并不推荐,因为这可能会导致OpenAI封禁您的账号。请您自行权衡利弊。如果出现账号被封的情况,与Writely无关。" 86 | } -------------------------------------------------------------------------------- /src/common/parse-stream.ts: -------------------------------------------------------------------------------- 1 | import { logger } from './debug' 2 | 3 | export const parseStream = (content: string) => { 4 | const lines = content 5 | .toString() 6 | .split('\n') 7 | .filter((line) => line.trim() !== '') 8 | 9 | let result = '' 10 | 11 | logger.debug('[EventSource]', content) 12 | 13 | let ended = false 14 | 15 | for (const line of lines) { 16 | const message = line.replace(/^data: /, '') 17 | 18 | if (message === '[DONE]') { 19 | // stream finished 20 | ended = true 21 | break 22 | } 23 | 24 | try { 25 | const parsed = JSON.parse(message) 26 | 27 | const text = 28 | parsed?.message?.content?.parts?.join('') || 29 | parsed.choices[0].text || 30 | parsed.choices[0]?.delta?.content || 31 | parsed.choices[0]?.message?.content || 32 | '' 33 | 34 | if (!text && !result) { 35 | continue 36 | } 37 | 38 | // ChatGPT Web 39 | if (!!parsed?.message?.content?.parts) { 40 | if (text.length > result.length) { 41 | result = text 42 | } 43 | console.log(text, '=======', result) 44 | } else { 45 | result += text 46 | } 47 | 48 | // edits don't support stream 49 | if (parsed.object === 'edit') { 50 | ended = true 51 | break 52 | } 53 | } catch { 54 | continue 55 | } 56 | } 57 | 58 | return { 59 | data: result, 60 | ended: ended, 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/common/store/settings.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from 'react' 2 | import { createContainer } from 'unstated-next' 3 | import browser from 'webextension-polyfill' 4 | import { omit, uniqueId } from 'lodash-es' 5 | import { ServiceProvider, Settings } from '../../options/types' 6 | 7 | const key = 'writingly-settings' 8 | 9 | export const defaultSetting: Settings = { 10 | model: 'gpt-3.5-turbo', 11 | url: 'https://api.openai.com/v1', 12 | } 13 | 14 | const _useSettings = () => { 15 | const [settings, _setSettings] = useState() 16 | const [loading, setLoading] = useState(true) 17 | 18 | useEffect(() => { 19 | refresh() 20 | }, []) 21 | 22 | const setSettings = useCallback( 23 | async (newSettings: Partial) => { 24 | _setSettings({ 25 | ...settings, 26 | ...newSettings, 27 | }) 28 | 29 | saveSetting(newSettings) 30 | }, 31 | [settings] 32 | ) 33 | 34 | const refresh = useCallback(async () => { 35 | const initSettings = async () => { 36 | _setSettings(await getSetting()) 37 | setLoading(false) 38 | } 39 | 40 | initSettings() 41 | }, []) 42 | 43 | return { 44 | settings, 45 | setSettings, 46 | refresh, 47 | loading, 48 | } 49 | } 50 | 51 | const { useContainer: useSettings, Provider: SettingsProvider } = 52 | createContainer(_useSettings) 53 | 54 | export { useSettings, SettingsProvider } 55 | 56 | export const getSetting = async () => { 57 | const res = { 58 | ...((await browser.storage.local.get(key))?.[key] || {}), 59 | ...((await browser.storage.sync.get(key))?.[key] || {}), 60 | } 61 | 62 | patchDefaultSetting(res) 63 | patchCustomInstructions(res) 64 | 65 | if (!res.serviceProvider) { 66 | res.serviceProvider = ServiceProvider.Writely 67 | } 68 | 69 | return res as Settings 70 | } 71 | 72 | export const saveSetting = async (newSettings: Partial) => { 73 | const settings = { 74 | ...(await getSetting()), 75 | ...newSettings, 76 | } 77 | 78 | // 只有 customInstruction 存在本地 79 | const localNewSettings = settings.customInstructions 80 | ? { 81 | customInstructions: settings.customInstructions, 82 | } 83 | : null 84 | const remoteSettings = omit(settings, 'customInstructions') 85 | 86 | browser.storage.sync.set({ 87 | [key]: remoteSettings, 88 | }) 89 | 90 | if (localNewSettings) { 91 | browser.storage.local.set({ [key]: localNewSettings }) 92 | } 93 | } 94 | 95 | const patchCustomInstructions = (setting: Settings) => { 96 | setting.customInstructions = 97 | setting.customInstructions?.map((instruction) => { 98 | if (typeof instruction === 'string') { 99 | return { 100 | id: uniqueId(), 101 | name: instruction, 102 | instruction: instruction, 103 | icon: '😄', 104 | } 105 | } 106 | 107 | return instruction 108 | }) || [] 109 | } 110 | 111 | const patchDefaultSetting = (setting: Settings) => { 112 | Object.keys(defaultSetting).forEach((s) => { 113 | if (!setting[s]) { 114 | setting[s] = defaultSetting[s] 115 | } 116 | }) 117 | } 118 | -------------------------------------------------------------------------------- /src/common/types.d.ts: -------------------------------------------------------------------------------- 1 | import { EventName } from './event-name'; 2 | 3 | export type MessagePayload = { 4 | type: T; 5 | data?: D; 6 | }; 7 | -------------------------------------------------------------------------------- /src/components/icon-btn.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren, useMemo } from 'react' 2 | import cx from 'classnames' 3 | 4 | export const IconBtn: React.FC< 5 | PropsWithChildren<{ 6 | disabled?: boolean 7 | color?: 'green' | 'gray' | 'orange' | 'red' | 'blue' 8 | className?: string 9 | onClick?: () => void 10 | }> 11 | > = ({ color, className, onClick, children, disabled }) => { 12 | const colorClass = useMemo(() => { 13 | switch (color) { 14 | case 'green': 15 | return 'text-green-400 hover:text-green-500' 16 | case 'gray': 17 | return 'text-gray-400 hover:text-gray-500' 18 | case 'orange': 19 | return 'text-orange-400 hover:text-orange-500' 20 | case 'red': 21 | return 'text-red-400 hover:text-red-500' 22 | case 'blue': 23 | return 'text-blue-400 hover:text-blue-500' 24 | default: 25 | return '' 26 | } 27 | }, []) 28 | 29 | if (disabled) { 30 | return null 31 | } 32 | 33 | return ( 34 |
42 | {children} 43 |
44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /src/components/icon/back.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from 'react'; 2 | 3 | export function MaterialSymbolsKeyboardBackspace( 4 | props: SVGProps 5 | ) { 6 | return ( 7 | 14 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/components/icon/chatgpt.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from 'react' 2 | 3 | export const ChatGPTIcon: React.FC> = (props) => { 4 | return ( 5 | 6 | 10 | 14 | 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /src/components/icon/checked.tsx: -------------------------------------------------------------------------------- 1 | export function MaterialSymbolsCheckCircleRounded( 2 | props: SVGProps 3 | ) { 4 | return ( 5 | 12 | 16 | 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /src/components/icon/close.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from 'react'; 2 | 3 | export function MdiClose(props: SVGProps) { 4 | return ( 5 | 12 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/components/icon/copy.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from 'react'; 2 | 3 | export function MaterialSymbolsContentCopyOutline( 4 | props: SVGProps 5 | ) { 6 | return ( 7 | 14 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/components/icon/delete.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from 'react'; 2 | 3 | export function IcBaselineDeleteOutline(props: SVGProps) { 4 | return ( 5 | 12 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/components/icon/drag.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from 'react'; 2 | 3 | export function DashiconsMove(props: SVGProps) { 4 | return ( 5 | 12 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/components/icon/edit.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from 'react' 2 | 3 | export function MaterialSymbolsEditOutline(props: SVGProps) { 4 | return ( 5 | 12 | 16 | 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /src/components/icon/feedback.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from 'react'; 2 | 3 | export function CodiconFeedback(props: SVGProps) { 4 | return ( 5 | 12 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/components/icon/github.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from 'react'; 2 | 3 | export function LogosGithubIcon(props: SVGProps) { 4 | return ( 5 | 12 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/components/icon/heart.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from 'react'; 2 | 3 | export function RiHeartFill(props: SVGProps) { 4 | return ( 5 | 12 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/components/icon/index.ts: -------------------------------------------------------------------------------- 1 | export * from './right'; 2 | export * from './send'; 3 | export * from './right'; 4 | export * from './back'; 5 | export * from './close'; 6 | export * from './write'; 7 | export * from './copy'; 8 | export * from './replay'; 9 | export * from './setting'; 10 | export * from './heart'; 11 | export * from './logo'; 12 | export * from './insert'; 13 | export * from './prompt-icons'; 14 | -------------------------------------------------------------------------------- /src/components/icon/insert.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from 'react'; 2 | 3 | export function CarbonRowInsert(props: SVGProps) { 4 | return ( 5 | 12 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/components/icon/link.tsx: -------------------------------------------------------------------------------- 1 | export function MaterialSymbolsAddLink(props: SVGProps) { 2 | return ( 3 | 10 | 14 | 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /src/components/icon/logo.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from 'react'; 2 | 3 | export const Logo: React.FC> = (props) => { 4 | return ( 5 | 16 | 21 | 26 | 27 | 28 | 29 | 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /src/components/icon/more.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from 'react'; 2 | 3 | export function MaterialSymbolsMoreHoriz(props: SVGProps) { 4 | return ( 5 | 12 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/components/icon/open-ai.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from 'react' 2 | 3 | export const OpenAILogo: React.FC> = (props) => { 4 | return ( 5 | 13 | 14 | 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /src/components/icon/prompt-icons.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from 'react'; 2 | 3 | export function IcBaselineAutoFixHigh(props: SVGProps) { 4 | return ( 5 | 12 | 16 | 17 | ); 18 | } 19 | 20 | export function IcBaselineCheck(props: SVGProps) { 21 | return ( 22 | 29 | 33 | 34 | ); 35 | } 36 | 37 | export function IcBaselineShortText(props: SVGProps) { 38 | return ( 39 | 46 | 47 | 48 | ); 49 | } 50 | 51 | export function MdiTextLong(props: SVGProps) { 52 | return ( 53 | 60 | 64 | 65 | ); 66 | } 67 | 68 | export function MdiTreeOutline(props: SVGProps) { 69 | return ( 70 | 77 | 81 | 82 | ); 83 | } 84 | 85 | export function IcOutlineAutoStories(props: SVGProps) { 86 | return ( 87 | 94 | 98 | 99 | ); 100 | } 101 | 102 | export function IcOutlineTranslate(props: SVGProps) { 103 | return ( 104 | 111 | 115 | 116 | ); 117 | } 118 | 119 | export function IcSharpPanoramaWideAngle(props: SVGProps) { 120 | return ( 121 | 128 | 132 | 133 | ); 134 | } 135 | 136 | export function IcOutlineLightbulb(props: SVGProps) { 137 | return ( 138 | 145 | 149 | 150 | ); 151 | } 152 | 153 | export function MdiMessageReplyTextOutline(props: SVGProps) { 154 | return ( 155 | 162 | 166 | 167 | ); 168 | } 169 | 170 | export function IcBaselinePodcasts(props: SVGProps) { 171 | return ( 172 | 179 | 183 | 184 | ); 185 | } 186 | 187 | export function MaterialSymbolsEnergyProgramTimeUsedSharp( 188 | props: SVGProps 189 | ) { 190 | return ( 191 | 198 | 202 | 203 | ); 204 | } 205 | 206 | export function MaterialSymbolsToolsPliersWireStripperOutlineSharp( 207 | props: SVGProps 208 | ) { 209 | return ( 210 | 217 | 221 | 222 | ); 223 | } 224 | 225 | export function IcRoundQuestionMark(props: SVGProps) { 226 | return ( 227 | 234 | 238 | 239 | ); 240 | } 241 | 242 | export function MaterialSymbolsFormatListBulletedSharp( 243 | props: SVGProps 244 | ) { 245 | return ( 246 | 253 | 257 | 258 | ); 259 | } 260 | -------------------------------------------------------------------------------- /src/components/icon/replace.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from 'react'; 2 | 3 | export function BiFileCheck(props: SVGProps) { 4 | return ( 5 | 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/components/icon/replay.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from 'react'; 2 | 3 | export function TablerRefresh(props: SVGProps) { 4 | return ( 5 | 12 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/components/icon/return.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from 'react'; 2 | 3 | export function IcOutlineKeyboardReturn(props: SVGProps) { 4 | return ( 5 | 12 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/components/icon/right.tsx: -------------------------------------------------------------------------------- 1 | export const RightArrowIcon: React.FC = () => { 2 | return ( 3 | 16 | 17 | 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /src/components/icon/send.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from 'react'; 2 | 3 | export function IcBaselineSend(props: SVGProps) { 4 | return ( 5 | 12 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/components/icon/setting.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from 'react'; 2 | 3 | export function DashiconsAdminGeneric(props: SVGProps) { 4 | return ( 5 | 12 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/components/icon/stop.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from 'react' 2 | 3 | export function MaterialSymbolsStopCircleOutline( 4 | props: SVGProps 5 | ) { 6 | return ( 7 | 14 | 18 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/components/icon/up.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from 'react' 2 | 3 | export function MaterialSymbolsArrowUpward(props: SVGProps) { 4 | return ( 5 | 12 | 16 | 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /src/components/icon/update.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from 'react'; 2 | 3 | export function IcOutlineCheck(props: SVGProps) { 4 | return ( 5 | 12 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/components/icon/write.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from 'react' 2 | 3 | export function IcOutlineModeEdit(props: SVGProps) { 4 | return ( 5 | 12 | 16 | 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /src/components/icon/writely.tsx: -------------------------------------------------------------------------------- 1 | import React, { SVGProps } from 'react' 2 | 3 | export const IconWritely: React.FC> = (props) => { 4 | return ( 5 | 12 | 17 | 27 | 40 | 41 | 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /src/content/app.tsx: -------------------------------------------------------------------------------- 1 | import { Menu } from './container/index' 2 | // first, import these 2 badboys 3 | import { StyleProvider } from '@ant-design/cssinjs' 4 | import Cache from '@ant-design/cssinjs/es/Cache' 5 | import { conatinerId, tag } from './shadow-dom' 6 | import { SettingsProvider } from '../common/store/settings' 7 | 8 | // second, create custom Cache entity 9 | class CustomCache extends Cache { 10 | override update(keys, valFn) { 11 | const shadowRoot = document.getElementsByTagName(tag)[0].shadowRoot 12 | let path = keys.join('%') 13 | let prevValue = this.cache.get(path)! 14 | let nextValue = valFn(prevValue) 15 | let id = keys.join('-') 16 | let style = shadowRoot.getElementById(id) 17 | if (!style) { 18 | style = document.createElement('style') 19 | style.id = id 20 | shadowRoot.appendChild(style) 21 | } 22 | style.innerText = nextValue 23 | super.update(keys, valFn) 24 | } 25 | } 26 | 27 | export const App: React.FC = () => { 28 | return ( 29 | 30 |
31 | 32 |
33 |
34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /src/content/container/ask-writely/content/index.tsx: -------------------------------------------------------------------------------- 1 | import { Avatar, Button, Input, Tag, Tooltip } from 'antd' 2 | import { 3 | forwardRef, 4 | PropsWithChildren, 5 | useCallback, 6 | useMemo, 7 | useState, 8 | } from 'react' 9 | import cx from 'classnames' 10 | import { ResultPanel } from '../result-panel' 11 | import { PromptCenter } from '../prompts' 12 | import { IcBaselineSend, Logo } from '@/components/icon' 13 | import i18next from 'i18next' 14 | import { IcOutlineKeyboardReturn } from '@/components/icon/return' 15 | import { useView } from '../../store/view' 16 | import { DashiconsMove } from '@/components/icon/drag' 17 | import { QuickPrompt } from './quick-prompt' 18 | import { useInstruction } from '../../store/instruction' 19 | 20 | export const Content: React.FC = () => { 21 | return 22 | } 23 | 24 | const CenterContent = forwardRef((_, ref) => { 25 | const { instruction, setInstruction } = useInstruction() 26 | const { viewStatus, goToInputPage } = useView() 27 | 28 | const handleClickIcon = useCallback(() => { 29 | goToInputPage() 30 | }, [goToInputPage]) 31 | 32 | if (viewStatus === 'icon') { 33 | return ( 34 |
35 | } 38 | /> 39 |
40 | ) 41 | } 42 | 43 | if (viewStatus === 'result') { 44 | return 45 | } 46 | 47 | return 48 | }) 49 | 50 | const InputPanel: React.FC<{ 51 | keyword: string 52 | onChange: (keyword: string, instruction?: string) => void 53 | }> = ({ onChange }) => { 54 | const { goToResult } = useView() 55 | const [value, setValue] = useState('') 56 | 57 | return ( 58 | <> 59 |
64 | { 67 | onChange(value) 68 | goToResult() 69 | }} 70 | autoFocus 71 | autoSize={{ minRows: 1, maxRows: 4 }} 72 | placeholder="Ask writely to..." 73 | value={value} 74 | onChange={(e) => setValue(e.target.value)} 75 | /> 76 |
77 | 78 |
{ 81 | onChange(value) 82 | goToResult() 83 | }} 84 | > 85 | 93 |
94 |
95 |
96 | 101 |
102 |
107 |
108 | { 111 | goToResult() 112 | onChange(instruction) 113 | }} 114 | /> 115 |
116 |
117 | 118 | ) 119 | } 120 | 121 | const SendToWritelyTip: React.FC = ({ children }) => { 122 | return ( 123 | 126 | {i18next.t('Send to writely')} 127 | 128 | } 129 | > 130 | {children} 131 | 132 | ) 133 | } 134 | 135 | const DragTip: React.FC = () => { 136 | return ( 137 | {i18next.t('Drag')}}> 138 |
139 | 140 |
141 |
142 | ) 143 | } 144 | -------------------------------------------------------------------------------- /src/content/container/ask-writely/content/list.tsx: -------------------------------------------------------------------------------- 1 | import { MaterialSymbolsMoreHoriz } from '@/components/icon/more' 2 | import { Popover } from 'antd' 3 | import i18next from 'i18next' 4 | import React, { useCallback, useRef } from 'react' 5 | import { RightArrowIcon } from '../../../../components/icon' 6 | 7 | export type ListProps = { 8 | items: { 9 | label: React.ReactNode 10 | children: ListProps['items'] 11 | icon: React.ReactNode 12 | instruction: string 13 | }[] 14 | onClick?: (item: ListProps['items'][number]) => void 15 | max?: number 16 | } 17 | 18 | export const List: React.FC = ({ items, onClick, max }) => { 19 | const handleClick = useCallback( 20 | (item) => { 21 | if (item.children) { 22 | return 23 | } 24 | 25 | onClick?.(item) 26 | }, 27 | [onClick] 28 | ) 29 | 30 | const maxShownItem = typeof max === 'number' ? max : Infinity 31 | 32 | if (items.length === 0) { 33 | return null 34 | } 35 | 36 | const shouldShowMore = items.length > maxShownItem 37 | 38 | return ( 39 |
40 | {items.slice(0, maxShownItem).map((item, index) => { 41 | const itemEle = 42 | 43 | if (!item.children?.length) { 44 | return itemEle 45 | } 46 | 47 | return ( 48 | e.parentElement} 52 | content={} 53 | > 54 |
{itemEle}
55 |
56 | ) 57 | })} 58 | {shouldShowMore ? ( 59 | e.parentElement} 62 | content={ 63 | 64 | } 65 | > 66 |
67 | , 70 | label: i18next.t('More'), 71 | }} 72 | /> 73 |
74 |
75 | ) : null} 76 |
77 | ) 78 | } 79 | 80 | const Item: React.FC<{ item: any; onClick?: (item: any) => void }> = ({ 81 | item, 82 | onClick, 83 | }) => { 84 | const hasChildren = !!item.children?.length 85 | 86 | return ( 87 |
onClick?.(item)} 89 | className="h-7 hover:bg-zinc-200 rounded-none hover:rounded-md flex items-center justify-between text-[13px] hover:text-[14px] cursor-pointer px-1.5 transition-all duration-300" 90 | > 91 |
92 | {item.icon}{' '} 93 | {item.label} 94 |
95 | {hasChildren ? ( 96 |
97 | 98 |
99 | ) : null} 100 |
101 | ) 102 | } 103 | -------------------------------------------------------------------------------- /src/content/container/ask-writely/content/quick-prompt.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react' 2 | import { PromptCenter } from '../prompts' 3 | import { List, ListProps } from './list' 4 | 5 | export const QuickPrompt: React.FC<{ 6 | filter: string 7 | onClick: (instruction: string) => void 8 | }> = ({ filter, onClick }) => { 9 | const promptCenter = useMemo(() => new PromptCenter(), []) 10 | const items = promptCenter.useDropDownItems(filter) 11 | 12 | return ( 13 |
14 | {items.map((item, index) => ( 15 | 16 | ))} 17 |
18 | ) 19 | } 20 | 21 | const Card: React.FC<{ 22 | category: string 23 | menus: ListProps['items'] 24 | onClick: (instruction: string) => void 25 | }> = ({ category, menus, onClick }) => { 26 | return ( 27 |
31 |
32 |
{category}
33 | onClick(i.instruction || (i.label as string))} 37 | /> 38 |
39 |
40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /src/content/container/ask-writely/index.tsx: -------------------------------------------------------------------------------- 1 | import { MutableRefObject, useRef } from 'react' 2 | import ReactDraggable from 'react-draggable' 3 | import { useSelectionManager } from '../store/selection' 4 | import { useView } from '../store/view' 5 | import { Content } from './content' 6 | 7 | let fixedRef: MutableRefObject 8 | 9 | export const AskWritely: React.FC = () => { 10 | const selectionManager = useSelectionManager() 11 | const { position } = selectionManager 12 | const { viewStatus } = useView() 13 | const _fixedRef = useRef() 14 | 15 | fixedRef = _fixedRef 16 | 17 | if (viewStatus === 'none') { 18 | return null 19 | } 20 | 21 | const showIcon = viewStatus === 'icon' 22 | 23 | const content = ( 24 |
window.innerHeight 32 | ? position.y - 300 33 | : position.y 34 | }px`, 35 | left: `${ 36 | showIcon ? position.x : Math.min(position.x, window.innerWidth - 320) 37 | }px`, 38 | zIndex: 9999999999999, 39 | }} 40 | > 41 | 42 |
43 | ) 44 | 45 | if (viewStatus === 'icon') { 46 | return content 47 | } 48 | 49 | return {content} 50 | } 51 | 52 | export const getFixedDom = () => { 53 | return fixedRef.current 54 | } 55 | -------------------------------------------------------------------------------- /src/content/container/ask-writely/prompts.tsx: -------------------------------------------------------------------------------- 1 | import { getSetting, useSettings } from '@/common/store/settings' 2 | import { 3 | IcBaselineAutoFixHigh, 4 | IcBaselineCheck, 5 | IcBaselinePodcasts, 6 | IcBaselineShortText, 7 | IcOutlineAutoStories, 8 | IcOutlineLightbulb, 9 | IcOutlineTranslate, 10 | IcRoundQuestionMark, 11 | IcSharpPanoramaWideAngle, 12 | MaterialSymbolsEnergyProgramTimeUsedSharp, 13 | MaterialSymbolsFormatListBulletedSharp, 14 | MaterialSymbolsToolsPliersWireStripperOutlineSharp, 15 | MdiMessageReplyTextOutline, 16 | MdiTextLong, 17 | MdiTreeOutline, 18 | } from '@/components/icon' 19 | import i18n from 'i18next' 20 | 21 | export const defaultPrompt = (params: { task: string }) => { 22 | return (content: string) => { 23 | return i18n.t('Prompt template', { 24 | content, 25 | task: params.task, 26 | }) 27 | } 28 | } 29 | 30 | function getRandomEmoji() { 31 | const emojis = [ 32 | '😀', 33 | '😂', 34 | '😍', 35 | '🤔', 36 | '🙄', 37 | '😇', 38 | '🤤', 39 | '🥳', 40 | '🥺', 41 | '😎', 42 | '😜', 43 | '🤪', 44 | ] 45 | const randomIndex = Math.floor(Math.random() * emojis.length) 46 | return emojis[randomIndex] 47 | } 48 | 49 | let settings = null 50 | 51 | ;(async function () { 52 | settings = await getSetting() 53 | })() 54 | 55 | const getPrompts = () => { 56 | const customInstructions = settings?.customInstructions || [] 57 | 58 | const predefined = [ 59 | { 60 | category: i18n.t('Edit or review selection'), 61 | menus: [ 62 | { 63 | label: i18n.t('Improve writing'), 64 | icon: , 65 | }, 66 | { 67 | label: i18n.t('Fix spell and grammar'), 68 | icon: , 69 | }, 70 | { 71 | label: i18n.t('Make shorter'), 72 | icon: , 73 | }, 74 | { 75 | label: i18n.t('Make Longer'), 76 | icon: , 77 | }, 78 | { 79 | label: i18n.t('Change tone'), 80 | icon: , 81 | children: [ 82 | i18n.t('Professional'), 83 | i18n.t('Casual'), 84 | i18n.t('Straightforward'), 85 | i18n.t('Confident'), 86 | i18n.t('Friendly'), 87 | ].map((label) => { 88 | return { 89 | label, 90 | instruction: i18n.t('Change tone to'), 91 | } 92 | }), 93 | }, 94 | ], 95 | }, 96 | { 97 | category: i18n.t('Generate from selection'), 98 | menus: [ 99 | { 100 | label: i18n.t('Summarize'), 101 | icon: , 102 | }, 103 | { 104 | label: i18n.t('Translate to'), 105 | icon: , 106 | children: [ 107 | { 108 | label: i18n.t('English'), 109 | icon: '🇬🇧 ', 110 | }, 111 | { 112 | label: i18n.t('Chinese'), 113 | icon: '🇨🇳 ', 114 | }, 115 | { 116 | label: i18n.t('Japanese'), 117 | icon: '🇯🇵 ', 118 | }, 119 | { 120 | label: i18n.t('Korean'), 121 | icon: '🇰🇷 ', 122 | }, 123 | { 124 | label: i18n.t('German'), 125 | icon: '🇩🇪 ', 126 | }, 127 | { 128 | label: i18n.t('French'), 129 | icon: '🇫🇷 ', 130 | }, 131 | { 132 | label: i18n.t('Italian'), 133 | icon: '🇮🇹 ', 134 | }, 135 | ].map((item) => { 136 | return { 137 | ...item, 138 | instruction: i18n.t('Translate to') + ' ' + item.label, 139 | } 140 | }), 141 | }, 142 | { 143 | label: i18n.t('Explain this'), 144 | icon: , 145 | }, 146 | { 147 | label: i18n.t('Find action items'), 148 | icon: , 149 | }, 150 | ], 151 | }, 152 | { 153 | category: i18n.t('Draft with AI'), 154 | icon: , 155 | menus: [ 156 | { 157 | label: i18n.t('Brain storm ideas'), 158 | icon: , 159 | }, 160 | { 161 | label: i18n.t('Blog post'), 162 | icon: , 163 | }, 164 | { 165 | label: i18n.t('Social media post'), 166 | icon: , 167 | }, 168 | { 169 | label: i18n.t('Press release'), 170 | icon: , 171 | }, 172 | { 173 | label: i18n.t('Creative story'), 174 | icon: , 175 | }, 176 | { 177 | label: i18n.t('Essay'), 178 | icon: , 179 | }, 180 | { 181 | label: i18n.t('Poem'), 182 | icon: , 183 | }, 184 | { 185 | label: i18n.t('Job description'), 186 | icon: , 187 | }, 188 | { 189 | label: i18n.t('Pros and cons list'), 190 | icon: , 191 | }, 192 | ].map((item) => { 193 | return { 194 | ...item, 195 | instruction: i18n.t('Write a') + ' ' + item.label, 196 | } 197 | }), 198 | }, 199 | ] 200 | 201 | if (customInstructions?.length) { 202 | predefined.unshift({ 203 | category: i18n.t('Custom instructions'), 204 | menus: customInstructions?.map((i) => ({ 205 | label: i.name, 206 | icon: i.icon, 207 | instruction: i.instruction, 208 | })), 209 | }) 210 | } 211 | 212 | return predefined 213 | } 214 | 215 | export class PromptCenter { 216 | protected prompts 217 | 218 | constructor() { 219 | this.initPrompts() 220 | } 221 | 222 | private initPrompts = () => { 223 | this.prompts = this.constructPrompts(getPrompts()) 224 | } 225 | 226 | private constructPrompts = (prompts, prefix: string = '') => { 227 | return prompts.map((p) => { 228 | if (!prompts.children?.length) { 229 | return { 230 | ...p, 231 | prompt: defaultPrompt({ 232 | task: p.label, 233 | }), 234 | } 235 | } 236 | 237 | return { 238 | ...p, 239 | children: this.constructPrompts(p.children, prefix + ' ' + p.label), 240 | } 241 | }) 242 | } 243 | 244 | public useDropDownItems = (keyword = '') => { 245 | const result = [] 246 | const match = (str: string) => { 247 | return str.toLowerCase().includes(keyword.toLowerCase()) 248 | } 249 | 250 | this.prompts.forEach((category) => { 251 | if (match(category.category)) { 252 | result.push(category) 253 | } else { 254 | const matchedMenus = [] 255 | 256 | category.menus.forEach((menu) => { 257 | if (match(menu.label) || match(menu.instruction || '')) { 258 | matchedMenus.push(menu) 259 | } else { 260 | const matchedSubMenus = [] 261 | 262 | if (menu.children) { 263 | menu.children.forEach((c) => { 264 | if (match(c.label) || match(c.instruction || '')) { 265 | matchedSubMenus.push(c) 266 | } 267 | }) 268 | } 269 | 270 | if (matchedSubMenus.length) { 271 | matchedMenus.push(matchedSubMenus) 272 | } 273 | } 274 | }) 275 | 276 | if (matchedMenus.length) { 277 | result.push({ 278 | ...category, 279 | menus: matchedMenus, 280 | }) 281 | } 282 | } 283 | }) 284 | 285 | return result 286 | } 287 | } 288 | -------------------------------------------------------------------------------- /src/content/container/ask-writely/result-panel/actions/base-action.tsx: -------------------------------------------------------------------------------- 1 | import { Tooltip } from 'antd'; 2 | import { PropsWithChildren, useCallback, useRef, useState } from 'react'; 3 | 4 | export const BaseAction: React.FC< 5 | PropsWithChildren<{ 6 | tooltip: string; 7 | successTooltip?: string; 8 | onClick?: () => void; 9 | }> 10 | > = ({ tooltip, successTooltip, children, onClick }) => { 11 | const [title, setTitle] = useState(tooltip); 12 | const [open, setOpen] = useState(false); 13 | 14 | const handleMouseEnter = useCallback(() => { 15 | setOpen(true); 16 | }, []); 17 | 18 | const handleMouseLeave = useCallback(() => { 19 | setOpen(false); 20 | setTitle(tooltip); 21 | }, []); 22 | 23 | const handleClick = useCallback(() => { 24 | onClick?.(); 25 | successTooltip && setTitle(successTooltip); 26 | }, [onClick]); 27 | 28 | return ( 29 | 30 |
36 | {children} 37 |
38 |
39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /src/content/container/ask-writely/result-panel/actions/copy.tsx: -------------------------------------------------------------------------------- 1 | import { MaterialSymbolsContentCopyOutline } from '@/components/icon'; 2 | import i18next from 'i18next'; 3 | import { SVGProps, useCallback } from 'react'; 4 | import { BaseAction } from './base-action'; 5 | 6 | export const copy = (dom: HTMLDivElement) => { 7 | if (!dom) { 8 | return; 9 | } 10 | 11 | const s = window.getSelection(); 12 | s.removeAllRanges(); 13 | s.addRange(new Range()); 14 | s.getRangeAt(0).selectNode(dom); 15 | 16 | // TODO: use clipboard to copy 17 | document.execCommand('copy'); 18 | 19 | s.removeAllRanges(); 20 | }; 21 | 22 | export const Copy: React.FC<{ 23 | dom: React.MutableRefObject; 24 | }> = ({ dom }) => { 25 | const handleClick = useCallback(() => { 26 | copy(dom.current); 27 | }, []); 28 | 29 | return ( 30 | 35 | 36 | 37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /src/content/container/ask-writely/result-panel/actions/index.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react'; 2 | 3 | export const Actions: React.FC = ({ children }) => { 4 | return ( 5 |
{children}
6 | ); 7 | }; 8 | -------------------------------------------------------------------------------- /src/content/container/ask-writely/result-panel/actions/replace.tsx: -------------------------------------------------------------------------------- 1 | import { MaterialSymbolsContentCopyOutline } from '@/components/icon'; 2 | import { BiFileCheck } from '@/components/icon/replace'; 3 | import { useSelectionManager } from '@/content/container/store/selection'; 4 | import i18next from 'i18next'; 5 | import { useCallback } from 'react'; 6 | import { BaseAction } from './base-action'; 7 | import { copy } from './copy'; 8 | 9 | export const Replace: React.FC<{ 10 | dom: React.MutableRefObject; 11 | }> = ({ dom }) => { 12 | const selection = useSelectionManager(); 13 | 14 | const handleClick = useCallback(() => { 15 | copy(dom.current); 16 | return selection.replace(dom.current.innerText); 17 | }, []); 18 | 19 | return ( 20 | 25 | 26 | 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /src/content/container/ask-writely/result-panel/actions/replay.tsx: -------------------------------------------------------------------------------- 1 | import { TablerRefresh } from '@/components/icon' 2 | import { useResultPanel } from '@/content/container/store/result-panel' 3 | import i18next from 'i18next' 4 | import { BaseAction } from './base-action' 5 | 6 | export const Replay: React.FC = () => { 7 | const { setLoading, setIsError } = useResultPanel() 8 | 9 | return ( 10 | { 14 | setLoading(true) 15 | setIsError(false) 16 | }} 17 | > 18 | 19 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/content/container/ask-writely/result-panel/actions/update.tsx: -------------------------------------------------------------------------------- 1 | import { IcOutlineCheck } from '@/components/icon/update'; 2 | import { useResultPanel } from '@/content/container/store/result-panel'; 3 | import { useSelectionManager } from '@/content/container/store/selection'; 4 | import i18next from 'i18next'; 5 | import { useCallback } from 'react'; 6 | import { BaseAction } from './base-action'; 7 | import { copy } from './copy'; 8 | 9 | export const Insert: React.FC<{ 10 | dom: React.MutableRefObject; 11 | }> = ({ dom }) => { 12 | const selection = useSelectionManager(); 13 | 14 | const handleClick = useCallback(async () => { 15 | copy(dom.current); 16 | return selection.append(dom.current?.innerText); 17 | }, []); 18 | 19 | return ( 20 | 25 | 26 | 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /src/content/container/ask-writely/result-panel/content.tsx: -------------------------------------------------------------------------------- 1 | import { IcOutlineModeEdit } from '@/components/icon' 2 | import { MutableRefObject, useCallback, useEffect, useRef } from 'react' 3 | import mdit from 'markdown-it' 4 | import hljsPlugin from 'markdown-it-highlightjs' 5 | import { Actions } from './actions' 6 | import { Copy } from './actions/copy' 7 | import { Replay } from './actions/replay' 8 | import cx from 'classnames' 9 | import { useSelectionManager } from '../../store/selection' 10 | import { Alert, Tooltip, message } from 'antd' 11 | import { useOpenAIEditPrompt } from '@/common/api/openai' 12 | import { useResultPanel } from '../../store/result-panel' 13 | import { Insert } from './actions/update' 14 | import { Replace } from './actions/replace' 15 | import { IconBtn } from '@/components/icon-btn' 16 | import { MaterialSymbolsStopCircleOutline } from '@/components/icon/stop' 17 | import i18next from 'i18next' 18 | import { getToken } from '@/common/api/writely' 19 | import { LoginInstruction } from './login-instruction' 20 | import { useSettings } from '@/common/store/settings' 21 | import { ServiceProvider } from '@/options/types' 22 | import { chatgptWeb } from '@/common/api/chatgpt-web' 23 | 24 | const md = mdit().use(hljsPlugin) 25 | 26 | export const Content: React.FC<{ 27 | text: string 28 | abortRef: MutableRefObject<() => void> 29 | }> = ({ text: task, abortRef }) => { 30 | const mdContainerRef = useRef() 31 | const selectionManager = useSelectionManager() 32 | const queryOpenAIPrompt = useOpenAIEditPrompt() 33 | const { 34 | result, 35 | setResult, 36 | loading, 37 | setLoading, 38 | setText, 39 | text: resultText, 40 | setIsError, 41 | isError, 42 | } = useResultPanel() 43 | const { settings } = useSettings() 44 | const sequenceRef = useRef(0) 45 | const { isOriginText } = useResultPanel() 46 | 47 | const handleQuery = useCallback(async () => { 48 | if (!selectionManager.text) { 49 | return message.warning('No selection') 50 | } 51 | 52 | sequenceRef.current += 1 53 | const currentSequence = sequenceRef.current 54 | 55 | const handler = (text: string, err: Error, end: boolean) => { 56 | if (currentSequence != sequenceRef.current) { 57 | return 58 | } 59 | 60 | if (end) { 61 | text && setText(text) 62 | setLoading(false) 63 | 64 | if (err) { 65 | setText(err.message) 66 | setIsError(true) 67 | } 68 | 69 | return 70 | } 71 | 72 | if (err) { 73 | setText(err.message) 74 | setLoading(false) 75 | setIsError(true) 76 | } else { 77 | setText(text) 78 | setIsError(false) 79 | } 80 | } 81 | 82 | try { 83 | abortRef.current = queryOpenAIPrompt(selectionManager.text, task, handler) 84 | // queryOpenAIEdit(selectionManager.text, text, handler); 85 | } catch (e) { 86 | setResult(e.toString()) 87 | setLoading(false) 88 | } 89 | }, [queryOpenAIPrompt]) 90 | 91 | useEffect(() => { 92 | if (loading) { 93 | setResult('') 94 | setText('') 95 | handleQuery() 96 | } 97 | }, [loading]) 98 | 99 | useEffect(() => { 100 | if (!isOriginText) { 101 | setResult(md.render(resultText)) 102 | } else { 103 | setResult(resultText) 104 | } 105 | }, [isOriginText, resultText]) 106 | 107 | useEffect(() => setLoading(true), []) 108 | 109 | const content = ( 110 |
115 | ) 116 | 117 | if (!getToken() && settings.serviceProvider === ServiceProvider.Writely) { 118 | return 119 | } 120 | 121 | if ( 122 | !chatgptWeb.accessToken && 123 | settings.serviceProvider === ServiceProvider.ChatGPT 124 | ) { 125 | return 126 | } 127 | 128 | return ( 129 |
130 |
131 | {loading ? ( 132 |
133 | { 135 | abortRef?.current?.() 136 | setLoading(false) 137 | }} 138 | /> 139 |
140 | ) : null} 141 | {isError ? : content} 142 | {loading ? : null} 143 |
144 |
150 | {loading ? null : ( 151 | 152 | 153 | 154 | 155 | 156 | 157 | )} 158 |
159 |
160 | ) 161 | } 162 | 163 | const StopGenerate: React.FC<{ onClick?: () => void }> = ({ onClick }) => { 164 | return ( 165 |
166 | 167 | 172 | 173 | 174 | 175 |
176 | ) 177 | } 178 | 179 | const AutoScroll: React.FC = () => { 180 | const divRef = useRef() 181 | 182 | useVisibleEffect(divRef) 183 | 184 | return
185 | } 186 | 187 | const useVisibleEffect = (ref: MutableRefObject) => { 188 | useEffect(() => { 189 | const observer = new IntersectionObserver((entries) => { 190 | entries.forEach((entry) => { 191 | if (entry.intersectionRatio !== 1) { 192 | ref.current.scrollIntoView({ 193 | behavior: 'smooth', 194 | }) 195 | } 196 | }) 197 | }) 198 | 199 | if (!ref.current) { 200 | return 201 | } 202 | 203 | observer.observe(ref.current) 204 | 205 | return () => observer.disconnect() 206 | }, []) 207 | } 208 | -------------------------------------------------------------------------------- /src/content/container/ask-writely/result-panel/header.tsx: -------------------------------------------------------------------------------- 1 | import { Switch, Tooltip } from 'antd' 2 | import { 3 | MdiClose, 4 | MaterialSymbolsKeyboardBackspace, 5 | DashiconsAdminGeneric, 6 | RiHeartFill, 7 | } from '@/components/icon' 8 | import { MutableRefObject, ReactNode } from 'react' 9 | import { useView } from '../../store/view' 10 | import i18next from 'i18next' 11 | import { useResultPanel } from '../../store/result-panel' 12 | import type { MessagePayload } from '@/common/types' 13 | import { EventName } from '@/common/event-name' 14 | import browser from 'webextension-polyfill' 15 | 16 | export const Header: React.FC<{ abortRef: MutableRefObject<() => void> }> = ({ 17 | abortRef, 18 | }) => { 19 | const { hide, goToInputPage } = useView() 20 | const { isOriginText, setIsOriginText } = useResultPanel() 21 | 22 | const back = () => { 23 | goToInputPage() 24 | abortRef.current?.() 25 | } 26 | 27 | return ( 28 |
29 |
30 | } 32 | tooltip="Back" 33 | onClick={back} 34 | /> 35 | } tooltip="Close window" onClick={hide} /> 36 |
37 |
38 | setIsOriginText(e)} 45 | className={isOriginText ? '!bg-amber-800' : '!bg-gray-400'} 46 | /> 47 | } 48 | tooltip={i18next.t('Display original text')} 49 | > 50 | 57 | 58 | 59 | } 60 | tooltip={i18next.t('Star')} 61 | /> 62 | { 64 | browser.runtime.sendMessage({ 65 | type: EventName.openOptionsPage, 66 | }) 67 | }} 68 | icon={} 69 | tooltip="Jump to settings" 70 | /> 71 |
72 |
73 | ) 74 | } 75 | 76 | const Operation: React.FC<{ 77 | icon: ReactNode 78 | tooltip: string 79 | onClick?: () => void 80 | }> = ({ icon, tooltip, onClick }) => { 81 | return ( 82 | 83 |
onClick?.()} 85 | className="text-white p-3 text-base flex items-center justify-center cursor-pointer hover:bg-zinc-700 transition-all duration-700" 86 | > 87 | {icon} 88 |
89 |
90 | ) 91 | } 92 | -------------------------------------------------------------------------------- /src/content/container/ask-writely/result-panel/index.tsx: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react' 2 | import { ResultPanelProvider } from '../../store/result-panel' 3 | import { Content } from './content' 4 | import { Header } from './header' 5 | 6 | export const ResultPanel: React.FC<{ 7 | text: string 8 | }> = ({ text }) => { 9 | const abortRef = useRef<() => void>(() => {}) 10 | 11 | return ( 12 | 13 |
14 |
15 |
16 |
17 | 18 |
19 |
20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/content/container/ask-writely/result-panel/login-instruction.tsx: -------------------------------------------------------------------------------- 1 | import { openOptionPage } from '@/common/browser' 2 | import Link from 'antd/es/typography/Link' 3 | import i18next from 'i18next' 4 | 5 | export const LoginInstruction: React.FC<{ 6 | accountType: 'Writely' | 'ChatGPT' 7 | }> = ({ accountType }) => { 8 | return ( 9 |
10 | 11 | {i18next 12 | .t('No Writely account detected') 13 | .replace('Writely', accountType || 'Writely')} 14 | , {i18next.t('please Go to')}{' '} 15 | 16 | {i18next.t('Extension Settings')} 17 | {i18next.t('to connect')} 18 |
19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/content/container/index.tsx: -------------------------------------------------------------------------------- 1 | import { ConfigProvider } from 'antd'; 2 | import { useEffect } from 'react'; 3 | import { theme } from '../../common/antd-theme'; 4 | import { AskWritely, getFixedDom } from './ask-writely'; 5 | import { SelectionManagerProvider } from './store/selection'; 6 | import 'highlight.js/styles/github.css'; 7 | import { ViewProvider } from './store/view'; 8 | import { InstructionProvider } from './store/instruction'; 9 | 10 | export const Menu: React.FC = () => { 11 | return ( 12 | getFixedDom()} 15 | getTargetContainer={() => getFixedDom()} 16 | > 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /src/content/container/store/instruction.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { createContainer } from 'unstated-next'; 3 | 4 | export const { useContainer: useInstruction, Provider: InstructionProvider } = 5 | createContainer(() => { 6 | const [instruction, setInstruction] = useState(''); 7 | 8 | return { 9 | instruction, 10 | setInstruction, 11 | }; 12 | }); 13 | -------------------------------------------------------------------------------- /src/content/container/store/result-panel.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from 'react' 2 | import { createContainer } from 'unstated-next' 3 | 4 | const { useContainer: useResultPanel, Provider: ResultPanelProvider } = 5 | createContainer(() => { 6 | const [loading, setLoading] = useState(false) 7 | const [result, setResult] = useState('') 8 | const [text, setText] = useState('') 9 | const [isError, setIsError] = useState(false) 10 | const [isOriginText, setIsOriginText] = useState(false) 11 | 12 | return { 13 | loading, 14 | setLoading, 15 | result, 16 | setResult, 17 | text, 18 | isOriginText, 19 | setIsOriginText, 20 | setText: useCallback((newText: string) => { 21 | setText((value) => { 22 | // ChatGPT web last stream is empty 23 | if (newText.trim()?.length) { 24 | return newText 25 | } 26 | 27 | return value 28 | }) 29 | }, []), 30 | isError, 31 | setIsError, 32 | } 33 | }) 34 | 35 | export { useResultPanel, ResultPanelProvider } 36 | -------------------------------------------------------------------------------- /src/content/container/store/selection.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { createContainer } from 'unstated-next'; 3 | import { SelectionManager } from '../../utils/selection'; 4 | 5 | const { 6 | useContainer: useSelectionManager, 7 | Provider: SelectionManagerProvider, 8 | } = createContainer(() => { 9 | return useMemo(() => { 10 | return new SelectionManager(); 11 | }, []); 12 | }); 13 | 14 | export { useSelectionManager, SelectionManagerProvider }; 15 | -------------------------------------------------------------------------------- /src/content/container/store/view.ts: -------------------------------------------------------------------------------- 1 | import { EventName } from '@/common/event-name' 2 | import { MessagePayload } from '@/common/types' 3 | import { useCallback, useEffect, useRef, useState } from 'react' 4 | import { createContainer } from 'unstated-next' 5 | import browser from 'webextension-polyfill' 6 | import { useInstruction } from './instruction' 7 | import { useSelectionManager } from './selection' 8 | 9 | const { useContainer: useView, Provider: ViewProvider } = createContainer( 10 | () => { 11 | const [viewStatus, setViewStatus] = useState< 12 | 'icon' | 'input' | 'result' | 'none' 13 | >('none') 14 | const viewStatusRef = useRef() 15 | viewStatusRef.current = viewStatus 16 | const selection = useSelectionManager() 17 | const { setInstruction } = useInstruction() 18 | const disposeListRef = useRef<(() => void)[]>([]) 19 | 20 | const disposeAll = useCallback(() => { 21 | disposeListRef.current.forEach((c) => c()) 22 | disposeListRef.current = [] 23 | }, []) 24 | 25 | const goToInputPage = useCallback(() => { 26 | if (viewStatusRef.current !== 'icon') { 27 | document.addEventListener('click', hide) 28 | disposeListRef.current.push(() => { 29 | document.removeEventListener('click', hide) 30 | }) 31 | } 32 | setViewStatus('input') 33 | selection.setLock(true) 34 | }, []) 35 | 36 | const goToResult = useCallback(() => { 37 | setViewStatus('result') 38 | disposeAll() 39 | }, []) 40 | 41 | const goToIcon = useCallback(() => { 42 | selection.setLock(true) 43 | setViewStatus('icon') 44 | document.addEventListener('click', hide) 45 | disposeListRef.current.push(() => { 46 | document.removeEventListener('click', hide) 47 | }) 48 | }, []) 49 | 50 | const hide = useCallback(() => { 51 | setViewStatus('none') 52 | selection.setLock(false) 53 | disposeAll() 54 | }, []) 55 | 56 | useEffect(() => { 57 | let id = null 58 | 59 | selection.onSelectionChange((s) => { 60 | if (s.toString() && viewStatusRef.current === 'none') { 61 | goToIcon() 62 | 63 | clearTimeout(id) 64 | id = setTimeout(() => { 65 | if (viewStatusRef.current === 'icon') { 66 | hide() 67 | } 68 | }, 3000) 69 | } 70 | }) 71 | 72 | return () => { 73 | disposeAll() 74 | clearTimeout(id) 75 | } 76 | }, []) 77 | 78 | useEffect(() => { 79 | const listener = (message: MessagePayload) => { 80 | if (message.type === EventName.launchWritely) { 81 | goToInputPage() 82 | return 83 | } 84 | 85 | if (message.type === EventName.launchWritelyResultPanel) { 86 | setInstruction(message.data?.instruction) 87 | goToResult() 88 | return 89 | } 90 | } 91 | 92 | browser.runtime.onMessage.addListener(listener) 93 | 94 | return () => browser.runtime.onMessage.removeListener(listener) 95 | }, []) 96 | 97 | return { 98 | viewStatus, 99 | goToInputPage, 100 | goToResult, 101 | goToIcon, 102 | hide, 103 | } 104 | } 105 | ) 106 | 107 | export { useView, ViewProvider } 108 | -------------------------------------------------------------------------------- /src/content/index.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ 2 | 3 | /* Document 4 | ========================================================================== */ 5 | 6 | /** 7 | * 1. Correct the line height in all browsers. 8 | * 2. Prevent adjustments of font size after orientation changes in iOS. 9 | */ 10 | 11 | html { 12 | line-height: 1.15; /* 1 */ 13 | -webkit-text-size-adjust: 100%; /* 2 */ 14 | } 15 | 16 | /* Sections 17 | ========================================================================== */ 18 | 19 | /** 20 | * Remove the margin in all browsers. 21 | */ 22 | 23 | body { 24 | margin: 0; 25 | } 26 | 27 | /** 28 | * Render the `main` element consistently in IE. 29 | */ 30 | 31 | main { 32 | display: block; 33 | } 34 | 35 | /** 36 | * Correct the font size and margin on `h1` elements within `section` and 37 | * `article` contexts in Chrome, Firefox, and Safari. 38 | */ 39 | 40 | h1 { 41 | font-size: 2em; 42 | margin: 0.67em 0; 43 | } 44 | 45 | /* Grouping content 46 | ========================================================================== */ 47 | 48 | /** 49 | * 1. Add the correct box sizing in Firefox. 50 | * 2. Show the overflow in Edge and IE. 51 | */ 52 | 53 | hr { 54 | box-sizing: content-box; /* 1 */ 55 | height: 0; /* 1 */ 56 | overflow: visible; /* 2 */ 57 | } 58 | 59 | /** 60 | * 1. Correct the inheritance and scaling of font size in all browsers. 61 | * 2. Correct the odd `em` font sizing in all browsers. 62 | */ 63 | 64 | pre { 65 | font-family: monospace, monospace; /* 1 */ 66 | font-size: 1em; /* 2 */ 67 | } 68 | 69 | /* Text-level semantics 70 | ========================================================================== */ 71 | 72 | /** 73 | * Remove the gray background on active links in IE 10. 74 | */ 75 | 76 | a { 77 | background-color: transparent; 78 | } 79 | 80 | /** 81 | * 1. Remove the bottom border in Chrome 57- 82 | * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. 83 | */ 84 | 85 | abbr[title] { 86 | border-bottom: none; /* 1 */ 87 | text-decoration: underline; /* 2 */ 88 | text-decoration: underline dotted; /* 2 */ 89 | } 90 | 91 | /** 92 | * Add the correct font weight in Chrome, Edge, and Safari. 93 | */ 94 | 95 | b, 96 | strong { 97 | font-weight: bolder; 98 | } 99 | 100 | /** 101 | * 1. Correct the inheritance and scaling of font size in all browsers. 102 | * 2. Correct the odd `em` font sizing in all browsers. 103 | */ 104 | 105 | code, 106 | kbd, 107 | samp { 108 | font-family: monospace, monospace; /* 1 */ 109 | font-size: 1em; /* 2 */ 110 | } 111 | 112 | /** 113 | * Add the correct font size in all browsers. 114 | */ 115 | 116 | small { 117 | font-size: 80%; 118 | } 119 | 120 | /** 121 | * Prevent `sub` and `sup` elements from affecting the line height in 122 | * all browsers. 123 | */ 124 | 125 | sub, 126 | sup { 127 | font-size: 75%; 128 | line-height: 0; 129 | position: relative; 130 | vertical-align: baseline; 131 | } 132 | 133 | sub { 134 | bottom: -0.25em; 135 | } 136 | 137 | sup { 138 | top: -0.5em; 139 | } 140 | 141 | /* Embedded content 142 | ========================================================================== */ 143 | 144 | /** 145 | * Remove the border on images inside links in IE 10. 146 | */ 147 | 148 | img { 149 | border-style: none; 150 | } 151 | 152 | /* Forms 153 | ========================================================================== */ 154 | 155 | /** 156 | * 1. Change the font styles in all browsers. 157 | * 2. Remove the margin in Firefox and Safari. 158 | */ 159 | 160 | button, 161 | input, 162 | optgroup, 163 | select, 164 | textarea { 165 | font-family: inherit; /* 1 */ 166 | font-size: 100%; /* 1 */ 167 | line-height: 1.15; /* 1 */ 168 | margin: 0; /* 2 */ 169 | } 170 | 171 | /** 172 | * Show the overflow in IE. 173 | * 1. Show the overflow in Edge. 174 | */ 175 | 176 | button, 177 | input { 178 | /* 1 */ 179 | overflow: visible; 180 | } 181 | 182 | /** 183 | * Remove the inheritance of text transform in Edge, Firefox, and IE. 184 | * 1. Remove the inheritance of text transform in Firefox. 185 | */ 186 | 187 | button, 188 | select { 189 | /* 1 */ 190 | text-transform: none; 191 | } 192 | 193 | /** 194 | * Correct the inability to style clickable types in iOS and Safari. 195 | */ 196 | 197 | button, 198 | [type='button'], 199 | [type='reset'], 200 | [type='submit'] { 201 | -webkit-appearance: button; 202 | } 203 | 204 | /** 205 | * Remove the inner border and padding in Firefox. 206 | */ 207 | 208 | button::-moz-focus-inner, 209 | [type='button']::-moz-focus-inner, 210 | [type='reset']::-moz-focus-inner, 211 | [type='submit']::-moz-focus-inner { 212 | border-style: none; 213 | padding: 0; 214 | } 215 | 216 | /** 217 | * Restore the focus styles unset by the previous rule. 218 | */ 219 | 220 | button:-moz-focusring, 221 | [type='button']:-moz-focusring, 222 | [type='reset']:-moz-focusring, 223 | [type='submit']:-moz-focusring { 224 | outline: 1px dotted ButtonText; 225 | } 226 | 227 | /** 228 | * Correct the padding in Firefox. 229 | */ 230 | 231 | fieldset { 232 | padding: 0.35em 0.75em 0.625em; 233 | } 234 | 235 | /** 236 | * 1. Correct the text wrapping in Edge and IE. 237 | * 2. Correct the color inheritance from `fieldset` elements in IE. 238 | * 3. Remove the padding so developers are not caught out when they zero out 239 | * `fieldset` elements in all browsers. 240 | */ 241 | 242 | legend { 243 | box-sizing: border-box; /* 1 */ 244 | color: inherit; /* 2 */ 245 | display: table; /* 1 */ 246 | max-width: 100%; /* 1 */ 247 | padding: 0; /* 3 */ 248 | white-space: normal; /* 1 */ 249 | } 250 | 251 | /** 252 | * Add the correct vertical alignment in Chrome, Firefox, and Opera. 253 | */ 254 | 255 | progress { 256 | vertical-align: baseline; 257 | } 258 | 259 | /** 260 | * Remove the default vertical scrollbar in IE 10+. 261 | */ 262 | 263 | textarea { 264 | overflow: auto; 265 | } 266 | 267 | /** 268 | * 1. Add the correct box sizing in IE 10. 269 | * 2. Remove the padding in IE 10. 270 | */ 271 | 272 | [type='checkbox'], 273 | [type='radio'] { 274 | box-sizing: border-box; /* 1 */ 275 | padding: 0; /* 2 */ 276 | } 277 | 278 | /** 279 | * Correct the cursor style of increment and decrement buttons in Chrome. 280 | */ 281 | 282 | [type='number']::-webkit-inner-spin-button, 283 | [type='number']::-webkit-outer-spin-button { 284 | height: auto; 285 | } 286 | 287 | /** 288 | * 1. Correct the odd appearance in Chrome and Safari. 289 | * 2. Correct the outline style in Safari. 290 | */ 291 | 292 | [type='search'] { 293 | -webkit-appearance: textfield; /* 1 */ 294 | outline-offset: -2px; /* 2 */ 295 | } 296 | 297 | /** 298 | * Remove the inner padding in Chrome and Safari on macOS. 299 | */ 300 | 301 | [type='search']::-webkit-search-decoration { 302 | -webkit-appearance: none; 303 | } 304 | 305 | /** 306 | * 1. Correct the inability to style clickable types in iOS and Safari. 307 | * 2. Change font properties to `inherit` in Safari. 308 | */ 309 | 310 | ::-webkit-file-upload-button { 311 | -webkit-appearance: button; /* 1 */ 312 | font: inherit; /* 2 */ 313 | } 314 | 315 | /* Interactive 316 | ========================================================================== */ 317 | 318 | /* 319 | * Add the correct display in Edge, IE 10+, and Firefox. 320 | */ 321 | 322 | details { 323 | display: block; 324 | } 325 | 326 | /* 327 | * Add the correct display in all browsers. 328 | */ 329 | 330 | summary { 331 | display: list-item; 332 | } 333 | 334 | /* Misc 335 | ========================================================================== */ 336 | 337 | /** 338 | * Add the correct display in IE 10+. 339 | */ 340 | 341 | template { 342 | display: none; 343 | } 344 | 345 | /** 346 | * Add the correct display in IE 10. 347 | */ 348 | 349 | [hidden] { 350 | display: none; 351 | } 352 | 353 | @tailwind components; 354 | @tailwind utilities; 355 | -------------------------------------------------------------------------------- /src/content/index.tsx: -------------------------------------------------------------------------------- 1 | import '@webcomponents/webcomponentsjs/webcomponents-bundle' 2 | import { createRoot } from 'react-dom/client' 3 | import { StyleProvider } from '@ant-design/cssinjs' 4 | import { App } from './app' 5 | import './index.css' 6 | import { tag, conatinerId } from './shadow-dom.js' 7 | import { initI18n } from '../common/i18n' 8 | import 'animate.css' 9 | 10 | const render = async () => { 11 | const container = document.createElement(tag) 12 | container.className = 'writly-container' 13 | document.documentElement.append(container) 14 | 15 | await initI18n() 16 | 17 | createRoot(container.shadowRoot.querySelector(`#${conatinerId}`)).render( 18 | 19 | 20 | 21 | ) 22 | } 23 | 24 | render() 25 | -------------------------------------------------------------------------------- /src/content/shadow-dom.ts: -------------------------------------------------------------------------------- 1 | const tag = 'writely-container' 2 | const conatinerId = 'writely-container-id' 3 | 4 | // Create a class for the element 5 | class WritelyContainer extends HTMLElement { 6 | constructor() { 7 | // Always call super first in constructor 8 | super() 9 | 10 | // Create a shadow root 11 | const shadow = this.attachShadow({ mode: 'open' }) 12 | 13 | const container = document.createElement('div') 14 | container.id = conatinerId 15 | container.setAttribute('style', 'font-size:16px;') 16 | shadow.appendChild(container) 17 | 18 | /** 19 | * Prevent bubble, cause the host website might listen them to make thing unexpected 20 | * For example notion, it listens on keyup event to delete content 21 | * https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent 22 | * https://developer.mozilla.org/en-US/docs/Web/API/InputEvent/inputType 23 | */ 24 | ;[ 25 | 'click', 26 | 'keydown', 27 | 'keypress', 28 | 'keyup', 29 | 'copy', 30 | 'paste', 31 | 'mouseup', 32 | ].forEach((eventName) => { 33 | shadow.addEventListener(eventName, (e) => { 34 | e.stopPropagation() 35 | }) 36 | }) 37 | } 38 | } 39 | 40 | // Define the new element 41 | customElements.define('writely-container', WritelyContainer) 42 | 43 | export { tag, conatinerId } 44 | -------------------------------------------------------------------------------- /src/content/utils/edit-detector.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | class EditDetector { 4 | private activeElement: HTMLElement; 5 | private timerId = null; 6 | 7 | public get boundingReact() { 8 | return this.activeElement.getBoundingClientRect(); 9 | } 10 | 11 | public useVisibility = () => { 12 | const [visible, setVisible] = useState(false); 13 | 14 | window.addEventListener('focusin', (e) => { 15 | if (isEditable(e.target as HTMLElement)) { 16 | this.activeElement = e.target as HTMLElement; 17 | setVisible(true); 18 | } 19 | }); 20 | 21 | window.addEventListener('blur', (e) => { 22 | if (e.target !== this.activeElement) { 23 | return; 24 | } 25 | 26 | clearTimeout(this.timerId); 27 | 28 | this.timerId = setTimeout(() => { 29 | this.activeElement = null; 30 | setVisible(false); 31 | }, 1000); 32 | }); 33 | 34 | return visible; 35 | }; 36 | 37 | public keepFocus = () => { 38 | clearTimeout(this.timerId); 39 | setTimeout(() => this.activeElement.focus(), 100); 40 | }; 41 | } 42 | 43 | const isEditable = (ele: HTMLElement) => { 44 | if (!ele) { 45 | return false; 46 | } 47 | 48 | if ( 49 | ele.nodeType.toString().toLowerCase() === 'textarea' || 50 | ele.isContentEditable 51 | ) { 52 | return true; 53 | } 54 | 55 | return isEditable(ele.parentElement); 56 | }; 57 | 58 | export const editDetecor = new EditDetector(); 59 | -------------------------------------------------------------------------------- /src/content/utils/selection/highlight.ts: -------------------------------------------------------------------------------- 1 | export class Highlight { 2 | highlighter: any; 3 | private className: string = `writely-highlight-${Date.now()}`; 4 | 5 | private surroundContainers: HTMLElement[] = []; 6 | 7 | public highlight(range: Range) { 8 | try { 9 | let span = document.createElement('span'); 10 | span.setAttribute('className', this.className); 11 | span.setAttribute('style', 'background-color: #ababb7 !important;'); 12 | this.surroundContainers.push(span); 13 | range.surroundContents(span); 14 | } catch { 15 | // 16 | } 17 | } 18 | 19 | public unhighlight() { 20 | this.surroundContainers.forEach((container) => { 21 | container.replaceWith(container.innerHTML); 22 | }); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/content/utils/selection/index.ts: -------------------------------------------------------------------------------- 1 | import { Highlight } from './highlight' 2 | import { debounce } from 'lodash-es' 3 | export class SelectionManager { 4 | protected selectChangeHandlers = [] 5 | // lock a selction, means when selection change, we won't emit onSelectionChange 6 | protected locked = false 7 | protected selection: Selection 8 | protected highlight: Highlight 9 | protected savedRange: Range 10 | private textPasted: boolean 11 | 12 | public text: string = '' 13 | public position: { x: number; y: number } 14 | 15 | constructor() { 16 | this.setup() 17 | this.highlight = new Highlight() 18 | } 19 | 20 | get activeSelection() { 21 | return this.selection 22 | } 23 | 24 | get isLocked() { 25 | return this.locked 26 | } 27 | 28 | public onSelectionChange = (cb: (selection: Selection) => void) => { 29 | this.selectChangeHandlers.push(cb) 30 | 31 | return () => { 32 | this.selectChangeHandlers = this.selectChangeHandlers.filter( 33 | (handler) => handler != cb 34 | ) 35 | } 36 | } 37 | 38 | public setLock(locked: boolean) { 39 | if (this.locked === locked) { 40 | return 41 | } 42 | 43 | this.locked = locked 44 | 45 | if (!locked) { 46 | this.textPasted = false 47 | } 48 | } 49 | 50 | public async append(text?: string, replace?: boolean) { 51 | this.restoreRange() 52 | const container = this.selection.getRangeAt(0).commonAncestorContainer 53 | 54 | // for input/text-area. In most case we selected the parent selection. not themself 55 | const inputNode = [...container.childNodes].find( 56 | (child) => child.nodeName === 'TEXTAREA' || child.nodeName === 'INPUT' 57 | ) 58 | 59 | if (inputNode) { 60 | this.selection.getRangeAt(0).selectNode(inputNode) 61 | 62 | if (!replace) { 63 | this.selection.collapseToEnd() 64 | } 65 | 66 | ;(inputNode as any).focus() 67 | return document.execCommand('insertText', false, text) 68 | } else { 69 | if (!replace) { 70 | this.selection.collapseToEnd() 71 | container?.parentElement?.focus?.() 72 | } 73 | 74 | // don't know why. but at first time settimeout excute paste actions, everything works as expected 75 | if (this.textPasted) { 76 | document.execCommand('paste') 77 | } else { 78 | setTimeout(() => { 79 | document.execCommand('paste') 80 | this.textPasted = true 81 | }, 100) 82 | } 83 | } 84 | } 85 | 86 | public replace(text?: string) { 87 | this.append(text, true) 88 | } 89 | 90 | private setup() { 91 | // debounce the event 92 | const eventHandler = debounce((e: MouseEvent) => { 93 | this.selection = 94 | (e.target as any)?.ownerDocument?.getSelection() || 95 | window.getSelection() 96 | 97 | const valid = !!this.selection.toString() 98 | 99 | if (valid) { 100 | this.position = { 101 | x: Math.max(e.x - 30, 10), 102 | y: e.y + 10, 103 | } 104 | 105 | if (!this.locked) { 106 | this.savedRange = this.selection.getRangeAt(0).cloneRange() 107 | this.setText() 108 | } 109 | 110 | this.selectChangeHandlers.forEach((handler) => handler(this.selection)) 111 | } 112 | }, 300) 113 | 114 | // listen keyup and check if there is a selection 115 | document.addEventListener('mouseup', eventHandler, true) 116 | 117 | // bind mouseup for every iframes 118 | const iframes = [...document.getElementsByTagName('iframe')] 119 | iframes.forEach((f) => { 120 | if (f.contentDocument) { 121 | f.contentDocument.addEventListener('mouseup', eventHandler, true) 122 | } 123 | }) 124 | } 125 | 126 | private setText() { 127 | this.text = this.selection.toString() 128 | } 129 | 130 | private restoreRange() { 131 | this.selection.removeAllRanges() 132 | this.selection.addRange(this.savedRange) 133 | 134 | // clone a new copy, to prevent it's being altered 135 | this.savedRange = this.savedRange.cloneRange() 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | import { Browser } from 'webextension-polyfill' 2 | 3 | declare global { 4 | var browser: Browser 5 | } 6 | 7 | declare module '*.png' 8 | -------------------------------------------------------------------------------- /src/options/app.tsx: -------------------------------------------------------------------------------- 1 | import { SettingsForm } from './setting-form' 2 | import { SettingsProvider } from '../common/store/settings' 3 | import { ConfigProvider } from 'antd' 4 | import { theme } from '../common/antd-theme' 5 | 6 | export const App: React.FC = () => { 7 | return ( 8 | 9 | 10 |
11 |
12 | 13 |
14 |
15 |
16 |
17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /src/options/index.css: -------------------------------------------------------------------------------- 1 | /* 2 | 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) 3 | 2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) 4 | */ 5 | 6 | *, 7 | ::before, 8 | ::after { 9 | box-sizing: border-box; /* 1 */ 10 | border-width: 0; /* 2 */ 11 | border-style: solid; /* 2 */ 12 | border-color: theme('borderColor.DEFAULT', currentColor); /* 2 */ 13 | } 14 | 15 | ::before, 16 | ::after { 17 | --tw-content: ''; 18 | } 19 | 20 | /* 21 | 1. Use a consistent sensible line-height in all browsers. 22 | 2. Prevent adjustments of font size after orientation changes in iOS. 23 | 3. Use a more readable tab size. 24 | 4. Use the user's configured `sans` font-family by default. 25 | 5. Use the user's configured `sans` font-feature-settings by default. 26 | */ 27 | 28 | html { 29 | line-height: 1.5; /* 1 */ 30 | -webkit-text-size-adjust: 100%; /* 2 */ 31 | -moz-tab-size: 4; /* 3 */ 32 | tab-size: 4; /* 3 */ 33 | font-family: theme( 34 | 'fontFamily.sans', 35 | ui-sans-serif, 36 | system-ui, 37 | -apple-system, 38 | BlinkMacSystemFont, 39 | 'Segoe UI', 40 | Roboto, 41 | 'Helvetica Neue', 42 | Arial, 43 | 'Noto Sans', 44 | sans-serif, 45 | 'Apple Color Emoji', 46 | 'Segoe UI Emoji', 47 | 'Segoe UI Symbol', 48 | 'Noto Color Emoji' 49 | ); /* 4 */ 50 | font-feature-settings: theme( 51 | 'fontFamily.sans[1].fontFeatureSettings', 52 | normal 53 | ); /* 5 */ 54 | } 55 | 56 | /* 57 | 1. Remove the margin in all browsers. 58 | 2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. 59 | */ 60 | 61 | body { 62 | margin: 0; /* 1 */ 63 | line-height: inherit; /* 2 */ 64 | } 65 | 66 | /* 67 | 1. Add the correct height in Firefox. 68 | 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) 69 | 3. Ensure horizontal rules are visible by default. 70 | */ 71 | 72 | hr { 73 | height: 0; /* 1 */ 74 | color: inherit; /* 2 */ 75 | border-top-width: 1px; /* 3 */ 76 | } 77 | 78 | /* 79 | Add the correct text decoration in Chrome, Edge, and Safari. 80 | */ 81 | 82 | abbr:where([title]) { 83 | text-decoration: underline dotted; 84 | } 85 | 86 | /* 87 | Remove the default font size and weight for headings. 88 | */ 89 | 90 | h1, 91 | h2, 92 | h3, 93 | h4, 94 | h5, 95 | h6 { 96 | font-size: inherit; 97 | font-weight: inherit; 98 | } 99 | 100 | /* 101 | Reset links to optimize for opt-in styling instead of opt-out. 102 | */ 103 | 104 | a { 105 | color: inherit; 106 | text-decoration: inherit; 107 | } 108 | 109 | /* 110 | Add the correct font weight in Edge and Safari. 111 | */ 112 | 113 | b, 114 | strong { 115 | font-weight: bolder; 116 | } 117 | 118 | /* 119 | 1. Use the user's configured `mono` font family by default. 120 | 2. Correct the odd `em` font sizing in all browsers. 121 | */ 122 | 123 | code, 124 | kbd, 125 | samp, 126 | pre { 127 | font-family: theme( 128 | 'fontFamily.mono', 129 | ui-monospace, 130 | SFMono-Regular, 131 | Menlo, 132 | Monaco, 133 | Consolas, 134 | 'Liberation Mono', 135 | 'Courier New', 136 | monospace 137 | ); /* 1 */ 138 | font-size: 1em; /* 2 */ 139 | } 140 | 141 | /* 142 | Add the correct font size in all browsers. 143 | */ 144 | 145 | small { 146 | font-size: 80%; 147 | } 148 | 149 | /* 150 | Prevent `sub` and `sup` elements from affecting the line height in all browsers. 151 | */ 152 | 153 | sub, 154 | sup { 155 | font-size: 75%; 156 | line-height: 0; 157 | position: relative; 158 | vertical-align: baseline; 159 | } 160 | 161 | sub { 162 | bottom: -0.25em; 163 | } 164 | 165 | sup { 166 | top: -0.5em; 167 | } 168 | 169 | /* 170 | 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) 171 | 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) 172 | 3. Remove gaps between table borders by default. 173 | */ 174 | 175 | table { 176 | text-indent: 0; /* 1 */ 177 | border-color: inherit; /* 2 */ 178 | border-collapse: collapse; /* 3 */ 179 | } 180 | 181 | /* 182 | 1. Change the font styles in all browsers. 183 | 2. Remove the margin in Firefox and Safari. 184 | 3. Remove default padding in all browsers. 185 | */ 186 | 187 | button, 188 | input, 189 | optgroup, 190 | select, 191 | textarea { 192 | font-family: inherit; /* 1 */ 193 | font-size: 100%; /* 1 */ 194 | font-weight: inherit; /* 1 */ 195 | line-height: inherit; /* 1 */ 196 | color: inherit; /* 1 */ 197 | margin: 0; /* 2 */ 198 | padding: 0; /* 3 */ 199 | } 200 | 201 | /* 202 | Remove the inheritance of text transform in Edge and Firefox. 203 | */ 204 | 205 | button, 206 | select { 207 | text-transform: none; 208 | } 209 | 210 | /* 211 | 1. Correct the inability to style clickable types in iOS and Safari. 212 | 2. Remove default button styles. 213 | */ 214 | 215 | button, 216 | [type='button'], 217 | [type='reset'], 218 | [type='submit'] { 219 | -webkit-appearance: button; /* 1 */ 220 | } 221 | 222 | /* 223 | Use the modern Firefox focus style for all focusable elements. 224 | */ 225 | 226 | :-moz-focusring { 227 | outline: auto; 228 | } 229 | 230 | /* 231 | Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) 232 | */ 233 | 234 | :-moz-ui-invalid { 235 | box-shadow: none; 236 | } 237 | 238 | /* 239 | Add the correct vertical alignment in Chrome and Firefox. 240 | */ 241 | 242 | progress { 243 | vertical-align: baseline; 244 | } 245 | 246 | /* 247 | Correct the cursor style of increment and decrement buttons in Safari. 248 | */ 249 | 250 | ::-webkit-inner-spin-button, 251 | ::-webkit-outer-spin-button { 252 | height: auto; 253 | } 254 | 255 | /* 256 | 1. Correct the odd appearance in Chrome and Safari. 257 | 2. Correct the outline style in Safari. 258 | */ 259 | 260 | [type='search'] { 261 | -webkit-appearance: textfield; /* 1 */ 262 | outline-offset: -2px; /* 2 */ 263 | } 264 | 265 | /* 266 | Remove the inner padding in Chrome and Safari on macOS. 267 | */ 268 | 269 | ::-webkit-search-decoration { 270 | -webkit-appearance: none; 271 | } 272 | 273 | /* 274 | 1. Correct the inability to style clickable types in iOS and Safari. 275 | 2. Change font properties to `inherit` in Safari. 276 | */ 277 | 278 | ::-webkit-file-upload-button { 279 | -webkit-appearance: button; /* 1 */ 280 | font: inherit; /* 2 */ 281 | } 282 | 283 | /* 284 | Add the correct display in Chrome and Safari. 285 | */ 286 | 287 | summary { 288 | display: list-item; 289 | } 290 | 291 | /* 292 | Removes the default spacing and border for appropriate elements. 293 | */ 294 | 295 | blockquote, 296 | dl, 297 | dd, 298 | h1, 299 | h2, 300 | h3, 301 | h4, 302 | h5, 303 | h6, 304 | hr, 305 | figure, 306 | p, 307 | pre { 308 | margin: 0; 309 | } 310 | 311 | fieldset { 312 | margin: 0; 313 | padding: 0; 314 | } 315 | 316 | legend { 317 | padding: 0; 318 | } 319 | 320 | ol, 321 | ul, 322 | menu { 323 | list-style: none; 324 | margin: 0; 325 | padding: 0; 326 | } 327 | 328 | /* 329 | Prevent resizing textareas horizontally by default. 330 | */ 331 | 332 | textarea { 333 | resize: vertical; 334 | } 335 | 336 | /* 337 | 1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) 338 | 2. Set the default placeholder color to the user's configured gray 400 color. 339 | */ 340 | 341 | input::placeholder, 342 | textarea::placeholder { 343 | opacity: 1; /* 1 */ 344 | color: theme('colors.gray.400', #9ca3af); /* 2 */ 345 | } 346 | 347 | /* 348 | Set the default cursor for buttons. 349 | */ 350 | 351 | button, 352 | [role='button'] { 353 | cursor: pointer; 354 | } 355 | 356 | /* 357 | Make sure disabled buttons don't get the pointer cursor. 358 | */ 359 | :disabled { 360 | cursor: default; 361 | } 362 | 363 | /* 364 | 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) 365 | 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) 366 | This can trigger a poorly considered lint error in some tools but is included by design. 367 | */ 368 | 369 | img, 370 | svg, 371 | video, 372 | canvas, 373 | audio, 374 | iframe, 375 | embed, 376 | object { 377 | display: block; /* 1 */ 378 | vertical-align: middle; /* 2 */ 379 | } 380 | 381 | /* 382 | Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) 383 | */ 384 | 385 | img, 386 | video { 387 | max-width: 100%; 388 | height: auto; 389 | } 390 | 391 | /* Make elements with the HTML hidden attribute stay hidden by default */ 392 | [hidden] { 393 | display: none; 394 | } 395 | 396 | @tailwind components; 397 | @tailwind utilities; 398 | 399 | /** 400 | * antd override 401 | */ 402 | .ant-radio { 403 | display: none !important; 404 | } 405 | -------------------------------------------------------------------------------- /src/options/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/options/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client'; 2 | import { App } from './app'; 3 | import 'antd/dist/reset.css'; 4 | import './index.css'; 5 | import '../common/i18n'; 6 | import { initI18n } from '../common/i18n'; 7 | 8 | const render = async () => { 9 | await initI18n(); 10 | createRoot(document.getElementById('app')).render(); 11 | }; 12 | 13 | render(); 14 | -------------------------------------------------------------------------------- /src/options/setting-form/block.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react'; 2 | 3 | export const Block: React.FC = ({ children }) => { 4 | return
{children}
; 5 | }; 6 | -------------------------------------------------------------------------------- /src/options/setting-form/custom-list.tsx: -------------------------------------------------------------------------------- 1 | import { IconBtn } from '@/components/icon-btn' 2 | import { IcBaselineDeleteOutline } from '@/components/icon/delete' 3 | import { IcOutlineCheck } from '@/components/icon/update' 4 | import { Input } from 'antd' 5 | import { useControllableValue } from 'ahooks' 6 | import { useState } from 'react' 7 | 8 | export const CustomList: React.FC<{ 9 | value?: string[] 10 | onChange?: (value: string[]) => void 11 | }> = (props) => { 12 | const [value, setValue] = useControllableValue(props, { 13 | defaultValue: [], 14 | }) 15 | const [inputValue, setInputValue] = useState('') 16 | 17 | return ( 18 |
19 |
20 | setInputValue(e.target.value)} 24 | onPressEnter={() => { 25 | if (inputValue.trim()) { 26 | setValue([inputValue.trim(), ...(value || [])]) 27 | setInputValue('') 28 | } 29 | }} 30 | /> 31 | { 35 | if (inputValue.trim()) { 36 | setValue([inputValue.trim(), ...(value || [])]) 37 | setInputValue('') 38 | } 39 | }} 40 | > 41 | 42 | 43 |
44 |
45 | {(value || []).map((p) => { 46 | return ( 47 |
48 |
{p}
49 | setValue((value || []).filter((i) => i !== p))} 52 | > 53 | 54 | 55 |
56 | ) 57 | })} 58 |
59 |
60 | ) 61 | } 62 | -------------------------------------------------------------------------------- /src/options/setting-form/index.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Form, Tooltip } from 'antd' 2 | import { useCallback, useEffect, useState } from 'react' 3 | import { useSWRConfig } from 'swr' 4 | import { Settings } from '../types' 5 | import { useSettings } from '../../common/store/settings' 6 | import { OPENAISettings } from './open-api' 7 | import { SystemSetting } from './system' 8 | import { LogosGithubIcon } from '@/components/icon/github' 9 | import { CodiconFeedback } from '@/components/icon/feedback' 10 | import { useForm } from 'antd/es/form/Form' 11 | import i18next from 'i18next' 12 | import { ProviderSetting } from './provider' 13 | 14 | export const SettingsForm: React.FC = () => { 15 | const { loading, settings, setSettings } = useSettings() 16 | const { mutate } = useSWRConfig() 17 | const [form] = useForm() 18 | 19 | const handleFormChange = useCallback( 20 | async (changedValue: Settings) => { 21 | await setSettings(changedValue) 22 | 23 | if (changedValue.lang) { 24 | location.reload() 25 | } 26 | }, 27 | [setSettings] 28 | ) 29 | 30 | useEffect(() => { 31 | form.setFieldsValue(settings) 32 | }, [settings]) 33 | 34 | useEffect(() => { 35 | if (!loading) { 36 | mutate('models') 37 | } 38 | }, [loading]) 39 | 40 | if (loading) { 41 | return
loading...
42 | } 43 | 44 | return ( 45 |
46 |
47 |
{i18next.t('Settings')}
48 |
49 |
50 | 51 | 52 |
53 | 54 |
55 |
56 |
57 | 58 | 59 |
60 | 61 |
62 |
63 |
64 |
65 |
66 |
67 |
73 |
74 | 75 | 76 | 77 |
78 |
79 |
80 | ) 81 | } 82 | -------------------------------------------------------------------------------- /src/options/setting-form/instructions/actions/add.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Popover } from 'antd' 2 | import i18next from 'i18next' 3 | import { useModalState } from '../modal-state' 4 | 5 | export const Add: React.FC = () => { 6 | const { setIsOpen } = useModalState() 7 | 8 | return ( 9 | 10 | 11 | 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/options/setting-form/instructions/actions/export.tsx: -------------------------------------------------------------------------------- 1 | import { download } from '@/common/file' 2 | import { getSetting } from '@/common/store/settings' 3 | import { Button, message, Popover } from 'antd' 4 | import i18next from 'i18next' 5 | import { useCallback } from 'react' 6 | 7 | export const Export: React.FC = () => { 8 | const handleExport = useCallback(async () => { 9 | const settings = await getSetting() 10 | const instructions = settings.customInstructions 11 | 12 | if (!instructions || !instructions?.length) { 13 | message.error(i18next.t('No data')) 14 | return 15 | } 16 | 17 | const blob = new Blob([JSON.stringify(instructions)]) 18 | const url = URL.createObjectURL(blob) 19 | 20 | download(url, 'writely-instructions.json') 21 | }, []) 22 | 23 | return ( 24 | 25 | 26 | 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /src/options/setting-form/instructions/actions/import.tsx: -------------------------------------------------------------------------------- 1 | import { batchAdd } from '@/common/api/instructions' 2 | import { useSettings } from '@/common/store/settings' 3 | import { Button, message, Popover, Upload } from 'antd' 4 | import i18next from 'i18next' 5 | import { debounce } from 'lodash-es' 6 | import { useCallback, useState } from 'react' 7 | 8 | export const Import: React.FC = () => { 9 | const { refresh } = useSettings() 10 | const [loading, setLoading] = useState(false) 11 | 12 | const handleUploadChange = useCallback( 13 | debounce( 14 | async (file: File) => { 15 | try { 16 | setLoading(true) 17 | const text = await file.text() 18 | const json = JSON.parse(text) 19 | await batchAdd(json) 20 | await refresh() 21 | message.success(i18next.t('😄 Imported successfully')) 22 | } catch { 23 | message.error(i18next.t('😭 Error format')) 24 | } finally { 25 | setLoading(false) 26 | } 27 | }, 28 | 1000, 29 | { leading: true, trailing: false } 30 | ), 31 | [] 32 | ) 33 | 34 | return ( 35 | 36 | handleUploadChange(file.originFileObj)} 40 | > 41 | 42 | 43 | 44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /src/options/setting-form/instructions/emoji.tsx: -------------------------------------------------------------------------------- 1 | import { useControllableValue } from 'ahooks' 2 | import { Popover } from 'antd' 3 | import EmojiPicker from 'emoji-picker-react' 4 | import { useEffect } from 'react' 5 | 6 | export const Emoji: React.FC<{ 7 | value?: string 8 | onChange?: (value: string) => void 9 | }> = (props) => { 10 | const [value, setValue] = useControllableValue(props) 11 | 12 | useEffect(() => { 13 | if (!value) { 14 | setValue('😄') 15 | } 16 | }, [value]) 17 | 18 | return ( 19 | setValue(e.emoji)} />}> 20 |
21 | {value} 22 |
23 |
24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/options/setting-form/instructions/index.tsx: -------------------------------------------------------------------------------- 1 | import { Instruction } from '@/options/types' 2 | import { Add } from './actions/add' 3 | import { Export } from './actions/export' 4 | import { Import } from './actions/import' 5 | import { InstructionModal } from './instruction-modal' 6 | import { List } from './list' 7 | import { ModalStateProvider } from './modal-state' 8 | 9 | export const Instructions: React.FC<{ value?: Instruction[] }> = ({ 10 | value, 11 | }) => { 12 | return ( 13 | 14 |
15 |
16 | 17 | 18 | 19 |
20 | 21 | 22 |
23 |
24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/options/setting-form/instructions/instruction-modal.tsx: -------------------------------------------------------------------------------- 1 | import { addOne, update } from '@/common/api/instructions' 2 | import { useSettings } from '@/common/store/settings' 3 | import { Form, Input, Modal } from 'antd' 4 | import i18next from 'i18next' 5 | import { useCallback, useEffect, useState } from 'react' 6 | import { Emoji } from './emoji' 7 | import { useModalState } from './modal-state' 8 | 9 | const { TextArea } = Input 10 | 11 | export const InstructionModal: React.FC = () => { 12 | const { isOpen, reset, editTarget } = useModalState() 13 | const { refresh } = useSettings() 14 | const [form] = Form.useForm() 15 | const [loading, setLoading] = useState(false) 16 | 17 | const handleOk = useCallback(async () => { 18 | try { 19 | setLoading(true) 20 | const value = await form.validateFields() 21 | 22 | if (editTarget) { 23 | await update({ 24 | ...editTarget, 25 | ...value, 26 | }) 27 | } else { 28 | await addOne(value) 29 | } 30 | 31 | reset() 32 | await refresh() 33 | } finally { 34 | setLoading(false) 35 | } 36 | }, [form, editTarget]) 37 | 38 | useEffect(() => { 39 | if (!isOpen) { 40 | form.setFieldsValue({}) 41 | } else { 42 | if (editTarget) { 43 | form.setFieldsValue(editTarget) 44 | } else { 45 | form.resetFields() 46 | } 47 | } 48 | }, [isOpen]) 49 | 50 | return ( 51 | 60 |
61 | 67 | 68 | 69 | 75 | 78 | 79 | 85 | 88 | 89 |
90 |
91 | ) 92 | } 93 | -------------------------------------------------------------------------------- /src/options/setting-form/instructions/list.tsx: -------------------------------------------------------------------------------- 1 | import { remove, setTopPinned } from '@/common/api/instructions' 2 | import { useSettings } from '@/common/store/settings' 3 | import { IconBtn } from '@/components/icon-btn' 4 | import { IcBaselineDeleteOutline } from '@/components/icon/delete' 5 | import { MaterialSymbolsEditOutline } from '@/components/icon/edit' 6 | import { MaterialSymbolsArrowUpward } from '@/components/icon/up' 7 | import { Instruction } from '@/options/types' 8 | import { 9 | Popconfirm, 10 | Popover, 11 | Table, 12 | TableProps, 13 | Tooltip, 14 | Typography, 15 | } from 'antd' 16 | import i18next from 'i18next' 17 | import { useModalState } from './modal-state' 18 | 19 | const { Paragraph } = Typography 20 | 21 | export const List: React.FC<{ value: Instruction[] }> = ({ value }) => { 22 | const columns = useColumns() 23 | 24 | return 25 | } 26 | 27 | const useColumns = () => { 28 | const { refresh } = useSettings() 29 | const { setIsOpen, setEditTarget } = useModalState() 30 | 31 | const columns: TableProps['columns'] = [ 32 | { 33 | title: i18next.t('Icon'), 34 | dataIndex: 'icon', 35 | }, 36 | { 37 | title: i18next.t('Name'), 38 | dataIndex: 'name', 39 | width: 200, 40 | render: (value) => { 41 | return ( 42 | 43 | {value} 44 | 45 | ) 46 | }, 47 | }, 48 | { 49 | title: i18next.t('Instruction'), 50 | dataIndex: 'instruction', 51 | width: 200, 52 | render: (value) => { 53 | return ( 54 | 58 | {value} 59 | 60 | ) 61 | }, 62 | }, 63 | { 64 | title: i18next.t('Actions'), 65 | render: (_, record) => { 66 | return ( 67 |
68 | { 72 | await remove(record.id) 73 | await refresh() 74 | }} 75 | > 76 | 77 | 78 | 79 | 80 | { 82 | setIsOpen(true) 83 | setEditTarget(record) 84 | }} 85 | > 86 | 87 | 88 | 89 | { 91 | await setTopPinned(record.id) 92 | await refresh() 93 | }} 94 | > 95 | 96 | 97 | 98 |
99 | ) 100 | }, 101 | }, 102 | ] 103 | 104 | return columns 105 | } 106 | -------------------------------------------------------------------------------- /src/options/setting-form/instructions/modal-state.tsx: -------------------------------------------------------------------------------- 1 | import { Instruction } from '@/options/types' 2 | import { useCallback, useState } from 'react' 3 | import { createContainer } from 'unstated-next' 4 | 5 | const { useContainer: useModalState, Provider: ModalStateProvider } = 6 | createContainer(() => { 7 | const [isOpen, setIsOpen] = useState() 8 | const [editTarget, setEditTarget] = useState() 9 | 10 | const reset = useCallback(() => { 11 | setIsOpen(false) 12 | setEditTarget(undefined) 13 | }, []) 14 | 15 | return { 16 | isOpen, 17 | setIsOpen, 18 | editTarget, 19 | setEditTarget, 20 | reset, 21 | } 22 | }) 23 | 24 | export { useModalState, ModalStateProvider } 25 | -------------------------------------------------------------------------------- /src/options/setting-form/open-api.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Card, 4 | Form, 5 | Input, 6 | Modal, 7 | Radio, 8 | Tag, 9 | Tooltip, 10 | Typography, 11 | } from 'antd' 12 | import React, { useCallback, useState } from 'react' 13 | import { useModels, useOpenAIEditPrompt } from '../../common/api/openai' 14 | import cx from 'classnames' 15 | import i18next from 'i18next' 16 | import { ServiceProvider } from '../types' 17 | 18 | export const OPENAISettings: React.FC = () => { 19 | const value = Form.useWatch('serviceProvider') 20 | 21 | if (value !== ServiceProvider.OpenAI) { 22 | return null 23 | } 24 | 25 | return ( 26 | 27 | 34 | Don't know or don't have one? reach{' '} 35 | 39 | here 40 | {' '} 41 | for more details 42 | 43 | } 44 | > 45 | 46 | 47 | 57 | {i18next.t('Models Introduction')} 58 | 59 | } 60 | > 61 | 62 | 63 | 73 | {i18next.t('Temperature Introduction')} 74 | 75 | } 76 | > 77 | 78 | 79 | {i18next.t('accurate')} 80 | 81 | 82 | {i18next.t('balance')} 83 | 84 | 85 | {i18next.t('creative')} 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | ) 95 | } 96 | 97 | const ConnectionTest: React.FC = () => { 98 | const [modalVisible, setModalVisible] = useState(false) 99 | const queryOpenAIEdit = useOpenAIEditPrompt() 100 | const [loading, setLoading] = useState(false) 101 | const [result, setResult] = useState('') 102 | const [message, setMessage] = useState('hello') 103 | 104 | const handleOk = useCallback(async () => { 105 | setLoading(true) 106 | setResult('') 107 | 108 | try { 109 | await queryOpenAIEdit(message, 'test', (text, err, end) => { 110 | setResult(text) 111 | 112 | if (err) { 113 | setLoading(false) 114 | setResult(err.message) 115 | } 116 | 117 | if (end) { 118 | setLoading(false) 119 | } 120 | }) 121 | } catch (e) { 122 | setResult(e.toString()) 123 | setLoading(false) 124 | } 125 | }, [queryOpenAIEdit]) 126 | 127 | return ( 128 |
129 | 132 | setModalVisible(false)} 137 | title={i18next.t('Test connection')} 138 | onOk={handleOk} 139 | okButtonProps={{ loading }} 140 | > 141 | setMessage(e.target.value)} 144 | > 145 | {result} 146 | 147 |
148 | ) 149 | } 150 | 151 | const ModelCard: React.FC< 152 | React.PropsWithChildren<{ tooltip: string; model: string }> 153 | > = ({ model, tooltip, children }) => { 154 | const m = Form.useWatch('model') 155 | const content = ( 156 |
162 |
{children ? children : model}
163 |
164 | ) 165 | 166 | if (tooltip) { 167 | return {content} 168 | } 169 | 170 | return content 171 | } 172 | 173 | const FormModelSelect: React.FC<{ 174 | value?: string 175 | onChange?: (v: string) => void 176 | }> = ({ value, onChange }) => { 177 | const models = useModels() 178 | 179 | return ( 180 | <> 181 | { 185 | onChange?.(e.target.value) 186 | }} 187 | /> 188 |
189 | {models.map((m) => ( 190 |
{ 193 | onChange?.(m) 194 | }} 195 | key={m} 196 | > 197 | {m} 198 |
199 | ))} 200 |
201 | 202 | ) 203 | } 204 | 205 | const FormUrlInput: React.FC<{ 206 | value?: string 207 | onChange?: (v: string) => void 208 | }> = ({ value, onChange }) => { 209 | return ( 210 | <> 211 | onChange?.(e.target.value)} 215 | /> 216 |
217 | {[ 218 | 'https://api.openai.com/v1', 219 | 'https://api.deepseek.com', 220 | 'https://api.groq.com/openai/v1', 221 | ].map((m) => ( 222 |
{ 225 | onChange?.(m) 226 | }} 227 | key={m} 228 | > 229 | {m} 230 |
231 | ))} 232 |
233 | 234 | ) 235 | } 236 | -------------------------------------------------------------------------------- /src/options/setting-form/provider.tsx: -------------------------------------------------------------------------------- 1 | import { OpenAILogo } from '@/components/icon/open-ai' 2 | import { IconWritely } from '@/components/icon/writely' 3 | import { Card, Form, Popover, Radio, Spin, Tooltip } from 'antd' 4 | import i18next from 'i18next' 5 | import { ServiceProvider } from '../types' 6 | import classNames from 'classnames' 7 | import { MaterialSymbolsAddLink } from '@/components/icon/link' 8 | import { useUser } from '@/common/api/writely' 9 | import Link from 'antd/es/typography/Link' 10 | import { MaterialSymbolsCheckCircleRounded } from '@/components/icon/checked' 11 | import { ChatGPTIcon } from '@/components/icon/chatgpt' 12 | import { useChatGPTWebInfo } from '@/common/api/chatgpt-web' 13 | 14 | export const ProviderSetting: React.FC = () => { 15 | const value = Form.useWatch('serviceProvider') 16 | const activeClassNames = 'bg-black text-white' 17 | 18 | const isCheckedWritely = value === ServiceProvider.Writely 19 | const isCheckedOpenAI = value === ServiceProvider.OpenAI 20 | const isCheckedChatGPT = value === ServiceProvider.ChatGPT 21 | 22 | return ( 23 | 24 | 25 |
26 | console.log(e.target.value)} 29 | > 30 | 31 | 36 |
47 | 48 | Writely 49 |
50 |
51 |
52 | 53 | 58 |
67 | 68 |
69 |
70 |
71 | 72 | 77 |
88 | 89 | ChatGPT 90 |
91 |
92 |
93 |
94 |
95 |
96 | {isCheckedWritely ? : null} 97 | {isCheckedChatGPT ? : null} 98 |
99 | ) 100 | } 101 | 102 | const LinkToWritelySite: React.FC = () => { 103 | const { isLoading, data } = useUser() 104 | const email = data?.data?.user?.email 105 | 106 | return ( 107 |
108 | {isLoading ? ( 109 | 110 | ) : email ? ( 111 |
112 | 117 | {data?.data?.user?.email} 118 | 119 | 120 | 121 | 122 |
123 | ) : ( 124 | <> 125 | {i18next.t('Connect your writely account')} 126 | 131 | 132 | 133 | 134 | )} 135 |
136 | ) 137 | } 138 | 139 | const LinkToChatgptWeb: React.FC = () => { 140 | const { isLoading, error, data } = useChatGPTWebInfo() 141 | 142 | if (error) { 143 | return error 144 | } 145 | 146 | const name = data?.user?.name || data?.user?.email 147 | 148 | return ( 149 |
150 | {isLoading ? ( 151 | 152 | ) : name ? ( 153 |
154 | 159 | {name} 160 | 161 | 162 | 163 | 164 |
165 | ) : ( 166 | <> 167 | {i18next.t('Connect your Chatgpt account')} 168 | 173 | 174 | 175 | 176 | )} 177 |
178 | ) 179 | } 180 | -------------------------------------------------------------------------------- /src/options/setting-form/system.tsx: -------------------------------------------------------------------------------- 1 | import { Card, Form, Radio, Switch } from 'antd' 2 | import i18next from 'i18next' 3 | import { langs } from '../../common/langs' 4 | import { Instructions } from './instructions' 5 | 6 | export const SystemSetting: React.FC = () => { 7 | return ( 8 | 9 | 10 | 11 | {langs.map((lang, index) => ( 12 | 13 | {lang.label} 14 | 15 | ))} 16 | 17 | 18 | 23 | 24 | 25 | 29 | 30 | 31 | 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /src/options/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './settings'; 2 | -------------------------------------------------------------------------------- /src/options/types/settings.ts: -------------------------------------------------------------------------------- 1 | export type Instruction = { 2 | id: string 3 | name: string 4 | instruction: string 5 | icon: string 6 | } 7 | 8 | export enum ServiceProvider { 9 | Writely = 'writely', 10 | OpenAI = 'openai', 11 | ChatGPT = 'chatgpt', 12 | } 13 | 14 | export type Settings = { 15 | apiKey?: string 16 | model?: string 17 | lang?: string 18 | temperature?: string 19 | url?: string 20 | customInstructions?: Instruction[] 21 | debug?: boolean 22 | serviceProvider?: ServiceProvider 23 | } 24 | -------------------------------------------------------------------------------- /src/popup/app.tsx: -------------------------------------------------------------------------------- 1 | import { DashiconsAdminGeneric } from '@/components/icon' 2 | import './index.css' 3 | import browser from 'webextension-polyfill' 4 | 5 | export const App: React.FC = () => { 6 | return ( 7 |
8 |
9 |
10 | Writely {process.env.version || 'a'} 11 |
12 |
{ 15 | const url = browser.runtime.getURL('dist/options/index.html') 16 | window.open(url) 17 | }} 18 | > 19 | 20 |
21 |
22 |
23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /src/popup/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /src/popup/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/popup/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client' 2 | import { App } from './app' 3 | 4 | createRoot(document.getElementById('app')).render() 5 | -------------------------------------------------------------------------------- /tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ['./src/**/*.{ts,tsx}'], 4 | theme: { 5 | fontSize: { 6 | xs: '12px', 7 | sm: '14px', 8 | base: '16px', 9 | lg: '18px', 10 | xl: '20px', 11 | '2xl': '24px', 12 | '3xl': '30px', 13 | '4xl': '36px', 14 | '5xl': '48px', 15 | '6xl': '60px', 16 | '7xl': '72px', 17 | }, 18 | spacing: { 19 | px: '1px', 20 | 0: '0', 21 | 0.5: '2px', 22 | 1: '4px', 23 | 1.5: '6px', 24 | 2: '8px', 25 | 2.5: '10px', 26 | 3: '12px', 27 | 3.5: '14px', 28 | 4: '16px', 29 | 5: '20px', 30 | 6: '24px', 31 | 7: '28px', 32 | 8: '32px', 33 | 9: '36px', 34 | 10: '40px', 35 | 11: '44px', 36 | 12: '48px', 37 | 14: '56px', 38 | 16: '64px', 39 | 20: '80px', 40 | 24: '96px', 41 | 28: '112px', 42 | 32: '128px', 43 | 36: '144px', 44 | 40: '160px', 45 | 44: '176px', 46 | 48: '192px', 47 | 52: '208px', 48 | 56: '224px', 49 | 60: '240px', 50 | 64: '256px', 51 | 72: '288px', 52 | 80: '320px', 53 | 96: '384px', 54 | }, 55 | extend: { 56 | lineHeight: { 57 | 3: '12px', 58 | 4: '16px', 59 | 5: '20px', 60 | 6: '24px', 61 | 7: '28px', 62 | 8: '32px', 63 | 9: '36px', 64 | 10: '40px', 65 | }, 66 | keyframes: { 67 | swaying: { 68 | '0%, 100%': { 69 | transform: 70 | 'translateX(-20px); color: red; filter: hue-rotate(0deg);', 71 | }, 72 | '50%': { 73 | transform: 74 | 'translateX(20px); color: pink; filter: hue-rotate(360deg);', 75 | }, 76 | }, 77 | 'breathe-heavy': { 78 | '0%, 100%': { 79 | opacity: '1', 80 | }, 81 | '50%': { 82 | opacity: '0.6', 83 | transform: "scale(0.9)" 84 | }, 85 | }, 86 | breathe: { 87 | '0%, 100%': { 88 | opacity: '1', 89 | }, 90 | '50%': { 91 | opacity: '0.8', 92 | }, 93 | }, 94 | }, 95 | animation: { 96 | swaying: 'swaying 3s ease-in-out infinite', 97 | breathe: 'breathe 3s ease-in-out infinite', 98 | 'breathe-heavy': 'breathe-heavy 3s ease-in-out infinite' 99 | }, 100 | }, 101 | }, 102 | plugins: [], 103 | }; 104 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "ESNext", 5 | "outDir": "dist", 6 | "esModuleInterop": true, 7 | "jsx": "react-jsx", 8 | "moduleResolution": "node", 9 | "resolveJsonModule": true, 10 | "baseUrl": "./", 11 | "paths": { 12 | "@/*": ["src/*"] 13 | }, 14 | }, 15 | "include": ["src/**/*"] 16 | } -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | import { copy } from 'esbuild-plugin-copy' 3 | import { NodeModulesPolyfillPlugin } from '@esbuild-plugins/node-modules-polyfill' 4 | import { execSync } from 'child_process' 5 | 6 | let latestTag = '' 7 | 8 | try { 9 | latestTag = execSync( 10 | 'git describe --tags `git rev-list --tags --max-count=1`', 11 | { encoding: 'utf8' } 12 | ).trim() 13 | } catch (error) { 14 | console.error(`执行出错: ${error.message}`) 15 | } 16 | 17 | const tag = 'writely-container' 18 | 19 | export default defineConfig({ 20 | entry: [ 21 | './src/content/index.tsx', 22 | './src/options/index.tsx', 23 | './src/popup/index.tsx', 24 | './src/background/index.ts', 25 | ], 26 | target: 'chrome112', 27 | format: 'esm', 28 | splitting: false, 29 | sourcemap: false, 30 | clean: true, 31 | minify: true, 32 | define: { 33 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), 34 | 'process.env.version': JSON.stringify(latestTag), 35 | }, 36 | injectStyle: (css) => { 37 | return ` 38 | var setWritelyStyle = function() { 39 | var style = document.createElement('style'); 40 | style.type = 'text/css'; 41 | style.innerHTML = ${css}; 42 | if (!chrome.runtime.openOptionsPage) { 43 | setTimeout(() => { 44 | try { 45 | var root = document.getElementsByTagName('${tag}')[0].shadowRoot; 46 | root.appendChild(style); 47 | } catch { 48 | setWritelyStyle() 49 | } 50 | }, 100) 51 | } else { 52 | var root = document.head || document.getElementsByTagName('head')[0]; 53 | root.appendChild(style) 54 | } 55 | 56 | }; 57 | setWritelyStyle(); 58 | ` 59 | }, 60 | outExtension: () => ({ js: '.js' }), 61 | esbuildPlugins: [ 62 | NodeModulesPolyfillPlugin(), 63 | copy({ 64 | assets: [ 65 | { 66 | from: ['./src/options/index.html'], 67 | to: ['./options'], 68 | }, 69 | { 70 | from: ['./src/popup/index.html'], 71 | to: ['./popup'], 72 | }, 73 | { 74 | from: ['./node_modules/animate.css/animate.css'], 75 | to: ['./content'], 76 | }, 77 | { 78 | from: ['./src/assets/*'], 79 | to: ['./assets'], 80 | }, 81 | ], 82 | watch: process.env.NODE_ENV !== 'production', 83 | }), 84 | ], 85 | }) 86 | --------------------------------------------------------------------------------