├── assets ├── icon.png └── icon.development.png ├── postcss.config.js ├── tsconfig.json ├── .gitignore ├── .github └── workflows │ ├── ci.yml │ ├── submit.yml │ └── release.yml ├── components ├── ThemeToggle.tsx ├── CheckForUpdates.tsx ├── UpdateBanner.tsx ├── TemplateCategory.tsx ├── Stats.tsx ├── Header.tsx ├── FeedbackModal.tsx ├── SettingsManager.tsx └── TemplateManager.tsx ├── background.ts ├── .prettierrc.mjs ├── LICENSE ├── tailwind.config.js ├── package.json ├── utils ├── checkForUpdates.ts └── index.ts ├── styles.css ├── popup.tsx ├── options.tsx ├── lib ├── ai-copilot.ts └── analytics.ts ├── types └── index.ts ├── CHANGELOG.md ├── store └── GlobalContext.tsx ├── styles.ts ├── contents ├── LinkedInMessageExtractor.ts ├── TypingSimulator.ts └── copilot.ts ├── README.md └── static-data └── index.ts /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Emmaccen/LinkedIn-Copilot/HEAD/assets/icon.png -------------------------------------------------------------------------------- /assets/icon.development.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Emmaccen/LinkedIn-Copilot/HEAD/assets/icon.development.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('postcss').ProcessOptions} 3 | */ 4 | module.exports = { 5 | plugins: { 6 | tailwindcss: {}, 7 | autoprefixer: {} 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "plasmo/templates/tsconfig.base", 3 | "exclude": ["node_modules"], 4 | "include": [".plasmo/index.d.ts", "./**/*.ts", "./**/*.tsx"], 5 | "compilerOptions": { 6 | "paths": { 7 | "~*": ["./*"] 8 | }, 9 | "baseUrl": "." 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 3 | 4 | # dependencies 5 | /node_modules 6 | /.pnp 7 | .pnp.js 8 | 9 | # testing 10 | /coverage 11 | 12 | # misc 13 | .DS_Store 14 | *.pem 15 | 16 | # debug 17 | npm-debug.log* 18 | yarn-debug.log* 19 | yarn-error.log* 20 | .pnpm-debug.log* 21 | 22 | # local env files 23 | .env*.local 24 | 25 | out/ 26 | build/ 27 | dist/ 28 | 29 | # plasmo 30 | .plasmo 31 | 32 | # typescript 33 | .tsbuildinfo 34 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | push: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | name: build 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | 18 | - name: Setup Node.js 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: "18" 22 | cache: "npm" 23 | 24 | - name: Install dependencies 25 | run: npm ci 26 | 27 | - name: Build 28 | run: npm run build 29 | -------------------------------------------------------------------------------- /components/ThemeToggle.tsx: -------------------------------------------------------------------------------- 1 | import { Moon, Sun } from "lucide-react" 2 | 3 | import { useGlobalState } from "~store/GlobalContext" 4 | 5 | export const ThemeToggle = () => { 6 | const { theme, setTheme } = useGlobalState() 7 | 8 | return ( 9 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /background.ts: -------------------------------------------------------------------------------- 1 | import Analytics from "~lib/analytics" 2 | import { AnalyticsEventTypes } from "~types" 3 | 4 | chrome.runtime.onInstalled.addListener(() => { 5 | Analytics.fireEvent("install") 6 | }) 7 | 8 | chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { 9 | if (message.type === AnalyticsEventTypes.GA_EVENT) { 10 | Analytics.fireEvent(message.eventName, message.eventParams).then((res) => { 11 | sendResponse(res) 12 | }) 13 | } else if (message.type === AnalyticsEventTypes.GA_ERROR_EVENT) { 14 | Analytics.fireErrorEvent(message.error, message.eventParams).then((res) => { 15 | sendResponse(res) 16 | }) 17 | } 18 | }) 19 | -------------------------------------------------------------------------------- /.prettierrc.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('prettier').Options} 3 | */ 4 | export default { 5 | printWidth: 80, 6 | tabWidth: 2, 7 | useTabs: false, 8 | semi: false, 9 | singleQuote: false, 10 | trailingComma: "none", 11 | bracketSpacing: true, 12 | bracketSameLine: true, 13 | plugins: ["@ianvs/prettier-plugin-sort-imports"], 14 | importOrder: [ 15 | "", // Node.js built-in modules 16 | "", // Imports not matched by other special words or groups. 17 | "", // Empty line 18 | "^@plasmo/(.*)$", 19 | "", 20 | "^@plasmohq/(.*)$", 21 | "", 22 | "^~(.*)$", 23 | "", 24 | "^[./]" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/submit.yml: -------------------------------------------------------------------------------- 1 | name: "Submit to Web Store" 2 | on: 3 | workflow_dispatch: 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | - name: Cache pnpm modules 11 | uses: actions/cache@v3 12 | with: 13 | path: ~/.pnpm-store 14 | key: ${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} 15 | restore-keys: | 16 | ${{ runner.os }}- 17 | - uses: pnpm/action-setup@v2.2.4 18 | with: 19 | version: latest 20 | run_install: true 21 | - name: Use Node.js 16.x 22 | uses: actions/setup-node@v3.4.1 23 | with: 24 | node-version: 16.x 25 | cache: "pnpm" 26 | - name: Build the extension 27 | run: pnpm build 28 | - name: Package the extension into a zip artifact 29 | run: pnpm package 30 | - name: Browser Platform Publish 31 | uses: PlasmoHQ/bpp@v3 32 | with: 33 | keys: ${{ secrets.SUBMIT_KEYS }} 34 | artifact: build/chrome-mv3-prod.zip 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Emmanuel Lucius 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 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | mode: "jit", 4 | darkMode: "class", 5 | content: [ 6 | "./**/*.tsx", 7 | "./popup/**/*.{ts,tsx}", // Plasmo popup UI 8 | "./options/**/*.{ts,tsx}" // Plasmo options page 9 | ], 10 | theme: { 11 | extend: { 12 | colors: { 13 | background: "var(--background)", 14 | foreground: "var(--foreground)", 15 | card: "var(--card)", 16 | "card-foreground": "var(--card-foreground)", 17 | muted: "var(--muted)", 18 | "muted-foreground": "var(--muted-foreground)", 19 | accent: "var(--accent)", 20 | "accent-foreground": "var(--accent-foreground)", 21 | border: "var(--border)", 22 | input: "var(--input)", 23 | ring: "var(--ring)", 24 | destructive: "var(--destructive)", 25 | "destructive-foreground": "var(--destructive-foreground)", 26 | "brand-blue": "oklch(var(--brand-blue) / )", 27 | "brand-green": "oklch(var(--brand-green) / )" 28 | }, 29 | borderRadius: { 30 | lg: "var(--radius)", 31 | md: "calc(var(--radius) - 2px)", 32 | sm: "calc(var(--radius) - 4px)" 33 | } 34 | } 35 | }, 36 | plugins: [require("@tailwindcss/typography")] 37 | } 38 | -------------------------------------------------------------------------------- /components/CheckForUpdates.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react" 2 | 3 | import UpdateBanner from "~components/UpdateBanner" 4 | import { checkForUpdatesAndCacheResponse } from "~utils/checkForUpdates" 5 | 6 | const CheckForUpdates = () => { 7 | const [updateInfo, setUpdateInfo] = useState<{ 8 | hasUpdate: boolean 9 | currentVersion: string 10 | latestVersion: string 11 | } | null>(null) 12 | const [showUpdateBanner, setShowUpdateBanner] = useState(false) 13 | 14 | useEffect(() => { 15 | checkForUpdatesAndCacheResponse().then((info) => { 16 | if (info && info.hasUpdate && !info.dismissedUpdate) { 17 | setUpdateInfo(info) 18 | setShowUpdateBanner(true) 19 | } 20 | }) 21 | }, []) 22 | 23 | const handleDismissUpdate = () => { 24 | setShowUpdateBanner(false) 25 | chrome.storage.local.set({ 26 | dismissedUpdate: true 27 | }) 28 | } 29 | return ( 30 |
31 | {showUpdateBanner && updateInfo && ( 32 |
33 | 38 |
39 | )} 40 |
41 | ) 42 | } 43 | 44 | export default CheckForUpdates 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "linkedin-copilot", 3 | "displayName": "Linkedin Copilot", 4 | "version": "0.0.4", 5 | "description": "An AI copilot for LinkedIn. Generate smart replies, craft engaging posts, and boost your networking and engagement with ease.", 6 | "author": "Lucius Emmanuel ", 7 | "scripts": { 8 | "dev": "plasmo dev", 9 | "build": "plasmo build", 10 | "package": "plasmo package" 11 | }, 12 | "dependencies": { 13 | "@headlessui/react": "^2.2.7", 14 | "groq-sdk": "^0.30.0", 15 | "lucide-react": "^0.539.0", 16 | "plasmo": "0.90.5", 17 | "react": "18.2.0", 18 | "react-dom": "18.2.0", 19 | "zod": "^4.0.17" 20 | }, 21 | "devDependencies": { 22 | "@ianvs/prettier-plugin-sort-imports": "4.1.1", 23 | "@tailwindcss/typography": "^0.5.16", 24 | "@types/chrome": "0.0.258", 25 | "@types/node": "20.11.5", 26 | "@types/react": "18.2.48", 27 | "@types/react-dom": "18.2.18", 28 | "autoprefixer": "^10.4.21", 29 | "postcss": "^8.5.6", 30 | "prettier": "3.2.4", 31 | "tailwindcss": "^3.4.17", 32 | "typescript": "5.3.3" 33 | }, 34 | "manifest": { 35 | "host_permissions": [ 36 | "https://www.linkedin.com/*" 37 | ], 38 | "permissions": [ 39 | "scripting", 40 | "storage" 41 | ] 42 | }, 43 | "packageManager": "yarn@1.22.22+sha1.ac34549e6aa8e7ead463a7407e1c7390f61a6610" 44 | } 45 | -------------------------------------------------------------------------------- /utils/checkForUpdates.ts: -------------------------------------------------------------------------------- 1 | const UPDATE_CHECK_INTERVAL = 24 * 60 * 60 * 1000 * 7 // 7 days 2 | 3 | const shouldCheckForUpdates = async () => { 4 | const { lastUpdateCheck } = await chrome.storage.local.get("lastUpdateCheck") 5 | const now = Date.now() 6 | return !lastUpdateCheck || now - lastUpdateCheck > UPDATE_CHECK_INTERVAL 7 | } 8 | 9 | export const checkForUpdatesAndCacheResponse = async () => { 10 | if (await shouldCheckForUpdates()) { 11 | const updateInfo = await checkForUpdates() 12 | if (updateInfo) { 13 | await chrome.storage.local.set({ 14 | updateInfo, 15 | lastUpdateCheck: Date.now(), 16 | dismissedUpdate: false 17 | }) 18 | } 19 | return { ...updateInfo, dismissedUpdate: false } 20 | } 21 | 22 | const { updateInfo, dismissedUpdate } = await chrome.storage.local.get([ 23 | "updateInfo", 24 | "dismissedUpdate" 25 | ]) 26 | 27 | return { ...updateInfo, dismissedUpdate } 28 | } 29 | 30 | const checkForUpdates = async () => { 31 | try { 32 | const response = await fetch( 33 | "https://raw.githubusercontent.com/emmaccen/linkedin-copilot/main/package.json" 34 | ) 35 | const packageData = await response.json() 36 | const latestVersion = packageData.version 37 | const currentVersion = chrome.runtime.getManifest().version 38 | 39 | return { 40 | hasUpdate: latestVersion !== currentVersion, 41 | latestVersion, 42 | currentVersion 43 | } 44 | } catch (error) { 45 | console.error("Update check failed:", error) 46 | return null 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | @plugin "@tailwindcss/typography"; 5 | 6 | /* Base Theme Variables */ 7 | @layer base { 8 | :root { 9 | --background: oklch(1 0 0); /* white */ 10 | --foreground: oklch(0.145 0 0); /* near black */ 11 | 12 | --card: var(--background); 13 | --card-foreground: var(--foreground); 14 | 15 | --muted: oklch(0.97 0 0); /* light gray */ 16 | --muted-foreground: oklch(0.556 0 0); /* medium gray */ 17 | 18 | --accent: oklch(0.95 0 0); 19 | --accent-foreground: var(--foreground); 20 | 21 | --border: oklch(0.922 0 0); /* light border gray */ 22 | --input: var(--border); 23 | 24 | --ring: oklch(0.708 0 0); /* medium ring gray */ 25 | 26 | --destructive: oklch(0.577 0.245 27.325); /* red-ish */ 27 | --destructive-foreground: oklch(0.985 0 0); 28 | 29 | --brand-blue: 0.55 0.22 262.89; /* blue accent */ 30 | --brand-green: 0.63 0.17 149.21; /* green accent */ 31 | --radius: 0.5rem; 32 | } 33 | 34 | .dark { 35 | --background: oklch(0.145 0 0); /* near black */ 36 | --foreground: oklch(0.985 0 0); /* white */ 37 | 38 | --card: oklch(0.205 0 0); /* slightly lighter black */ 39 | --card-foreground: var(--foreground); 40 | 41 | --muted: oklch(0.269 0 0); /* dark gray */ 42 | --muted-foreground: oklch(0.708 0 0); /* lighter gray text */ 43 | 44 | --accent: oklch(0.371 0 0); 45 | --accent-foreground: oklch(0.985 0 0); 46 | 47 | --border: oklch(1 0 0 / 0.1); 48 | --input: oklch(1 0 0 / 0.15); 49 | 50 | --ring: oklch(0.556 0 0); 51 | 52 | --destructive: oklch(0.704 0.191 22.216); 53 | --destructive-foreground: oklch(0.985 0 0); 54 | 55 | --brand-blue: 0.55 0.22 262.89; /* blue accent */ 56 | --brand-green: 0.63 0.17 149.21 /* green accent */; 57 | } 58 | 59 | /* Apply background & foreground globally */ 60 | * { 61 | @apply border-border; 62 | } 63 | 64 | body { 65 | @apply bg-background text-foreground; 66 | } 67 | 68 | /* Typography plugin + prose defaults */ 69 | .prose { 70 | @apply text-foreground; 71 | } 72 | .dark .prose { 73 | @apply text-foreground; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build & Release (tag only) 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | build-and-release: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | 21 | - name: Setup Node.js 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: "18" 25 | cache: "npm" 26 | 27 | - name: Install dependencies 28 | run: npm ci 29 | 30 | - name: Build extension 31 | run: npm run build 32 | 33 | - name: Get version from tag 34 | id: get_version 35 | run: | 36 | TAG_REF="${GITHUB_REF#refs/tags/}" 37 | VERSION="${TAG_REF#v}" 38 | echo "TAG=${TAG_REF}" >> "$GITHUB_OUTPUT" 39 | echo "VERSION=${VERSION}" >> "$GITHUB_OUTPUT" 40 | 41 | - name: Ensure build exists 42 | run: | 43 | if [ ! -d "build/chrome-mv3-prod" ]; then 44 | echo "ERROR: build/chrome-mv3-prod not found. Did npm run build fail?" 45 | ls -la build || true 46 | exit 1 47 | fi 48 | 49 | - name: Create artifacts dir & versioned zip 50 | run: | 51 | set -e 52 | VERSION=${{ steps.get_version.outputs.VERSION }} 53 | mkdir -p artifacts 54 | rm -f artifacts/linkedin-copilot-v${VERSION}.zip || true 55 | cd build/chrome-mv3-prod 56 | zip -r ../../artifacts/linkedin-copilot-v${VERSION}.zip . 57 | 58 | - name: Create latest zip 59 | run: | 60 | set -e 61 | mkdir -p artifacts 62 | rm -f artifacts/linkedin-copilot-latest.zip || true 63 | cd build/chrome-mv3-prod 64 | zip -r ../../artifacts/linkedin-copilot-latest.zip . 65 | 66 | - name: Create GitHub Release and upload artifacts 67 | uses: ncipollo/release-action@v1 68 | with: 69 | tag: ${{ steps.get_version.outputs.TAG }} 70 | name: "LinkedIn Copilot v${{ steps.get_version.outputs.VERSION }}" 71 | body: | 72 | Release v${{ steps.get_version.outputs.VERSION }} — auto-built artifact. 73 | artifacts: "artifacts/linkedin-copilot-v${{ steps.get_version.outputs.VERSION }}.zip,artifacts/linkedin-copilot-latest.zip" 74 | prerelease: false 75 | makeLatest: true 76 | env: 77 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 78 | -------------------------------------------------------------------------------- /popup.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react" 2 | 3 | import "./styles.css" 4 | 5 | import { Stats } from "~components/Stats" 6 | import { GlobalProvider } from "~store/GlobalContext" 7 | import { ENCRYPTION_KEY_NAME, loadFromLocalStorage } from "~utils" 8 | 9 | const Popup = () => { 10 | const [isAiConfigured, setIsAiConfigured] = useState(false) 11 | 12 | useEffect(() => { 13 | loadFromLocalStorage(ENCRYPTION_KEY_NAME).then((storedApiKey) => { 14 | if (storedApiKey) setIsAiConfigured(true) 15 | else setIsAiConfigured(false) 16 | }) 17 | }, []) 18 | 19 | return ( 20 |
21 |
22 |
23 |

LinkedIn Copilot

24 |
25 |

26 | 27 | Manage your templates, user profile and AI configuration from the 28 | 29 | 34 | Options page 35 | 36 |

37 |
38 |
39 |
41 | {isAiConfigured ? ( 42 |

43 | Your AI is configured and ready to use! You can now close this 44 | popup and start using LinkedIn Copilot. 45 |

46 | ) : ( 47 |

48 | Your AI is not yet configured. Please visit the options page to 49 | set up your API key and user details. 50 |

51 | )} 52 | 61 |
62 | 63 |
64 |
65 | ) 66 | } 67 | 68 | const ExtensionPopup = () => { 69 | return {} 70 | } 71 | 72 | export default ExtensionPopup 73 | -------------------------------------------------------------------------------- /components/UpdateBanner.tsx: -------------------------------------------------------------------------------- 1 | interface UpdateBannerProps { 2 | currentVersion: string 3 | latestVersion: string 4 | onDismiss: () => void 5 | } 6 | 7 | const UpdateBanner: React.FC = ({ 8 | currentVersion, 9 | latestVersion, 10 | onDismiss 11 | }) => { 12 | const openInNewTab = (url: string) => { 13 | try { 14 | window.open(url, "_blank") 15 | } catch { 16 | window.location.href = url 17 | } 18 | } 19 | const getTag = (version: string) => { 20 | return version?.toString().startsWith("v") 21 | ? version.toString() 22 | : `v${version}` 23 | } 24 | 25 | const handleDownload = async () => { 26 | const tag = getTag(latestVersion) 27 | 28 | const assetUrl = `https://github.com/Emmaccen/LinkedIn-Copilot/releases/download/${tag}/linkedin-copilot-latest.zip` 29 | 30 | openInNewTab(assetUrl) 31 | } 32 | 33 | return ( 34 |
35 |
36 |
37 |
38 | 43 | 49 | 50 |
51 |
52 |

53 | New version available 54 |

55 |

56 | LinkedIn Copilot v{latestVersion} is now available. You're 57 | currently using v{currentVersion}. 58 |

59 |
60 |
61 | 78 |
79 | 80 |
81 | 86 | 91 | View release notes 92 | 93 |
94 |
95 | ) 96 | } 97 | 98 | export default UpdateBanner 99 | -------------------------------------------------------------------------------- /options.tsx: -------------------------------------------------------------------------------- 1 | // import copilotIcon from "data-base64:~assets/icon.png" 2 | import { useState } from "react" 3 | 4 | import "./styles.css" 5 | 6 | import { Tab, TabGroup, TabList, TabPanel, TabPanels } from "@headlessui/react" 7 | import { AlertCircle, CheckCircle, InfoIcon } from "lucide-react" 8 | 9 | import CheckForUpdates from "~components/CheckForUpdates" 10 | import { ImportExportTemplate } from "~components/Header" 11 | import { SettingsManager } from "~components/SettingsManager" 12 | import { Stats } from "~components/Stats" 13 | import { TemplateCategoryManager } from "~components/TemplateCategory" 14 | import TemplateManager from "~components/TemplateManager" 15 | import { GlobalProvider, useGlobalState } from "~store/GlobalContext" 16 | 17 | function classNames(...classes: string[]) { 18 | return classes.filter(Boolean).join(" ") 19 | } 20 | 21 | const Options = () => { 22 | const [selectedIndex, setSelectedIndex] = useState(0) 23 | const { notifications } = useGlobalState() 24 | 25 | return ( 26 |
27 | {/* */} 28 | {!!notifications.length && ( 29 |
30 | {notifications.map((notification) => ( 31 |
40 |
41 | {notification.type === "success" && } 42 | {notification.type === "error" && } 43 | {notification.type === "info" && } 44 | {notification.message} 45 |
46 |
47 | ))} 48 |
49 | )} 50 |
51 |

LinkedIn Copilot

52 | {/* LinkedIn copilot icon */} 59 |
60 |

61 | Manage your templates and configure your ai and extension settings. 62 |

63 | 64 | 65 | 66 | {["Templates", "Settings"].map((tab) => ( 67 | 70 | classNames( 71 | "py-2 border-b-2 -mb-px outline-none text-base", 72 | selected 73 | ? "border-foreground text-foreground font-medium" 74 | : "border-transparent text-muted-foreground hover:text-foreground" 75 | ) 76 | }> 77 | {tab} 78 | 79 | ))} 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 |
96 | ) 97 | } 98 | const OptionsPage = () => { 99 | return {} 100 | } 101 | export default OptionsPage 102 | -------------------------------------------------------------------------------- /lib/ai-copilot.ts: -------------------------------------------------------------------------------- 1 | import Groq from "groq-sdk" 2 | 3 | import { ENCRYPTED_API_KEY_NAME, getDecryptedApiKey } from "~utils" 4 | 5 | interface PromptType { 6 | message: string 7 | systemMessage: string 8 | } 9 | 10 | export const groqInstance = (() => { 11 | let instance: Groq | null = null 12 | 13 | const createInstance = async (): Promise => { 14 | try { 15 | const apiKey = await getDecryptedApiKey() 16 | 17 | if (apiKey) { 18 | instance = new Groq({ 19 | apiKey: apiKey, 20 | dangerouslyAllowBrowser: true 21 | }) 22 | return instance 23 | } else { 24 | console.warn("No API key found in storage") 25 | return null 26 | } 27 | } catch (error) { 28 | console.error("Failed to create Groq instance:", error) 29 | return null 30 | } 31 | } 32 | 33 | return { 34 | // This method creates a new instance of Groq 35 | // It can be called to reset the instance if needed 36 | createInstance, 37 | // This method ensures that we only create the instance once 38 | getInstance: async (): Promise => { 39 | if (!instance) { 40 | instance = await createInstance() 41 | } 42 | return instance 43 | }, 44 | // Method to reset instance (useful for key changes) 45 | resetInstance: () => { 46 | instance = null 47 | } 48 | } 49 | })() 50 | 51 | chrome.storage.onChanged.addListener(async (changes, namespace) => { 52 | if (namespace === "local" && changes[ENCRYPTED_API_KEY_NAME]) { 53 | // console.log("Encrypted API key changed, resetting Groq instance") 54 | groqInstance.resetInstance() 55 | await groqInstance.createInstance() 56 | // console.log("Groq instance reset and recreated") 57 | } 58 | }) 59 | 60 | export async function generateReply({ message, systemMessage }: PromptType) { 61 | return await getGroqChatStream({ message, systemMessage }) 62 | } 63 | 64 | export async function getGroqChatStream({ 65 | message, 66 | systemMessage 67 | }: PromptType) { 68 | const groq = await groqInstance.getInstance() 69 | 70 | // Add null check before using groq 71 | if (!groq) { 72 | throw new Error( 73 | "Groq instance not available - API key may not be configured" 74 | ) 75 | } 76 | 77 | return groq.chat.completions.create({ 78 | messages: [ 79 | { 80 | role: "system", 81 | content: systemMessage 82 | }, 83 | { 84 | role: "user", 85 | content: message 86 | } 87 | ], 88 | model: "llama-3.3-70b-versatile", 89 | 90 | // The maximum number of tokens to generate. Requests can use up to 91 | // 2048 tokens shared between prompt and completion. 92 | max_completion_tokens: 2000, 93 | 94 | temperature: 0.3, 95 | 96 | stop: null, 97 | 98 | stream: true 99 | }) 100 | } 101 | 102 | export async function createLinkedInPostWithAi({ 103 | message, 104 | systemMessage 105 | }: PromptType) { 106 | const groq = await groqInstance.getInstance() 107 | return await groq.chat.completions.create({ 108 | messages: [ 109 | { 110 | role: "system", 111 | content: systemMessage 112 | }, 113 | { 114 | role: "user", 115 | content: message 116 | } 117 | ], 118 | model: "llama-3.3-70b-versatile", 119 | 120 | // The maximum number of tokens to generate. Requests can use up to 121 | // 2048 tokens shared between prompt and completion. 122 | max_completion_tokens: 2000, 123 | 124 | temperature: 0.3, 125 | 126 | stop: null, 127 | 128 | stream: true 129 | }) 130 | } 131 | 132 | /** 133 | * NOTES FROM LUCIUS EMMANUEL 134 | * If you ever wonder how long you can use your free-tier API key, you can check the limits at https://console.groq.com/docs/rate-limits 135 | * But basically, the model used above can generate ~400,000 characters per day. And that's pretty sick! 136 | */ 137 | -------------------------------------------------------------------------------- /components/TemplateCategory.tsx: -------------------------------------------------------------------------------- 1 | import { Input } from "@headlessui/react" 2 | import { Plus, Save, X } from "lucide-react" 3 | import { useState } from "react" 4 | 5 | import type { TemplateCategory } from "~types" 6 | import { loadFromLocalStorage, saveToLocalStorage } from "~utils" 7 | 8 | export const TemplateCategoryManager = () => { 9 | const [showCategoryForm, setShowCategoryForm] = useState(false) 10 | const [newCategory, setNewCategory] = useState({ 11 | name: "", 12 | icon: "" 13 | }) 14 | 15 | const addNewCategory = async () => { 16 | if (!newCategory.icon.trim() && !newCategory.name.trim()) { 17 | return 18 | } 19 | const previousTemplates = 20 | await loadFromLocalStorage>("templates") 21 | if (previousTemplates) { 22 | const updatedTemplates = { 23 | ...previousTemplates 24 | } 25 | updatedTemplates[newCategory.name] = { 26 | active: false, 27 | context: ["feed"], 28 | icon: newCategory.icon, 29 | templates: [] 30 | } 31 | saveToLocalStorage>( 32 | "templates", 33 | updatedTemplates 34 | ) 35 | } 36 | setNewCategory({ 37 | name: "", 38 | icon: "" 39 | }) 40 | setShowCategoryForm(false) 41 | } 42 | return ( 43 |
44 |
45 | Add New Category 46 | 47 | {!showCategoryForm ? ( 48 | 54 | ) : ( 55 |
56 |
57 | 62 | setNewCategory({ ...newCategory, icon: e.target.value }) 63 | } 64 | placeholder="Icon" 65 | className="border border-border bg-background rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent w-16 mr-2" 66 | /> 67 | 71 | setNewCategory({ ...newCategory, name: e.target.value }) 72 | } 73 | placeholder="Category name" 74 | className="border border-border bg-background rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent w-64" 75 | /> 76 | 81 | 86 |
87 |

88 | Press Cmd+Ctrl+Space (Mac) or Win+. (Windows) to open the emoji 89 | picker. 90 |

91 |
92 | )} 93 |
94 |
95 | ) 96 | } 97 | -------------------------------------------------------------------------------- /types/index.ts: -------------------------------------------------------------------------------- 1 | export type Theme = "light" | "dark" 2 | export type NotificationType = "success" | "error" | "info" | "warning" 3 | 4 | export interface UserSettings { 5 | typingDelay: number 6 | enableTypingSimulation: boolean 7 | } 8 | export interface UserDetails { 9 | fullName: string 10 | professionalTitle: string 11 | professionalSummary: string 12 | } 13 | export interface Notification { 14 | id: string 15 | message: string 16 | type: NotificationType 17 | } 18 | 19 | export type GlobalState = { 20 | theme: Theme 21 | setTheme: (theme: Theme) => void 22 | notifications?: Notification[] 23 | pushNotification?: (message: string, type?: NotificationType) => void 24 | removeNotification?: (id: string) => void 25 | templates?: Record 26 | userDetails: UserDetails 27 | userSettings: UserSettings 28 | } 29 | 30 | export interface Template { 31 | id: string 32 | message: string 33 | aiGenerated: boolean 34 | active: boolean 35 | placeholders: string[] 36 | } 37 | export interface TemplateCategory { 38 | active: boolean 39 | context: ContextType[] 40 | icon: string 41 | templates: Template[] 42 | } 43 | export interface DropdownAction { 44 | id: string 45 | label: string 46 | icon: string 47 | category: string 48 | } 49 | 50 | export interface UserInfo { 51 | name?: string 52 | desc?: string 53 | [key: string]: string | undefined 54 | } 55 | 56 | export interface UsageStats { 57 | [date: string]: { 58 | [category: string]: number 59 | total?: number 60 | } 61 | } 62 | 63 | export interface AiDMChatMessage { 64 | sender: string 65 | text: string 66 | timestamp: string 67 | isOtherPerson: boolean 68 | element: Element 69 | } 70 | 71 | export interface AiDMChatContext { 72 | messages: AiDMChatMessage[] 73 | totalCharacters: number 74 | truncated: boolean 75 | } 76 | 77 | export type ContextType = "feed" | "dm" | "connection" | "post" 78 | 79 | export enum AnalyticsEventTypes { 80 | "AI_POST_CREATED" = "AI_POST_CREATED", 81 | "AI_DM_SINGLE_REPLY_CREATED" = "AI_DM_SINGLE_REPLY_CREATED", 82 | "AI_DM_CHAT_HISTORY_REPLY_CREATED" = "AI_DM_CHAT_HISTORY_REPLY_CREATED", 83 | "TEMPLATE_USED" = "TEMPLATE_USED", 84 | "POST_COMMENT_CREATED" = "POST_COMMENT_CREATED", 85 | "GA_EVENT" = "GA_EVENT", 86 | "GA_ERROR_EVENT" = "GA_ERROR_EVENT" 87 | } 88 | 89 | export interface GAUserInfo { 90 | user_agent: string 91 | language: string 92 | timezone: string 93 | country: string 94 | country_code: string 95 | region: string 96 | city: string 97 | timestamp: number 98 | region_code: string 99 | } 100 | 101 | export interface LocationInfo { 102 | ip: string 103 | network: string 104 | version: string 105 | city: string 106 | region: string 107 | region_code: string 108 | country: string 109 | country_name: string 110 | country_code: string 111 | country_code_iso3: string 112 | country_capital: string 113 | country_tld: string 114 | continent_code: string 115 | in_eu: boolean 116 | postal: any 117 | latitude: number 118 | longitude: number 119 | timezone: string 120 | utc_offset: string 121 | country_calling_code: string 122 | currency: string 123 | currency_name: string 124 | languages: string 125 | country_area: number 126 | country_population: number 127 | asn: string 128 | org: string 129 | } 130 | export interface LocationInfoType { 131 | ip: string 132 | success: boolean 133 | type: string 134 | continent: string 135 | continent_code: string 136 | country: string 137 | country_code: string 138 | region: string 139 | region_code: string 140 | city: string 141 | latitude: number 142 | longitude: number 143 | is_eu: boolean 144 | postal: string 145 | calling_code: string 146 | capital: string 147 | borders: string 148 | flag: Flag 149 | connection: Connection 150 | timezone: Timezone 151 | } 152 | 153 | export interface Connection { 154 | asn: number 155 | org: string 156 | isp: string 157 | domain: string 158 | } 159 | 160 | export interface Flag { 161 | img: string 162 | emoji: string 163 | emoji_unicode: string 164 | } 165 | 166 | export interface Timezone { 167 | id: string 168 | abbr: string 169 | is_dst: boolean 170 | offset: number 171 | utc: string 172 | current_time: Date 173 | } 174 | -------------------------------------------------------------------------------- /components/Stats.tsx: -------------------------------------------------------------------------------- 1 | import { MessageSquare, Target, TrendingUp, Zap } from "lucide-react" 2 | import React, { useCallback, useEffect, useState } from "react" 3 | 4 | import { useGlobalState } from "~store/GlobalContext" 5 | import type { TemplateCategory } from "~types" 6 | 7 | type StatCardProps = { 8 | label: string 9 | value: number 10 | icon: React.ReactNode 11 | } 12 | 13 | function StatCard({ label, value, icon }: StatCardProps) { 14 | return ( 15 |
16 |
{icon}
17 |
18 |

{value}

19 |

{label}

20 |
21 |
22 | ) 23 | } 24 | type Stats = { 25 | totalTemplates: number 26 | activeTemplates: number 27 | categories: number 28 | activeCategories: number 29 | } 30 | export const Stats = () => { 31 | const { templates } = useGlobalState() 32 | const provideStats = useCallback( 33 | (templates: Record) => { 34 | let stats: Stats = { 35 | totalTemplates: 0, 36 | activeTemplates: 0, 37 | categories: 0, 38 | activeCategories: 0 39 | } 40 | stats["totalTemplates"] = Object.values(templates).reduce( 41 | (acc, category) => acc + category.templates.length, 42 | 0 43 | ) 44 | stats["activeTemplates"] = Object.values(templates).reduce( 45 | (acc, category) => 46 | acc + category.templates.filter((t) => t.active).length, 47 | 0 48 | ) 49 | stats["categories"] = Object.keys(templates).length 50 | stats["activeCategories"] = Object.values(templates).filter( 51 | (category) => category.active 52 | ).length 53 | 54 | return stats 55 | }, 56 | [templates] 57 | ) 58 | 59 | const statsData = provideStats(templates) 60 | 61 | return ( 62 |
63 | 78 | 79 | 80 | } 81 | /> 82 | 97 | 98 | 99 | } 100 | /> 101 | 116 | 117 | 118 | 119 | 120 | } 121 | /> 122 | 137 | 138 | 139 | 140 | } 141 | /> 142 |
143 | ) 144 | } 145 | -------------------------------------------------------------------------------- /components/Header.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@headlessui/react" 2 | import { Download, Upload } from "lucide-react" 3 | import React, { useState } from "react" 4 | 5 | import { useGlobalState } from "~store/GlobalContext" 6 | import type { Template, TemplateCategory } from "~types" 7 | import { saveToLocalStorage } from "~utils" 8 | 9 | const createSchemas = async () => { 10 | const { z } = await import("zod") 11 | 12 | const contextTypeSchema = z.enum(["feed", "dm", "connection", "post"]) 13 | 14 | const templateSchema = z.object({ 15 | id: z.string().min(1, "Template id is required"), 16 | message: z.string().min(1, "Message cannot be empty"), 17 | aiGenerated: z.boolean(), 18 | active: z.boolean(), 19 | placeholders: z.array(z.string()) 20 | }) 21 | 22 | const templateCategorySchema = z.object({ 23 | active: z.boolean(), 24 | context: z.array(contextTypeSchema).min(1, "Context array cannot be empty"), 25 | icon: z.string().min(1, "Icon is required"), 26 | templates: z 27 | .array(templateSchema) 28 | .min(1, "At least one template is required") 29 | }) 30 | 31 | const templatesFileSchema = z.record(z.string(), templateCategorySchema) 32 | 33 | return { templatesFileSchema } 34 | } 35 | 36 | export const ImportExportTemplate = () => { 37 | const { pushNotification, templates } = useGlobalState() 38 | 39 | // File upload handler with dynamic import 40 | const handleFileUpload = async ( 41 | event: React.ChangeEvent 42 | ) => { 43 | const file = event.target.files[0] 44 | if (!file) return 45 | 46 | const reader = new FileReader() 47 | reader.onload = async (e) => { 48 | try { 49 | const result = e.target.result 50 | const uploadedTemplates = 51 | typeof result === "string" ? JSON.parse(result) : {} 52 | 53 | // Dynamically import and create schemas 54 | const { templatesFileSchema } = await createSchemas() 55 | 56 | // Validate structure 57 | const validated = templatesFileSchema.safeParse(uploadedTemplates) 58 | 59 | if (!validated.success && !validated.data) { 60 | throw new Error( 61 | "Invalid template structure: " + validated.error.message 62 | ) 63 | } 64 | // Merge with existing templates 65 | const mergedTemplates = { ...templates, ...validated.data } 66 | saveToLocalStorage>( 67 | "templates", 68 | mergedTemplates 69 | ) 70 | pushNotification("Templates imported successfully!", "success") 71 | } catch (error) { 72 | pushNotification( 73 | "Error importing templates: Invalid template structure", 74 | "error" 75 | ) 76 | console.error("Error importing templates:", error) 77 | } 78 | } 79 | reader.readAsText(file) 80 | } 81 | 82 | const exportTemplates = () => { 83 | const templatesFromStorage = localStorage.getItem("templates") 84 | if (!templatesFromStorage) { 85 | pushNotification("No templates to export", "info") 86 | return 87 | } 88 | const dataStr = JSON.stringify(JSON.parse(templatesFromStorage), null, 2) 89 | const dataUri = 90 | "data:application/json;charset=utf-8," + encodeURIComponent(dataStr) 91 | 92 | const exportFileDefaultName = "linkedin-copilot-templates.json" 93 | 94 | const linkElement = document.createElement("a") 95 | linkElement.setAttribute("href", dataUri) 96 | linkElement.setAttribute("download", exportFileDefaultName) 97 | linkElement.click() 98 | linkElement.remove() 99 | } 100 | 101 | return ( 102 |
103 |

Import / Export Templates

104 |
105 |
106 | 113 | 118 |
119 | 124 |
125 |

126 | Import templates from a JSON file or export your current templates for 127 | backup. 128 |

129 |
130 | ) 131 | } 132 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [v0.0.4] — 1-10-2025 4 | 5 | - Improved region event analytics gathering 6 | - Replaced old ipapi with a new and more reliable CORS friendly api 7 | 8 | ## [v0.0.3] — 18-09-2025 9 | 10 | ### Changed 11 | 12 | - Remove HEAD check for GitHub asset availability in the update flow. 13 | - The extension now opens the release asset URL directly to avoid CORS failures in the extension environment. 14 | - This simplifies update checks and improves reliability for end users. 15 | 16 | ### Added 17 | 18 | - Build status checks in Github actions 19 | - This ensures PRs pointed to the `main` branch builds successfully before allowing merge 20 | - Keeps workflow clean and free of build errors 21 | 22 | ### Updated 23 | 24 | - Updated system messages for comment creation 25 | - Increased `max_completion_tokens` for AI generation 26 | - Reduced `temperature` to `0.2` to increase deterministic tendencies 27 | 28 | ### Fixed 29 | 30 | - Ensure download link normalization: the app now prepends `v` to numeric package.json versions when constructing release URLs (e.g. `0.0.2` -> `v0.0.2`) so direct downloads work consistently. 31 | - Build/release workflow clarified: releases are produced from tags (`vX.Y.Z`) and uploaded as GitHub Release assets (`linkedin-copilot-vX.Y.Z.zip` + `linkedin-copilot-latest.zip`). 32 | 33 | ### Notes (v0.0.2) 34 | 35 | - Beta / prerelease workflow was removed to keep versioning numeric and Chrome-compatible (manifest version constraints). If you previously had `-beta` tags, those have been cleaned up. 36 | - To update: the repo uses numeric semantic versions (no `-beta`). Create a numeric tag (e.g. `v0.0.2`) to trigger the release pipeline. 37 | 38 | ## [v0.0.2] - 16-09-2025 39 | 40 | ### Initial Release 41 | 42 | ### ✨ Features 43 | 44 | - **AI-Powered Content Generation** 45 | 46 | - Smart replies to LinkedIn feed posts with full context awareness 47 | - Direct message responses based on chat history or individual messages 48 | - Post creation and editing assistance via "Pilot Button" in LinkedIn's compose dialog 49 | - All AI responses personalized based on user's professional profile 50 | 51 | - **Template System** 52 | 53 | - Pre-built response templates organized by categories (Business DMs, etc.) 54 | - Support for smart placeholders ({{name}} auto-replacement) 55 | - Template management with import/export functionality (JSON format) 56 | - Context-aware template suggestions (Feed, DM, Connection, Post) 57 | - Individual template activation/deactivation controls 58 | 59 | - **Smart Context Detection** 60 | 61 | - Automatic detection of LinkedIn feed comments vs direct messages 62 | - Post content extraction for relevant AI responses 63 | - User information extraction for personalized template placeholders 64 | - Real-time DOM monitoring for dynamic LinkedIn content 65 | 66 | - **Enhanced User Experience** 67 | 68 | - Realistic typing simulation with configurable delays 69 | - Streaming AI responses for immediate feedback 70 | - Visual processing indicators during AI generation 71 | - Clean, integrated UI that matches LinkedIn's design 72 | 73 | - **Settings & Customization** 74 | - Groq API integration for free AI processing 75 | - User profile configuration (name, title, professional summary) 76 | - Typing simulation preferences 77 | - Template category management 78 | - Light/Dark theme support 79 | 80 | ### 🛠 Technical Implementation 81 | 82 | - **Built with Plasmo Framework** for modern Chrome extension development 83 | - **React + TypeScript** frontend with Tailwind CSS styling 84 | - **Chrome Storage API** for local data persistence 85 | - **Mutation Observers** for real-time LinkedIn content detection 86 | - **Stream-based AI Processing** for responsive user experience 87 | - **Analytics Integration** for usage tracking and improvement insights 88 | 89 | ### 🔒 Privacy & Security 90 | 91 | - **Local Data Storage** - all templates and settings stored in browser 92 | - **No Automated Actions** - extension only drafts content, users retain full control 93 | - **Context-Only Processing** - LinkedIn content analyzed locally for AI context 94 | - **User-Controlled Sending** - all messages/posts require manual user approval 95 | 96 | ### 📊 Analytics & Insights 97 | 98 | - Anonymous usage tracking for feature optimization 99 | - Error reporting for stability improvements 100 | - Template usage statistics 101 | - AI generation success metrics 102 | 103 | ### 📌 Known Limitations 104 | 105 | - **Groq API Dependency** - requires user to obtain free Groq API key 106 | - **LinkedIn Layout Changes** - may need updates if LinkedIn modifies their UI structure 107 | - **Template Randomization** - currently uses random selection from active templates (smart selection planned for future) 108 | 109 | ### 🛠 Future Considerations 110 | 111 | Planned enhancements based on user feedback and analytics: 112 | 113 | - **AI-Generated Templates** - automatic template creation based on user behavior 114 | - **Smart Template Selection** - context-aware template recommendations 115 | - **Enhanced Personalization** - deeper LinkedIn profile integration 116 | - **Multi-language Support** - AI responses in different languages 117 | - **Advanced Analytics Dashboard** - detailed usage insights for users 118 | 119 | --- 120 | 121 | ### 📝 Installation Notes 122 | 123 | - Chrome extension requires "Developer mode" for manual installation 124 | - Groq API key setup required (free tier with generous limits) 125 | - User profile completion recommended for optimal AI personalization 126 | -------------------------------------------------------------------------------- /store/GlobalContext.tsx: -------------------------------------------------------------------------------- 1 | import type React from "react" 2 | import { createContext, useContext, useEffect, useState } from "react" 3 | 4 | import type { 5 | GlobalState, 6 | Notification, 7 | NotificationType, 8 | TemplateCategory, 9 | Theme, 10 | UserDetails, 11 | UserSettings 12 | } from "~types" 13 | import { 14 | loadFromLocalStorage, 15 | saveToLocalStorage, 16 | STORAGE_CHANGE_EVENT 17 | } from "~utils" 18 | 19 | const GlobalContext = createContext(undefined) 20 | 21 | export const GlobalProvider = ({ children }: { children: React.ReactNode }) => { 22 | const [theme, setThemeState] = useState("light") 23 | const [userSettings, setUserSettings] = useState({ 24 | typingDelay: 40, 25 | enableTypingSimulation: true 26 | }) 27 | const [userDetails, setUserDetails] = useState({ 28 | fullName: "", 29 | professionalTitle: "", 30 | professionalSummary: "" 31 | }) 32 | const [notifications, setNotifications] = useState([]) 33 | const [templates, setTemplates] = useState>( 34 | {} 35 | ) 36 | 37 | useEffect(() => { 38 | loadFromLocalStorage("userSettings").then( 39 | (storedUserSettings) => { 40 | if (storedUserSettings) setUserSettings(storedUserSettings) 41 | } 42 | ) 43 | loadFromLocalStorage("userDetails").then( 44 | (storedUserDetails) => { 45 | if (storedUserDetails) setUserDetails(storedUserDetails) 46 | } 47 | ) 48 | loadFromLocalStorage>("templates").then( 49 | (storedTemplates) => { 50 | if (storedTemplates) setTemplates(storedTemplates) 51 | } 52 | ) 53 | loadFromLocalStorage("theme").then((storedTheme) => { 54 | if (storedTheme) { 55 | applyTheme(storedTheme) 56 | } else { 57 | const prefersDark = window.matchMedia( 58 | "(prefers-color-scheme: dark)" 59 | ).matches 60 | applyTheme(prefersDark ? "dark" : "light") 61 | } 62 | }) 63 | }, []) 64 | 65 | useEffect(() => { 66 | const handleStorageChange = ( 67 | changes: { [key: string]: chrome.storage.StorageChange }, 68 | namespace: "sync" | "local" | "managed" | "session" 69 | ) => { 70 | if (namespace === "local") { 71 | if (changes["templates"]) { 72 | const updatedTemplates = JSON.parse( 73 | changes["templates"].newValue || "{}" 74 | ) as Record 75 | setTemplates(updatedTemplates) 76 | } 77 | if (changes["userSettings"]) { 78 | const newUserSettings = JSON.parse( 79 | changes["userSettings"].newValue || "{}" 80 | ) as UserSettings 81 | setUserSettings(newUserSettings) 82 | } 83 | if (changes["userDetails"]) { 84 | const newUserDetails = JSON.parse( 85 | changes["userDetails"].newValue || "{}" 86 | ) as UserDetails 87 | setUserDetails(newUserDetails) 88 | } 89 | } 90 | } 91 | 92 | // Handle custom events 93 | const handleCustomStorageChange = (event: CustomEvent) => { 94 | if (event.detail.key === "templates") { 95 | const updatedTemplates = JSON.parse( 96 | event.detail.newValue || "{}" 97 | ) as Record 98 | setTemplates(updatedTemplates) 99 | } 100 | if (event.detail.key === "theme") { 101 | // const newTheme = event.detail.newValue as Theme 102 | // applyTheme(newTheme) 103 | } 104 | if (event.detail.key === "userSettings") { 105 | const newUserSettings = JSON.parse( 106 | event.detail.newValue || "{}" 107 | ) as UserSettings 108 | setUserSettings(newUserSettings) 109 | } 110 | if (event.detail.key === "userDetails") { 111 | const newUserDetails = JSON.parse( 112 | event.detail.newValue || "{}" 113 | ) as UserDetails 114 | setUserDetails(newUserDetails) 115 | } 116 | } 117 | 118 | chrome.storage.onChanged.addListener(handleStorageChange) 119 | 120 | window.addEventListener( 121 | STORAGE_CHANGE_EVENT, 122 | handleCustomStorageChange as EventListener 123 | ) 124 | 125 | return () => { 126 | chrome.storage.onChanged.removeListener(handleStorageChange) 127 | window.removeEventListener( 128 | STORAGE_CHANGE_EVENT, 129 | handleCustomStorageChange as EventListener 130 | ) 131 | } 132 | }, []) 133 | 134 | const applyTheme = (newTheme: Theme) => { 135 | setThemeState(newTheme) 136 | document.documentElement.classList.toggle("dark", newTheme === "dark") 137 | saveToLocalStorage("theme", newTheme) 138 | } 139 | 140 | const pushNotification = ( 141 | message: string, 142 | type: NotificationType = "info" 143 | ) => { 144 | const id = crypto.randomUUID() 145 | setNotifications((prev) => [...prev, { id, message, type }]) 146 | setTimeout(() => removeNotification(id), 4000) 147 | } 148 | 149 | const removeNotification = (id: string) => { 150 | setNotifications((prev) => prev.filter((n) => n.id !== id)) 151 | } 152 | 153 | const setTheme = (newTheme: Theme) => applyTheme(newTheme) 154 | 155 | return ( 156 | 167 | {children} 168 | 169 | ) 170 | } 171 | 172 | export const useGlobalState = (): GlobalState => { 173 | const context = useContext(GlobalContext) 174 | if (!context) { 175 | throw new Error("useGlobalState must be used within a Provider") 176 | } 177 | return context 178 | } 179 | -------------------------------------------------------------------------------- /styles.ts: -------------------------------------------------------------------------------- 1 | export const linkedInCopilotStyles = ` 2 | .copilot-capsule-container { 3 | display: flex; 4 | align-items: center; 5 | gap: 10px; 6 | overflow: scroll; 7 | padding: 15px 5px; 8 | font-size: 12px; 9 | } 10 | 11 | .copilot-capsule { 12 | padding: 12px 16px; 13 | border-radius: 30px; 14 | cursor: pointer; 15 | border: 1px solid currentColor; 16 | transition: background-color 0.2s ease; 17 | white-space: nowrap; 18 | } 19 | .copilot-capsule-ai { 20 | background: linear-gradient(163deg, #3c52d0 0%, #544067 100%); 21 | border: none; 22 | color: #fff; 23 | 24 | } 25 | 26 | .copilot-dropdown-option:first-child:hover { 27 | background: linear-gradient(135deg, #f0f2ff 0%, #e8ebff 100%); 28 | } 29 | 30 | .linkedin-auto-reply-notification { 31 | position: fixed; 32 | top: 20px; 33 | right: 20px; 34 | padding: 12px 20px; 35 | border-radius: 8px; 36 | color: white; 37 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; 38 | font-size: 14px; 39 | font-weight: 500; 40 | z-index: 10000; 41 | transform: translateX(100%); 42 | transition: transform 0.3s ease; 43 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 44 | } 45 | 46 | .linkedin-auto-reply-notification.show { 47 | transform: translateX(0); 48 | } 49 | 50 | .linkedin-auto-reply-notification.info { 51 | background-color: #0073b1; 52 | } 53 | 54 | .linkedin-auto-reply-notification.success { 55 | background-color: #057642; 56 | } 57 | 58 | .linkedin-auto-reply-notification.warning { 59 | background-color: #f5a623; 60 | } 61 | 62 | .linkedin-auto-reply-notification.error { 63 | background-color: #d93025; 64 | } 65 | 66 | .aiDirectDmReplyButton { 67 | margin: 5px 5px 0px auto; 68 | padding: 12px 16px; 69 | cursor: pointer; 70 | transition: background-color 0.2s ease; 71 | white-space: nowrap; 72 | color: #fff; 73 | width: fit-content; 74 | border-radius: 11px; 75 | font-size: 12px; 76 | background: linear-gradient(163deg, #3c52d0 0%, #544067 100%); 77 | } 78 | 79 | /* AI Processing Animation */ 80 | .ai-processing { 81 | position: relative; 82 | overflow: hidden; 83 | } 84 | 85 | .ai-processing::before { 86 | content: ''; 87 | position: absolute; 88 | top: -2px; 89 | left: -2px; 90 | right: -2px; 91 | bottom: -2px; 92 | background: conic-gradient( 93 | from 0deg, 94 | #6366f1, 95 | #8b5cf6, 96 | #ec4899, 97 | #f59e0b, 98 | #10b981, 99 | #06b6d4, 100 | #6366f1 101 | ); 102 | border-radius: inherit; 103 | animation: ai-rotate 2s linear infinite; 104 | z-index: -1; 105 | } 106 | 107 | .ai-processing::after { 108 | content: ''; 109 | position: absolute; 110 | top: 0; 111 | left: 0; 112 | right: 0; 113 | bottom: 0; 114 | background: inherit; 115 | border-radius: inherit; 116 | z-index: -1; 117 | } 118 | 119 | /* Pulsing glow effect */ 120 | .ai-processing { 121 | animation: ai-pulse 1.5s ease-in-out infinite alternate; 122 | box-shadow: 123 | 0 0 20px rgba(99, 102, 241, 0.3), 124 | 0 0 40px rgba(139, 92, 246, 0.2), 125 | 0 0 60px rgba(236, 72, 153, 0.1); 126 | } 127 | 128 | @keyframes ai-rotate { 129 | 0% { 130 | transform: rotate(0deg); 131 | } 132 | 100% { 133 | transform: rotate(360deg); 134 | } 135 | } 136 | 137 | @keyframes ai-pulse { 138 | 0% { 139 | box-shadow: 140 | 0 0 20px rgba(99, 102, 241, 0.3), 141 | 0 0 40px rgba(139, 92, 246, 0.2), 142 | 0 0 60px rgba(236, 72, 153, 0.1); 143 | } 144 | 100% { 145 | box-shadow: 146 | 0 0 30px rgba(99, 102, 241, 0.5), 147 | 0 0 60px rgba(139, 92, 246, 0.3), 148 | 0 0 80px rgba(236, 72, 153, 0.2); 149 | } 150 | } 151 | 152 | /* Subtle inner glow for text elements */ 153 | // .ai-processing input, 154 | // .ai-processing textarea, 155 | // .ai-processing [contenteditable] { 156 | // background: linear-gradient(45deg, 157 | // rgba(99, 102, 241, 0.05), 158 | // rgba(139, 92, 246, 0.05), 159 | // rgba(236, 72, 153, 0.05) 160 | // ); 161 | // } 162 | 163 | /* Optional: Add shimmer effect for extra flair */ 164 | .ai-processing-shimmer::before { 165 | background: linear-gradient( 166 | 90deg, 167 | transparent 0%, 168 | rgba(255, 255, 255, 0.4) 50%, 169 | transparent 100% 170 | ); 171 | animation: ai-shimmer 2s ease-in-out infinite; 172 | } 173 | 174 | @keyframes ai-shimmer { 175 | 0% { 176 | transform: translateX(-100%); 177 | } 178 | 100% { 179 | transform: translateX(100%); 180 | } 181 | } 182 | 183 | .writeWithAiTip { 184 | background: -webkit-linear-gradient(135deg, #667eea 0%, #764ba2 100%); 185 | -webkit-background-clip: text; 186 | -webkit-text-fill-color: transparent; 187 | display: inline-flex; 188 | gap: 4px; 189 | align-items: center; 190 | margin: 0 0 15px; 191 | } 192 | .writeWithAiButton { 193 | border: 1px solid #667eea; 194 | border-radius: 10px; 195 | padding: 5px 10px; 196 | font-size: 14px; 197 | font-weight: 500; 198 | cursor: pointer; 199 | white-space: nowrap; 200 | } 201 | ` 202 | -------------------------------------------------------------------------------- /utils/index.ts: -------------------------------------------------------------------------------- 1 | export const STORAGE_CHANGE_EVENT = "customStorageChange" 2 | export const ENCRYPTION_KEY_NAME = "linkedin-copilot-key" // For storing the encryption key 3 | export const ENCRYPTED_API_KEY_NAME = "encrypted-groq-api-key" // For storing the encrypted API key 4 | const dispatchStorageEvent = (key: string, newValue: string | null) => { 5 | window.dispatchEvent( 6 | new CustomEvent(STORAGE_CHANGE_EVENT, { 7 | detail: { key, newValue } 8 | }) 9 | ) 10 | } 11 | 12 | export const loadFromLocalStorage = async ( 13 | key: string, 14 | validator?: (data: unknown) => data is T 15 | ): Promise | null => { 16 | try { 17 | const storedValue = await chrome.storage.local.get(key) 18 | 19 | if (Object.keys(storedValue).length === 0) { 20 | return null 21 | } 22 | 23 | let parsedValue: unknown 24 | try { 25 | parsedValue = JSON.parse(storedValue[key]) 26 | } catch { 27 | parsedValue = storedValue 28 | } 29 | 30 | if (validator && !validator(parsedValue)) { 31 | console.warn(`Invalid data structure for key "${key}"`) 32 | return null 33 | } 34 | 35 | return parsedValue as T 36 | } catch (error) { 37 | console.error("Error loading from storage:", error) 38 | return null 39 | } 40 | } 41 | 42 | export const saveToLocalStorage = async ( 43 | key: string, 44 | value: T, 45 | announce: boolean = false 46 | ): Promise => { 47 | try { 48 | const serializedValue = JSON.stringify(value) 49 | 50 | await chrome.storage.local.set({ [key]: serializedValue }) 51 | if (announce) { 52 | dispatchStorageEvent(key, serializedValue) 53 | } 54 | return true 55 | } catch (error) { 56 | console.error("Error saving to storage:", error) 57 | return false 58 | } 59 | } 60 | export const removeFromLocalStorage = async (key: string) => { 61 | try { 62 | await chrome.storage.local.remove(key) 63 | } catch (error) { 64 | console.error("Error removing from storage:", error) 65 | } 66 | } 67 | export const clearLocalStorage = async () => { 68 | try { 69 | await chrome.storage.local.clear() 70 | } catch (error) { 71 | console.error("Error clearing storage:", error) 72 | } 73 | } 74 | 75 | function bufferToBase64(buffer: Uint8Array): string { 76 | return btoa(String.fromCharCode(...buffer)) 77 | } 78 | 79 | function base64ToBuffer(base64: string): Uint8Array { 80 | const binary = atob(base64) 81 | return new Uint8Array([...binary].map((char) => char.charCodeAt(0))) 82 | } 83 | 84 | export async function getOrCreateKey(): Promise { 85 | const stored = await chrome.storage.local.get(ENCRYPTION_KEY_NAME) 86 | 87 | if (stored[ENCRYPTION_KEY_NAME]) { 88 | try { 89 | const rawKey = base64ToBuffer(stored[ENCRYPTION_KEY_NAME]) 90 | return await crypto.subtle.importKey("raw", rawKey, "AES-GCM", false, [ 91 | "encrypt", 92 | "decrypt" 93 | ]) 94 | } catch (error) { 95 | console.warn("Failed to import stored key, creating new one:", error) 96 | // Clear corrupted key and create new one 97 | await chrome.storage.local.remove(ENCRYPTION_KEY_NAME) 98 | } 99 | } 100 | 101 | // Create new key 102 | const raw = crypto.getRandomValues(new Uint8Array(32)) // 256-bit 103 | await chrome.storage.local.set({ 104 | [ENCRYPTION_KEY_NAME]: bufferToBase64(raw) 105 | }) 106 | 107 | return await crypto.subtle.importKey("raw", raw, "AES-GCM", false, [ 108 | "encrypt", 109 | "decrypt" 110 | ]) 111 | } 112 | 113 | export async function encryptApiKey(apiKey: string): Promise { 114 | try { 115 | const key = await getOrCreateKey() 116 | const iv = crypto.getRandomValues(new Uint8Array(12)) 117 | const encoded = new TextEncoder().encode(apiKey) 118 | 119 | const encrypted = await crypto.subtle.encrypt( 120 | { name: "AES-GCM", iv }, 121 | key, 122 | encoded 123 | ) 124 | 125 | const result = { 126 | iv: bufferToBase64(iv), 127 | data: bufferToBase64(new Uint8Array(encrypted)) 128 | } 129 | 130 | return JSON.stringify(result) 131 | } catch (error) { 132 | console.error("Encryption failed:", error) 133 | throw new Error("Failed to encrypt API key") 134 | } 135 | } 136 | 137 | export async function decryptApiKey(cipherText: string): Promise { 138 | try { 139 | const key = await getOrCreateKey() 140 | 141 | // Add more detailed logging 142 | // console.log("Cipher text received:", cipherText) 143 | 144 | const payload = JSON.parse(cipherText) 145 | // console.log("Parsed payload:", payload) 146 | 147 | // Validate payload structure 148 | if (!payload.iv || !payload.data) { 149 | throw new Error("Invalid cipher text format - missing iv or data") 150 | } 151 | 152 | const iv = base64ToBuffer(payload.iv) 153 | const data = base64ToBuffer(payload.data) 154 | 155 | const decrypted = await crypto.subtle.decrypt( 156 | { name: "AES-GCM", iv }, 157 | key, 158 | data 159 | ) 160 | 161 | const result = new TextDecoder().decode(decrypted) 162 | // console.log("Decryption successful, API key length:", result.length) 163 | return result 164 | } catch (error) { 165 | console.error("Decryption failed:", error) 166 | console.error("Error details:", error.message) 167 | console.error("Stack trace:", error.stack) 168 | 169 | // If decryption fails, it might be a key mismatch - clear and retry once 170 | if ( 171 | error.message.includes("decrypt") || 172 | error.message.includes("OperationError") 173 | ) { 174 | console.warn("Clearing encryption key due to decrypt failure") 175 | await chrome.storage.local.remove(ENCRYPTION_KEY_NAME) 176 | } 177 | 178 | throw new Error(`Failed to decrypt API key: ${error.message}`) 179 | } 180 | } 181 | 182 | // Helper function to save encrypted API key 183 | export async function saveEncryptedApiKey(apiKey: string): Promise { 184 | try { 185 | const encryptedKey = await encryptApiKey(apiKey) 186 | await chrome.storage.local.set({ 187 | [ENCRYPTED_API_KEY_NAME]: encryptedKey 188 | }) 189 | } catch (error) { 190 | console.error("Failed to save encrypted API key:", error) 191 | throw error 192 | } 193 | } 194 | 195 | // Helper function to get decrypted API key 196 | export async function getDecryptedApiKey(): Promise { 197 | try { 198 | const stored = await chrome.storage.local.get(ENCRYPTED_API_KEY_NAME) 199 | if (!stored[ENCRYPTED_API_KEY_NAME]) { 200 | return null 201 | } 202 | 203 | return await decryptApiKey(stored[ENCRYPTED_API_KEY_NAME]) 204 | } catch (error) { 205 | console.error("Failed to get decrypted API key:", error) 206 | return null 207 | } 208 | } 209 | 210 | // Debug function to check storage contents 211 | export async function debugStorage(): Promise { 212 | const allStorage = await chrome.storage.local.get(null) 213 | console.log("All storage contents:", allStorage) 214 | console.log("Encryption key exists:", !!allStorage[ENCRYPTION_KEY_NAME]) 215 | console.log("Encrypted API key exists:", !!allStorage[ENCRYPTED_API_KEY_NAME]) 216 | } 217 | -------------------------------------------------------------------------------- /components/FeedbackModal.tsx: -------------------------------------------------------------------------------- 1 | import { Dialog, DialogPanel, DialogTitle } from "@headlessui/react" 2 | import { useState } from "react" 3 | 4 | interface FeedbackProps { 5 | isOpen: boolean 6 | onClose: () => void 7 | } 8 | 9 | type FeedbackType = "bug" | "feature" | "general" 10 | 11 | const FeedbackModal: React.FC = ({ isOpen, onClose }) => { 12 | const [feedbackType, setFeedbackType] = useState("general") 13 | const [message, setMessage] = useState("") 14 | const [email, setEmail] = useState("") 15 | const [isSubmitting, setIsSubmitting] = useState(false) 16 | const [submitted, setSubmitted] = useState(false) 17 | 18 | const submitFeedback = async (feedbackData: { 19 | type: string 20 | message: string 21 | email?: string 22 | }) => { 23 | const timestamp = new Date().toISOString() 24 | setIsSubmitting(true) 25 | const response = await fetch( 26 | `https://firestore.googleapis.com/v1/projects/${process.env.PLASMO_PUBLIC_FIREBASE_PROJECT_ID}/databases/(default)/documents/${feedbackData.type}?documentId=${encodeURIComponent(timestamp)}&key=${process.env.PLASMO_PUBLIC_FIREBASE_API_KEY}`, 27 | { 28 | method: "POST", 29 | headers: { 30 | "Content-Type": "application/json" 31 | }, 32 | body: JSON.stringify({ 33 | fields: { 34 | type: { stringValue: feedbackData.type }, 35 | message: { stringValue: feedbackData.message }, 36 | email: { stringValue: feedbackData.email || "" }, 37 | timestamp: { timestampValue: new Date().toISOString() }, 38 | version: { stringValue: chrome.runtime.getManifest().version } 39 | } 40 | }) 41 | } 42 | ) 43 | setIsSubmitting(false) 44 | return response.json() 45 | } 46 | 47 | if (submitted) { 48 | return ( 49 | { 52 | setSubmitted(false) 53 | onClose() 54 | }} 55 | className="relative z-50"> 56 |
57 |
58 | 59 |
60 |
61 | 66 | 72 | 73 |
74 |

75 | Thank you! 76 |

77 |

78 | Your feedback has been sent. We appreciate you helping us 79 | improve LinkedIn Copilot. 80 |

81 |
82 |
83 |
84 |
85 | ) 86 | } 87 | 88 | return ( 89 | 90 |
91 |
92 | 93 | 94 | Send Feedback 95 | 96 | 97 |
{ 99 | e.preventDefault() 100 | submitFeedback({ 101 | type: feedbackType, 102 | message: message, 103 | email: email 104 | }).then(() => { 105 | setSubmitted(true) 106 | }) 107 | }} 108 | className="space-y-4"> 109 |
110 | 113 | 123 |
124 | 125 |
126 | 129 |