Added multi-api support and advanced mode

This commit is contained in:
2026-01-17 16:46:01 -05:00
parent 3bb350f3cf
commit 2638e08453
5 changed files with 809 additions and 166 deletions

View File

@@ -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);
}

View File

@@ -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 || "",

View File

@@ -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;
}

View File

@@ -27,32 +27,15 @@
<div class="panel-body">
<div class="row">
<div></div>
<button id="resetApiBtn" class="ghost" type="button">Reset to OpenAI</button>
<div class="row-actions">
<button id="addApiConfigBtn" class="ghost" type="button">Add Config</button>
</div>
</div>
<div class="field">
<label for="activeApiKeySelect">Active key</label>
<select id="activeApiKeySelect"></select>
</div>
<div class="field">
<label for="apiBaseUrl">API Base URL</label>
<input
id="apiBaseUrl"
type="text"
placeholder="https://api.openai.com/v1"
/>
</div>
<div class="field">
<label for="apiKeyHeader">API Key Header</label>
<input id="apiKeyHeader" type="text" placeholder="Authorization" />
</div>
<div class="field">
<label for="apiKeyPrefix">API Key Prefix</label>
<input id="apiKeyPrefix" type="text" placeholder="Bearer " />
</div>
<div class="field">
<label for="model">Model name</label>
<input id="model" type="text" placeholder="gpt-4o-mini" />
<label for="activeApiConfigSelect">Active config</label>
<select id="activeApiConfigSelect"></select>
</div>
<div id="apiConfigs" class="api-configs"></div>
</div>
</details>
@@ -62,7 +45,7 @@
<span class="caret-closed"></span>
<span class="caret-open"></span>
</span>
<h2>API Keys</h2>
<h2>API KEYS</h2>
</summary>
<div class="panel-body">
<div class="row">

View File

@@ -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();