aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/manifest.json12
-rw-r--r--src/options/options.html58
-rw-r--r--src/popup/popup.html92
-rw-r--r--src/popup/popup.ts96
4 files changed, 209 insertions, 49 deletions
diff --git a/src/manifest.json b/src/manifest.json
index fb7c621..b0b4635 100644
--- a/src/manifest.json
+++ b/src/manifest.json
@@ -15,9 +15,19 @@
"background.js"
]
},
+ "icons": {
+ "16": "icons/16x16.png",
+ "32": "icons/32x32.png",
+ "48": "icons/48x48.png",
+ "128": "icons/128x128.png"
+ },
"compose_action": {
"default_title": "Write with AI",
- "default_popup": "popup/popup.html"
+ "default_popup": "popup/popup.html",
+ "default_icon": {
+ "16": "icons/16x16.png",
+ "32": "icons/32x32.png"
+ }
},
"options_ui": {
"page": "options/options.html",
diff --git a/src/options/options.html b/src/options/options.html
index bf70f4c..d317f1e 100644
--- a/src/options/options.html
+++ b/src/options/options.html
@@ -5,27 +5,71 @@
<title>AI Compose Settings</title>
<style>
body {
- font-family: sans-serif;
- padding: 10px;
+ font-family: system-ui, sans-serif;
+ margin: 0;
+ padding: 16px;
+ width: 360px;
+ background: var(--bg);
+ color: var(--fg);
+ }
+ h2 {
+ margin-top: 0;
+ font-size: 1.1rem;
}
label {
display: block;
- margin: 10px 0 4px;
+ margin: 12px 0 6px;
+ font-size: 0.9rem;
}
input {
width: 100%;
- padding: 6px;
+ padding: 8px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--input-bg);
+ color: var(--fg);
}
button {
+ margin-top: 14px;
+ padding: 8px 16px;
+ border: none;
+ border-radius: 6px;
+ background: var(--accent);
+ color: white;
+ font-weight: 500;
+ cursor: pointer;
+ }
+ p#msg {
margin-top: 10px;
- padding: 6px 12px;
+ font-size: 0.85rem;
+ color: var(--accent);
+ }
+ @media (prefers-color-scheme: light) {
+ :root {
+ --bg: #ffffff;
+ --fg: #111111;
+ --border: #cccccc;
+ --input-bg: #f9f9f9;
+ --accent: #3b82f6;
+ }
+ }
+ @media (prefers-color-scheme: dark) {
+ :root {
+ --bg: #1e1e1e;
+ --fg: #f5f5f5;
+ --border: #444444;
+ --input-bg: #2a2a2a;
+ --accent: #60a5fa;
+ }
}
</style>
</head>
<body>
<h2>Settings</h2>
- <label>Proxy Endpoint <input id="endpoint" type="url" /></label>
- <label>Auth Token <input id="token" type="text" /></label>
+ <label for="endpoint">Proxy Endpoint</label>
+ <input id="endpoint" type="url" />
+ <label for="token">Auth Token</label>
+ <input id="token" type="text" />
<button id="save">Save</button>
<p id="msg"></p>
diff --git a/src/popup/popup.html b/src/popup/popup.html
index 808bc9e..c8838ba 100644
--- a/src/popup/popup.html
+++ b/src/popup/popup.html
@@ -2,34 +2,92 @@
<html>
<head>
<meta charset="utf-8" />
- <title>AI Prompt</title>
+ <title>AI Compose</title>
<style>
body {
- font-family: sans-serif;
- padding: 10px;
+ font-family: system-ui, sans-serif;
+ margin: 0;
+ padding: 16px;
+ width: 360px;
+ background: var(--bg);
+ color: var(--fg);
+ }
+ h2 {
+ margin-top: 0;
+ font-size: 1.1rem;
}
textarea {
- width: 100%;
- height: 120px;
+ width: calc(100% - 16px);
+ min-height: 120px;
+ resize: vertical;
+ padding: 8px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--input-bg);
+ color: var(--fg);
+ font-size: 0.9rem;
+ line-height: 1.4;
+ }
+ .actions {
+ margin-top: 14px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
}
button {
- margin-top: 8px;
- padding: 6px 12px;
+ padding: 8px 16px;
+ border: none;
+ border-radius: 6px;
+ background: var(--accent);
+ color: white;
+ font-weight: 500;
+ cursor: pointer;
+ }
+ button:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ }
+ #spinner {
+ display: none;
+ width: 18px;
+ height: 18px;
+ border: 2px solid rgba(0, 0, 0, 0.2);
+ border-top-color: var(--accent);
+ border-radius: 50%;
+ animation: spin 0.6s linear infinite;
+ }
+ @keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+ }
+ @media (prefers-color-scheme: light) {
+ :root {
+ --bg: #ffffff;
+ --fg: #111111;
+ --border: #cccccc;
+ --input-bg: #f9f9f9;
+ --accent: #3b82f6;
+ }
}
- pre {
- background: #f5f5f5;
- padding: 6px;
- max-height: 200px;
- overflow: auto;
+ @media (prefers-color-scheme: dark) {
+ :root {
+ --bg: #1e1e1e;
+ --fg: #f5f5f5;
+ --border: #444444;
+ --input-bg: #2a2a2a;
+ --accent: #60a5fa;
+ }
}
</style>
</head>
<body>
- <h3>Enter Prompt</h3>
- <textarea id="prompt"></textarea>
- <br />
- <button id="send">Show Compose Context</button>
- <pre id="output">No data yet</pre>
+ <h2>Compose with AI</h2>
+ <textarea id="prompt" placeholder="Write your instructions here..."></textarea>
+ <div class="actions">
+ <button id="send" disabled>Insert</button>
+ <div id="spinner"></div>
+ </div>
<script src="../browser-polyfill.js"></script>
<script src="popup.js"></script>
diff --git a/src/popup/popup.ts b/src/popup/popup.ts
index dc28bdc..e3d4e29 100644
--- a/src/popup/popup.ts
+++ b/src/popup/popup.ts
@@ -5,9 +5,22 @@ interface Payload {
context: ComposeContext;
}
-const promptEl: HTMLTextAreaElement = document.getElementById("prompt") as HTMLTextAreaElement;
-const sendBtn: HTMLButtonElement = document.getElementById("send") as HTMLButtonElement;
-const outputEl: HTMLElement = document.getElementById("output") as HTMLElement;
+interface Settings {
+ proxyEndpoint?: string;
+ proxyToken?: string;
+}
+
+function getElements(): {
+ promptEl: HTMLTextAreaElement;
+ sendBtn: HTMLButtonElement;
+ spinner: HTMLElement;
+} {
+ return {
+ promptEl: document.getElementById("prompt") as HTMLTextAreaElement,
+ sendBtn: document.getElementById("send") as HTMLButtonElement,
+ spinner: document.getElementById("spinner") as HTMLElement,
+ };
+}
async function getActiveComposeTabId(): Promise<number | undefined> {
const tabs: browser.tabs.Tab[] = (await browser.tabs.query({
@@ -22,26 +35,17 @@ function insertAtReply(body: string, aiText: string, isHtml: boolean): string {
const parser: DOMParser = new DOMParser();
const doc: Document = parser.parseFromString(body, "text/html");
const paragraphs: NodeListOf<HTMLParagraphElement> = doc.querySelectorAll("body > p");
-
- let target: HTMLParagraphElement | null = null;
for (const p of Array.from(paragraphs)) {
if (p.textContent && p.textContent.trim().length > 0) {
- target = p;
- break;
+ p.insertAdjacentHTML("afterend", aiText);
+ return doc.body.innerHTML;
}
}
-
- if (target) {
- target.insertAdjacentHTML("afterend", aiText);
- return doc.body.innerHTML;
- }
-
const citeDiv: HTMLElement | null = doc.querySelector("div.moz-cite-prefix");
if (citeDiv) {
citeDiv.insertAdjacentHTML("beforebegin", aiText);
return doc.body.innerHTML;
}
-
doc.body.insertAdjacentHTML("afterbegin", aiText);
return doc.body.innerHTML;
} else {
@@ -54,6 +58,11 @@ function insertAtReply(body: string, aiText: string, isHtml: boolean): string {
}
async function handleInsert(): Promise<void> {
+ const { promptEl, sendBtn, spinner } = getElements();
+ spinner.style.display = "block";
+ sendBtn.disabled = true;
+ promptEl.disabled = true;
+
const ctx: ComposeContext = (await browser.runtime.sendMessage({
type: "getComposeContext",
})) as ComposeContext;
@@ -63,32 +72,71 @@ async function handleInsert(): Promise<void> {
context: ctx,
};
+ const settings: Settings = await browser.storage.local.get(["proxyEndpoint", "proxyToken"]);
+ if (!settings.proxyEndpoint) {
+ alert("Proxy endpoint is not configured in settings.");
+ spinner.style.display = "none";
+ sendBtn.disabled = promptEl.value.trim().length === 0;
+ promptEl.disabled = false;
+ return;
+ }
+
const tabId: number | undefined = await getActiveComposeTabId();
if (tabId === undefined) {
- outputEl.textContent = "No active compose tab.";
+ alert("No active compose tab.");
+ spinner.style.display = "none";
+ sendBtn.disabled = promptEl.value.trim().length === 0;
+ promptEl.disabled = false;
+ return;
+ }
+
+ let aiResult: string;
+ try {
+ const res: Response = await fetch(settings.proxyEndpoint + "/generate", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ ...(settings.proxyToken ? { Authorization: `Bearer ${settings.proxyToken}` } : {}),
+ },
+ body: JSON.stringify(payload),
+ });
+
+ if (!res.ok) {
+ throw new Error(`Server responded with ${res.status}`);
+ }
+
+ const data: { response?: string } = (await res.json()) as { response?: string };
+ if (!data.response) {
+ throw new Error("No response field in server reply.");
+ }
+ aiResult = data.response;
+ } catch (err) {
+ alert("Failed to fetch AI response: " + (err as Error).message);
+ spinner.style.display = "none";
+ sendBtn.disabled = promptEl.value.trim().length === 0;
+ promptEl.disabled = false;
return;
}
const isHtml: boolean =
ctx.compose.isHtml ?? Boolean(ctx.compose.bodyHTML && ctx.compose.bodyHTML.trim());
- const aiResult: string = isHtml
- ? "<p>This response will be inserted by AI</p>"
- : "This response will be inserted by AI";
-
- const replyPrefix: string | undefined =
- ctx.compose.bodyPlain?.split("\n\n")[0].trim() || undefined;
const oldBody: string = isHtml ? (ctx.compose.bodyHTML ?? "") : (ctx.compose.bodyPlain ?? "");
-
const newBody: string = insertAtReply(oldBody, aiResult, isHtml);
await (browser as any).compose.setComposeDetails(tabId, { body: newBody });
- outputEl.textContent = JSON.stringify(payload, null, 2);
+ spinner.style.display = "none";
+ sendBtn.disabled = promptEl.value.trim().length === 0;
+ promptEl.disabled = false;
}
+const { sendBtn, promptEl } = getElements();
sendBtn.addEventListener("click", (): void => {
handleInsert().catch((err: unknown): void => {
console.error("Popup insert failed:", err);
- outputEl.textContent = "Error inserting text. See console.";
+ alert("Error inserting text. See console for details.");
});
});
+promptEl.addEventListener("input", (): void => {
+ sendBtn.disabled = promptEl.value.trim().length === 0;
+});