From 286156bcadc5f289527351f0900bfdc8feab7035 Mon Sep 17 00:00:00 2001 From: "Xiaochao Dong (@damnever)" Date: Sun, 6 Aug 2023 01:10:46 +0800 Subject: [PATCH] PopClip extension for revising, polishing, and translating texts, powered by ChatGPT. Signed-off-by: Xiaochao Dong (@damnever) --- .gitignore | 30 +++++++++++ Config.json | 82 +++++++++++++++++++++++++++++ Makefile | 4 ++ main.ts | 147 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 263 insertions(+) create mode 100644 .gitignore create mode 100644 Config.json create mode 100644 Makefile create mode 100644 main.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5b12393 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +## macOS +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +ChatGPTx.popclipextz +main.js diff --git a/Config.json b/Config.json new file mode 100644 index 0000000..4b1b4c9 --- /dev/null +++ b/Config.json @@ -0,0 +1,82 @@ +{ + "name": "ChatGPTx", + "icon": "iconify:ri:openai-fill", + "icon options": { + "preserve color": false + }, + "identifier": "com.damnever.popclipext.ChatGPTx", + "description": "PopClip extension for revising, polishing, and translating texts, powered by ChatGPT. The output will be pasted/appended or previewed.", + "popclip version": 4096, + "javascript file": "main.js", + "options": [ + { + "identifier": "apiType", + "label": "API Type", + "type": "multiple", + "description": "The API type.", + "default value": "openai", + "values": [ + "openai", + "azure" + ] + }, + { + "identifier": "apiBase", + "label": "API Base", + "type": "string", + "description": "The API base URL, azure ref: https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#chat-completions.", + "default value": "https://api.openai.com/v1" + }, + { + "identifier": "apiKey", + "label": "API Key", + "type": "string", + "description": "The API Key for /chat/completions.", + }, + { + "identifier": "apiVersion", + "label": "API Version, azure only", + "type": "string", + "description": "The API version for /chat/completions.", + "default value": "2023-05-15" + }, + { + "identifier": "model", + "label": "Model", + "type": "string", + "description": "The model, such as gpt-35-turbo or something else.", + "default value": "gpt-3.5-turbo" + }, + { + "identifier": "revise", + "label": "Revise", + "type": "boolean", + "description": "Revise revises the text.", + "inset": true + }, + { + "identifier": "polish", + "label": "Polish", + "type": "boolean", + "description": "Polish correct the grammar and polish the text.", + "inset": true + }, + { + "identifier": "translate", + "label": "Translate", + "type": "boolean", + "description": "Translate translates the text into Chinese by default.", + "inset": true + }, + { + "identifier": "prompts", + "label": "Custom Prompts", + "type": "string", + "description": "Customize prompts for revising, polishing, or translating texts. Separate each action on a separate line, for example: [revise] Revise the text.\n[polish] Polish the text.", + "default value": "", + } + ], + "entitlements": [ + "network" + ], +} diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f71a2a6 --- /dev/null +++ b/Makefile @@ -0,0 +1,4 @@ +build: + rm -f ./main.js ChatGPTx.popclipextz + tsc main.ts || exit 0 + pushd .. && zip -r ChatGPTx.popclipextz ChatGPTx.popclipext && mv ChatGPTx.popclipextz ChatGPTx.popclipext && popd diff --git a/main.ts b/main.ts new file mode 100644 index 0000000..a77a8c4 --- /dev/null +++ b/main.ts @@ -0,0 +1,147 @@ +// @ts-nocheck +import axios from "axios"; + +// TODO: history??? + +interface Message { + role: "user" | "system" | "assistant" + content: string +} + +interface Response { + data: { + choices: [{ + message: Message + }]; + } +} + +// Doc: https://pilotmoon.github.io/PopClip-Extensions/interfaces/Input.html +// Source: https://github.com/pilotmoon/PopClip-Extensions/blob/master/popclip.d.ts +interface Input { + // content: PasteboardContent + // data: { emails: RangedStrings; nonHttpUrls: RangedStrings; paths: RangedStrings; urls: RangedStrings } + html: string + markdown: string + matchedText: string + rtf: string + text: string + xhtml: string +} + +interface Options { + apiType: "openai" | "azure" + apiBase: "string" + apiKey: string + apiVersion: string + model: string + revise: boolean + polish: boolean + translate: boolean + prompts: string +} + +type AllowedActions = "revise" | "polish" | "translate" + +const defaultPrompts: ReadonlyMap = new Map(Object.entries({ + "revise": "Please revise the text to make it clearer, more concise, and more coherent, and please list the changes and briefly explain why (NOTE: do not translate the content).", + "polish": "Please correct the grammar and polish the text while adhering as closely as possible to the original intent (NOTE: do not translate the content).", + "translate": "Please translate the text into Chinese and only provide me with the translated content.", +})) + +function getPrompt(action: AllowedActions, customPrompts: string): string { + if (customPrompts !== "") { + const prompts = customPrompts.split("\n") + for (let i = 0; i < prompts.length; i++) { + const parts = prompts[i].trim().split("]") + if (parts[0].substring(1) == action) { + return parts.slice(1).join("]") + } + } + } + return defaultPrompts.get(action) || "" +} + +function constructClientOptions(options: Options): object { + if (options.apiType === "openai") { + return { + "baseURL": options.apiBase, + headers: { Authorization: `Bearer ${options.apiKey}` }, + timeout: 10000, + } + } else if (options.apiType === "azure") { + // Ref: https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#chat-completions + return { + "baseURL": options.apiBase, + headers: { "api-key": `${options.apiKey}` }, + params: { + "api-version": options.apiVersion, + }, + timeout: 10000, + } + } + throw new Error(`unsupported api type: ${options.apiType}`); +} + +const chat = async (input: Input, options: Options, action: AllowedActions) => { + if (!options[action]) { + popclip.showText(`action disabled: ${action}`); + return + } + const prompt = getPrompt(action, options.prompts) + + const openai = axios.create(constructClientOptions(options)) + try { + const { data }: Response = await openai.post( + "chat/completions", + { + model: options.model, + messages: [ + // { role: "system", content: "You are a professional multilingual assistant who will help me revise, polish, or translate texts. Please strictly follow user instructions." }, + { + role: "user", content: `${prompt} +The input text being used for this task is enclosed within triple quotation marks below the next line: + +"""${input.text}"""` + }, + ], + }, + ) + const answer = data.choices[0].message.content.trim() + + // Ref: https://pilotmoon.github.io/PopClip-Extensions/interfaces/PopClip.html + if (popclip.context.canPaste) { // Ref: https://pilotmoon.github.io/PopClip-Extensions/interfaces/Context.html + popclip.pasteText(`\n\n${answer}`, { restore: true }) + popclip.showSuccess() + } else { + popclip.showText(answer, { preview: true }) + } + } catch (e) { + popclip.showFailure() + popclip.showText(String(e)) + } +} + +export const actions = [ + { + title: "ChatGPTx: revise", + // icon: "symbol:square.and.pencil", + icon: "iconify:uil:edit", + requirements: ["option-revise=1"], + code: async (input: Input, options: Options) => chat(input, options, "revise"), + }, + { + title: "ChatGPTx: polish", + // icon: "symbol:wand.and.stars", + icon: "iconify:lucide:stars", + requirements: ["option-polish=1"], + code: async (input: Input, options: Options) => chat(input, options, "polish"), + }, + { + title: "ChatGPTx: translate", + // icon: "symbol:rectangle.landscape.rotate", + icon: "iconify:system-uicons:translate", + requirements: ["option-translate=1"], + code: async (input: Input, options: Options) => chat(input, options, "translate"), + }, +]