├── .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 | 
5 |
6 |
7 |
8 |
9 |
10 |
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 扩展:
33 |
34 |
35 | ### 配置
36 | 1. 获取 Open AI API Key。 如果您没有,请在 https://platform.openai.com/account/api-keys 上进行申请。
37 | 2. 单击插件图标,然后单击“设置”图标。
38 |
39 |
40 |
41 |
42 | 3. 进行配置。
43 |
44 |
45 |
46 | 4. 将鼠标滑动到任何网页上的单词上,一个“W”图标将出现在鼠标附近,单击以使用。
47 |
48 | 
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 | 
6 |
7 |
8 |
9 |
10 |
11 |
12 |
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:
44 |
45 | 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 |
53 |
54 | 3. Perform configuration.
55 |
56 |
57 |
58 | 4. After sliding the word on any webpage, a "W" icon will appear near the mouse, click to use.
59 |
60 | 
61 | 
62 |
63 | ## More demos [Demos](./DEMO.md)
64 |
65 | ## Star History
66 |
67 | [](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 |
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 |
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 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/src/components/icon/chatgpt.tsx:
--------------------------------------------------------------------------------
1 | import { SVGProps } from 'react'
2 |
3 | export const ChatGPTIcon: React.FC> = (props) => {
4 | return (
5 |
15 | )
16 | }
17 |
--------------------------------------------------------------------------------
/src/components/icon/checked.tsx:
--------------------------------------------------------------------------------
1 | export function MaterialSymbolsCheckCircleRounded(
2 | props: SVGProps
3 | ) {
4 | return (
5 |
17 | )
18 | }
19 |
--------------------------------------------------------------------------------
/src/components/icon/close.tsx:
--------------------------------------------------------------------------------
1 | import { SVGProps } from 'react';
2 |
3 | export function MdiClose(props: SVGProps) {
4 | return (
5 |
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 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/src/components/icon/delete.tsx:
--------------------------------------------------------------------------------
1 | import { SVGProps } from 'react';
2 |
3 | export function IcBaselineDeleteOutline(props: SVGProps) {
4 | return (
5 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/src/components/icon/drag.tsx:
--------------------------------------------------------------------------------
1 | import { SVGProps } from 'react';
2 |
3 | export function DashiconsMove(props: SVGProps) {
4 | return (
5 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/src/components/icon/edit.tsx:
--------------------------------------------------------------------------------
1 | import { SVGProps } from 'react'
2 |
3 | export function MaterialSymbolsEditOutline(props: SVGProps) {
4 | return (
5 |
17 | )
18 | }
19 |
--------------------------------------------------------------------------------
/src/components/icon/feedback.tsx:
--------------------------------------------------------------------------------
1 | import { SVGProps } from 'react';
2 |
3 | export function CodiconFeedback(props: SVGProps) {
4 | return (
5 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/src/components/icon/github.tsx:
--------------------------------------------------------------------------------
1 | import { SVGProps } from 'react';
2 |
3 | export function LogosGithubIcon(props: SVGProps) {
4 | return (
5 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/src/components/icon/heart.tsx:
--------------------------------------------------------------------------------
1 | import { SVGProps } from 'react';
2 |
3 | export function RiHeartFill(props: SVGProps) {
4 | return (
5 |
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 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/src/components/icon/link.tsx:
--------------------------------------------------------------------------------
1 | export function MaterialSymbolsAddLink(props: SVGProps) {
2 | return (
3 |
15 | )
16 | }
17 |
--------------------------------------------------------------------------------
/src/components/icon/logo.tsx:
--------------------------------------------------------------------------------
1 | import { SVGProps } from 'react';
2 |
3 | export const Logo: React.FC> = (props) => {
4 | return (
5 |
30 | );
31 | };
32 |
--------------------------------------------------------------------------------
/src/components/icon/more.tsx:
--------------------------------------------------------------------------------
1 | import { SVGProps } from 'react';
2 |
3 | export function MaterialSymbolsMoreHoriz(props: SVGProps) {
4 | return (
5 |
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 |
15 | )
16 | }
17 |
--------------------------------------------------------------------------------
/src/components/icon/prompt-icons.tsx:
--------------------------------------------------------------------------------
1 | import { SVGProps } from 'react';
2 |
3 | export function IcBaselineAutoFixHigh(props: SVGProps) {
4 | return (
5 |
17 | );
18 | }
19 |
20 | export function IcBaselineCheck(props: SVGProps) {
21 | return (
22 |
34 | );
35 | }
36 |
37 | export function IcBaselineShortText(props: SVGProps) {
38 | return (
39 |
48 | );
49 | }
50 |
51 | export function MdiTextLong(props: SVGProps) {
52 | return (
53 |
65 | );
66 | }
67 |
68 | export function MdiTreeOutline(props: SVGProps) {
69 | return (
70 |
82 | );
83 | }
84 |
85 | export function IcOutlineAutoStories(props: SVGProps) {
86 | return (
87 |
99 | );
100 | }
101 |
102 | export function IcOutlineTranslate(props: SVGProps) {
103 | return (
104 |
116 | );
117 | }
118 |
119 | export function IcSharpPanoramaWideAngle(props: SVGProps) {
120 | return (
121 |
133 | );
134 | }
135 |
136 | export function IcOutlineLightbulb(props: SVGProps) {
137 | return (
138 |
150 | );
151 | }
152 |
153 | export function MdiMessageReplyTextOutline(props: SVGProps) {
154 | return (
155 |
167 | );
168 | }
169 |
170 | export function IcBaselinePodcasts(props: SVGProps) {
171 | return (
172 |
184 | );
185 | }
186 |
187 | export function MaterialSymbolsEnergyProgramTimeUsedSharp(
188 | props: SVGProps
189 | ) {
190 | return (
191 |
203 | );
204 | }
205 |
206 | export function MaterialSymbolsToolsPliersWireStripperOutlineSharp(
207 | props: SVGProps
208 | ) {
209 | return (
210 |
222 | );
223 | }
224 |
225 | export function IcRoundQuestionMark(props: SVGProps) {
226 | return (
227 |
239 | );
240 | }
241 |
242 | export function MaterialSymbolsFormatListBulletedSharp(
243 | props: SVGProps
244 | ) {
245 | return (
246 |
258 | );
259 | }
260 |
--------------------------------------------------------------------------------
/src/components/icon/replace.tsx:
--------------------------------------------------------------------------------
1 | import { SVGProps } from 'react';
2 |
3 | export function BiFileCheck(props: SVGProps) {
4 | return (
5 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/src/components/icon/replay.tsx:
--------------------------------------------------------------------------------
1 | import { SVGProps } from 'react';
2 |
3 | export function TablerRefresh(props: SVGProps) {
4 | return (
5 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/src/components/icon/return.tsx:
--------------------------------------------------------------------------------
1 | import { SVGProps } from 'react';
2 |
3 | export function IcOutlineKeyboardReturn(props: SVGProps) {
4 | return (
5 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/src/components/icon/right.tsx:
--------------------------------------------------------------------------------
1 | export const RightArrowIcon: React.FC = () => {
2 | return (
3 |
18 | );
19 | };
20 |
--------------------------------------------------------------------------------
/src/components/icon/send.tsx:
--------------------------------------------------------------------------------
1 | import { SVGProps } from 'react';
2 |
3 | export function IcBaselineSend(props: SVGProps) {
4 | return (
5 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/src/components/icon/setting.tsx:
--------------------------------------------------------------------------------
1 | import { SVGProps } from 'react';
2 |
3 | export function DashiconsAdminGeneric(props: SVGProps) {
4 | return (
5 |
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 |
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/src/components/icon/up.tsx:
--------------------------------------------------------------------------------
1 | import { SVGProps } from 'react'
2 |
3 | export function MaterialSymbolsArrowUpward(props: SVGProps) {
4 | return (
5 |
17 | )
18 | }
19 |
--------------------------------------------------------------------------------
/src/components/icon/update.tsx:
--------------------------------------------------------------------------------
1 | import { SVGProps } from 'react';
2 |
3 | export function IcOutlineCheck(props: SVGProps) {
4 | return (
5 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/src/components/icon/write.tsx:
--------------------------------------------------------------------------------
1 | import { SVGProps } from 'react'
2 |
3 | export function IcOutlineModeEdit(props: SVGProps) {
4 | return (
5 |
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 |
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 |
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 | }
100 | >
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 |
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 |
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 |
66 |
67 |
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 |
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 |
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 |
--------------------------------------------------------------------------------