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 = {
apiKey: "",
apiKeys: [],
activeApiKeyId: "",
apiConfigs: [],
activeApiConfigId: "",
envConfigs: [],
activeEnvConfigId: "",
apiBaseUrl: "https://api.openai.com/v1",
apiKeyHeader: "Authorization",
apiKeyPrefix: "Bearer ",
@@ -80,6 +86,186 @@ chrome.runtime.onInstalled.addListener(async () => {
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) {
await chrome.storage.local.set(updates);
}
@@ -175,6 +361,9 @@ async function handleAnalysisRequest(port, payload, signal) {
const {
apiKey,
apiMode,
apiUrl,
requestTemplate,
apiBaseUrl,
apiKeyHeader,
apiKeyPrefix,
@@ -186,6 +375,21 @@ async function handleAnalysisRequest(port, payload, signal) {
tabId
} = 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) {
safePost(port, { type: "ERROR", message: "Missing API base URL." });
return;
@@ -200,6 +404,7 @@ async function handleAnalysisRequest(port, payload, signal) {
safePost(port, { type: "ERROR", message: "Missing model name." });
return;
}
}
if (!postingText) {
safePost(port, { type: "ERROR", message: "No job posting text provided." });
@@ -217,6 +422,24 @@ async function handleAnalysisRequest(port, payload, signal) {
openKeepalive(tabId);
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({
apiKey,
apiBaseUrl,
@@ -231,6 +454,7 @@ async function handleAnalysisRequest(port, payload, signal) {
broadcast({ type: "DELTA", text });
}
});
}
broadcast({ type: "DONE" });
} 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({
apiKey,
apiBaseUrl,
@@ -296,39 +587,54 @@ 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,
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,
"name": "WWCompanion",
"version": "0.2.3",
"version": "0.3.0",
"description": "AI companion for WaterlooWorks job postings.",
"permissions": ["storage", "activeTab"],
"host_permissions": ["https://waterlooworks.uwaterloo.ca/*"],

View File

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

View File

@@ -17,17 +17,19 @@
<section class="panel">
<div class="controls-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>
<select id="taskSelect"></select>
</div>
<div class="button-row">
<button id="extractBtn" class="primary">Extract</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>
<button id="runBtn" class="accent">Run</button>
<button id="abortBtn" class="ghost stop-btn hidden" disabled>Stop</button>
</div>
</div>
</div>

View File

@@ -1,10 +1,7 @@
const extractBtn = document.getElementById("extractBtn");
const analyzeBtn = document.getElementById("analyzeBtn");
const runBtn = document.getElementById("runBtn");
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 envSelect = document.getElementById("envSelect");
const outputEl = document.getElementById("output");
const statusEl = document.getElementById("status");
const postingCountEl = document.getElementById("postingCount");
@@ -16,14 +13,19 @@ const clearOutputBtn = document.getElementById("clearOutputBtn");
const OUTPUT_STORAGE_KEY = "lastOutput";
const AUTO_RUN_KEY = "autoRunDefaultTask";
const LAST_TASK_KEY = "lastSelectedTaskId";
const LAST_ENV_KEY = "lastSelectedEnvId";
const state = {
postingText: "",
tasks: [],
envs: [],
port: null,
isAnalyzing: false,
outputRaw: "",
autoRunPending: false
autoRunPending: false,
selectedTaskId: "",
selectedEnvId: ""
};
function getStorage(keys) {
@@ -245,15 +247,12 @@ function applyTheme(theme) {
function setAnalyzing(isAnalyzing) {
state.isAnalyzing = isAnalyzing;
analyzeBtn.disabled = isAnalyzing;
runBtn.disabled = isAnalyzing;
abortBtn.disabled = !isAnalyzing;
extractBtn.disabled = isAnalyzing;
extractRunBtn.disabled = isAnalyzing;
if (buttonRow && stopRow) {
buttonRow.classList.toggle("hidden", isAnalyzing);
stopRow.classList.toggle("hidden", !isAnalyzing);
}
runBtn.classList.toggle("hidden", isAnalyzing);
abortBtn.classList.toggle("hidden", !isAnalyzing);
updateTaskSelectState();
updateEnvSelectState();
}
function updatePostingCount() {
@@ -286,11 +285,70 @@ function renderTasks(tasks) {
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() {
const hasTasks = state.tasks.length > 0;
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) {
try {
return new URL(url).hostname === "waterlooworks.uwaterloo.ca";
@@ -377,9 +435,45 @@ function ensurePort() {
return port;
}
async function loadTasks() {
const { tasks = [] } = await getStorage(["tasks"]);
async function loadConfig() {
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);
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();
}
@@ -428,9 +522,24 @@ async function handleAnalyze() {
return;
}
const { apiKey, apiBaseUrl, apiKeyHeader, apiKeyPrefix, model, systemPrompt, resume } =
await getStorage([
"apiKey",
const {
apiKeys = [],
activeApiKeyId = "",
apiConfigs = [],
activeApiConfigId = "",
envConfigs = [],
apiBaseUrl,
apiKeyHeader,
apiKeyPrefix,
model,
systemPrompt,
resume
} = await getStorage([
"apiKeys",
"activeApiKeyId",
"apiConfigs",
"activeApiConfigId",
"envConfigs",
"apiBaseUrl",
"apiKeyHeader",
"apiKeyPrefix",
@@ -439,20 +548,71 @@ async function handleAnalyze() {
"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.");
return;
}
if (apiKeyHeader && !apiKey) {
setStatus("Add your API key in Settings.");
if (resolvedApiKeyHeader && !apiKey) {
setStatus("Add an API key in Settings.");
return;
}
if (!model) {
if (!resolvedModel) {
setStatus("Set a model name in Settings.");
return;
}
}
const promptText = buildUserMessage(resume || "", task.text || "", state.postingText);
updatePromptCount(promptText.length);
@@ -467,11 +627,14 @@ async function handleAnalyze() {
type: "START_ANALYSIS",
payload: {
apiKey,
apiBaseUrl,
apiKeyHeader,
apiKeyPrefix,
model,
systemPrompt: systemPrompt || "",
apiMode: isAdvanced ? "advanced" : "basic",
apiUrl: resolvedApiUrl,
requestTemplate: resolvedTemplate,
apiBaseUrl: resolvedApiBaseUrl,
apiKeyHeader: resolvedApiKeyHeader,
apiKeyPrefix: resolvedApiKeyPrefix,
model: resolvedModel,
systemPrompt: resolvedSystemPrompt,
resume: resume || "",
taskText: task.text || "",
postingText: state.postingText,
@@ -527,20 +690,26 @@ function handleCopyRaw() {
void copyTextToClipboard(text, "Markdown");
}
extractBtn.addEventListener("click", handleExtract);
analyzeBtn.addEventListener("click", handleAnalyze);
extractRunBtn.addEventListener("click", handleExtractAndAnalyze);
runBtn.addEventListener("click", handleExtractAndAnalyze);
abortBtn.addEventListener("click", handleAbort);
settingsBtn.addEventListener("click", () => chrome.runtime.openOptionsPage());
copyRenderedBtn.addEventListener("click", handleCopyRendered);
copyRawBtn.addEventListener("click", handleCopyRaw);
clearOutputBtn.addEventListener("click", () => void handleClearOutput());
taskSelect.addEventListener("change", () => {
selectTask(taskSelect.value, { resetEnv: true });
void persistSelections();
});
envSelect.addEventListener("change", () => {
setEnvironmentSelection(envSelect.value);
void persistSelections();
});
updatePostingCount();
updatePromptCount(0);
renderOutput();
setAnalyzing(false);
loadTasks();
loadConfig();
loadTheme();
async function loadSavedOutput() {
@@ -562,7 +731,8 @@ function maybeRunDefaultTask() {
if (!state.autoRunPending) return;
if (state.isAnalyzing) return;
if (!state.tasks.length) return;
taskSelect.value = state.tasks[0].id;
selectTask(state.tasks[0].id, { resetEnv: true });
void persistSelections();
state.autoRunPending = false;
void handleExtractAndAnalyze();
}

View File

@@ -124,6 +124,11 @@ body {
margin-bottom: 12px;
}
.row-actions {
display: flex;
gap: 8px;
}
.row-title {
display: flex;
align-items: baseline;
@@ -229,6 +234,83 @@ button:active {
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) {
:root:not([data-theme]),
:root[data-theme="system"] {
@@ -251,7 +333,3 @@ button:active {
gap: 6px;
justify-content: flex-end;
}
.task-actions .delete {
color: #c0392b;
}

View File

@@ -16,49 +16,6 @@
<button id="saveBtn" class="accent">Save Settings</button>
</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">
<summary class="panel-summary">
<span class="panel-caret" aria-hidden="true">
@@ -85,14 +42,14 @@
<span class="caret-closed"></span>
<span class="caret-open"></span>
</span>
<h2>System Prompt</h2>
<h2>API KEYS</h2>
</summary>
<div class="panel-body">
<textarea
id="systemPrompt"
rows="8"
placeholder="Define tone and standards..."
></textarea>
<div class="row">
<div></div>
<button id="addApiKeyBtn" class="ghost" type="button">Add Key</button>
</div>
<div id="apiKeys" class="api-keys"></div>
</div>
</details>
@@ -102,7 +59,49 @@
<span class="caret-closed"></span>
<span class="caret-open"></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>
<span class="hint hint-accent">Text to your profile goes here</span>
</div>
</summary>
<div class="panel-body">
<textarea id="resume" rows="10" placeholder="Paste your resume text..."></textarea>

File diff suppressed because it is too large Load Diff