Added multi-key support

This commit is contained in:
2026-01-17 16:12:41 -05:00
parent 3eb9863c3e
commit 3bb350f3cf
6 changed files with 244 additions and 25 deletions

View File

@@ -15,6 +15,8 @@ const DEFAULT_TASKS = [
const DEFAULT_SETTINGS = { const DEFAULT_SETTINGS = {
apiKey: "", apiKey: "",
apiKeys: [],
activeApiKeyId: "",
apiBaseUrl: "https://api.openai.com/v1", apiBaseUrl: "https://api.openai.com/v1",
apiKeyHeader: "Authorization", apiKeyHeader: "Authorization",
apiKeyPrefix: "Bearer ", apiKeyPrefix: "Bearer ",
@@ -80,6 +82,17 @@ chrome.runtime.onInstalled.addListener(async () => {
if (missing) updates[key] = value; if (missing) updates[key] = value;
} }
const hasApiKeys =
Array.isArray(stored.apiKeys) && stored.apiKeys.length > 0;
if (!hasApiKeys && stored.apiKey) {
const id = crypto?.randomUUID
? crypto.randomUUID()
: `key-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
updates.apiKeys = [{ id, name: "Default", key: stored.apiKey }];
updates.activeApiKeyId = id;
}
if (Object.keys(updates).length) { if (Object.keys(updates).length) {
await chrome.storage.local.set(updates); await chrome.storage.local.set(updates);
} }

View File

@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "WWCompanion", "name": "WWCompanion",
"version": "0.2.3", "version": "0.2.4",
"description": "AI companion for WaterlooWorks job postings.", "description": "AI companion for WaterlooWorks job postings.",
"permissions": ["storage", "activeTab"], "permissions": ["storage", "activeTab"],
"host_permissions": ["https://waterlooworks.uwaterloo.ca/*"], "host_permissions": ["https://waterlooworks.uwaterloo.ca/*"],

View File

@@ -428,9 +428,18 @@ async function handleAnalyze() {
return; return;
} }
const { apiKey, apiBaseUrl, apiKeyHeader, apiKeyPrefix, model, systemPrompt, resume } = const {
await getStorage([ apiKeys = [],
"apiKey", activeApiKeyId = "",
apiBaseUrl,
apiKeyHeader,
apiKeyPrefix,
model,
systemPrompt,
resume
} = await getStorage([
"apiKeys",
"activeApiKeyId",
"apiBaseUrl", "apiBaseUrl",
"apiKeyHeader", "apiKeyHeader",
"apiKeyPrefix", "apiKeyPrefix",
@@ -444,8 +453,13 @@ async function handleAnalyze() {
return; return;
} }
const resolvedKeys = Array.isArray(apiKeys) ? apiKeys : [];
const activeKey =
resolvedKeys.find((entry) => entry.id === activeApiKeyId) || resolvedKeys[0];
const apiKey = activeKey?.key || "";
if (apiKeyHeader && !apiKey) { if (apiKeyHeader && !apiKey) {
setStatus("Add your API key in Settings."); setStatus("Add an API key in Settings.");
return; return;
} }

View File

@@ -229,6 +229,30 @@ button:active {
gap: 8px; gap: 8px;
} }
.api-keys {
display: grid;
gap: 12px;
}
.api-key-card {
padding: 12px;
border-radius: 12px;
border: 1px solid var(--border);
background: var(--card-bg);
display: grid;
gap: 8px;
}
.api-key-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.api-key-actions .delete {
color: #c0392b;
}
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root:not([data-theme]), :root:not([data-theme]),
:root[data-theme="system"] { :root[data-theme="system"] {

View File

@@ -30,11 +30,8 @@
<button id="resetApiBtn" class="ghost" type="button">Reset to OpenAI</button> <button id="resetApiBtn" class="ghost" type="button">Reset to OpenAI</button>
</div> </div>
<div class="field"> <div class="field">
<label for="apiKey">API Key</label> <label for="activeApiKeySelect">Active key</label>
<div class="inline"> <select id="activeApiKeySelect"></select>
<input id="apiKey" type="password" autocomplete="off" placeholder="sk-..." />
<button id="toggleKey" class="ghost" type="button">Show</button>
</div>
</div> </div>
<div class="field"> <div class="field">
<label for="apiBaseUrl">API Base URL</label> <label for="apiBaseUrl">API Base URL</label>
@@ -59,6 +56,23 @@
</div> </div>
</details> </details>
<details class="panel">
<summary class="panel-summary">
<span class="panel-caret" aria-hidden="true">
<span class="caret-closed"></span>
<span class="caret-open"></span>
</span>
<h2>API Keys</h2>
</summary>
<div class="panel-body">
<div class="row">
<div></div>
<button id="addApiKeyBtn" class="ghost" type="button">Add Key</button>
</div>
<div id="apiKeys" class="api-keys"></div>
</div>
</details>
<details class="panel"> <details class="panel">
<summary class="panel-summary"> <summary class="panel-summary">
<span class="panel-caret" aria-hidden="true"> <span class="panel-caret" aria-hidden="true">

View File

@@ -1,4 +1,3 @@
const apiKeyInput = document.getElementById("apiKey");
const apiBaseUrlInput = document.getElementById("apiBaseUrl"); const apiBaseUrlInput = document.getElementById("apiBaseUrl");
const apiKeyHeaderInput = document.getElementById("apiKeyHeader"); const apiKeyHeaderInput = document.getElementById("apiKeyHeader");
const apiKeyPrefixInput = document.getElementById("apiKeyPrefix"); const apiKeyPrefixInput = document.getElementById("apiKeyPrefix");
@@ -6,10 +5,12 @@ const modelInput = document.getElementById("model");
const systemPromptInput = document.getElementById("systemPrompt"); const systemPromptInput = document.getElementById("systemPrompt");
const resumeInput = document.getElementById("resume"); const resumeInput = document.getElementById("resume");
const saveBtn = document.getElementById("saveBtn"); const saveBtn = document.getElementById("saveBtn");
const addApiKeyBtn = document.getElementById("addApiKeyBtn");
const apiKeysContainer = document.getElementById("apiKeys");
const activeApiKeySelect = document.getElementById("activeApiKeySelect");
const addTaskBtn = document.getElementById("addTaskBtn"); const addTaskBtn = document.getElementById("addTaskBtn");
const tasksContainer = document.getElementById("tasks"); const tasksContainer = document.getElementById("tasks");
const statusEl = document.getElementById("status"); const statusEl = document.getElementById("status");
const toggleKeyBtn = document.getElementById("toggleKey");
const themeSelect = document.getElementById("themeSelect"); const themeSelect = document.getElementById("themeSelect");
const resetApiBtn = document.getElementById("resetApiBtn"); const resetApiBtn = document.getElementById("resetApiBtn");
@@ -41,6 +42,118 @@ function newTaskId() {
return `task-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; return `task-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
} }
function newApiKeyId() {
if (crypto?.randomUUID) return crypto.randomUUID();
return `key-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
}
function buildApiKeyCard(entry) {
const card = document.createElement("div");
card.className = "api-key-card";
card.dataset.id = entry.id || newApiKeyId();
const nameField = document.createElement("div");
nameField.className = "field";
const nameLabel = document.createElement("label");
nameLabel.textContent = "Name";
const nameInput = document.createElement("input");
nameInput.type = "text";
nameInput.value = entry.name || "";
nameInput.className = "api-key-name";
nameField.appendChild(nameLabel);
nameField.appendChild(nameInput);
const keyField = document.createElement("div");
keyField.className = "field";
const keyLabel = document.createElement("label");
keyLabel.textContent = "Key";
const keyInline = document.createElement("div");
keyInline.className = "inline";
const keyInput = document.createElement("input");
keyInput.type = "password";
keyInput.autocomplete = "off";
keyInput.placeholder = "sk-...";
keyInput.value = entry.key || "";
keyInput.className = "api-key-value";
const showBtn = document.createElement("button");
showBtn.type = "button";
showBtn.className = "ghost";
showBtn.textContent = "Show";
showBtn.addEventListener("click", () => {
const isPassword = keyInput.type === "password";
keyInput.type = isPassword ? "text" : "password";
showBtn.textContent = isPassword ? "Hide" : "Show";
});
keyInline.appendChild(keyInput);
keyInline.appendChild(showBtn);
keyField.appendChild(keyLabel);
keyField.appendChild(keyInline);
const actions = document.createElement("div");
actions.className = "api-key-actions";
const deleteBtn = document.createElement("button");
deleteBtn.type = "button";
deleteBtn.className = "ghost delete";
deleteBtn.textContent = "Delete";
deleteBtn.addEventListener("click", () => {
card.remove();
updateApiKeySelect();
});
actions.appendChild(deleteBtn);
const updateSelect = () => updateApiKeySelect(activeApiKeySelect.value);
nameInput.addEventListener("input", updateSelect);
keyInput.addEventListener("input", updateSelect);
card.appendChild(nameField);
card.appendChild(keyField);
card.appendChild(actions);
return card;
}
function collectApiKeys() {
const cards = [...apiKeysContainer.querySelectorAll(".api-key-card")];
return cards.map((card) => {
const nameInput = card.querySelector(".api-key-name");
const keyInput = card.querySelector(".api-key-value");
return {
id: card.dataset.id || newApiKeyId(),
name: (nameInput?.value || "Default").trim(),
key: (keyInput?.value || "").trim()
};
});
}
function updateApiKeySelect(preferredId) {
const keys = collectApiKeys();
activeApiKeySelect.innerHTML = "";
if (!keys.length) {
const option = document.createElement("option");
option.value = "";
option.textContent = "No keys configured";
activeApiKeySelect.appendChild(option);
activeApiKeySelect.disabled = true;
return;
}
activeApiKeySelect.disabled = false;
const selectedId =
preferredId && keys.some((key) => key.id === preferredId)
? preferredId
: keys[0].id;
for (const key of keys) {
const option = document.createElement("option");
option.value = key.id;
option.textContent = key.name || "Default";
activeApiKeySelect.appendChild(option);
}
activeApiKeySelect.value = selectedId;
}
function buildTaskCard(task) { function buildTaskCard(task) {
const card = document.createElement("div"); const card = document.createElement("div");
card.className = "task-card"; card.className = "task-card";
@@ -192,6 +305,8 @@ function collectTasks() {
async function loadSettings() { async function loadSettings() {
const { const {
apiKey = "", apiKey = "",
apiKeys = [],
activeApiKeyId = "",
apiBaseUrl = "", apiBaseUrl = "",
apiKeyHeader = "", apiKeyHeader = "",
apiKeyPrefix = "", apiKeyPrefix = "",
@@ -202,6 +317,8 @@ async function loadSettings() {
theme = "system" theme = "system"
} = await getStorage([ } = await getStorage([
"apiKey", "apiKey",
"apiKeys",
"activeApiKeyId",
"apiBaseUrl", "apiBaseUrl",
"apiKeyHeader", "apiKeyHeader",
"apiKeyPrefix", "apiKeyPrefix",
@@ -212,7 +329,6 @@ async function loadSettings() {
"theme" "theme"
]); ]);
apiKeyInput.value = apiKey;
apiBaseUrlInput.value = apiBaseUrl; apiBaseUrlInput.value = apiBaseUrl;
apiKeyHeaderInput.value = apiKeyHeader; apiKeyHeaderInput.value = apiKeyHeader;
apiKeyPrefixInput.value = apiKeyPrefix; apiKeyPrefixInput.value = apiKeyPrefix;
@@ -222,6 +338,29 @@ async function loadSettings() {
themeSelect.value = theme; themeSelect.value = theme;
applyTheme(theme); applyTheme(theme);
let resolvedKeys = Array.isArray(apiKeys) ? apiKeys : [];
let resolvedActiveId = activeApiKeyId;
if (!resolvedKeys.length && apiKey) {
const migrated = { id: newApiKeyId(), name: "Default", key: apiKey };
resolvedKeys = [migrated];
resolvedActiveId = migrated.id;
await chrome.storage.local.set({
apiKeys: resolvedKeys,
activeApiKeyId: resolvedActiveId
});
}
apiKeysContainer.innerHTML = "";
if (!resolvedKeys.length) {
apiKeysContainer.appendChild(buildApiKeyCard({ id: newApiKeyId(), name: "", key: "" }));
} else {
for (const entry of resolvedKeys) {
apiKeysContainer.appendChild(buildApiKeyCard(entry));
}
}
updateApiKeySelect(resolvedActiveId);
tasksContainer.innerHTML = ""; tasksContainer.innerHTML = "";
if (!tasks.length) { if (!tasks.length) {
tasksContainer.appendChild( tasksContainer.appendChild(
@@ -239,11 +378,17 @@ async function loadSettings() {
async function saveSettings() { async function saveSettings() {
const tasks = collectTasks(); const tasks = collectTasks();
const apiKeys = collectApiKeys();
const activeApiKeyId =
apiKeys.find((entry) => entry.id === activeApiKeySelect.value)?.id ||
apiKeys[0]?.id ||
"";
await chrome.storage.local.set({ await chrome.storage.local.set({
apiKey: apiKeyInput.value.trim(),
apiBaseUrl: apiBaseUrlInput.value.trim(), apiBaseUrl: apiBaseUrlInput.value.trim(),
apiKeyHeader: apiKeyHeaderInput.value.trim(), apiKeyHeader: apiKeyHeaderInput.value.trim(),
apiKeyPrefix: apiKeyPrefixInput.value, apiKeyPrefix: apiKeyPrefixInput.value,
apiKeys,
activeApiKeyId,
model: modelInput.value.trim(), model: modelInput.value.trim(),
systemPrompt: systemPromptInput.value, systemPrompt: systemPromptInput.value,
resume: resumeInput.value, resume: resumeInput.value,
@@ -265,10 +410,19 @@ addTaskBtn.addEventListener("click", () => {
updateTaskControls(); updateTaskControls();
}); });
toggleKeyBtn.addEventListener("click", () => { addApiKeyBtn.addEventListener("click", () => {
const isPassword = apiKeyInput.type === "password"; const newCard = buildApiKeyCard({ id: newApiKeyId(), name: "", key: "" });
apiKeyInput.type = isPassword ? "text" : "password"; const first = apiKeysContainer.firstElementChild;
toggleKeyBtn.textContent = isPassword ? "Hide" : "Show"; if (first) {
apiKeysContainer.insertBefore(newCard, first);
} else {
apiKeysContainer.appendChild(newCard);
}
updateApiKeySelect(activeApiKeySelect.value);
});
activeApiKeySelect.addEventListener("change", () => {
updateApiKeySelect(activeApiKeySelect.value);
}); });
themeSelect.addEventListener("change", () => applyTheme(themeSelect.value)); themeSelect.addEventListener("change", () => applyTheme(themeSelect.value));