Feat: browser extension (#27)

This commit is contained in:
Harshith Mullapudi 2025-07-23 09:04:50 +05:30 committed by GitHub
parent 7bf1bd9128
commit a60dc20bf2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 8107 additions and 0 deletions

37
.github/workflows/submit.yml vendored Normal file
View File

@ -0,0 +1,37 @@
name: "Submit to Web Store"
on:
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/extension
steps:
- uses: actions/checkout@v3
- name: Cache pnpm modules
uses: actions/cache@v3
with:
path: ~/.pnpm-store
key: ${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-
- uses: pnpm/action-setup@v2.2.4
with:
version: latest
run_install: true
- name: Use Node.js 16.x
uses: actions/setup-node@v3.4.1
with:
node-version: 16.x
cache: "pnpm"
- name: Build the extension
run: pnpm build
- name: Package the extension into a zip artifact
run: pnpm package
- name: Browser Platform Publish
uses: PlasmoHQ/bpp@v3
with:
keys: ${{ secrets.SUBMIT_KEYS }}
artifact: build/chrome-mv3-prod.zip

33
apps/extension/.gitignore vendored Normal file
View File

@ -0,0 +1,33 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env*.local
out/
build/
dist/
# plasmo
.plasmo
# typescript
.tsbuildinfo

View File

@ -0,0 +1,26 @@
/**
* @type {import('prettier').Options}
*/
export default {
printWidth: 80,
tabWidth: 2,
useTabs: false,
semi: false,
singleQuote: false,
trailingComma: "none",
bracketSpacing: true,
bracketSameLine: true,
plugins: ["@ianvs/prettier-plugin-sort-imports"],
importOrder: [
"<BUILTIN_MODULES>", // Node.js built-in modules
"<THIRD_PARTY_MODULES>", // Imports not matched by other special words or groups.
"", // Empty line
"^@plasmo/(.*)$",
"",
"^@plasmohq/(.*)$",
"",
"^~(.*)$",
"",
"^[./]"
]
}

33
apps/extension/README.md Normal file
View File

@ -0,0 +1,33 @@
This is a [Plasmo extension](https://docs.plasmo.com/) project bootstrapped with [`plasmo init`](https://www.npmjs.com/package/plasmo).
## Getting Started
First, run the development server:
```bash
pnpm dev
# or
npm run dev
```
Open your browser and load the appropriate development build. For example, if you are developing for the chrome browser, using manifest v3, use: `build/chrome-mv3-dev`.
You can start editing the popup by modifying `popup.tsx`. It should auto-update as you make changes. To add an options page, simply add a `options.tsx` file to the root of the project, with a react component default exported. Likewise to add a content page, add a `content.ts` file to the root of the project, importing some module and do some logic, then reload the extension on your browser.
For further guidance, [visit our Documentation](https://docs.plasmo.com/)
## Making production build
Run the following:
```bash
pnpm build
# or
npm run build
```
This should create a production bundle for your extension, ready to be zipped and published to the stores.
## Submit to the webstores
The easiest way to deploy your Plasmo extension is to use the built-in [bpp](https://bpp.browser.market) GitHub action. Prior to using this action however, make sure to build your extension and upload the first version to the store to establish the basic credentials. Then, simply follow [this setup instruction](https://docs.plasmo.com/framework/workflows/submit) and you should be on your way for automated submission!

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

View File

@ -0,0 +1,129 @@
import { useEffect, useState } from "react"
interface InputSuggestionsPopupProps {
facts: string[]
isLoading: boolean
onFactSelect: (fact: string) => void
onClose: () => void
}
export default function InputSuggestionsPopup({
facts,
isLoading,
onFactSelect,
onClose
}: InputSuggestionsPopupProps) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
// Auto-hide after 10 seconds
useEffect(() => {
const timer = setTimeout(() => {
onClose()
}, 10000)
return () => clearTimeout(timer)
}, [onClose])
return (
<div
style={{
background: "white",
border: "1px solid #ddd",
borderRadius: "8px",
padding: "16px",
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
fontFamily: "-apple-system, BlinkMacSystemFont, sans-serif",
fontSize: "14px",
maxWidth: "400px",
maxHeight: "300px",
overflowY: "auto"
}}>
{/* Header */}
<div
style={{
marginBottom: "12px",
fontWeight: "500",
color: "#333",
borderBottom: "1px solid #eee",
paddingBottom: "8px",
display: "flex",
justifyContent: "space-between",
alignItems: "center"
}}>
<span>AI Suggestions</span>
<button
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onClose()
}}
style={{
background: "none",
border: "none",
color: "#6B7280",
cursor: "pointer",
fontSize: "20px",
padding: "0",
width: "20px",
height: "20px",
display: "flex",
alignItems: "center",
justifyContent: "center"
}}>
×
</button>
</div>
{/* Content */}
<div style={{ marginBottom: "12px" }}>
{isLoading ? (
<div
style={{
color: "#666",
fontSize: "13px",
textAlign: "center",
padding: "20px"
}}>
Searching for relevant facts...
</div>
) : facts.length > 0 ? (
facts.slice(0, 5).map((fact, index) => (
<div
key={index}
onClick={() => onFactSelect(fact)}
onMouseEnter={() => setHoveredIndex(index)}
onMouseLeave={() => setHoveredIndex(null)}
style={{
padding: "8px 12px",
margin: "4px 0",
background: hoveredIndex === index ? "#e9ecef" : "#f8f9fa",
borderRadius: "4px",
cursor: "pointer",
border: "1px solid #e9ecef",
transition: "background-color 0.2s"
}}>
<div
style={{
fontSize: "13px",
color: "#333",
lineHeight: "1.4"
}}>
{fact}
</div>
</div>
))
) : (
<div
style={{
color: "#666",
fontSize: "13px",
textAlign: "center",
padding: "20px"
}}>
No relevant suggestions found
</div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,166 @@
import { useEffect, useRef, useState } from "react"
interface TextSelectionPopupProps {
selectedText: string
onSave: (text: string) => Promise<void>
onClose: () => void
isSaving: boolean
saveStatus: "idle" | "saving" | "success" | "error" | "empty"
}
export default function TextSelectionPopup({
selectedText,
onSave,
onClose,
isSaving,
saveStatus
}: TextSelectionPopupProps) {
const [text, setText] = useState(selectedText)
const textareaRef = useRef<HTMLTextAreaElement>(null)
// Focus textarea on mount
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.focus()
textareaRef.current.setSelectionRange(
textareaRef.current.value.length,
textareaRef.current.value.length
)
}
}, [])
const handleSave = async () => {
const textToSave = text.trim()
if (!textToSave) {
return
}
await onSave(textToSave)
}
const getButtonText = () => {
switch (saveStatus) {
case "saving":
return "Saving..."
case "success":
return "Saved!"
case "error":
return "Error"
case "empty":
return "No text to save"
default:
return "Add to Memory"
}
}
const getButtonColor = () => {
switch (saveStatus) {
case "success":
return "#10B981"
case "error":
case "empty":
return "#EF4444"
default:
return "#c15e50"
}
}
return (
<div
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
}}
onMouseDown={(e) => {
e.stopPropagation()
}}
style={{
background: "white",
border: "1px solid #ddd",
borderRadius: "8px",
padding: "16px",
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
fontFamily: "-apple-system, BlinkMacSystemFont, sans-serif",
fontSize: "14px",
width: "350px"
}}>
{/* Header */}
<div
style={{
marginBottom: "12px",
fontWeight: "500",
color: "#333",
borderBottom: "1px solid #eee",
paddingBottom: "8px"
}}>
Add to Core Memory
</div>
{/* Textarea */}
<textarea
ref={textareaRef}
value={text}
onChange={(e) => setText(e.target.value)}
onClick={(e) => {
e.stopPropagation()
}}
onMouseDown={(e) => {
e.stopPropagation()
}}
onFocus={(e) => {
e.stopPropagation()
}}
placeholder="Edit the text you want to save to memory..."
style={{
width: "100%",
minHeight: "80px",
maxHeight: "150px",
resize: "vertical",
border: "none",
outline: "none",
background: "#f8f9fa",
borderRadius: "4px",
padding: "8px",
fontFamily: "-apple-system, BlinkMacSystemFont, sans-serif",
fontSize: "13px",
lineHeight: "1.4",
color: "#333",
marginBottom: "12px",
boxSizing: "border-box"
}}
/>
{/* Buttons */}
<div style={{ display: "flex", gap: "8px" }}>
<button
onClick={handleSave}
disabled={isSaving || !text.trim()}
style={{
flex: 1,
background: getButtonColor(),
color: "white",
border: "none",
padding: "8px 16px",
borderRadius: "4px",
cursor: isSaving || !text.trim() ? "not-allowed" : "pointer",
fontSize: "13px",
opacity: isSaving || !text.trim() ? 0.6 : 1
}}>
{getButtonText()}
</button>
<button
onClick={onClose}
style={{
background: "#6B7280",
color: "white",
border: "none",
padding: "8px 16px",
borderRadius: "4px",
cursor: "pointer",
fontSize: "13px"
}}>
Close
</button>
</div>
</div>
)
}

View File

@ -0,0 +1,260 @@
export interface LogoProps {
width: number
height: number
}
export default function StaticLogo({ width, height }: LogoProps) {
return (
<svg
width={width}
height={height}
viewBox="0 0 282 282"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M80.0827 34.974C92.7457 3.8081 120.792 17.7546 134.476 29.5676C135.325 30.301 135.792 31.3761 135.792 32.4985V250.806C135.792 251.98 135.258 253.117 134.349 253.858C103.339 279.155 85.2835 259.158 80.0827 245.771C44.9187 241.11 43.965 209.932 47.8837 194.925C15.173 187.722 17.5591 152.731 22.841 136.135C9.34813 107.747 33.9141 90.4097 47.8837 85.2899C40.524 50.5456 66.2831 37.2692 80.0827 34.974Z"
stroke="#C15E50"
stroke-width="5"
/>
<line
y1="-2.5"
x2="95.4357"
y2="-2.5"
transform="matrix(0.591888 0.80602 -0.783494 0.6214 77.3574 37.2551)"
stroke="#C15E50"
stroke-width="5"
/>
<path
d="M49.1309 84.7972L136.212 176.505"
stroke="#C15E50"
stroke-width="5"
/>
<line
y1="-2.5"
x2="90.8224"
y2="-2.5"
transform="matrix(0.814972 0.5795 -0.549892 0.835235 32.5566 143.53)"
stroke="#C15E50"
stroke-width="5"
/>
<line
y1="-2.5"
x2="146.141"
y2="-2.5"
transform="matrix(0.686415 -0.72721 0.700262 0.713886 35.4785 139.482)"
stroke="#C15E50"
stroke-width="5"
/>
<line
y1="-2.5"
x2="77.4689"
y2="-2.5"
transform="matrix(0.528012 -0.849237 0.830187 0.557485 49.1133 196.162)"
stroke="#C15E50"
stroke-width="5"
/>
<line
y1="-2.5"
x2="111.328"
y2="-2.5"
transform="matrix(-0.979793 0.200014 -0.185721 -0.982602 135.791 117.215)"
stroke="#C15E50"
stroke-width="5"
/>
<line
y1="-2.5"
x2="58.5744"
y2="-2.5"
transform="matrix(0.532065 -0.846704 0.827428 0.561572 81.252 246.769)"
stroke="#C15E50"
stroke-width="5"
/>
<line
y1="-2.5"
x2="64.5426"
y2="-2.5"
transform="matrix(-0.503861 -0.863784 0.846088 -0.533044 137.443 252.884)"
stroke="#C15E50"
stroke-width="5"
/>
<line
y1="-2.5"
x2="45.7129"
y2="-2.5"
transform="matrix(-0.0852204 0.996362 -0.99576 -0.0919863 110.471 150.615)"
stroke="#C15E50"
stroke-width="5"
/>
<line
y1="-2.5"
x2="87.7453"
y2="-2.5"
transform="matrix(0.998935 -0.0461398 0.0427267 0.999087 49.1133 198.186)"
stroke="#C15E50"
stroke-width="5"
/>
<line
y1="-2.5"
x2="64.6589"
y2="-2.5"
transform="matrix(-0.165686 0.986178 -0.983932 -0.178541 100.73 66.6073)"
stroke="#C15E50"
stroke-width="5"
/>
<circle cx="103.955" cy="66.3968" r="11.0444" fill="#C15E50" />
<circle cx="90.4996" cy="129.403" r="11.5465" fill="#C15E50" />
<circle cx="103.955" cy="197.449" r="11.0444" fill="#C15E50" />
<circle cx="48.4992" cy="86.0547" r="8.53434" fill="#C15E50" />
<path
d="M86.0294 34.6113C86.0294 38.7701 82.6579 42.1416 78.4991 42.1416C74.3402 42.1416 70.9688 38.7701 70.9688 34.6113C70.9688 30.4524 74.3402 27.081 78.4991 27.081C82.6579 27.081 86.0294 30.4524 86.0294 34.6113Z"
fill="#C15E50"
/>
<circle cx="29.0525" cy="140.996" r="13.0525" fill="#C15E50" />
<circle cx="79.0009" cy="246.875" r="7.02828" fill="#C15E50" />
<path
d="M53.0314 195.433C53.0314 199.869 49.4352 203.466 44.9991 203.466C40.563 203.466 36.9668 199.869 36.9668 195.433C36.9668 190.997 40.563 187.401 44.9991 187.401C49.4352 187.401 53.0314 190.997 53.0314 195.433Z"
fill="#C15E50"
/>
<path
d="M202.806 247.026C190.143 278.192 162.097 264.245 148.413 252.432C147.563 251.699 147.097 250.624 147.097 249.501V31.1935C147.097 30.0203 147.631 28.8833 148.54 28.1417C179.549 2.84476 197.605 22.8418 202.806 36.2294C237.97 40.8903 238.924 72.0684 235.005 87.0748C267.716 94.2779 265.33 129.269 260.048 145.865C273.541 174.253 248.975 191.59 235.005 196.71C242.365 231.454 216.606 244.731 202.806 247.026Z"
stroke="#C15E50"
stroke-width="5"
/>
<line
y1="-2.5"
x2="95.4357"
y2="-2.5"
transform="matrix(-0.591888 -0.80602 0.783494 -0.6214 205.531 244.745)"
stroke="#C15E50"
stroke-width="5"
/>
<path
d="M233.758 197.203L146.677 105.495"
stroke="#C15E50"
stroke-width="5"
/>
<line
y1="-2.5"
x2="90.8224"
y2="-2.5"
transform="matrix(-0.814972 -0.5795 0.549892 -0.835235 250.332 138.47)"
stroke="#C15E50"
stroke-width="5"
/>
<line
y1="-2.5"
x2="146.141"
y2="-2.5"
transform="matrix(-0.686415 0.72721 -0.700262 -0.713886 247.41 142.518)"
stroke="#C15E50"
stroke-width="5"
/>
<line
y1="-2.5"
x2="77.4689"
y2="-2.5"
transform="matrix(-0.528012 0.849237 -0.830187 -0.557485 233.775 85.838)"
stroke="#C15E50"
stroke-width="5"
/>
<line
y1="-2.5"
x2="111.328"
y2="-2.5"
transform="matrix(0.979793 -0.200014 0.185721 0.982602 147.098 164.785)"
stroke="#C15E50"
stroke-width="5"
/>
<line
y1="-2.5"
x2="58.5744"
y2="-2.5"
transform="matrix(-0.532065 0.846704 -0.827428 -0.561572 201.637 35.2307)"
stroke="#C15E50"
stroke-width="5"
/>
<line
y1="-2.5"
x2="64.5426"
y2="-2.5"
transform="matrix(0.503861 0.863784 -0.846088 0.533044 145.445 29.1161)"
stroke="#C15E50"
stroke-width="5"
/>
<line
y1="-2.5"
x2="45.7129"
y2="-2.5"
transform="matrix(0.0852204 -0.996362 0.99576 0.0919863 172.418 131.385)"
stroke="#C15E50"
stroke-width="5"
/>
<line
y1="-2.5"
x2="87.7453"
y2="-2.5"
transform="matrix(-0.998935 0.0461398 -0.0427267 -0.999087 233.775 83.8137)"
stroke="#C15E50"
stroke-width="5"
/>
<line
y1="-2.5"
x2="64.6589"
y2="-2.5"
transform="matrix(0.165686 -0.986178 0.983932 0.178541 182.158 215.393)"
stroke="#C15E50"
stroke-width="5"
/>
<circle
cx="178.934"
cy="215.603"
r="11.0444"
transform="rotate(180 178.934 215.603)"
fill="#C15E50"
/>
<circle
cx="192.389"
cy="152.597"
r="11.5465"
transform="rotate(180 192.389 152.597)"
fill="#C15E50"
/>
<circle
cx="178.934"
cy="84.5506"
r="11.0444"
transform="rotate(180 178.934 84.5506)"
fill="#C15E50"
/>
<circle
cx="234.389"
cy="195.945"
r="8.53434"
transform="rotate(180 234.389 195.945)"
fill="#C15E50"
/>
<path
d="M196.859 247.389C196.859 243.23 200.231 239.858 204.39 239.858C208.548 239.858 211.92 243.23 211.92 247.389C211.92 251.548 208.548 254.919 204.39 254.919C200.231 254.919 196.859 251.548 196.859 247.389Z"
fill="#C15E50"
/>
<circle
cx="253.836"
cy="141.004"
r="13.0525"
transform="rotate(180 253.836 141.004)"
fill="#C15E50"
/>
<circle
cx="203.888"
cy="35.1255"
r="7.02828"
transform="rotate(180 203.888 35.1255)"
fill="#C15E50"
/>
<path
d="M229.857 86.5668C229.857 82.1307 233.453 78.5345 237.89 78.5345C242.326 78.5345 245.922 82.1307 245.922 86.5668C245.922 91.0029 242.326 94.5991 237.89 94.5991C233.453 94.5991 229.857 91.0029 229.857 86.5668Z"
fill="#C15E50"
/>
</svg>
)
}

View File

@ -0,0 +1,309 @@
import { createElement } from "react"
import { createRoot, type Root } from "react-dom/client"
import InputSuggestionsPopup from "~components/InputSuggestionsPopup"
import { searchFacts } from "~utils/api"
import { isExtensionContextValid } from "~utils/context"
let inputDots: Map<HTMLElement, HTMLDivElement> = new Map()
let inputPopups: Map<HTMLElement, HTMLDivElement> = new Map()
let reactRoots: Map<HTMLElement, Root> = new Map()
let currentUrl = window.location.href
// Track page navigation and reset popups
function checkPageNavigation() {
if (window.location.href !== currentUrl) {
console.log("Page navigation detected, resetting input popups")
currentUrl = window.location.href
clearAllInputElements()
}
}
// Input field monitoring
function createInputDot(input: HTMLElement, rect: DOMRect) {
if (inputDots.has(input)) return
const dot = document.createElement("div")
dot.style.cssText = `
position: absolute;
width: 54px;
height: 20px;
cursor: pointer;
z-index: 10001;
opacity: 0;
transition: opacity 0.2s ease;
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2));
background: #c15e50;
border-radius: 4px;
padding: 3px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
`
// Add the logo SVG with white color and Core text - smaller version for input fields
dot.innerHTML = `
<div style="display: flex; flex-direction: row; align-items: center; gap: 5px;">
<svg width="16" height="16" viewBox="0 0 282 282" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M80.0827 34.974C92.7457 3.8081 120.792 17.7546 134.476 29.5676C135.325 30.301 135.792 31.3761 135.792 32.4985V250.806C135.792 251.98 135.258 253.117 134.349 253.858C103.339 279.155 85.2835 259.158 80.0827 245.771C44.9187 241.11 43.965 209.932 47.8837 194.925C15.173 187.722 17.5591 152.731 22.841 136.135C9.34813 107.747 33.9141 90.4097 47.8837 85.2899C40.524 50.5456 66.2831 37.2692 80.0827 34.974Z" stroke="white" stroke-width="5"/>
<circle cx="103.955" cy="66.3968" r="11.0444" fill="white"/>
<circle cx="90.4996" cy="129.403" r="11.5465" fill="white"/>
<circle cx="103.955" cy="197.449" r="11.0444" fill="white"/>
<circle cx="48.4992" cy="86.0547" r="8.53434" fill="white"/>
<path d="M202.806 247.026C190.143 278.192 162.097 264.245 148.413 252.432C147.563 251.699 147.097 250.624 147.097 249.501V31.1935C147.097 30.0203 147.631 28.8833 148.54 28.1417C179.549 2.84476 197.605 22.8418 202.806 36.2294C237.97 40.8903 238.924 72.0684 235.005 87.0748C267.716 94.2779 265.33 129.269 260.048 145.865C273.541 174.253 248.975 191.59 235.005 196.71C242.365 231.454 216.606 244.731 202.806 247.026Z" stroke="white" stroke-width="5"/>
<circle cx="178.934" cy="215.603" r="11.0444" transform="rotate(180 178.934 215.603)" fill="white"/>
<circle cx="192.389" cy="152.597" r="11.5465" transform="rotate(180 192.389 152.597)" fill="white"/>
<circle cx="178.934" cy="84.5506" r="11.0444" transform="rotate(180 178.934 84.5506)" fill="white"/>
<circle cx="234.389" cy="195.945" r="8.53434" transform="rotate(180 234.389 195.945)" fill="white"/>
</svg>
<div style="
color: white;
font-size: 8px;
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
font-weight: 600;
margin-right: 4px;
text-align: center;
line-height: 1;
">Core</div>
</div>
`
dot.style.top = `${rect.top + window.scrollY + 2}px`
dot.style.left = `${rect.right + window.scrollX - 24}px`
dot.addEventListener("click", () => showInputPopup(input))
dot.addEventListener("mouseenter", () => showInputPopup(input))
document.body.appendChild(dot)
inputDots.set(input, dot)
setTimeout(() => {
dot.style.opacity = "1"
}, 10)
}
// Facts popup for input fields with React
async function showInputPopup(input: HTMLElement) {
if (!isExtensionContextValid()) {
console.log("Extension context invalid, skipping input popup")
return
}
// Get value from input element, handling both input/textarea and contenteditable
let query = ""
if ("value" in input && (input as HTMLInputElement).value !== undefined) {
query = (input as HTMLInputElement).value.trim()
} else {
query = input.textContent?.trim() || ""
}
if (!query || inputPopups.has(input)) return
// Create popup container
const popup = document.createElement("div")
popup.style.cssText = `
position: fixed;
z-index: 10002;
opacity: 0;
transition: opacity 0.2s ease;
`
// Position popup
const rect = input.getBoundingClientRect()
let top = rect.bottom + 5
let left = rect.left
if (left + 400 > window.innerWidth) {
left = window.innerWidth - 410
}
if (left < 10) {
left = 10
}
if (top + 300 > window.innerHeight) {
top = rect.top - 310
}
popup.style.top = `${top}px`
popup.style.left = `${left}px`
document.body.appendChild(popup)
inputPopups.set(input, popup)
// Create React root
const root = createRoot(popup)
reactRoots.set(input, root)
// Handle fact selection
const handleFactSelect = (factText: string) => {
if (input) {
// Handle both input/textarea and contenteditable elements
if ("value" in input && (input as HTMLInputElement).value !== undefined) {
const inputEl = input as HTMLInputElement
inputEl.value += (inputEl.value ? " " : "") + factText
inputEl.dispatchEvent(new Event("input", { bubbles: true }))
} else {
// For contenteditable elements
const currentText = input.textContent || ""
input.textContent = currentText + (currentText ? " " : "") + factText
input.dispatchEvent(new Event("input", { bubbles: true }))
}
removeInputPopup(input)
}
}
// Handle popup close
const handleClose = () => {
removeInputPopup(input)
}
// Render loading state initially
root.render(
createElement(InputSuggestionsPopup, {
facts: [],
isLoading: true,
onFactSelect: handleFactSelect,
onClose: handleClose
})
)
// Show popup
setTimeout(() => {
popup.style.opacity = "1"
}, 10)
// Search for facts
try {
const searchResult = await searchFacts(query)
const facts = searchResult?.facts || []
// Update with search results
root.render(
createElement(InputSuggestionsPopup, {
facts,
isLoading: false,
onFactSelect: handleFactSelect,
onClose: handleClose
})
)
} catch (error) {
console.error("Error searching facts:", error)
// Show error state
root.render(
createElement(InputSuggestionsPopup, {
facts: [],
isLoading: false,
onFactSelect: handleFactSelect,
onClose: handleClose
})
)
}
}
// Input field monitoring
export function checkInputFields() {
// Check for page navigation first
checkPageNavigation()
const inputs = document.querySelectorAll(
"input[type='text'], input[type='search'], input[type='email'], input[type='url'], textarea, [contenteditable='true']"
)
inputs.forEach((input) => {
const element = input as HTMLElement
const inputElement = element as HTMLInputElement | HTMLTextAreaElement
// Skip Core extension popup textarea
if (
element.id === "memory-text" ||
element.closest('[style*="z-index: 10002"]')
) {
return
}
let value = ""
if (inputElement.value !== undefined) {
value = inputElement.value.trim()
} else {
value = element.textContent?.trim() || ""
}
const words = value.split(/\s+/).filter((word) => word.length > 0)
if (words.length > 1) {
const rect = element.getBoundingClientRect()
if (rect.width > 0 && rect.height > 0) {
createInputDot(element, rect)
}
} else {
removeInputDot(element)
removeInputPopup(element)
}
})
}
// Cleanup functions
function removeInputDot(input: HTMLElement) {
const dot = inputDots.get(input)
if (dot) {
dot.remove()
inputDots.delete(input)
}
}
export function removeInputPopup(input: HTMLElement) {
// Clean up React root first
const root = reactRoots.get(input)
if (root) {
root.unmount()
reactRoots.delete(input)
}
// Remove DOM element
const popup = inputPopups.get(input)
if (popup) {
popup.remove()
inputPopups.delete(input)
}
}
export function clearAllInputElements() {
// Clean up React roots
reactRoots.forEach((root) => root.unmount())
reactRoots.clear()
// Clean up DOM elements
inputDots.forEach((dot) => dot.remove())
inputPopups.forEach((popup) => popup.remove())
inputDots.clear()
inputPopups.clear()
}
// Check if click is on input elements
export function isInputElement(target: HTMLElement): boolean {
// Check if target is within any popup or dot
for (const popup of inputPopups.values()) {
if (popup.contains(target)) return true
}
for (const dot of inputDots.values()) {
if (dot.contains(target)) return true
}
return false
}
// Handle input popup click outside
export function handleInputClickOutside(target: HTMLElement) {
inputPopups.forEach((popup, input) => {
if (!popup.contains(target) && target !== input) {
removeInputPopup(input)
}
})
}
// Close all input popups
export function closeAllInputPopups() {
inputPopups.forEach((popup, input) => {
removeInputPopup(input)
})
}

View File

@ -0,0 +1,83 @@
import type { PlasmoCSConfig } from "plasmo"
import {
checkInputFields,
clearAllInputElements,
closeAllInputPopups,
handleInputClickOutside,
isInputElement
} from "~contents/input-suggestions"
import {
handleSelectionClickOutside,
handleTextSelection,
isSelectionElement,
removeSelectionElements
} from "~contents/text-selection"
import { setupContextMonitoring } from "~utils/context"
export const config: PlasmoCSConfig = {
matches: ["<all_urls>"]
}
// Global cleanup function
function globalCleanup() {
console.log("Performing global cleanup...")
removeSelectionElements()
clearAllInputElements()
}
// Event handlers
function initializeExtension() {
try {
console.log("Core extension initialized")
// Setup context monitoring with cleanup
const stopMonitoring = setupContextMonitoring(globalCleanup)
// Text selection handler
document.addEventListener("mouseup", handleTextSelection)
// Keyboard event handlers
document.addEventListener("keyup", (e) => {
if (e.key === "Escape") {
removeSelectionElements()
closeAllInputPopups()
}
})
// Monitor input fields
setInterval(checkInputFields, 1000)
// Initial check
setTimeout(checkInputFields, 1000)
// Click outside to close popups
document.addEventListener("click", (e) => {
const target = e.target as HTMLElement
// Don't close if clicking on extension elements
if (isSelectionElement(target) || isInputElement(target)) {
return
}
// Handle click outside for both features
handleSelectionClickOutside(target)
handleInputClickOutside(target)
})
// Cleanup on page unload
window.addEventListener("beforeunload", () => {
stopMonitoring()
globalCleanup()
})
} catch (error) {
console.error("Extension initialization error:", error)
}
}
// Initialize when DOM is ready
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initializeExtension)
} else {
initializeExtension()
}

View File

@ -0,0 +1,340 @@
import { addEpisode } from "~utils/api"
import { isExtensionContextValid } from "~utils/context"
import { createRoot, type Root } from "react-dom/client"
import { createElement } from "react"
import TextSelectionPopup from "~components/TextSelectionPopup"
let selectionDot: HTMLDivElement | null = null
let selectionPopup: HTMLDivElement | null = null
let selectionRoot: Root | null = null
let autoHideTimer: NodeJS.Timeout | null = null
let isHoveringElements = false
// Logo dot for text selection
function createSelectionDot(rect: DOMRect) {
removeSelectionElements()
selectionDot = document.createElement("div")
selectionDot.style.cssText = `
position: absolute;
width: 54px;
height: 20px;
cursor: pointer;
z-index: 10001;
opacity: 0;
transition: opacity 0.2s ease;
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2));
background: #c15e50;
border-radius: 6px;
padding: 4px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
`
// Add the logo SVG with white color and Core text
selectionDot.innerHTML = `
<div style="display: flex; flex-direction: row; align-items: center; gap: 5px;">
<svg width="16" height="16" viewBox="0 0 282 282" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M80.0827 34.974C92.7457 3.8081 120.792 17.7546 134.476 29.5676C135.325 30.301 135.792 31.3761 135.792 32.4985V250.806C135.792 251.98 135.258 253.117 134.349 253.858C103.339 279.155 85.2835 259.158 80.0827 245.771C44.9187 241.11 43.965 209.932 47.8837 194.925C15.173 187.722 17.5591 152.731 22.841 136.135C9.34813 107.747 33.9141 90.4097 47.8837 85.2899C40.524 50.5456 66.2831 37.2692 80.0827 34.974Z" stroke="white" stroke-width="5"/>
<circle cx="103.955" cy="66.3968" r="11.0444" fill="white"/>
<circle cx="90.4996" cy="129.403" r="11.5465" fill="white"/>
<circle cx="103.955" cy="197.449" r="11.0444" fill="white"/>
<circle cx="48.4992" cy="86.0547" r="8.53434" fill="white"/>
<path d="M202.806 247.026C190.143 278.192 162.097 264.245 148.413 252.432C147.563 251.699 147.097 250.624 147.097 249.501V31.1935C147.097 30.0203 147.631 28.8833 148.54 28.1417C179.549 2.84476 197.605 22.8418 202.806 36.2294C237.97 40.8903 238.924 72.0684 235.005 87.0748C267.716 94.2779 265.33 129.269 260.048 145.865C273.541 174.253 248.975 191.59 235.005 196.71C242.365 231.454 216.606 244.731 202.806 247.026Z" stroke="white" stroke-width="5"/>
<circle cx="178.934" cy="215.603" r="11.0444" transform="rotate(180 178.934 215.603)" fill="white"/>
<circle cx="192.389" cy="152.597" r="11.5465" transform="rotate(180 192.389 152.597)" fill="white"/>
<circle cx="178.934" cy="84.5506" r="11.0444" transform="rotate(180 178.934 84.5506)" fill="white"/>
<circle cx="234.389" cy="195.945" r="8.53434" transform="rotate(180 234.389 195.945)" fill="white"/>
</svg>
<div style="
color: white;
font-size: 8px;
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
font-weight: 600;
margin-right: 4px;
text-align: center;
line-height: 1;
">Core</div>
</div>
`
const top = rect.bottom + window.scrollY + 5
const left = Math.min(
rect.right + window.scrollX - 16,
window.innerWidth - 40
)
selectionDot.style.top = `${top}px`
selectionDot.style.left = `${left}px`
selectionDot.addEventListener("click", showSelectionPopup)
selectionDot.addEventListener("mouseenter", showSelectionPopup)
document.body.appendChild(selectionDot)
setTimeout(() => {
if (selectionDot) {
selectionDot.style.opacity = "1"
}
}, 10)
// Start auto-hide timer
startAutoHideTimer(8000) // Longer initial timer
// Handle hover events
selectionDot.addEventListener("mouseenter", () => {
isHoveringElements = true
clearAutoHideTimer()
})
selectionDot.addEventListener("mouseleave", () => {
isHoveringElements = false
if (!selectionPopup) {
startAutoHideTimer(3000) // Shorter timer when leaving dot
}
})
}
// Timer management functions
function startAutoHideTimer(delay: number) {
clearAutoHideTimer()
autoHideTimer = setTimeout(() => {
if (!isHoveringElements) {
removeSelectionElements()
}
}, delay)
}
function clearAutoHideTimer() {
if (autoHideTimer) {
clearTimeout(autoHideTimer)
autoHideTimer = null
}
}
// Popup for text selection with React
function showSelectionPopup() {
const selectedText = window.getSelection()?.toString().trim()
if (!selectedText || !selectionDot) return
removeSelectionPopup()
// Create popup container
selectionPopup = document.createElement("div")
selectionPopup.style.cssText = `
position: fixed;
z-index: 10002;
opacity: 0;
transition: opacity 0.2s ease;
`
// Position popup
const dotRect = selectionDot.getBoundingClientRect()
let top = dotRect.bottom + 5
let left = dotRect.left
// Keep popup within viewport bounds
if (left + 350 > window.innerWidth) {
left = window.innerWidth - 360
}
if (left < 10) {
left = 10
}
// If popup would go below viewport, show above the dot
if (top + 250 > window.innerHeight) {
top = dotRect.top - 255
}
// Ensure popup doesn't go above viewport
if (top < 10) {
top = 10
}
selectionPopup.style.top = `${top}px`
selectionPopup.style.left = `${left}px`
document.body.appendChild(selectionPopup)
// Handle popup hover events
selectionPopup.addEventListener("mouseenter", () => {
isHoveringElements = true
clearAutoHideTimer()
})
selectionPopup.addEventListener("mouseleave", () => {
isHoveringElements = false
startAutoHideTimer(5000) // Auto-hide after leaving popup
})
// Prevent popup from closing when clicking inside it
selectionPopup.addEventListener("click", (e) => {
e.stopPropagation()
})
// Create React root
selectionRoot = createRoot(selectionPopup)
// State management for the component
let saveStatus: 'idle' | 'saving' | 'success' | 'error' | 'empty' = 'idle'
let isSaving = false
// Handle save
const handleSave = async (text: string) => {
if (!isExtensionContextValid()) {
saveStatus = 'error'
renderPopup()
return
}
if (!text.trim()) {
saveStatus = 'empty'
renderPopup()
setTimeout(() => {
saveStatus = 'idle'
renderPopup()
}, 2000)
return
}
isSaving = true
saveStatus = 'saving'
renderPopup()
try {
const success = await addEpisode(text)
if (success) {
saveStatus = 'success'
renderPopup()
setTimeout(() => removeSelectionElements(), 1500)
} else {
saveStatus = 'error'
renderPopup()
setTimeout(() => {
saveStatus = 'idle'
isSaving = false
renderPopup()
}, 2000)
}
} catch (error) {
saveStatus = 'error'
renderPopup()
setTimeout(() => {
saveStatus = 'idle'
isSaving = false
renderPopup()
}, 2000)
}
}
// Handle close
const handleClose = () => {
removeSelectionElements()
}
// Render function
const renderPopup = () => {
if (selectionRoot) {
selectionRoot.render(
createElement(TextSelectionPopup, {
selectedText,
onSave: handleSave,
onClose: handleClose,
isSaving,
saveStatus
})
)
}
}
// Initial render
renderPopup()
// Show popup
setTimeout(() => {
if (selectionPopup) {
selectionPopup.style.opacity = "1"
}
}, 10)
}
// Text selection handler
export function handleTextSelection() {
const selection = window.getSelection()
const selectedText = selection?.toString().trim()
if (
selectedText &&
selectedText.length > 0 &&
selection &&
selection.rangeCount > 0
) {
const range = selection.getRangeAt(0)
const rect = range.getBoundingClientRect()
createSelectionDot(rect)
} else {
removeSelectionElements()
}
}
// Cleanup functions
export function removeSelectionElements() {
clearAutoHideTimer()
isHoveringElements = false
if (selectionDot) {
selectionDot.remove()
selectionDot = null
}
removeSelectionPopup()
}
function removeSelectionPopup() {
// Clean up React root first
if (selectionRoot) {
selectionRoot.unmount()
selectionRoot = null
}
// Remove DOM element
if (selectionPopup) {
selectionPopup.remove()
selectionPopup = null
}
}
// Check if click is on selection elements
export function isSelectionElement(target: HTMLElement): boolean {
// Check if clicking on the dot
if (selectionDot && selectionDot.contains(target)) {
return true
}
// Check if clicking on the popup (including textarea and buttons)
if (selectionPopup && selectionPopup.contains(target)) {
return true
}
// Check if clicking on specific popup elements by ID
if (target.id === 'memory-text' || target.id === 'save-btn' || target.id === 'close-btn') {
return true
}
// Check if target is within any element with our popup z-index
if (target.closest('[style*="z-index: 10002"]')) {
return true
}
return false
}
// Close selection popup if clicking elsewhere
export function handleSelectionClickOutside(target: HTMLElement) {
// Only close if we're not clicking on any selection elements
if (selectionPopup && !isSelectionElement(target)) {
removeSelectionElements()
}
}

View File

@ -0,0 +1,35 @@
{
"name": "core-extension",
"displayName": "Core extension",
"version": "0.0.1",
"description": "Core memory extension ",
"author": "Redplanethq (harshith@tegon.ai)",
"scripts": {
"dev": "plasmo dev",
"build": "plasmo build",
"package": "plasmo package"
},
"dependencies": {
"plasmo": "0.90.5",
"@plasmohq/storage": "1.15.0",
"react": "18.2.0",
"react-dom": "18.2.0"
},
"devDependencies": {
"@ianvs/prettier-plugin-sort-imports": "4.1.1",
"@types/chrome": "0.0.258",
"@types/node": "20.11.5",
"@types/react": "18.2.48",
"@types/react-dom": "18.2.18",
"prettier": "3.2.4",
"typescript": "5.3.3"
},
"manifest": {
"host_permissions": [
"https://*/*"
],
"permissions": [
"storage"
]
}
}

6331
apps/extension/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

151
apps/extension/popup.tsx Normal file
View File

@ -0,0 +1,151 @@
import { useState } from "react"
import { useStorage } from "@plasmohq/storage/hook"
function IndexPopup() {
// useStorage returns [value, setValue, { remove }]
const [apiKey, setApiKey, { remove }] = useStorage<string>("core_api_key")
const [inputValue, setInputValue] = useState(apiKey ?? "")
const [isLoading, setIsLoading] = useState(false)
const [message, setMessage] = useState("")
// Keep inputValue in sync with apiKey if apiKey changes externally
// (e.g. from another tab)
// This is optional, but helps UX
if (apiKey !== undefined && inputValue !== apiKey) {
setInputValue(apiKey)
}
const saveApiKey = async () => {
if (!inputValue.trim()) {
setMessage("Please enter an API key")
return
}
setIsLoading(true)
try {
await setApiKey(inputValue.trim())
setMessage("API key saved successfully!")
setTimeout(() => setMessage(""), 3000)
} catch (error) {
setMessage("Failed to save API key")
console.error("Save error:", error)
} finally {
setIsLoading(false)
}
}
const clearApiKey = async () => {
try {
await remove()
setInputValue("")
setMessage("API key cleared")
setTimeout(() => setMessage(""), 3000)
} catch (error) {
setMessage("Failed to clear API key")
console.error("Clear error:", error)
}
}
return (
<div style={{ padding: 20, minWidth: 300 }}>
<h2 style={{ margin: "0 0 16px 0", fontSize: 18, color: "#333" }}>
Core Memory Extension
</h2>
<div style={{ marginBottom: 16 }}>
<label
style={{
display: "block",
marginBottom: 8,
fontSize: 14,
color: "#555"
}}>
Core API Key:
</label>
<input
type="password"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="Enter your Core API key"
style={{
width: "100%",
padding: "8px 12px",
border: "1px solid #ddd",
borderRadius: 4,
fontSize: 14,
marginBottom: 8
}}
/>
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
<button
onClick={saveApiKey}
disabled={isLoading}
style={{
flex: 1,
padding: "8px 16px",
background: "#c15e50",
color: "white",
border: "none",
borderRadius: 4,
cursor: isLoading ? "not-allowed" : "pointer",
fontSize: 14,
opacity: isLoading ? 0.6 : 1
}}>
{isLoading ? "Saving..." : "Save"}
</button>
{apiKey && (
<button
onClick={clearApiKey}
style={{
padding: "8px 16px",
background: "#EF4444",
color: "white",
border: "none",
borderRadius: 4,
cursor: "pointer",
fontSize: 14
}}>
Clear
</button>
)}
</div>
{message && (
<div
style={{
padding: 8,
borderRadius: 4,
fontSize: 13,
backgroundColor: message.includes("success")
? "#10B981"
: "#EF4444",
color: "white",
marginBottom: 12
}}>
{message}
</div>
)}
{apiKey && (
<div style={{ fontSize: 12, color: "#10B981", marginBottom: 16 }}>
API key configured
</div>
)}
</div>
<div style={{ fontSize: 13, color: "#666", lineHeight: 1.4 }}>
<p style={{ margin: "0 0 8px 0" }}>
<strong>How to use:</strong>
</p>
<ul style={{ margin: 0, paddingLeft: 16 }}>
<li>Select text on any page to save to Core</li>
<li>Look for red dots in input fields for AI suggestions</li>
</ul>
</div>
</div>
)
}
export default IndexPopup

View File

@ -0,0 +1,19 @@
{
"extends": "plasmo/templates/tsconfig.base",
"exclude": [
"node_modules"
],
"include": [
".plasmo/index.d.ts",
"./**/*.ts",
"./**/*.tsx"
],
"compilerOptions": {
"paths": {
"~*": [
"./*"
]
},
"baseUrl": "."
}
}

View File

@ -0,0 +1,96 @@
import { Storage } from "@plasmohq/storage"
import { isExtensionContextValid, withValidContext } from "./context"
const storage = new Storage()
const API_BASE_URL = "https://core.heysol.ai/api/v1"
export interface CoreEpisode {
episodeBody: string
referenceTime: Date
source: string
}
export interface CoreSearchQuery {
query: string
}
export interface CoreSearchResponse {
facts: string[]
episodes: CoreEpisode[]
}
async function getApiKey(): Promise<string | null> {
if (!isExtensionContextValid()) {
console.log("Extension context invalid, cannot get API key")
return null
}
return withValidContext(async () => {
return await storage.get("core_api_key")
}, null)
}
export async function addEpisode(episodeBody: string): Promise<boolean> {
const apiKey = await getApiKey()
if (!apiKey) {
console.error("No API key found")
return false
}
try {
const response = await fetch(`${API_BASE_URL}/add`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`
},
body: JSON.stringify({
episodeBody,
referenceTime: new Date(),
source: "Core extension"
})
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
return true
} catch (error) {
console.error("Failed to add episode:", error)
return false
}
}
export async function searchFacts(
query: string
): Promise<CoreSearchResponse | null> {
const apiKey = await getApiKey()
if (!apiKey) {
console.error("No API key found")
return null
}
try {
const response = await fetch(`${API_BASE_URL}/search`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`
},
body: JSON.stringify({ query })
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const responseJson = await response.json()
return responseJson
} catch (error) {
console.error("Failed to search facts:", error)
return null
}
}

View File

@ -0,0 +1,59 @@
// Utility to check if extension context is still valid
export function isExtensionContextValid(): boolean {
try {
// Try to access chrome.runtime - this will throw if context is invalidated
return !!chrome?.runtime?.id
} catch (error) {
console.log("Extension context invalidated")
return false
}
}
// Wrapper for operations that require valid extension context
export async function withValidContext<T>(
operation: () => Promise<T>,
fallback?: T
): Promise<T | undefined> {
if (!isExtensionContextValid()) {
console.log("Skipping operation - extension context invalid")
return fallback
}
try {
return await operation()
} catch (error) {
if (error.message?.includes("Extension context invalidated")) {
console.log("Extension context invalidated during operation")
return fallback
}
throw error
}
}
// Graceful cleanup when context becomes invalid
export function setupContextMonitoring(cleanupCallback: () => void) {
// Check context periodically
const checkInterval = setInterval(() => {
if (!isExtensionContextValid()) {
console.log("Extension context lost, cleaning up...")
cleanupCallback()
clearInterval(checkInterval)
}
}, 5000)
// Listen for disconnect events
try {
chrome.runtime?.onConnect?.addListener((port) => {
port.onDisconnect.addListener(() => {
if (chrome.runtime.lastError) {
console.log("Port disconnected, cleaning up...")
cleanupCallback()
}
})
})
} catch (error) {
// Ignore if chrome.runtime is not available
}
return () => clearInterval(checkInterval)
}