Compare commits
9 Commits
wwcompanio
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 31e99f351e | |||
| 4a44fac8cf | |||
| 5d3c76853b | |||
| 605cd93aa6 | |||
| d49c33268a | |||
| b5be587a63 | |||
| b08495815f | |||
| 3eb9863c3e | |||
| 3dbdc79b54 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
AGENTS.md
|
||||
**/*.crx
|
||||
**/*.pem
|
||||
**/*.zip
|
||||
|
||||
@@ -15,9 +15,9 @@ LLM-enabled AI companionship with WaterlooWorks job postings.
|
||||
## Usage
|
||||
|
||||
- Load the extension into a Chromium-based browser (root directory at [wwcompanion-extension](wwcompanion-extension/))
|
||||
- Configure API settings, prompts, and tasks in **Settings**.
|
||||
- Configure API settings, prompts, your profiles, and tasks in **Settings**.
|
||||
- Open a WaterlooWorks job posting modal.
|
||||
- Click **Extract** or **Extract & Run** in the popup.
|
||||
- Click **Run** in the popup to extract the posting text and run the task.
|
||||
- Review the streamed response, then copy or clear it as needed.
|
||||
|
||||
## Configuration
|
||||
@@ -25,8 +25,9 @@ LLM-enabled AI companionship with WaterlooWorks job postings.
|
||||
- Open **Settings** from the popup.
|
||||
- Add your API key, base URL, and model (defaults target
|
||||
OpenAI-compatible APIs).
|
||||
- Set your system prompt and resume text.
|
||||
- Customize task presets; the top task is used as the default.
|
||||
- Set your system prompt in environments and resume text.
|
||||
- Set your profile with text from your resume or a profile description text.
|
||||
- Customize task presets; **the top task is used as the default**.
|
||||
|
||||
## Example prompts
|
||||
|
||||
|
||||
@@ -15,6 +15,13 @@ const DEFAULT_TASKS = [
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
apiKey: "",
|
||||
apiKeys: [],
|
||||
activeApiKeyId: "",
|
||||
apiConfigs: [],
|
||||
activeApiConfigId: "",
|
||||
envConfigs: [],
|
||||
activeEnvConfigId: "",
|
||||
profiles: [],
|
||||
apiBaseUrl: "https://api.openai.com/v1",
|
||||
apiKeyHeader: "Authorization",
|
||||
apiKeyPrefix: "Bearer ",
|
||||
@@ -80,6 +87,221 @@ 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 hasProfiles =
|
||||
Array.isArray(stored.profiles) && stored.profiles.length > 0;
|
||||
if (!hasProfiles) {
|
||||
const id = crypto?.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: `profile-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
||||
updates.profiles = [
|
||||
{
|
||||
id,
|
||||
name: "Default",
|
||||
text: stored.resume || "",
|
||||
type: "Resume"
|
||||
}
|
||||
];
|
||||
} else {
|
||||
const normalizedProfiles = stored.profiles.map((profile) => ({
|
||||
...profile,
|
||||
text: profile.text ?? "",
|
||||
type: profile.type === "Profile" ? "Profile" : "Resume"
|
||||
}));
|
||||
const needsProfileUpdate = normalizedProfiles.some(
|
||||
(profile, index) =>
|
||||
(profile.text || "") !== (stored.profiles[index]?.text || "") ||
|
||||
(profile.type || "Resume") !== (stored.profiles[index]?.type || "Resume")
|
||||
);
|
||||
if (needsProfileUpdate) {
|
||||
updates.profiles = normalizedProfiles;
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedEnvConfigs = updates.envConfigs || stored.envConfigs || [];
|
||||
const defaultEnvId =
|
||||
resolvedEnvConfigs[0]?.id ||
|
||||
updates.activeEnvConfigId ||
|
||||
stored.activeEnvConfigId ||
|
||||
"";
|
||||
const resolvedProfiles = updates.profiles || stored.profiles || [];
|
||||
const defaultProfileId = resolvedProfiles[0]?.id || "";
|
||||
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,
|
||||
defaultProfileId: task.defaultProfileId || defaultProfileId
|
||||
}));
|
||||
const needsTaskUpdate = normalizedTasks.some(
|
||||
(task, index) =>
|
||||
task.defaultEnvId !== taskSource[index]?.defaultEnvId ||
|
||||
task.defaultProfileId !== taskSource[index]?.defaultProfileId
|
||||
);
|
||||
if (needsTaskUpdate) {
|
||||
updates.tasks = normalizedTasks;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length) {
|
||||
await chrome.storage.local.set(updates);
|
||||
}
|
||||
@@ -142,9 +364,10 @@ chrome.runtime.onMessage.addListener((message) => {
|
||||
}
|
||||
});
|
||||
|
||||
function buildUserMessage(resume, task, posting) {
|
||||
function buildUserMessage(resume, resumeType, task, posting) {
|
||||
const header = resumeType === "Profile" ? "=== PROFILE ===" : "=== RESUME ===";
|
||||
return [
|
||||
"=== RESUME ===",
|
||||
header,
|
||||
resume || "",
|
||||
"",
|
||||
"=== TASK ===",
|
||||
@@ -175,30 +398,50 @@ async function handleAnalysisRequest(port, payload, signal) {
|
||||
|
||||
const {
|
||||
apiKey,
|
||||
apiMode,
|
||||
apiUrl,
|
||||
requestTemplate,
|
||||
apiBaseUrl,
|
||||
apiKeyHeader,
|
||||
apiKeyPrefix,
|
||||
model,
|
||||
systemPrompt,
|
||||
resume,
|
||||
resumeType,
|
||||
taskText,
|
||||
postingText,
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -211,26 +454,50 @@ async function handleAnalysisRequest(port, payload, signal) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userMessage = buildUserMessage(resume, taskText, postingText);
|
||||
const userMessage = buildUserMessage(
|
||||
resume,
|
||||
resumeType,
|
||||
taskText,
|
||||
postingText
|
||||
);
|
||||
|
||||
await chrome.storage.local.set({ [OUTPUT_STORAGE_KEY]: "" });
|
||||
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,
|
||||
apiKeyHeader,
|
||||
apiKeyPrefix,
|
||||
apiBaseUrl,
|
||||
model,
|
||||
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 {
|
||||
@@ -255,6 +522,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 +630,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);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "WWCompanion",
|
||||
"version": "0.2.2",
|
||||
"version": "0.3.2",
|
||||
"description": "AI companion for WaterlooWorks job postings.",
|
||||
"permissions": ["storage", "activeTab"],
|
||||
"host_permissions": ["https://waterlooworks.uwaterloo.ca/*"],
|
||||
|
||||
@@ -127,28 +127,34 @@ select {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(64px, 0.8fr) minmax(0, 1.4fr) minmax(64px, 0.8fr);
|
||||
.selector-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.button-row button {
|
||||
white-space: nowrap;
|
||||
.selector-row .selector-field {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.button-row .primary {
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.stop-row {
|
||||
.task-row {
|
||||
display: flex;
|
||||
justify-content: stretch;
|
||||
margin-top: 4px;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stop-row button {
|
||||
width: 100%;
|
||||
.task-row button {
|
||||
padding: 6px 15px;
|
||||
}
|
||||
|
||||
.task-row .task-field {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.task-row select {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
@@ -192,7 +198,7 @@ button:active {
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.stop-row .ghost {
|
||||
.stop-btn {
|
||||
background: #c0392b;
|
||||
border-color: #c0392b;
|
||||
color: #fff6f2;
|
||||
|
||||
@@ -17,17 +17,23 @@
|
||||
<section class="panel">
|
||||
<div class="controls-block">
|
||||
<div class="config-block">
|
||||
<div class="field inline-field">
|
||||
<label for="taskSelect">Task</label>
|
||||
<select id="taskSelect"></select>
|
||||
<div class="selector-row">
|
||||
<div class="field selector-field">
|
||||
<label for="envSelect">Environment</label>
|
||||
<select id="envSelect"></select>
|
||||
</div>
|
||||
<div class="field selector-field">
|
||||
<label for="profileSelect">Profile</label>
|
||||
<select id="profileSelect"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button id="extractBtn" class="primary">Extract</button>
|
||||
<button id="extractRunBtn" class="accent">Extract & 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 class="task-row">
|
||||
<div class="field inline-field task-field">
|
||||
<label for="taskSelect">Task</label>
|
||||
<select id="taskSelect"></select>
|
||||
</div>
|
||||
<button id="runBtn" class="accent">Run</button>
|
||||
<button id="abortBtn" class="ghost stop-btn hidden" disabled>Stop</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -48,7 +54,7 @@
|
||||
<button id="copyRawBtn" class="ghost" type="button">Copy Markdown</button>
|
||||
<button id="clearOutputBtn" class="ghost" type="button">Clear</button>
|
||||
</div>
|
||||
<button id="settingsBtn" class="link">Open Settings</button>
|
||||
<button id="settingsBtn" class="link">Settings</button>
|
||||
</footer>
|
||||
|
||||
<script src="popup.js"></script>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
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 profileSelect = document.getElementById("profileSelect");
|
||||
const outputEl = document.getElementById("output");
|
||||
const statusEl = document.getElementById("status");
|
||||
const postingCountEl = document.getElementById("postingCount");
|
||||
@@ -16,23 +14,32 @@ 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 LAST_PROFILE_KEY = "lastSelectedProfileId";
|
||||
|
||||
const state = {
|
||||
postingText: "",
|
||||
tasks: [],
|
||||
envs: [],
|
||||
profiles: [],
|
||||
port: null,
|
||||
isAnalyzing: false,
|
||||
outputRaw: "",
|
||||
autoRunPending: false
|
||||
autoRunPending: false,
|
||||
selectedTaskId: "",
|
||||
selectedEnvId: "",
|
||||
selectedProfileId: ""
|
||||
};
|
||||
|
||||
function getStorage(keys) {
|
||||
return new Promise((resolve) => chrome.storage.local.get(keys, resolve));
|
||||
}
|
||||
|
||||
function buildUserMessage(resume, task, posting) {
|
||||
function buildUserMessage(resume, resumeType, task, posting) {
|
||||
const header = resumeType === "Profile" ? "=== PROFILE ===" : "=== RESUME ===";
|
||||
return [
|
||||
"=== RESUME ===",
|
||||
header,
|
||||
resume || "",
|
||||
"",
|
||||
"=== TASK ===",
|
||||
@@ -77,6 +84,20 @@ function applyInline(text) {
|
||||
)}" target="_blank" rel="noreferrer">${label}</a>`;
|
||||
});
|
||||
|
||||
output = output.replace(/https?:\/\/[^\s<]+/g, (match) => {
|
||||
let url = match;
|
||||
let suffix = "";
|
||||
while (/[),.;!?]$/.test(url)) {
|
||||
suffix = url.slice(-1) + suffix;
|
||||
url = url.slice(0, -1);
|
||||
}
|
||||
const safeUrl = sanitizeUrl(url);
|
||||
if (!safeUrl) return match;
|
||||
return `<a href="${escapeAttribute(
|
||||
safeUrl
|
||||
)}" target="_blank" rel="noreferrer">${safeUrl}</a>${suffix}`;
|
||||
});
|
||||
|
||||
output = output.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
||||
output = output.replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
||||
output = output.replace(/_([^_]+)_/g, "<em>$1</em>");
|
||||
@@ -245,15 +266,13 @@ 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();
|
||||
updateProfileSelectState();
|
||||
}
|
||||
|
||||
function updatePostingCount() {
|
||||
@@ -286,11 +305,114 @@ 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 renderProfiles(profiles) {
|
||||
state.profiles = profiles;
|
||||
profileSelect.innerHTML = "";
|
||||
|
||||
if (!profiles.length) {
|
||||
const option = document.createElement("option");
|
||||
option.textContent = "No profiles configured";
|
||||
option.value = "";
|
||||
profileSelect.appendChild(option);
|
||||
updateProfileSelectState();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const profile of profiles) {
|
||||
const option = document.createElement("option");
|
||||
option.value = profile.id;
|
||||
option.textContent = profile.name || "Default";
|
||||
profileSelect.appendChild(option);
|
||||
}
|
||||
updateProfileSelectState();
|
||||
}
|
||||
|
||||
function updateProfileSelectState() {
|
||||
const hasProfiles = state.profiles.length > 0;
|
||||
profileSelect.disabled = state.isAnalyzing || !hasProfiles;
|
||||
}
|
||||
|
||||
function getTaskDefaultEnvId(task) {
|
||||
return task?.defaultEnvId || state.envs[0]?.id || "";
|
||||
}
|
||||
|
||||
function getTaskDefaultProfileId(task) {
|
||||
return task?.defaultProfileId || state.profiles[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 setProfileSelection(profileId) {
|
||||
const target =
|
||||
profileId && state.profiles.some((profile) => profile.id === profileId)
|
||||
? profileId
|
||||
: state.profiles[0]?.id || "";
|
||||
if (target) {
|
||||
profileSelect.value = target;
|
||||
}
|
||||
state.selectedProfileId = 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));
|
||||
setProfileSelection(getTaskDefaultProfileId(task));
|
||||
}
|
||||
}
|
||||
|
||||
async function persistSelections() {
|
||||
await chrome.storage.local.set({
|
||||
[LAST_TASK_KEY]: state.selectedTaskId,
|
||||
[LAST_ENV_KEY]: state.selectedEnvId,
|
||||
[LAST_PROFILE_KEY]: state.selectedProfileId
|
||||
});
|
||||
}
|
||||
|
||||
function isWaterlooWorksUrl(url) {
|
||||
try {
|
||||
return new URL(url).hostname === "waterlooworks.uwaterloo.ca";
|
||||
@@ -377,9 +499,58 @@ function ensurePort() {
|
||||
return port;
|
||||
}
|
||||
|
||||
async function loadTasks() {
|
||||
const { tasks = [] } = await getStorage(["tasks"]);
|
||||
async function loadConfig() {
|
||||
const stored = await getStorage([
|
||||
"tasks",
|
||||
"envConfigs",
|
||||
"profiles",
|
||||
LAST_TASK_KEY,
|
||||
LAST_ENV_KEY,
|
||||
LAST_PROFILE_KEY
|
||||
]);
|
||||
const tasks = Array.isArray(stored.tasks) ? stored.tasks : [];
|
||||
const envs = Array.isArray(stored.envConfigs) ? stored.envConfigs : [];
|
||||
const profiles = Array.isArray(stored.profiles) ? stored.profiles : [];
|
||||
renderTasks(tasks);
|
||||
renderEnvironments(envs);
|
||||
renderProfiles(profiles);
|
||||
|
||||
if (!tasks.length) {
|
||||
state.selectedTaskId = "";
|
||||
setEnvironmentSelection(envs[0]?.id || "");
|
||||
setProfileSelection(profiles[0]?.id || "");
|
||||
return;
|
||||
}
|
||||
|
||||
const storedTaskId = stored[LAST_TASK_KEY];
|
||||
const storedEnvId = stored[LAST_ENV_KEY];
|
||||
const storedProfileId = stored[LAST_PROFILE_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 (storedProfileId && profiles.some((profile) => profile.id === storedProfileId)) {
|
||||
setProfileSelection(storedProfileId);
|
||||
} else {
|
||||
setProfileSelection(getTaskDefaultProfileId(task));
|
||||
}
|
||||
|
||||
if (
|
||||
storedTaskId !== state.selectedTaskId ||
|
||||
storedEnvId !== state.selectedEnvId ||
|
||||
storedProfileId !== state.selectedProfileId
|
||||
) {
|
||||
await persistSelections();
|
||||
}
|
||||
|
||||
maybeRunDefaultTask();
|
||||
}
|
||||
|
||||
@@ -428,33 +599,114 @@ async function handleAnalyze() {
|
||||
return;
|
||||
}
|
||||
|
||||
const { apiKey, apiBaseUrl, apiKeyHeader, apiKeyPrefix, model, systemPrompt, resume } =
|
||||
await getStorage([
|
||||
"apiKey",
|
||||
"apiBaseUrl",
|
||||
"apiKeyHeader",
|
||||
"apiKeyPrefix",
|
||||
"model",
|
||||
"systemPrompt",
|
||||
"resume"
|
||||
]);
|
||||
const {
|
||||
apiKeys = [],
|
||||
activeApiKeyId = "",
|
||||
apiConfigs = [],
|
||||
activeApiConfigId = "",
|
||||
envConfigs = [],
|
||||
profiles = [],
|
||||
apiBaseUrl,
|
||||
apiKeyHeader,
|
||||
apiKeyPrefix,
|
||||
model,
|
||||
systemPrompt,
|
||||
resume
|
||||
} = await getStorage([
|
||||
"apiKeys",
|
||||
"activeApiKeyId",
|
||||
"apiConfigs",
|
||||
"activeApiConfigId",
|
||||
"envConfigs",
|
||||
"profiles",
|
||||
"apiBaseUrl",
|
||||
"apiKeyHeader",
|
||||
"apiKeyPrefix",
|
||||
"model",
|
||||
"systemPrompt",
|
||||
"resume"
|
||||
]);
|
||||
|
||||
if (!apiBaseUrl) {
|
||||
setStatus("Set an API base URL in Settings.");
|
||||
const resolvedConfigs = Array.isArray(apiConfigs) ? apiConfigs : [];
|
||||
const resolvedEnvs = Array.isArray(envConfigs) ? envConfigs : [];
|
||||
const resolvedProfiles = Array.isArray(profiles) ? profiles : [];
|
||||
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;
|
||||
}
|
||||
|
||||
if (apiKeyHeader && !apiKey) {
|
||||
setStatus("Add your API key in Settings.");
|
||||
return;
|
||||
const selectedProfileId = profileSelect.value;
|
||||
const activeProfile =
|
||||
resolvedProfiles.find((entry) => entry.id === selectedProfileId) ||
|
||||
resolvedProfiles[0];
|
||||
const resumeText = activeProfile?.text || resume || "";
|
||||
const resumeType = activeProfile?.type || "Resume";
|
||||
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 (resolvedApiKeyHeader && !apiKey) {
|
||||
setStatus("Add an API key in Settings.");
|
||||
return;
|
||||
}
|
||||
if (!resolvedModel) {
|
||||
setStatus("Set a model name in Settings.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!model) {
|
||||
setStatus("Set a model name in Settings.");
|
||||
return;
|
||||
}
|
||||
|
||||
const promptText = buildUserMessage(resume || "", task.text || "", state.postingText);
|
||||
const promptText = buildUserMessage(
|
||||
resumeText,
|
||||
resumeType,
|
||||
task.text || "",
|
||||
state.postingText
|
||||
);
|
||||
updatePromptCount(promptText.length);
|
||||
|
||||
state.outputRaw = "";
|
||||
@@ -467,12 +719,16 @@ async function handleAnalyze() {
|
||||
type: "START_ANALYSIS",
|
||||
payload: {
|
||||
apiKey,
|
||||
apiBaseUrl,
|
||||
apiKeyHeader,
|
||||
apiKeyPrefix,
|
||||
model,
|
||||
systemPrompt: systemPrompt || "",
|
||||
resume: resume || "",
|
||||
apiMode: isAdvanced ? "advanced" : "basic",
|
||||
apiUrl: resolvedApiUrl,
|
||||
requestTemplate: resolvedTemplate,
|
||||
apiBaseUrl: resolvedApiBaseUrl,
|
||||
apiKeyHeader: resolvedApiKeyHeader,
|
||||
apiKeyPrefix: resolvedApiKeyPrefix,
|
||||
model: resolvedModel,
|
||||
systemPrompt: resolvedSystemPrompt,
|
||||
resume: resumeText,
|
||||
resumeType,
|
||||
taskText: task.text || "",
|
||||
postingText: state.postingText,
|
||||
tabId: tab.id
|
||||
@@ -527,20 +783,30 @@ 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();
|
||||
});
|
||||
profileSelect.addEventListener("change", () => {
|
||||
setProfileSelection(profileSelect.value);
|
||||
void persistSelections();
|
||||
});
|
||||
|
||||
updatePostingCount();
|
||||
updatePromptCount(0);
|
||||
renderOutput();
|
||||
setAnalyzing(false);
|
||||
loadTasks();
|
||||
loadConfig();
|
||||
loadTheme();
|
||||
|
||||
async function loadSavedOutput() {
|
||||
@@ -562,7 +828,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();
|
||||
}
|
||||
|
||||
@@ -42,6 +42,77 @@ body {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.settings-layout {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.toc {
|
||||
flex: 0 0 160px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-self: flex-start;
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
box-shadow: var(--panel-shadow);
|
||||
}
|
||||
|
||||
.toc-title {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.toc-heading {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.toc-links {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toc a {
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.toc a:hover {
|
||||
background: var(--card-bg);
|
||||
}
|
||||
|
||||
.sidebar-errors {
|
||||
margin-top: auto;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #c0392b;
|
||||
background: rgba(192, 57, 43, 0.08);
|
||||
color: #c0392b;
|
||||
font-size: 11px;
|
||||
padding: 8px;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settings-main {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.title-block {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
@@ -69,10 +140,52 @@ body {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: var(--panel-shadow);
|
||||
}
|
||||
|
||||
.panel-summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.panel-caret {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: 16px;
|
||||
justify-content: center;
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
font-family: "Segoe UI Symbol", "Apple Symbols", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.panel-caret .caret-open {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.panel[open] .panel-caret .caret-open {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.panel[open] .panel-caret .caret-closed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.panel[open] .panel-summary {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -81,6 +194,11 @@ body {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.row-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
@@ -186,6 +304,117 @@ 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: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.api-config-actions-left,
|
||||
.api-config-actions-right {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.profiles {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card-bg);
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.profile-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.profile-actions .delete {
|
||||
background: #c0392b;
|
||||
border-color: #c0392b;
|
||||
color: #fff6f2;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme]),
|
||||
:root[data-theme="system"] {
|
||||
@@ -209,13 +438,13 @@ button:active {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 34px;
|
||||
padding: 6px 0;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.settings-layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.icon-btn.delete {
|
||||
color: #c0392b;
|
||||
.toc {
|
||||
width: 100%;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,72 +16,140 @@
|
||||
<button id="saveBtn" class="accent">Save Settings</button>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<div class="row">
|
||||
<h2>API</h2>
|
||||
<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 class="settings-layout">
|
||||
<nav class="toc" aria-label="Settings table of contents">
|
||||
<div class="toc-heading">WWCompanion Settings</div>
|
||||
<button id="saveBtnSidebar" class="accent" type="button">Save Settings</button>
|
||||
<div id="statusSidebar" class="status"> </div>
|
||||
<div class="toc-title">Sections</div>
|
||||
<div class="toc-links">
|
||||
<a href="#appearance-panel">Appearance</a>
|
||||
<a href="#api-keys-panel">API Keys</a>
|
||||
<a href="#api-panel">API</a>
|
||||
<a href="#environment-panel">Environment</a>
|
||||
<a href="#profiles-panel">My Profiles</a>
|
||||
<a href="#tasks-panel">Task Presets</a>
|
||||
</div>
|
||||
<div id="sidebarErrors" class="sidebar-errors hidden"></div>
|
||||
</nav>
|
||||
<main class="settings-main">
|
||||
<details class="panel" id="appearance-panel">
|
||||
<summary class="panel-summary">
|
||||
<span class="panel-caret" aria-hidden="true">
|
||||
<span class="caret-closed">▸</span>
|
||||
<span class="caret-open">▾</span>
|
||||
</span>
|
||||
<h2>Appearance</h2>
|
||||
</summary>
|
||||
<div class="panel-body">
|
||||
<div class="field">
|
||||
<label for="themeSelect">Theme</label>
|
||||
<select id="themeSelect">
|
||||
<option value="system">System</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
</select>
|
||||
</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>
|
||||
</section>
|
||||
</details>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Appearance</h2>
|
||||
<div class="field">
|
||||
<label for="themeSelect">Theme</label>
|
||||
<select id="themeSelect">
|
||||
<option value="system">System</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
</select>
|
||||
<details class="panel" id="api-keys-panel">
|
||||
<summary class="panel-summary">
|
||||
<span class="panel-caret" aria-hidden="true">
|
||||
<span class="caret-closed">▸</span>
|
||||
<span class="caret-open">▾</span>
|
||||
</span>
|
||||
<h2>API KEYS</h2>
|
||||
</summary>
|
||||
<div class="panel-body">
|
||||
<div class="row">
|
||||
<div></div>
|
||||
<button id="addApiKeyBtn" class="ghost" type="button">Add Key</button>
|
||||
</div>
|
||||
<div id="apiKeys" class="api-keys"></div>
|
||||
</div>
|
||||
</section>
|
||||
</details>
|
||||
|
||||
<section class="panel">
|
||||
<h2>System Prompt</h2>
|
||||
<textarea id="systemPrompt" rows="8" placeholder="Define tone and standards..."></textarea>
|
||||
</section>
|
||||
<details class="panel" id="api-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>
|
||||
<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>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Resume</h2>
|
||||
<textarea id="resume" rows="10" placeholder="Paste your resume text..."></textarea>
|
||||
</section>
|
||||
<details class="panel" id="environment-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>
|
||||
|
||||
<section class="panel">
|
||||
<div class="row">
|
||||
<details class="panel" id="profiles-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>My Profiles</h2>
|
||||
<span class="hint hint-accent">Text to your resumes or generic profiles goes here</span>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="panel-body">
|
||||
<div class="row">
|
||||
<div></div>
|
||||
<button id="addProfileBtn" class="ghost" type="button">Add Profile</button>
|
||||
</div>
|
||||
<div id="profiles" class="profiles"></div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="panel" id="tasks-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>Task Presets</h2>
|
||||
<span class="hint hint-accent">Top task is the default</span>
|
||||
</div>
|
||||
<button id="addTaskBtn" class="ghost" type="button">Add Task</button>
|
||||
</summary>
|
||||
<div class="panel-body">
|
||||
<div class="row">
|
||||
<div></div>
|
||||
<button id="addTaskBtn" class="ghost" type="button">Add Task</button>
|
||||
</div>
|
||||
<div id="tasks" class="tasks"></div>
|
||||
</div>
|
||||
<div id="tasks" class="tasks"></div>
|
||||
</section>
|
||||
</details>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="settings.js"></script>
|
||||
</body>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user