Added multi-key support
This commit is contained in:
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/*"],
|
||||||
|
|||||||
@@ -428,24 +428,38 @@ async function handleAnalyze() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { apiKey, apiBaseUrl, apiKeyHeader, apiKeyPrefix, model, systemPrompt, resume } =
|
const {
|
||||||
await getStorage([
|
apiKeys = [],
|
||||||
"apiKey",
|
activeApiKeyId = "",
|
||||||
"apiBaseUrl",
|
apiBaseUrl,
|
||||||
"apiKeyHeader",
|
apiKeyHeader,
|
||||||
"apiKeyPrefix",
|
apiKeyPrefix,
|
||||||
"model",
|
model,
|
||||||
"systemPrompt",
|
systemPrompt,
|
||||||
"resume"
|
resume
|
||||||
]);
|
} = await getStorage([
|
||||||
|
"apiKeys",
|
||||||
|
"activeApiKeyId",
|
||||||
|
"apiBaseUrl",
|
||||||
|
"apiKeyHeader",
|
||||||
|
"apiKeyPrefix",
|
||||||
|
"model",
|
||||||
|
"systemPrompt",
|
||||||
|
"resume"
|
||||||
|
]);
|
||||||
|
|
||||||
if (!apiBaseUrl) {
|
if (!apiBaseUrl) {
|
||||||
setStatus("Set an API base URL in Settings.");
|
setStatus("Set an API base URL in Settings.");
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"] {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
Reference in New Issue
Block a user