dev-multi-env: modularized environments (#1)

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-01-17 22:38:36 +00:00
parent 3eb9863c3e
commit b08495815f
8 changed files with 1785 additions and 225 deletions

View File

@@ -15,6 +15,12 @@ const DEFAULT_TASKS = [
const DEFAULT_SETTINGS = { const DEFAULT_SETTINGS = {
apiKey: "", apiKey: "",
apiKeys: [],
activeApiKeyId: "",
apiConfigs: [],
activeApiConfigId: "",
envConfigs: [],
activeEnvConfigId: "",
apiBaseUrl: "https://api.openai.com/v1", apiBaseUrl: "https://api.openai.com/v1",
apiKeyHeader: "Authorization", apiKeyHeader: "Authorization",
apiKeyPrefix: "Bearer ", apiKeyPrefix: "Bearer ",
@@ -80,6 +86,186 @@ 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;
} 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;
}
}
const resolvedApiConfigs = updates.apiConfigs || stored.apiConfigs || [];
const resolvedActiveApiConfigId =
updates.activeApiConfigId ||
stored.activeApiConfigId ||
resolvedApiConfigs[0]?.id ||
"";
const hasEnvConfigs =
Array.isArray(stored.envConfigs) && stored.envConfigs.length > 0;
if (!hasEnvConfigs) {
const id = crypto?.randomUUID
? crypto.randomUUID()
: `env-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
updates.envConfigs = [
{
id,
name: "Default",
apiConfigId: resolvedActiveApiConfigId,
systemPrompt: stored.systemPrompt || DEFAULT_SETTINGS.systemPrompt
}
];
updates.activeEnvConfigId = id;
} else {
const normalizedEnvs = stored.envConfigs.map((config) => ({
...config,
apiConfigId: config.apiConfigId || resolvedActiveApiConfigId,
systemPrompt: config.systemPrompt ?? ""
}));
const envNeedsUpdate = normalizedEnvs.some((config, index) => {
const original = stored.envConfigs[index];
return (
config.apiConfigId !== original.apiConfigId ||
(config.systemPrompt || "") !== (original.systemPrompt || "")
);
});
if (envNeedsUpdate) {
updates.envConfigs = normalizedEnvs;
}
const envActiveId = updates.activeEnvConfigId || stored.activeEnvConfigId;
if (envActiveId) {
const exists = stored.envConfigs.some(
(config) => config.id === envActiveId
);
if (!exists) {
updates.activeEnvConfigId = stored.envConfigs[0].id;
}
} else {
updates.activeEnvConfigId = stored.envConfigs[0].id;
}
}
const resolvedEnvConfigs = updates.envConfigs || stored.envConfigs || [];
const defaultEnvId =
resolvedEnvConfigs[0]?.id ||
updates.activeEnvConfigId ||
stored.activeEnvConfigId ||
"";
const taskSource = Array.isArray(updates.tasks)
? updates.tasks
: Array.isArray(stored.tasks)
? stored.tasks
: [];
if (taskSource.length) {
const normalizedTasks = taskSource.map((task) => ({
...task,
defaultEnvId: task.defaultEnvId || defaultEnvId
}));
const needsTaskUpdate = normalizedTasks.some(
(task, index) => task.defaultEnvId !== taskSource[index]?.defaultEnvId
);
if (needsTaskUpdate) {
updates.tasks = normalizedTasks;
}
}
if (Object.keys(updates).length) { if (Object.keys(updates).length) {
await chrome.storage.local.set(updates); await chrome.storage.local.set(updates);
} }
@@ -175,6 +361,9 @@ async function handleAnalysisRequest(port, payload, signal) {
const { const {
apiKey, apiKey,
apiMode,
apiUrl,
requestTemplate,
apiBaseUrl, apiBaseUrl,
apiKeyHeader, apiKeyHeader,
apiKeyPrefix, apiKeyPrefix,
@@ -186,6 +375,21 @@ async function handleAnalysisRequest(port, payload, signal) {
tabId tabId
} = payload || {}; } = payload || {};
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;
}
if (apiKeyHeader && !apiKey) {
safePost(port, { type: "ERROR", message: "Missing API key." });
return;
}
} else {
if (!apiBaseUrl) { if (!apiBaseUrl) {
safePost(port, { type: "ERROR", message: "Missing API base URL." }); safePost(port, { type: "ERROR", message: "Missing API base URL." });
return; return;
@@ -200,6 +404,7 @@ async function handleAnalysisRequest(port, payload, signal) {
safePost(port, { type: "ERROR", message: "Missing model name." }); safePost(port, { type: "ERROR", message: "Missing model name." });
return; return;
} }
}
if (!postingText) { if (!postingText) {
safePost(port, { type: "ERROR", message: "No job posting text provided." }); safePost(port, { type: "ERROR", message: "No job posting text provided." });
@@ -217,6 +422,24 @@ async function handleAnalysisRequest(port, payload, signal) {
openKeepalive(tabId); openKeepalive(tabId);
try { try {
if (isAdvanced) {
await streamCustomCompletion({
apiKey,
apiUrl,
requestTemplate,
apiKeyHeader,
apiKeyPrefix,
apiBaseUrl,
model,
systemPrompt: systemPrompt || "",
userMessage,
signal,
onDelta: (text) => {
streamState.outputText += text;
broadcast({ type: "DELTA", text });
}
});
} else {
await streamChatCompletion({ await streamChatCompletion({
apiKey, apiKey,
apiBaseUrl, apiBaseUrl,
@@ -231,6 +454,7 @@ async function handleAnalysisRequest(port, payload, signal) {
broadcast({ type: "DELTA", text }); broadcast({ type: "DELTA", text });
} }
}); });
}
broadcast({ type: "DONE" }); broadcast({ type: "DONE" });
} finally { } finally {
@@ -255,6 +479,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({ async function streamChatCompletion({
apiKey, apiKey,
apiBaseUrl, apiBaseUrl,
@@ -296,39 +587,54 @@ async function streamChatCompletion({
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); 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(); await readSseStream(response, onDelta);
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; async function streamCustomCompletion({
if (delta) onDelta(delta); apiKey,
apiUrl,
requestTemplate,
apiKeyHeader,
apiKeyPrefix,
apiBaseUrl,
model,
systemPrompt,
userMessage,
signal,
onDelta
}) {
const replacements = {
PROMPT_GOES_HERE: userMessage,
SYSTEM_PROMPT_GOES_HERE: systemPrompt,
API_KEY_GOES_HERE: apiKey,
MODEL_GOES_HERE: model || "",
API_BASE_URL_GOES_HERE: apiBaseUrl || ""
};
const resolvedUrl = replaceUrlTokens(apiUrl, replacements);
const body = buildTemplateBody(requestTemplate, replacements);
const headers = {
"Content-Type": "application/json"
};
const authHeader = buildAuthHeader(apiKeyHeader, apiKeyPrefix, apiKey);
if (authHeader) {
headers[authHeader.name] = authHeader.value;
} }
const response = await fetch(resolvedUrl, {
method: "POST",
headers,
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

@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "WWCompanion", "name": "WWCompanion",
"version": "0.2.3", "version": "0.3.0",
"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

@@ -127,28 +127,33 @@ select {
font-size: 12px; font-size: 12px;
} }
.button-row { .env-row {
display: grid; display: flex;
grid-template-columns: minmax(64px, 0.8fr) minmax(0, 1.4fr) minmax(64px, 0.8fr); align-items: flex-end;
}
.env-row .env-field {
flex: 1;
margin: 0;
}
.task-row {
display: flex;
align-items: flex-end;
gap: 8px; gap: 8px;
} }
.button-row button { .task-row button {
white-space: nowrap; padding: 6px 15px;
} }
.button-row .primary { .task-row .task-field {
padding: 6px 8px; flex: 1;
margin: 0;
} }
.stop-row { .task-row select {
display: flex; min-width: 0;
justify-content: stretch;
margin-top: 4px;
}
.stop-row button {
width: 100%;
} }
.hidden { .hidden {
@@ -192,7 +197,7 @@ button:active {
border: 1px solid var(--border); border: 1px solid var(--border);
} }
.stop-row .ghost { .stop-btn {
background: #c0392b; background: #c0392b;
border-color: #c0392b; border-color: #c0392b;
color: #fff6f2; color: #fff6f2;

View File

@@ -17,17 +17,19 @@
<section class="panel"> <section class="panel">
<div class="controls-block"> <div class="controls-block">
<div class="config-block"> <div class="config-block">
<div class="field inline-field"> <div class="env-row">
<div class="field inline-field env-field">
<label for="envSelect">Environment</label>
<select id="envSelect"></select>
</div>
</div>
<div class="task-row">
<div class="field inline-field task-field">
<label for="taskSelect">Task</label> <label for="taskSelect">Task</label>
<select id="taskSelect"></select> <select id="taskSelect"></select>
</div> </div>
<div class="button-row"> <button id="runBtn" class="accent">Run</button>
<button id="extractBtn" class="primary">Extract</button> <button id="abortBtn" class="ghost stop-btn hidden" disabled>Stop</button>
<button id="extractRunBtn" class="accent">Extract &amp; Run</button>
<button id="analyzeBtn" class="ghost">Run Task</button>
</div>
<div id="stopRow" class="stop-row hidden">
<button id="abortBtn" class="ghost" disabled>Stop</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,10 +1,7 @@
const extractBtn = document.getElementById("extractBtn"); const runBtn = document.getElementById("runBtn");
const analyzeBtn = document.getElementById("analyzeBtn");
const abortBtn = document.getElementById("abortBtn"); const abortBtn = document.getElementById("abortBtn");
const extractRunBtn = document.getElementById("extractRunBtn");
const stopRow = document.getElementById("stopRow");
const buttonRow = document.querySelector(".button-row");
const taskSelect = document.getElementById("taskSelect"); const taskSelect = document.getElementById("taskSelect");
const envSelect = document.getElementById("envSelect");
const outputEl = document.getElementById("output"); const outputEl = document.getElementById("output");
const statusEl = document.getElementById("status"); const statusEl = document.getElementById("status");
const postingCountEl = document.getElementById("postingCount"); const postingCountEl = document.getElementById("postingCount");
@@ -16,14 +13,19 @@ const clearOutputBtn = document.getElementById("clearOutputBtn");
const OUTPUT_STORAGE_KEY = "lastOutput"; const OUTPUT_STORAGE_KEY = "lastOutput";
const AUTO_RUN_KEY = "autoRunDefaultTask"; const AUTO_RUN_KEY = "autoRunDefaultTask";
const LAST_TASK_KEY = "lastSelectedTaskId";
const LAST_ENV_KEY = "lastSelectedEnvId";
const state = { const state = {
postingText: "", postingText: "",
tasks: [], tasks: [],
envs: [],
port: null, port: null,
isAnalyzing: false, isAnalyzing: false,
outputRaw: "", outputRaw: "",
autoRunPending: false autoRunPending: false,
selectedTaskId: "",
selectedEnvId: ""
}; };
function getStorage(keys) { function getStorage(keys) {
@@ -245,15 +247,12 @@ function applyTheme(theme) {
function setAnalyzing(isAnalyzing) { function setAnalyzing(isAnalyzing) {
state.isAnalyzing = isAnalyzing; state.isAnalyzing = isAnalyzing;
analyzeBtn.disabled = isAnalyzing; runBtn.disabled = isAnalyzing;
abortBtn.disabled = !isAnalyzing; abortBtn.disabled = !isAnalyzing;
extractBtn.disabled = isAnalyzing; runBtn.classList.toggle("hidden", isAnalyzing);
extractRunBtn.disabled = isAnalyzing; abortBtn.classList.toggle("hidden", !isAnalyzing);
if (buttonRow && stopRow) {
buttonRow.classList.toggle("hidden", isAnalyzing);
stopRow.classList.toggle("hidden", !isAnalyzing);
}
updateTaskSelectState(); updateTaskSelectState();
updateEnvSelectState();
} }
function updatePostingCount() { function updatePostingCount() {
@@ -286,11 +285,70 @@ function renderTasks(tasks) {
updateTaskSelectState(); updateTaskSelectState();
} }
function renderEnvironments(envs) {
state.envs = envs;
envSelect.innerHTML = "";
if (!envs.length) {
const option = document.createElement("option");
option.textContent = "No environments configured";
option.value = "";
envSelect.appendChild(option);
updateEnvSelectState();
return;
}
for (const env of envs) {
const option = document.createElement("option");
option.value = env.id;
option.textContent = env.name || "Default";
envSelect.appendChild(option);
}
updateEnvSelectState();
}
function updateTaskSelectState() { function updateTaskSelectState() {
const hasTasks = state.tasks.length > 0; const hasTasks = state.tasks.length > 0;
taskSelect.disabled = state.isAnalyzing || !hasTasks; taskSelect.disabled = state.isAnalyzing || !hasTasks;
} }
function updateEnvSelectState() {
const hasEnvs = state.envs.length > 0;
envSelect.disabled = state.isAnalyzing || !hasEnvs;
}
function getTaskDefaultEnvId(task) {
return task?.defaultEnvId || state.envs[0]?.id || "";
}
function setEnvironmentSelection(envId) {
const target =
envId && state.envs.some((env) => env.id === envId)
? envId
: state.envs[0]?.id || "";
if (target) {
envSelect.value = target;
}
state.selectedEnvId = target;
}
function selectTask(taskId, { resetEnv } = { resetEnv: false }) {
if (!taskId) return;
taskSelect.value = taskId;
state.selectedTaskId = taskId;
const task = state.tasks.find((item) => item.id === taskId);
if (resetEnv) {
setEnvironmentSelection(getTaskDefaultEnvId(task));
}
}
async function persistSelections() {
await chrome.storage.local.set({
[LAST_TASK_KEY]: state.selectedTaskId,
[LAST_ENV_KEY]: state.selectedEnvId
});
}
function isWaterlooWorksUrl(url) { function isWaterlooWorksUrl(url) {
try { try {
return new URL(url).hostname === "waterlooworks.uwaterloo.ca"; return new URL(url).hostname === "waterlooworks.uwaterloo.ca";
@@ -377,9 +435,45 @@ function ensurePort() {
return port; return port;
} }
async function loadTasks() { async function loadConfig() {
const { tasks = [] } = await getStorage(["tasks"]); const stored = await getStorage([
"tasks",
"envConfigs",
LAST_TASK_KEY,
LAST_ENV_KEY
]);
const tasks = Array.isArray(stored.tasks) ? stored.tasks : [];
const envs = Array.isArray(stored.envConfigs) ? stored.envConfigs : [];
renderTasks(tasks); renderTasks(tasks);
renderEnvironments(envs);
if (!tasks.length) {
state.selectedTaskId = "";
state.selectedEnvId = envs[0]?.id || "";
return;
}
const storedTaskId = stored[LAST_TASK_KEY];
const storedEnvId = stored[LAST_ENV_KEY];
const initialTaskId = tasks.some((task) => task.id === storedTaskId)
? storedTaskId
: tasks[0].id;
selectTask(initialTaskId, { resetEnv: false });
const task = tasks.find((item) => item.id === initialTaskId);
if (storedEnvId && envs.some((env) => env.id === storedEnvId)) {
setEnvironmentSelection(storedEnvId);
} else {
setEnvironmentSelection(getTaskDefaultEnvId(task));
}
if (
storedTaskId !== state.selectedTaskId ||
storedEnvId !== state.selectedEnvId
) {
await persistSelections();
}
maybeRunDefaultTask(); maybeRunDefaultTask();
} }
@@ -428,9 +522,24 @@ async function handleAnalyze() {
return; return;
} }
const { apiKey, apiBaseUrl, apiKeyHeader, apiKeyPrefix, model, systemPrompt, resume } = const {
await getStorage([ apiKeys = [],
"apiKey", activeApiKeyId = "",
apiConfigs = [],
activeApiConfigId = "",
envConfigs = [],
apiBaseUrl,
apiKeyHeader,
apiKeyPrefix,
model,
systemPrompt,
resume
} = await getStorage([
"apiKeys",
"activeApiKeyId",
"apiConfigs",
"activeApiConfigId",
"envConfigs",
"apiBaseUrl", "apiBaseUrl",
"apiKeyHeader", "apiKeyHeader",
"apiKeyPrefix", "apiKeyPrefix",
@@ -439,20 +548,71 @@ async function handleAnalyze() {
"resume" "resume"
]); ]);
if (!apiBaseUrl) { const resolvedConfigs = Array.isArray(apiConfigs) ? apiConfigs : [];
const resolvedEnvs = Array.isArray(envConfigs) ? envConfigs : [];
const selectedEnvId = envSelect.value;
const activeEnv =
resolvedEnvs.find((entry) => entry.id === selectedEnvId) ||
resolvedEnvs[0];
if (!activeEnv) {
setStatus("Add an environment in Settings.");
return;
}
const resolvedSystemPrompt =
activeEnv.systemPrompt ?? systemPrompt ?? "";
const resolvedApiConfigId =
activeEnv.apiConfigId || activeApiConfigId || resolvedConfigs[0]?.id || "";
const activeConfig =
resolvedConfigs.find((entry) => entry.id === resolvedApiConfigId) ||
resolvedConfigs[0];
if (!activeConfig) {
setStatus("Add an API configuration in Settings.");
return;
}
const isAdvanced = Boolean(activeConfig?.advanced);
const resolvedApiUrl = activeConfig?.apiUrl || "";
const resolvedTemplate = activeConfig?.requestTemplate || "";
const resolvedApiBaseUrl = activeConfig?.apiBaseUrl || apiBaseUrl || "";
const resolvedApiKeyHeader = activeConfig?.apiKeyHeader ?? apiKeyHeader ?? "";
const resolvedApiKeyPrefix = activeConfig?.apiKeyPrefix ?? apiKeyPrefix ?? "";
const resolvedModel = activeConfig?.model || model || "";
const resolvedKeys = Array.isArray(apiKeys) ? apiKeys : [];
const resolvedKeyId =
activeConfig?.apiKeyId || activeApiKeyId || resolvedKeys[0]?.id || "";
const activeKey = resolvedKeys.find((entry) => entry.id === resolvedKeyId);
const apiKey = activeKey?.key || "";
if (isAdvanced) {
if (!resolvedApiUrl) {
setStatus("Set an API URL in Settings.");
return;
}
if (!resolvedTemplate) {
setStatus("Set a request template in Settings.");
return;
}
const needsKey =
Boolean(resolvedApiKeyHeader) ||
resolvedTemplate.includes("API_KEY_GOES_HERE");
if (needsKey && !apiKey) {
setStatus("Add an API key in Settings.");
return;
}
} else {
if (!resolvedApiBaseUrl) {
setStatus("Set an API base URL in Settings."); setStatus("Set an API base URL in Settings.");
return; return;
} }
if (resolvedApiKeyHeader && !apiKey) {
if (apiKeyHeader && !apiKey) { setStatus("Add an API key in Settings.");
setStatus("Add your API key in Settings.");
return; return;
} }
if (!resolvedModel) {
if (!model) {
setStatus("Set a model name in Settings."); setStatus("Set a model name in Settings.");
return; return;
} }
}
const promptText = buildUserMessage(resume || "", task.text || "", state.postingText); const promptText = buildUserMessage(resume || "", task.text || "", state.postingText);
updatePromptCount(promptText.length); updatePromptCount(promptText.length);
@@ -467,11 +627,14 @@ async function handleAnalyze() {
type: "START_ANALYSIS", type: "START_ANALYSIS",
payload: { payload: {
apiKey, apiKey,
apiBaseUrl, apiMode: isAdvanced ? "advanced" : "basic",
apiKeyHeader, apiUrl: resolvedApiUrl,
apiKeyPrefix, requestTemplate: resolvedTemplate,
model, apiBaseUrl: resolvedApiBaseUrl,
systemPrompt: systemPrompt || "", apiKeyHeader: resolvedApiKeyHeader,
apiKeyPrefix: resolvedApiKeyPrefix,
model: resolvedModel,
systemPrompt: resolvedSystemPrompt,
resume: resume || "", resume: resume || "",
taskText: task.text || "", taskText: task.text || "",
postingText: state.postingText, postingText: state.postingText,
@@ -527,20 +690,26 @@ function handleCopyRaw() {
void copyTextToClipboard(text, "Markdown"); void copyTextToClipboard(text, "Markdown");
} }
extractBtn.addEventListener("click", handleExtract); runBtn.addEventListener("click", handleExtractAndAnalyze);
analyzeBtn.addEventListener("click", handleAnalyze);
extractRunBtn.addEventListener("click", handleExtractAndAnalyze);
abortBtn.addEventListener("click", handleAbort); abortBtn.addEventListener("click", handleAbort);
settingsBtn.addEventListener("click", () => chrome.runtime.openOptionsPage()); settingsBtn.addEventListener("click", () => chrome.runtime.openOptionsPage());
copyRenderedBtn.addEventListener("click", handleCopyRendered); copyRenderedBtn.addEventListener("click", handleCopyRendered);
copyRawBtn.addEventListener("click", handleCopyRaw); copyRawBtn.addEventListener("click", handleCopyRaw);
clearOutputBtn.addEventListener("click", () => void handleClearOutput()); clearOutputBtn.addEventListener("click", () => void handleClearOutput());
taskSelect.addEventListener("change", () => {
selectTask(taskSelect.value, { resetEnv: true });
void persistSelections();
});
envSelect.addEventListener("change", () => {
setEnvironmentSelection(envSelect.value);
void persistSelections();
});
updatePostingCount(); updatePostingCount();
updatePromptCount(0); updatePromptCount(0);
renderOutput(); renderOutput();
setAnalyzing(false); setAnalyzing(false);
loadTasks(); loadConfig();
loadTheme(); loadTheme();
async function loadSavedOutput() { async function loadSavedOutput() {
@@ -562,7 +731,8 @@ function maybeRunDefaultTask() {
if (!state.autoRunPending) return; if (!state.autoRunPending) return;
if (state.isAnalyzing) return; if (state.isAnalyzing) return;
if (!state.tasks.length) return; if (!state.tasks.length) return;
taskSelect.value = state.tasks[0].id; selectTask(state.tasks[0].id, { resetEnv: true });
void persistSelections();
state.autoRunPending = false; state.autoRunPending = false;
void handleExtractAndAnalyze(); void handleExtractAndAnalyze();
} }

View File

@@ -124,6 +124,11 @@ body {
margin-bottom: 12px; margin-bottom: 12px;
} }
.row-actions {
display: flex;
gap: 8px;
}
.row-title { .row-title {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
@@ -229,6 +234,83 @@ 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,
.api-config-actions .delete,
.env-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;
}
.env-configs {
display: grid;
gap: 12px;
}
.env-config-card {
padding: 12px;
border-radius: 12px;
border: 1px solid var(--border);
background: var(--card-bg);
display: grid;
gap: 8px;
}
.env-config-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
@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"] {
@@ -251,7 +333,3 @@ button:active {
gap: 6px; gap: 6px;
justify-content: flex-end; justify-content: flex-end;
} }
.task-actions .delete {
color: #c0392b;
}

View File

@@ -16,49 +16,6 @@
<button id="saveBtn" class="accent">Save Settings</button> <button id="saveBtn" class="accent">Save Settings</button>
</div> </div>
<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</h2>
</summary>
<div class="panel-body">
<div class="row">
<div></div>
<button id="resetApiBtn" class="ghost" type="button">Reset to OpenAI</button>
</div>
<div class="field">
<label for="apiKey">API Key</label>
<div class="inline">
<input id="apiKey" type="password" autocomplete="off" placeholder="sk-..." />
<button id="toggleKey" class="ghost" type="button">Show</button>
</div>
</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" />
</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">
@@ -85,14 +42,14 @@
<span class="caret-closed"></span> <span class="caret-closed"></span>
<span class="caret-open"></span> <span class="caret-open"></span>
</span> </span>
<h2>System Prompt</h2> <h2>API KEYS</h2>
</summary> </summary>
<div class="panel-body"> <div class="panel-body">
<textarea <div class="row">
id="systemPrompt" <div></div>
rows="8" <button id="addApiKeyBtn" class="ghost" type="button">Add Key</button>
placeholder="Define tone and standards..." </div>
></textarea> <div id="apiKeys" class="api-keys"></div>
</div> </div>
</details> </details>
@@ -102,7 +59,49 @@
<span class="caret-closed"></span> <span class="caret-closed"></span>
<span class="caret-open"></span> <span class="caret-open"></span>
</span> </span>
<h2>API</h2>
</summary>
<div class="panel-body">
<div class="row">
<div></div>
<div class="row-actions">
<button id="addApiConfigBtn" class="ghost" type="button">Add Config</button>
</div>
</div>
<div id="apiConfigs" class="api-configs"></div>
</div>
</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>
<div class="row-title">
<h2>Environment</h2>
<span class="hint hint-accent">API configuration and system prompt go here</span>
</div>
</summary>
<div class="panel-body">
<div class="row">
<div></div>
<button id="addEnvConfigBtn" class="ghost" type="button">Add Config</button>
</div>
<div id="envConfigs" class="env-configs"></div>
</div>
</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>
<div class="row-title">
<h2>Resume</h2> <h2>Resume</h2>
<span class="hint hint-accent">Text to your profile goes here</span>
</div>
</summary> </summary>
<div class="panel-body"> <div class="panel-body">
<textarea id="resume" rows="10" placeholder="Paste your resume text..."></textarea> <textarea id="resume" rows="10" placeholder="Paste your resume text..."></textarea>

File diff suppressed because it is too large Load Diff