diff --git a/wwcompanion-extension/background.js b/wwcompanion-extension/background.js
index a76ab6e..6b92c07 100644
--- a/wwcompanion-extension/background.js
+++ b/wwcompanion-extension/background.js
@@ -17,6 +17,8 @@ const DEFAULT_SETTINGS = {
apiKey: "",
apiKeys: [],
activeApiKeyId: "",
+ apiConfigs: [],
+ activeApiConfigId: "",
apiBaseUrl: "https://api.openai.com/v1",
apiKeyHeader: "Authorization",
apiKeyPrefix: "Bearer ",
@@ -91,6 +93,99 @@ chrome.runtime.onInstalled.addListener(async () => {
: `key-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
updates.apiKeys = [{ id, name: "Default", key: stored.apiKey }];
updates.activeApiKeyId = id;
+ } else if (hasApiKeys && stored.activeApiKeyId) {
+ const exists = stored.apiKeys.some((key) => key.id === stored.activeApiKeyId);
+ if (!exists) {
+ updates.activeApiKeyId = stored.apiKeys[0].id;
+ }
+ } else if (hasApiKeys && !stored.activeApiKeyId) {
+ updates.activeApiKeyId = stored.apiKeys[0].id;
+ }
+
+ const hasApiConfigs =
+ Array.isArray(stored.apiConfigs) && stored.apiConfigs.length > 0;
+
+ if (!hasApiConfigs) {
+ const fallbackKeyId =
+ updates.activeApiKeyId ||
+ stored.activeApiKeyId ||
+ stored.apiKeys?.[0]?.id ||
+ "";
+ const id = crypto?.randomUUID
+ ? crypto.randomUUID()
+ : `config-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
+ updates.apiConfigs = [
+ {
+ id,
+ name: "Default",
+ apiBaseUrl: stored.apiBaseUrl || DEFAULT_SETTINGS.apiBaseUrl,
+ apiKeyHeader: stored.apiKeyHeader || DEFAULT_SETTINGS.apiKeyHeader,
+ apiKeyPrefix: stored.apiKeyPrefix || DEFAULT_SETTINGS.apiKeyPrefix,
+ model: stored.model || DEFAULT_SETTINGS.model,
+ apiKeyId: fallbackKeyId,
+ apiUrl: "",
+ requestTemplate: "",
+ advanced: false
+ }
+ ];
+ updates.activeApiConfigId = id;
+ } else if (stored.activeApiConfigId) {
+ const exists = stored.apiConfigs.some(
+ (config) => config.id === stored.activeApiConfigId
+ );
+ if (!exists) {
+ updates.activeApiConfigId = stored.apiConfigs[0].id;
+ }
+ const fallbackKeyId =
+ updates.activeApiKeyId ||
+ stored.activeApiKeyId ||
+ stored.apiKeys?.[0]?.id ||
+ "";
+ const normalizedConfigs = stored.apiConfigs.map((config) => ({
+ ...config,
+ apiKeyId: config.apiKeyId || fallbackKeyId,
+ apiUrl: config.apiUrl || "",
+ requestTemplate: config.requestTemplate || "",
+ advanced: Boolean(config.advanced)
+ }));
+ const needsUpdate = normalizedConfigs.some((config, index) => {
+ const original = stored.apiConfigs[index];
+ return (
+ config.apiKeyId !== original.apiKeyId ||
+ (config.apiUrl || "") !== (original.apiUrl || "") ||
+ (config.requestTemplate || "") !== (original.requestTemplate || "") ||
+ Boolean(config.advanced) !== Boolean(original.advanced)
+ );
+ });
+ if (needsUpdate) {
+ updates.apiConfigs = normalizedConfigs;
+ }
+ } else {
+ updates.activeApiConfigId = stored.apiConfigs[0].id;
+ const fallbackKeyId =
+ updates.activeApiKeyId ||
+ stored.activeApiKeyId ||
+ stored.apiKeys?.[0]?.id ||
+ "";
+ const normalizedConfigs = stored.apiConfigs.map((config) => ({
+ ...config,
+ apiKeyId: config.apiKeyId || fallbackKeyId,
+ apiUrl: config.apiUrl || "",
+ requestTemplate: config.requestTemplate || "",
+ advanced: Boolean(config.advanced)
+ }));
+ const needsUpdate = normalizedConfigs.some((config, index) => {
+ const original = stored.apiConfigs[index];
+ return (
+ config.apiKeyId !== original.apiKeyId ||
+ (config.apiUrl || "") !== (original.apiUrl || "") ||
+ (config.requestTemplate || "") !== (original.requestTemplate || "") ||
+ Boolean(config.advanced) !== Boolean(original.advanced)
+ );
+ });
+ if (needsUpdate) {
+ updates.apiConfigs = normalizedConfigs;
+ }
}
if (Object.keys(updates).length) {
@@ -188,6 +283,9 @@ async function handleAnalysisRequest(port, payload, signal) {
const {
apiKey,
+ apiMode,
+ apiUrl,
+ requestTemplate,
apiBaseUrl,
apiKeyHeader,
apiKeyPrefix,
@@ -199,19 +297,31 @@ async function handleAnalysisRequest(port, payload, signal) {
tabId
} = payload || {};
- if (!apiBaseUrl) {
- safePost(port, { type: "ERROR", message: "Missing API base URL." });
- return;
- }
+ const isAdvanced = apiMode === "advanced";
+ if (isAdvanced) {
+ if (!apiUrl) {
+ safePost(port, { type: "ERROR", message: "Missing API URL." });
+ return;
+ }
+ if (!requestTemplate) {
+ safePost(port, { type: "ERROR", message: "Missing request template." });
+ return;
+ }
+ } else {
+ if (!apiBaseUrl) {
+ safePost(port, { type: "ERROR", message: "Missing API base URL." });
+ return;
+ }
- if (apiKeyHeader && !apiKey) {
- safePost(port, { type: "ERROR", message: "Missing API key." });
- return;
- }
+ if (apiKeyHeader && !apiKey) {
+ safePost(port, { type: "ERROR", message: "Missing API key." });
+ return;
+ }
- if (!model) {
- safePost(port, { type: "ERROR", message: "Missing model name." });
- return;
+ if (!model) {
+ safePost(port, { type: "ERROR", message: "Missing model name." });
+ return;
+ }
}
if (!postingText) {
@@ -230,20 +340,35 @@ async function handleAnalysisRequest(port, payload, signal) {
openKeepalive(tabId);
try {
- await streamChatCompletion({
- apiKey,
- apiBaseUrl,
- apiKeyHeader,
- apiKeyPrefix,
- model,
- systemPrompt: systemPrompt || "",
- userMessage,
- signal,
- onDelta: (text) => {
- streamState.outputText += text;
- broadcast({ type: "DELTA", text });
- }
- });
+ if (isAdvanced) {
+ await streamCustomCompletion({
+ apiKey,
+ apiUrl,
+ requestTemplate,
+ systemPrompt: systemPrompt || "",
+ userMessage,
+ signal,
+ onDelta: (text) => {
+ streamState.outputText += text;
+ broadcast({ type: "DELTA", text });
+ }
+ });
+ } else {
+ await streamChatCompletion({
+ apiKey,
+ apiBaseUrl,
+ apiKeyHeader,
+ apiKeyPrefix,
+ model,
+ systemPrompt: systemPrompt || "",
+ userMessage,
+ signal,
+ onDelta: (text) => {
+ streamState.outputText += text;
+ broadcast({ type: "DELTA", text });
+ }
+ });
+ }
broadcast({ type: "DONE" });
} finally {
@@ -268,6 +393,73 @@ function buildAuthHeader(apiKeyHeader, apiKeyPrefix, apiKey) {
};
}
+function replaceQuotedToken(template, token, value) {
+ const quoted = `"${token}"`;
+ const jsonValue = JSON.stringify(value ?? "");
+ return template.split(quoted).join(jsonValue);
+}
+
+function replaceTemplateTokens(template, replacements) {
+ let output = template || "";
+ for (const [token, value] of Object.entries(replacements)) {
+ output = replaceQuotedToken(output, token, value ?? "");
+ output = output.split(token).join(value ?? "");
+ }
+ return output;
+}
+
+function replaceUrlTokens(url, replacements) {
+ let output = url || "";
+ for (const [token, value] of Object.entries(replacements)) {
+ output = output.split(token).join(encodeURIComponent(value ?? ""));
+ }
+ return output;
+}
+
+function buildTemplateBody(template, replacements) {
+ const filled = replaceTemplateTokens(template, replacements);
+ try {
+ return JSON.parse(filled);
+ } catch {
+ throw new Error("Invalid request template JSON.");
+ }
+}
+
+async function readSseStream(response, onDelta) {
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = "";
+
+ // OpenAI-compatible SSE stream; parse incremental deltas from data lines.
+ while (true) {
+ const { value, done } = await reader.read();
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split("\n");
+ buffer = lines.pop() || "";
+
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (!trimmed.startsWith("data:")) continue;
+
+ const data = trimmed.slice(5).trim();
+ if (!data) continue;
+ if (data === "[DONE]") return;
+
+ let parsed;
+ try {
+ parsed = JSON.parse(data);
+ } catch {
+ continue;
+ }
+
+ const delta = parsed?.choices?.[0]?.delta?.content;
+ if (delta) onDelta(delta);
+ }
+ }
+}
+
async function streamChatCompletion({
apiKey,
apiBaseUrl,
@@ -309,39 +501,42 @@ async function streamChatCompletion({
if (!response.ok) {
const errorText = await response.text();
- throw new Error(`OpenAI API error ${response.status}: ${errorText}`);
+ throw new Error(`API error ${response.status}: ${errorText}`);
}
- const reader = response.body.getReader();
- const decoder = new TextDecoder();
- let buffer = "";
-
- // OpenAI streams Server-Sent Events; parse incremental deltas from data lines.
- while (true) {
- const { value, done } = await reader.read();
- if (done) break;
-
- buffer += decoder.decode(value, { stream: true });
- const lines = buffer.split("\n");
- buffer = lines.pop() || "";
-
- for (const line of lines) {
- const trimmed = line.trim();
- if (!trimmed.startsWith("data:")) continue;
-
- const data = trimmed.slice(5).trim();
- if (!data) continue;
- if (data === "[DONE]") return;
-
- let parsed;
- try {
- parsed = JSON.parse(data);
- } catch {
- continue;
- }
-
- const delta = parsed?.choices?.[0]?.delta?.content;
- if (delta) onDelta(delta);
- }
- }
+ await readSseStream(response, onDelta);
+}
+
+async function streamCustomCompletion({
+ apiKey,
+ apiUrl,
+ requestTemplate,
+ systemPrompt,
+ userMessage,
+ signal,
+ onDelta
+}) {
+ const replacements = {
+ PROMPT_GOES_HERE: userMessage,
+ SYSTEM_PROMPT_GOES_HERE: systemPrompt,
+ API_KEY_GOES_HERE: apiKey
+ };
+ const resolvedUrl = replaceUrlTokens(apiUrl, replacements);
+ const body = buildTemplateBody(requestTemplate, replacements);
+
+ const response = await fetch(resolvedUrl, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json"
+ },
+ body: JSON.stringify(body),
+ signal
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(`API error ${response.status}: ${errorText}`);
+ }
+
+ await readSseStream(response, onDelta);
}
diff --git a/wwcompanion-extension/popup.js b/wwcompanion-extension/popup.js
index e251a6b..3be90d0 100644
--- a/wwcompanion-extension/popup.js
+++ b/wwcompanion-extension/popup.js
@@ -431,6 +431,8 @@ async function handleAnalyze() {
const {
apiKeys = [],
activeApiKeyId = "",
+ apiConfigs = [],
+ activeApiConfigId = "",
apiBaseUrl,
apiKeyHeader,
apiKeyPrefix,
@@ -440,6 +442,8 @@ async function handleAnalyze() {
} = await getStorage([
"apiKeys",
"activeApiKeyId",
+ "apiConfigs",
+ "activeApiConfigId",
"apiBaseUrl",
"apiKeyHeader",
"apiKeyPrefix",
@@ -448,24 +452,56 @@ async function handleAnalyze() {
"resume"
]);
- if (!apiBaseUrl) {
- setStatus("Set an API base URL in Settings.");
- return;
- }
+ const resolvedConfigs = Array.isArray(apiConfigs) ? apiConfigs : [];
+ const activeConfig =
+ resolvedConfigs.find((entry) => entry.id === activeApiConfigId) ||
+ resolvedConfigs[0];
+ const isAdvanced = Boolean(activeConfig?.advanced);
+ const resolvedApiUrl = activeConfig?.apiUrl || "";
+ const resolvedTemplate = activeConfig?.requestTemplate || "";
+ const resolvedApiBaseUrl = isAdvanced
+ ? ""
+ : activeConfig?.apiBaseUrl || apiBaseUrl || "";
+ const resolvedApiKeyHeader = isAdvanced
+ ? ""
+ : activeConfig?.apiKeyHeader ?? apiKeyHeader ?? "";
+ const resolvedApiKeyPrefix = isAdvanced
+ ? ""
+ : activeConfig?.apiKeyPrefix ?? apiKeyPrefix ?? "";
+ const resolvedModel = isAdvanced ? "" : activeConfig?.model || model || "";
const resolvedKeys = Array.isArray(apiKeys) ? apiKeys : [];
- const activeKey =
- resolvedKeys.find((entry) => entry.id === activeApiKeyId) || resolvedKeys[0];
+ const resolvedKeyId =
+ activeConfig?.apiKeyId || activeApiKeyId || resolvedKeys[0]?.id || "";
+ const activeKey = resolvedKeys.find((entry) => entry.id === resolvedKeyId);
const apiKey = activeKey?.key || "";
- if (apiKeyHeader && !apiKey) {
- setStatus("Add an API key in Settings.");
- return;
- }
-
- if (!model) {
- setStatus("Set a model name in Settings.");
- return;
+ if (isAdvanced) {
+ if (!resolvedApiUrl) {
+ setStatus("Set an API URL in Settings.");
+ return;
+ }
+ if (!resolvedTemplate) {
+ setStatus("Set a request template in Settings.");
+ return;
+ }
+ if (resolvedTemplate.includes("API_KEY_GOES_HERE") && !apiKey) {
+ setStatus("Add an API key in Settings.");
+ return;
+ }
+ } else {
+ if (!resolvedApiBaseUrl) {
+ setStatus("Set an API base URL in Settings.");
+ return;
+ }
+ if (resolvedApiKeyHeader && !apiKey) {
+ setStatus("Add an API key in Settings.");
+ return;
+ }
+ if (!resolvedModel) {
+ setStatus("Set a model name in Settings.");
+ return;
+ }
}
const promptText = buildUserMessage(resume || "", task.text || "", state.postingText);
@@ -481,10 +517,13 @@ async function handleAnalyze() {
type: "START_ANALYSIS",
payload: {
apiKey,
- apiBaseUrl,
- apiKeyHeader,
- apiKeyPrefix,
- model,
+ apiMode: isAdvanced ? "advanced" : "basic",
+ apiUrl: resolvedApiUrl,
+ requestTemplate: resolvedTemplate,
+ apiBaseUrl: resolvedApiBaseUrl,
+ apiKeyHeader: resolvedApiKeyHeader,
+ apiKeyPrefix: resolvedApiKeyPrefix,
+ model: resolvedModel,
systemPrompt: systemPrompt || "",
resume: resume || "",
taskText: task.text || "",
diff --git a/wwcompanion-extension/settings.css b/wwcompanion-extension/settings.css
index 27bcc07..2326294 100644
--- a/wwcompanion-extension/settings.css
+++ b/wwcompanion-extension/settings.css
@@ -124,6 +124,11 @@ body {
margin-bottom: 12px;
}
+.row-actions {
+ display: flex;
+ gap: 8px;
+}
+
.row-title {
display: flex;
align-items: baseline;
@@ -249,8 +254,40 @@ button:active {
justify-content: flex-end;
}
-.api-key-actions .delete {
- color: #c0392b;
+.api-key-actions .delete,
+.api-config-actions .delete,
+.task-actions .delete {
+ background: #c0392b;
+ border-color: #c0392b;
+ color: #fff6f2;
+}
+
+.api-configs {
+ display: grid;
+ gap: 12px;
+}
+
+.api-config-card {
+ padding: 12px;
+ border-radius: 12px;
+ border: 1px solid var(--border);
+ background: var(--card-bg);
+ display: grid;
+ gap: 8px;
+}
+
+.api-config-card.is-advanced .basic-only {
+ display: none;
+}
+
+.api-config-card:not(.is-advanced) .advanced-only {
+ display: none;
+}
+
+.api-config-actions {
+ display: flex;
+ gap: 8px;
+ justify-content: flex-end;
}
@media (prefers-color-scheme: dark) {
@@ -275,7 +312,3 @@ button:active {
gap: 6px;
justify-content: flex-end;
}
-
-.task-actions .delete {
- color: #c0392b;
-}
diff --git a/wwcompanion-extension/settings.html b/wwcompanion-extension/settings.html
index 86babd3..9c3d605 100644
--- a/wwcompanion-extension/settings.html
+++ b/wwcompanion-extension/settings.html
@@ -27,32 +27,15 @@
-
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
@@ -62,7 +45,7 @@
▸
▾
- API Keys
+ API KEYS
diff --git a/wwcompanion-extension/settings.js b/wwcompanion-extension/settings.js
index 16d5da2..0983954 100644
--- a/wwcompanion-extension/settings.js
+++ b/wwcompanion-extension/settings.js
@@ -1,24 +1,22 @@
-const apiBaseUrlInput = document.getElementById("apiBaseUrl");
-const apiKeyHeaderInput = document.getElementById("apiKeyHeader");
-const apiKeyPrefixInput = document.getElementById("apiKeyPrefix");
-const modelInput = document.getElementById("model");
const systemPromptInput = document.getElementById("systemPrompt");
const resumeInput = document.getElementById("resume");
const saveBtn = document.getElementById("saveBtn");
+const addApiConfigBtn = document.getElementById("addApiConfigBtn");
+const apiConfigsContainer = document.getElementById("apiConfigs");
+const activeApiConfigSelect = document.getElementById("activeApiConfigSelect");
const addApiKeyBtn = document.getElementById("addApiKeyBtn");
const apiKeysContainer = document.getElementById("apiKeys");
-const activeApiKeySelect = document.getElementById("activeApiKeySelect");
const addTaskBtn = document.getElementById("addTaskBtn");
const tasksContainer = document.getElementById("tasks");
const statusEl = document.getElementById("status");
const themeSelect = document.getElementById("themeSelect");
-const resetApiBtn = document.getElementById("resetApiBtn");
const OPENAI_DEFAULTS = {
apiBaseUrl: "https://api.openai.com/v1",
apiKeyHeader: "Authorization",
apiKeyPrefix: "Bearer "
};
+const DEFAULT_MODEL = "gpt-4o-mini";
function getStorage(keys) {
return new Promise((resolve) => chrome.storage.local.get(keys, resolve));
@@ -47,6 +45,308 @@ function newApiKeyId() {
return `key-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
}
+function newApiConfigId() {
+ if (crypto?.randomUUID) return crypto.randomUUID();
+ return `config-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
+}
+
+function collectNames(container, selector) {
+ if (!container) return [];
+ return [...container.querySelectorAll(selector)]
+ .map((input) => (input.value || "").trim())
+ .filter(Boolean);
+}
+
+function buildUniqueDefaultName(names) {
+ const lower = new Set(names.map((name) => name.toLowerCase()));
+ if (!lower.has("default")) return "Default";
+ let index = 2;
+ while (lower.has(`default-${index}`)) {
+ index += 1;
+ }
+ return `Default-${index}`;
+}
+
+function ensureUniqueName(desired, existingNames) {
+ const trimmed = (desired || "").trim();
+ const lowerNames = existingNames.map((name) => name.toLowerCase());
+ if (trimmed && !lowerNames.includes(trimmed.toLowerCase())) {
+ return trimmed;
+ }
+ return buildUniqueDefaultName(existingNames);
+}
+
+function setApiConfigAdvanced(card, isAdvanced) {
+ card.classList.toggle("is-advanced", isAdvanced);
+ card.dataset.mode = isAdvanced ? "advanced" : "basic";
+
+ const basicFields = card.querySelectorAll(
+ ".basic-only input, .basic-only textarea"
+ );
+ const advancedFields = card.querySelectorAll(
+ ".advanced-only input, .advanced-only textarea"
+ );
+ basicFields.forEach((field) => {
+ field.disabled = isAdvanced;
+ });
+ advancedFields.forEach((field) => {
+ field.disabled = !isAdvanced;
+ });
+
+ const resetBtn = card.querySelector(".reset-openai");
+ if (resetBtn) resetBtn.disabled = isAdvanced;
+
+ const advancedBtn = card.querySelector(".advanced-toggle");
+ if (advancedBtn && isAdvanced) advancedBtn.remove();
+}
+
+function readApiConfigFromCard(card) {
+ const nameInput = card.querySelector(".api-config-name");
+ const keySelect = card.querySelector(".api-config-key-select");
+ const baseInput = card.querySelector(".api-config-base");
+ const headerInput = card.querySelector(".api-config-header");
+ const prefixInput = card.querySelector(".api-config-prefix");
+ const modelInput = card.querySelector(".api-config-model");
+ const urlInput = card.querySelector(".api-config-url");
+ const templateInput = card.querySelector(".api-config-template");
+ const isAdvanced = card.classList.contains("is-advanced");
+
+ return {
+ id: card.dataset.id || newApiConfigId(),
+ name: (nameInput?.value || "Default").trim(),
+ apiKeyId: keySelect?.value || "",
+ apiBaseUrl: (baseInput?.value || "").trim(),
+ apiKeyHeader: (headerInput?.value || "").trim(),
+ apiKeyPrefix: prefixInput?.value || "",
+ model: (modelInput?.value || "").trim(),
+ apiUrl: (urlInput?.value || "").trim(),
+ requestTemplate: (templateInput?.value || "").trim(),
+ advanced: isAdvanced
+ };
+}
+
+function buildApiConfigCard(config) {
+ const card = document.createElement("div");
+ card.className = "api-config-card";
+ card.dataset.id = config.id || newApiConfigId();
+ const isAdvanced = Boolean(config.advanced);
+
+ 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 = config.name || "";
+ nameInput.className = "api-config-name";
+ nameField.appendChild(nameLabel);
+ nameField.appendChild(nameInput);
+
+ const keyField = document.createElement("div");
+ keyField.className = "field";
+ const keyLabel = document.createElement("label");
+ keyLabel.textContent = "API Key";
+ const keySelect = document.createElement("select");
+ keySelect.className = "api-config-key-select";
+ keySelect.dataset.preferred = config.apiKeyId || "";
+ keyField.appendChild(keyLabel);
+ keyField.appendChild(keySelect);
+
+ const baseField = document.createElement("div");
+ baseField.className = "field basic-only";
+ const baseLabel = document.createElement("label");
+ baseLabel.textContent = "API Base URL";
+ const baseInput = document.createElement("input");
+ baseInput.type = "text";
+ baseInput.placeholder = OPENAI_DEFAULTS.apiBaseUrl;
+ baseInput.value = config.apiBaseUrl || "";
+ baseInput.className = "api-config-base";
+ baseField.appendChild(baseLabel);
+ baseField.appendChild(baseInput);
+
+ const headerField = document.createElement("div");
+ headerField.className = "field basic-only";
+ const headerLabel = document.createElement("label");
+ headerLabel.textContent = "API Key Header";
+ const headerInput = document.createElement("input");
+ headerInput.type = "text";
+ headerInput.placeholder = OPENAI_DEFAULTS.apiKeyHeader;
+ headerInput.value = config.apiKeyHeader || "";
+ headerInput.className = "api-config-header";
+ headerField.appendChild(headerLabel);
+ headerField.appendChild(headerInput);
+
+ const prefixField = document.createElement("div");
+ prefixField.className = "field basic-only";
+ const prefixLabel = document.createElement("label");
+ prefixLabel.textContent = "API Key Prefix";
+ const prefixInput = document.createElement("input");
+ prefixInput.type = "text";
+ prefixInput.placeholder = OPENAI_DEFAULTS.apiKeyPrefix;
+ prefixInput.value = config.apiKeyPrefix || "";
+ prefixInput.className = "api-config-prefix";
+ prefixField.appendChild(prefixLabel);
+ prefixField.appendChild(prefixInput);
+
+ const modelField = document.createElement("div");
+ modelField.className = "field basic-only";
+ const modelLabel = document.createElement("label");
+ modelLabel.textContent = "Model name";
+ const modelInput = document.createElement("input");
+ modelInput.type = "text";
+ modelInput.placeholder = DEFAULT_MODEL;
+ modelInput.value = config.model || "";
+ modelInput.className = "api-config-model";
+ modelField.appendChild(modelLabel);
+ modelField.appendChild(modelInput);
+
+ const urlField = document.createElement("div");
+ urlField.className = "field advanced-only";
+ const urlLabel = document.createElement("label");
+ urlLabel.textContent = "API URL";
+ const urlInput = document.createElement("input");
+ urlInput.type = "text";
+ urlInput.placeholder = "https://api.example.com/v1/chat/completions";
+ urlInput.value = config.apiUrl || "";
+ urlInput.className = "api-config-url";
+ urlField.appendChild(urlLabel);
+ urlField.appendChild(urlInput);
+
+ const templateField = document.createElement("div");
+ templateField.className = "field advanced-only";
+ const templateLabel = document.createElement("label");
+ templateLabel.textContent = "Request JSON template";
+ const templateInput = document.createElement("textarea");
+ templateInput.rows = 8;
+ templateInput.placeholder = [
+ "{",
+ " \"stream\": true,",
+ " \"messages\": [",
+ " { \"role\": \"system\", \"content\": \"SYSTEM_PROMPT_GOES_HERE\" },",
+ " { \"role\": \"user\", \"content\": \"PROMPT_GOES_HERE\" }",
+ " ],",
+ " \"api_key\": \"API_KEY_GOES_HERE\"",
+ "}"
+ ].join("\n");
+ templateInput.value = config.requestTemplate || "";
+ templateInput.className = "api-config-template";
+ templateField.appendChild(templateLabel);
+ templateField.appendChild(templateInput);
+
+ const actions = document.createElement("div");
+ actions.className = "api-config-actions";
+ if (!isAdvanced) {
+ const advancedBtn = document.createElement("button");
+ advancedBtn.type = "button";
+ advancedBtn.className = "ghost advanced-toggle";
+ advancedBtn.textContent = "Advanced Mode";
+ advancedBtn.addEventListener("click", () => {
+ setApiConfigAdvanced(card, true);
+ updateApiConfigSelect(activeApiConfigSelect.value);
+ });
+ actions.appendChild(advancedBtn);
+ }
+
+ const duplicateBtn = document.createElement("button");
+ duplicateBtn.type = "button";
+ duplicateBtn.className = "ghost duplicate";
+ duplicateBtn.textContent = "Duplicate";
+ duplicateBtn.addEventListener("click", () => {
+ const names = collectNames(apiConfigsContainer, ".api-config-name");
+ const copy = readApiConfigFromCard(card);
+ copy.id = newApiConfigId();
+ copy.name = ensureUniqueName(`${copy.name || "Default"} Copy`, names);
+ const newCard = buildApiConfigCard(copy);
+ card.insertAdjacentElement("afterend", newCard);
+ updateApiConfigKeyOptions();
+ updateApiConfigSelect(newCard.dataset.id);
+ });
+ actions.appendChild(duplicateBtn);
+
+ const resetBtn = document.createElement("button");
+ resetBtn.type = "button";
+ resetBtn.className = "ghost reset-openai";
+ resetBtn.textContent = "Reset to OpenAI";
+ resetBtn.addEventListener("click", () => {
+ if (card.classList.contains("is-advanced")) {
+ setStatus("Advanced mode cannot be reset to OpenAI.");
+ return;
+ }
+ baseInput.value = OPENAI_DEFAULTS.apiBaseUrl;
+ headerInput.value = OPENAI_DEFAULTS.apiKeyHeader;
+ prefixInput.value = OPENAI_DEFAULTS.apiKeyPrefix;
+ updateApiConfigSelect(activeApiConfigSelect.value);
+ });
+ actions.appendChild(resetBtn);
+
+ const deleteBtn = document.createElement("button");
+ deleteBtn.type = "button";
+ deleteBtn.className = "ghost delete";
+ deleteBtn.textContent = "Delete";
+ deleteBtn.addEventListener("click", () => {
+ card.remove();
+ updateApiConfigSelect(activeApiConfigSelect.value);
+ });
+ actions.appendChild(deleteBtn);
+
+ const updateSelect = () => updateApiConfigSelect(activeApiConfigSelect.value);
+ nameInput.addEventListener("input", updateSelect);
+ baseInput.addEventListener("input", updateSelect);
+ headerInput.addEventListener("input", updateSelect);
+ prefixInput.addEventListener("input", updateSelect);
+ modelInput.addEventListener("input", updateSelect);
+ urlInput.addEventListener("input", updateSelect);
+ templateInput.addEventListener("input", updateSelect);
+
+ card.appendChild(nameField);
+ card.appendChild(keyField);
+ card.appendChild(baseField);
+ card.appendChild(headerField);
+ card.appendChild(prefixField);
+ card.appendChild(modelField);
+ card.appendChild(urlField);
+ card.appendChild(templateField);
+ card.appendChild(actions);
+
+ setApiConfigAdvanced(card, isAdvanced);
+
+ return card;
+}
+
+function collectApiConfigs() {
+ const cards = [...apiConfigsContainer.querySelectorAll(".api-config-card")];
+ return cards.map((card) => readApiConfigFromCard(card));
+}
+
+function updateApiConfigSelect(preferredId) {
+ const configs = collectApiConfigs();
+ activeApiConfigSelect.innerHTML = "";
+
+ if (!configs.length) {
+ const option = document.createElement("option");
+ option.value = "";
+ option.textContent = "No configs configured";
+ activeApiConfigSelect.appendChild(option);
+ activeApiConfigSelect.disabled = true;
+ return;
+ }
+
+ activeApiConfigSelect.disabled = false;
+ const selectedId =
+ preferredId && configs.some((config) => config.id === preferredId)
+ ? preferredId
+ : configs[0].id;
+
+ for (const config of configs) {
+ const option = document.createElement("option");
+ option.value = config.id;
+ option.textContent = config.name || "Default";
+ activeApiConfigSelect.appendChild(option);
+ }
+
+ activeApiConfigSelect.value = selectedId;
+}
+
function buildApiKeyCard(entry) {
const card = document.createElement("div");
card.className = "api-key-card";
@@ -97,11 +397,11 @@ function buildApiKeyCard(entry) {
deleteBtn.textContent = "Delete";
deleteBtn.addEventListener("click", () => {
card.remove();
- updateApiKeySelect();
+ updateApiConfigKeyOptions();
});
actions.appendChild(deleteBtn);
- const updateSelect = () => updateApiKeySelect(activeApiKeySelect.value);
+ const updateSelect = () => updateApiConfigKeyOptions();
nameInput.addEventListener("input", updateSelect);
keyInput.addEventListener("input", updateSelect);
@@ -125,33 +425,37 @@ function collectApiKeys() {
});
}
-function updateApiKeySelect(preferredId) {
+function updateApiConfigKeyOptions() {
const keys = collectApiKeys();
- activeApiKeySelect.innerHTML = "";
+ const selects = apiConfigsContainer.querySelectorAll(".api-config-key-select");
+ selects.forEach((select) => {
+ const preferred = select.dataset.preferred || select.value;
+ select.innerHTML = "";
+ if (!keys.length) {
+ const option = document.createElement("option");
+ option.value = "";
+ option.textContent = "No keys configured";
+ select.appendChild(option);
+ select.disabled = true;
+ return;
+ }
- if (!keys.length) {
- const option = document.createElement("option");
- option.value = "";
- option.textContent = "No keys configured";
- activeApiKeySelect.appendChild(option);
- activeApiKeySelect.disabled = true;
- return;
- }
+ select.disabled = false;
+ for (const key of keys) {
+ const option = document.createElement("option");
+ option.value = key.id;
+ option.textContent = key.name || "Default";
+ select.appendChild(option);
+ }
- activeApiKeySelect.disabled = false;
- const selectedId =
- preferredId && keys.some((key) => key.id === preferredId)
- ? preferredId
- : keys[0].id;
+ if (preferred && keys.some((key) => key.id === preferred)) {
+ select.value = preferred;
+ } else {
+ select.value = 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;
+ select.dataset.preferred = select.value;
+ });
}
function buildTaskCard(task) {
@@ -242,7 +546,10 @@ function buildTaskCard(task) {
});
addBelowBtn.addEventListener("click", () => {
- const newCard = buildTaskCard({ id: newTaskId(), name: "", text: "" });
+ const name = buildUniqueDefaultName(
+ collectNames(tasksContainer, ".task-name")
+ );
+ const newCard = buildTaskCard({ id: newTaskId(), name, text: "" });
card.insertAdjacentElement("afterend", newCard);
updateTaskControls();
});
@@ -250,7 +557,10 @@ function buildTaskCard(task) {
duplicateBtn.addEventListener("click", () => {
const copy = {
id: newTaskId(),
- name: `${nameInput.value || "Untitled"} Copy`,
+ name: ensureUniqueName(
+ `${nameInput.value || "Untitled"} Copy`,
+ collectNames(tasksContainer, ".task-name")
+ ),
text: textArea.value
};
const newCard = buildTaskCard(copy);
@@ -307,6 +617,8 @@ async function loadSettings() {
apiKey = "",
apiKeys = [],
activeApiKeyId = "",
+ apiConfigs = [],
+ activeApiConfigId = "",
apiBaseUrl = "",
apiKeyHeader = "",
apiKeyPrefix = "",
@@ -319,6 +631,8 @@ async function loadSettings() {
"apiKey",
"apiKeys",
"activeApiKeyId",
+ "apiConfigs",
+ "activeApiConfigId",
"apiBaseUrl",
"apiKeyHeader",
"apiKeyPrefix",
@@ -329,10 +643,6 @@ async function loadSettings() {
"theme"
]);
- apiBaseUrlInput.value = apiBaseUrl;
- apiKeyHeaderInput.value = apiKeyHeader;
- apiKeyPrefixInput.value = apiKeyPrefix;
- modelInput.value = model;
systemPromptInput.value = systemPrompt;
resumeInput.value = resume;
themeSelect.value = theme;
@@ -349,17 +659,75 @@ async function loadSettings() {
apiKeys: resolvedKeys,
activeApiKeyId: resolvedActiveId
});
+ } else if (resolvedKeys.length) {
+ const hasActive = resolvedKeys.some((entry) => entry.id === resolvedActiveId);
+ if (!hasActive) {
+ resolvedActiveId = resolvedKeys[0].id;
+ await chrome.storage.local.set({ activeApiKeyId: resolvedActiveId });
+ }
}
apiKeysContainer.innerHTML = "";
if (!resolvedKeys.length) {
- apiKeysContainer.appendChild(buildApiKeyCard({ id: newApiKeyId(), name: "", key: "" }));
+ apiKeysContainer.appendChild(
+ buildApiKeyCard({ id: newApiKeyId(), name: "", key: "" })
+ );
} else {
for (const entry of resolvedKeys) {
apiKeysContainer.appendChild(buildApiKeyCard(entry));
}
}
- updateApiKeySelect(resolvedActiveId);
+
+ let resolvedConfigs = Array.isArray(apiConfigs) ? apiConfigs : [];
+ let resolvedActiveConfigId = activeApiConfigId;
+
+ if (!resolvedConfigs.length) {
+ const migrated = {
+ id: newApiConfigId(),
+ name: "Default",
+ apiBaseUrl: apiBaseUrl || OPENAI_DEFAULTS.apiBaseUrl,
+ apiKeyHeader: apiKeyHeader || OPENAI_DEFAULTS.apiKeyHeader,
+ apiKeyPrefix: apiKeyPrefix || OPENAI_DEFAULTS.apiKeyPrefix,
+ model: model || DEFAULT_MODEL,
+ apiKeyId: resolvedActiveId || resolvedKeys[0]?.id || "",
+ apiUrl: "",
+ requestTemplate: "",
+ advanced: false
+ };
+ resolvedConfigs = [migrated];
+ resolvedActiveConfigId = migrated.id;
+ await chrome.storage.local.set({
+ apiConfigs: resolvedConfigs,
+ activeApiConfigId: resolvedActiveConfigId
+ });
+ } else {
+ const fallbackKeyId = resolvedActiveId || resolvedKeys[0]?.id || "";
+ const withKeys = resolvedConfigs.map((config) => ({
+ ...config,
+ apiKeyId: config.apiKeyId || fallbackKeyId,
+ apiUrl: config.apiUrl || "",
+ requestTemplate: config.requestTemplate || "",
+ advanced: Boolean(config.advanced)
+ }));
+ if (withKeys.some((config, index) => config.apiKeyId !== resolvedConfigs[index].apiKeyId)) {
+ resolvedConfigs = withKeys;
+ await chrome.storage.local.set({ apiConfigs: resolvedConfigs });
+ }
+ const hasActive = resolvedConfigs.some(
+ (config) => config.id === resolvedActiveConfigId
+ );
+ if (!hasActive) {
+ resolvedActiveConfigId = resolvedConfigs[0].id;
+ await chrome.storage.local.set({ activeApiConfigId: resolvedActiveConfigId });
+ }
+ }
+
+ apiConfigsContainer.innerHTML = "";
+ for (const config of resolvedConfigs) {
+ apiConfigsContainer.appendChild(buildApiConfigCard(config));
+ }
+ updateApiConfigKeyOptions();
+ updateApiConfigSelect(resolvedActiveConfigId);
tasksContainer.innerHTML = "";
if (!tasks.length) {
@@ -379,17 +747,21 @@ async function loadSettings() {
async function saveSettings() {
const tasks = collectTasks();
const apiKeys = collectApiKeys();
+ const apiConfigs = collectApiConfigs();
+ const activeApiConfigId =
+ apiConfigs.find((entry) => entry.id === activeApiConfigSelect.value)?.id ||
+ apiConfigs[0]?.id ||
+ "";
+ const activeConfig = apiConfigs.find((entry) => entry.id === activeApiConfigId);
const activeApiKeyId =
- apiKeys.find((entry) => entry.id === activeApiKeySelect.value)?.id ||
+ activeConfig?.apiKeyId ||
apiKeys[0]?.id ||
"";
await chrome.storage.local.set({
- apiBaseUrl: apiBaseUrlInput.value.trim(),
- apiKeyHeader: apiKeyHeaderInput.value.trim(),
- apiKeyPrefix: apiKeyPrefixInput.value,
apiKeys,
activeApiKeyId,
- model: modelInput.value.trim(),
+ apiConfigs,
+ activeApiConfigId,
systemPrompt: systemPromptInput.value,
resume: resumeInput.value,
tasks,
@@ -400,7 +772,10 @@ async function saveSettings() {
saveBtn.addEventListener("click", () => void saveSettings());
addTaskBtn.addEventListener("click", () => {
- const newCard = buildTaskCard({ id: newTaskId(), name: "", text: "" });
+ const name = buildUniqueDefaultName(
+ collectNames(tasksContainer, ".task-name")
+ );
+ const newCard = buildTaskCard({ id: newTaskId(), name, text: "" });
const first = tasksContainer.firstElementChild;
if (first) {
tasksContainer.insertBefore(newCard, first);
@@ -411,31 +786,49 @@ addTaskBtn.addEventListener("click", () => {
});
addApiKeyBtn.addEventListener("click", () => {
- const newCard = buildApiKeyCard({ id: newApiKeyId(), name: "", key: "" });
+ const name = buildUniqueDefaultName(
+ collectNames(apiKeysContainer, ".api-key-name")
+ );
+ const newCard = buildApiKeyCard({ id: newApiKeyId(), name, key: "" });
const first = apiKeysContainer.firstElementChild;
if (first) {
apiKeysContainer.insertBefore(newCard, first);
} else {
apiKeysContainer.appendChild(newCard);
}
- updateApiKeySelect(activeApiKeySelect.value);
+ updateApiConfigKeyOptions();
});
-activeApiKeySelect.addEventListener("change", () => {
- updateApiKeySelect(activeApiKeySelect.value);
+addApiConfigBtn.addEventListener("click", () => {
+ const name = buildUniqueDefaultName(
+ collectNames(apiConfigsContainer, ".api-config-name")
+ );
+ const newCard = buildApiConfigCard({
+ id: newApiConfigId(),
+ name,
+ apiBaseUrl: OPENAI_DEFAULTS.apiBaseUrl,
+ apiKeyHeader: OPENAI_DEFAULTS.apiKeyHeader,
+ apiKeyPrefix: OPENAI_DEFAULTS.apiKeyPrefix,
+ model: DEFAULT_MODEL,
+ apiUrl: "",
+ requestTemplate: "",
+ advanced: false
+ });
+ const first = apiConfigsContainer.firstElementChild;
+ if (first) {
+ apiConfigsContainer.insertBefore(newCard, first);
+ } else {
+ apiConfigsContainer.appendChild(newCard);
+ }
+ updateApiConfigKeyOptions();
+ updateApiConfigSelect(activeApiConfigSelect.value);
+});
+
+activeApiConfigSelect.addEventListener("change", () => {
+ updateApiConfigSelect(activeApiConfigSelect.value);
});
themeSelect.addEventListener("change", () => applyTheme(themeSelect.value));
-resetApiBtn.addEventListener("click", async () => {
- apiBaseUrlInput.value = OPENAI_DEFAULTS.apiBaseUrl;
- apiKeyHeaderInput.value = OPENAI_DEFAULTS.apiKeyHeader;
- apiKeyPrefixInput.value = OPENAI_DEFAULTS.apiKeyPrefix;
- await chrome.storage.local.set({
- apiBaseUrl: OPENAI_DEFAULTS.apiBaseUrl,
- apiKeyHeader: OPENAI_DEFAULTS.apiKeyHeader,
- apiKeyPrefix: OPENAI_DEFAULTS.apiKeyPrefix
- });
- setStatus("OpenAI defaults restored.");
-});
+themeSelect.addEventListener("change", () => applyTheme(themeSelect.value));
loadSettings();