commit a02c6ed1dbc2ab7a28451eff8b5e43b3cf12c5b4 Author: Peisong Xiao Date: Sun Jan 18 03:46:51 2026 -0500 base for AI migration/extension of WWCompanion diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0c8fa58 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,882 @@ +# AGENTS.md + +This document defines how an **agent (human or LLM‑assisted)** should migrate the existing **WWCompanion** codebase, concepts, and behaviors into **SiteCompanion**, *without rewriting from scratch*. The migration is **patch‑style**: refactor, rename, generalize, and constrain—never invent new execution semantics. + + +SiteCompanion is a **tool, not a product**. It exists to remove copy/paste friction while preserving explicit user intent. Automation is restricted to **context binding only**. + +--- + + +## 0. Prime Directive (Non‑Negotiable) + +**Preserve the execution model.** + +> Nothing may execute without an explicit user click. + + +Allowed: + + +* Automatic **context restoration** (workspace loading on navigation) + +* UI reinjection on DOM mutation (idempotent) + + +Forbidden: + +* Auto‑execution +* Background scraping +* Inferred intent +* Heuristic task selection + +If a change violates this, it is **out of scope**. + +--- + +## 1. Terminology Mapping (Rename, Don’t Reimagine) + +| WWCompanion | SiteCompanion | Notes | +| ----------- | ------------- | -------------------------------------------------- | + +| Companion | SiteCompanion | Tool rename only | +| Prompt | Task | **Tasks are the most basic execution unit** | +| Quick Run | Shortcut | Shortcut = preconfigured run | +| Context | Site Text | Explicitly bound via paste/selection | +| Profile | Profile | Same meaning, now workspace-scoped | +| Mode | Env | Execution discipline (system prompt + constraints) | + +Rules: + +* The constructed prompt must use **`Profile`** as the header name for user context +* The extension must **never emit `Resume` as a pipeline header or constant prompt text** +* Users may supply resume-like content, but it is always placed under the `Profile` header + +Do **not** introduce new conceptual layers. + + +--- + +## 2. Core Execution Pipeline (Must Remain Intact) + + +The execution pipeline is stable and final: + +``` +Env (system prompt + discipline) +→ Profile (opaque user context) + +→ Task (semantic intent template) +→ Site Text (primary extracted text) +``` + + +Rules: + +* The pipeline must **not** contain a `Resume` stage +* Prompt headers must use **exactly** these names +* **Tasks** are the most basic executable unit + +* **Shortcuts** bind `(env + profile + task)` +* Shortcuts are the **only pinnable and toolbar-exposed unit** + +Agents must never collapse, rename, or reorder this pipeline. + + +--- + +## 3. Workspaces (Authoritative Model) + +### 3.1 Root Global Workspace + +SiteCompanion introduces a **real but hidden workspace** named **`global`**. + +`global` is **not a special case** in code. It is a full workspace that: + +* Always exists +* Is not user-deletable +* Is never auto-switched into + +`global` owns: + +* Global appearance defaults + +* **Global API configuration (providers, endpoints, API keys)** + +* Globally scoped Envs, Profiles, Tasks +* Global Presets + + +Rules: + +* API configuration is **global-only** and **non-inheritable** +* `global` participates in inheritance like any other workspace + +### 3.2 Workspace Definition + +A **Workspace is a strict namespace**, but not an ownership boundary. + +A workspace references: + + +* Appearance overrides +* Envs +* Profiles +* Tasks +* Presets +* Sites + +### 3.3 Deletion Semantics + + +Deleting a workspace: + + +* **Does not delete** any Envs, Profiles, Tasks, Presets, or Sites + +* Moves all referenced items to the **`global` workspace** +* Preserves names, identities, and semantics + +Deletion therefore removes a *namespace*, not data. + + +### 3.4 Inheritance Model (Unified) + +Scopes form a strict hierarchy: + + +``` +global → workspace → site +``` + +Inheritance rules: + +* Inherit by default +* Items may be disabled (subtractive) +* **Override by redefinition**: defining an item with the same name shadows the inherited one +* Nearest scope wins by name + + +Rules: + +* No merging of semantics +* No inference +* APIs / API keys do **not** inherit + +### 3.5 Migration Rule + +If WWCompanion has: + + +* Global config +* Implicit defaults +* Cross-context state + +Then: + +* Move capability-like config into `global` +* Move user-meaningful items into a default migrated workspace +* Remove all implicit fallback logic + + +### 3.3 Inheritance Model (Unified) + +Scopes form a strict hierarchy: + +``` +global → workspace → site +``` + + +Inheritance rules: + +* Inherit by default +* Items may be disabled (subtractive) +* **Override by redefinition**: defining an item with the same name shadows the inherited one +* Nearest scope wins by name + +Rules: + + +* No merging of semantics +* No inference +* APIs / API keys do **not** inherit + +### 3.4 Migration Rule + +If WWCompanion has: + + +* Global config +* Implicit defaults +* Cross-context state + +Then: + +* Move capability-like config into `global` +* Move user-meaningful items into a default migrated workspace +* Remove all implicit fallback logic + +--- + +## 4. Env & Profile Visibility (Final Decision) + +In SiteCompanion: + + +* **Env selector is always visible** +* **Profile selector is always visible** + +Even when: + + +* A preset defines defaults + +Visibility = inspectability. + +Agents must **not** introduce read‑only, hidden, or inferred env/profile states. + +--- + +## 5. Tasks vs Shortcuts (Critical Separation) + +### 5.1 Tasks + + +Tasks: + +* Are the **most basic execution unit** +* Define semantic intent +* Are directly runnable via **manual popup execution** +* Define a **default Environment** +* Define a **default Profile** +* Are never pinnable + +When a user selects or changes a Task in the popup: + +* The Environment selector must refresh to the Task’s default Environment +* The Profile selector must refresh to the Task’s default Profile + + +### 5.2 Shortcuts + +Shortcuts: + + +* Are concrete, named execution shortcuts +* Bind `(env + profile + task)` explicitly +* Are executable via **one click** +* Are pinnable +* **Never appear in the popup UI** + +Shortcuts exist purely as **injected-toolbar shortcuts**. + + +### 5.3 Manual Execution (Popup Configuration) + + +The popup UI allows **manual execution of Tasks** using explicit configuration. + +Rules: + +* The popup UI must **never list or reference shortcuts** +* Manual execution must use the **same execution pipeline** as shortcuts + +* Manual execution does **not** create a shortcut unless explicitly saved by the user +* Manual execution is equivalent to WWCompanion behavior + +Migration rule: + + +> Any WWCompanion feature that "runs" something maps either to **manual Task execution** or to a **toolbar Shortcut**. + +--- + +## 6. Site Binding & Context Extraction + +### 6.1 Site Ownership + + +Each site: + +* Belongs to exactly **one workspace** +* Has an explicit URL matcher +* Has an explicit extraction definition +* Inherits appearance, envs, profiles, tasks, presets from its workspace (unless overridden) + + +### 6.2 Unknown Sites (Two Paths Only) + +When encountering an unknown site, **only two paths are allowed**: + +#### Path A: Popup UI (default) + + +1. Toolbar appears in minimal state +2. User pastes or selects a portion of page text +3. System finds the *minimum DOM scope* whose `innerText` contains the pasted text +4. User confirms or retries +5. User selects workspace +6. Site config is created + + +#### Path B: Settings Page (manual) + +* User defines URL matcher +* User defines extraction logic explicitly + + +Rules: + +* No fuzzy matching +* No guessing +* No background scraping + + +--- + +## 7. Automatic Behavior (Strictly Limited) + +Allowed automation: + + +* Loading a site’s associated workspace on navigation + +Disallowed automation: + + +* Running presets +* Selecting presets +* Modifying env/profile/task + +**Context may be restored. Intent may not be inferred.** + +--- + +## 8. UI & Interaction Rules + + +SiteCompanion has **three UIs**, each with a strict role boundary. Wherever applicable, UI layout, interaction patterns, and visual hierarchy must **respect WWCompanion’s existing design**. + + +### 8.1 UI Surfaces (Authoritative) + +1. **Floating Injected Toolbar** + + * Appears on **recognized sites** only + * Floating, injected, idempotent on DOM mutation + * Supported positions only: four corners + bottom center + * Contains **toolbar presets only** + + +2. **Extension Popup UI** + + * Primary inspection, configuration, and manual execution surface + + * Never displays presets + + * State-machine driven (see below) + +3. **Settings UI** + + * Configuration and organization surface only + * No execution affordances + + +--- + +### 8.2 Floating Toolbar (Toolbar Presets Only) + + +The floating toolbar: + +* Displays buttons named after **all enabled presets in scope for the current site** +* Executes a preset with **one click** +* Is the **exclusive surface** from which presets may be executed + +Preset aggregation order: + +1. Enabled **Global Presets** +2. Enabled **Workspace Presets** + +3. Enabled **Site Presets** + + +Rules: + +* Presets must never appear in the popup UI + +* Presets must never appear in settings execution paths +* Presets should be labeled as **"Toolbar Presets"** where naming clarification is needed + +--- + +### 8.3 Popup UI – State Machine + +The popup UI has **exactly three states**, and **popup state must be persisted**. + +Persistence rule: + +* Closing the popup **must not reset state** +* Popup state is preserved until: + + * The user performs a manual action in the popup (selection / click), or + * The user executes a toolbar Shortcut + + +State transitions are therefore **explicit and durable**. + +--- + +#### State 1: Unknown Site + +Shown when a site has no saved configuration. + +UI elements: + +* Prompt asking the user to paste **partial text** they want SiteCompanion to extract each time + +* Button: **"Try Extracting Full Text"** + + +--- + +#### State 2: Extraction Review + +Entered after attempting full-text extraction. + +UI elements: + + +* Read-only buffer showing the extracted text +* Editable field to configure the **URL match pattern** for the site +* Button: **Retry** (returns to State 1) +* Button: **Confirm** (saves the site as recognized) + +Validation rules: + + +* URL patterns must be explicit +* **No URL pattern may be a substring of another** (conflict error) + +--- + + +#### State 3: Normal Execution + +Shown for recognized sites. + +Rules: + +* Layout must respect WWCompanion popup design +* Allows manual configuration of env, profile, and task +* Changing the Task refreshes env/profile to Task defaults +* Allows manual execution + +* **Never displays shortcuts or shortcut-derived UI** + +* Adds a single, read-only line indicating the **current Workspace** + +--- + +#### State 2: Extraction Review + +Entered after attempting full-text extraction. + +UI elements: + +* Read-only buffer showing the extracted text +* Editable field to configure the **URL match pattern** for the site +* Button: **Retry** (returns to State 1) +* Button: **Confirm** (saves the site as recognized) + +Validation rules: + +* URL patterns must be explicit +* **No URL pattern may be a substring of another** (conflict error) + + +--- + +#### State 3: Normal Execution + +Shown for recognized sites. + +Rules: + +* Layout must respect WWCompanion popup design +* Allows manual configuration of env, profile, task, and site text +* Allows manual execution + +* **Never displays presets or preset-derived UI** +* Adds a single, read-only line indicating the **current Workspace** + +--- + +### 8.4 Naming & Display Rules (UI-Wide) + + +* Naming conflict checking is **case-insensitive** +* Display must **preserve the original casing** of the name as entered + +--- + +### 8.2 Floating Toolbar (Presets Only) + +The floating toolbar: + +* Displays buttons named after **all presets enabled for the current site** +* Executes a preset with **one click** +* Is the **exclusive surface** from which presets may be executed + +Preset source order: + +* Enabled **Global Presets** +* Enabled **Workspace Presets** +* Enabled **Site Presets** + +Rules: + +* Presets must never appear in the popup UI +* Presets must never appear in the settings execution paths + +--- + +### 8.3 Popup UI – State Machine + + +The popup UI has **exactly three states**. + +#### State 1: Unknown Site + +Shown when a site has no saved configuration. + + +UI elements: + + +* Prompt asking the user to paste **partial text** they want SiteCompanion to extract each time +* Button: **"Try Extracting Full Text"** + +--- + +#### State 2: Extraction Review + + +Entered after attempting full-text extraction. + + +UI elements: + +* Read-only buffer showing the extracted text +* Editable field to configure the **URL match pattern** +* Button: **Retry** (returns to State 1) + +* Button: **Confirm** (saves the site as recognized) + +Validation rules: + +* URL patterns must be explicit +* **No URL pattern may be a substring of another** (conflict error) + +--- + + +#### State 3: Normal Execution + +Shown for recognized sites. + +Rules: + +* Layout must respect WWCompanion popup design +* Allows manual configuration of env, profile, task, and site text +* Allows manual execution +* **Never displays presets or preset-derived UI** +* Adds a single line indicating the **current Workspace** + +--- + +### 8.4 Naming & Display Rules (UI-Wide) + +* Naming conflict checking is **case-insensitive** +* Display must **preserve the original casing** of the name as entered + +--- + +--- + + +## 9. Preset Scope, Enablement, and Naming + +### 9.1 Preset Scope Resolution + +Toolbar presets shown for a site are the union of: + +* Enabled **Global Presets** +* Enabled **Workspace Presets** (if applicable) +* Enabled **Site Presets** (if applicable) + + +### 9.2 Enablement Semantics + +* Presets may exist at global, workspace, or site scope +* Lower tiers inherit enable/disable state from parents by default +* A lower-tier preset with the same name **overrides and disables** the inherited one + +### 9.3 Naming Scope + +Naming is scoped as: + +``` +Enabled Global + Enabled Workspace + Enabled Site + Module Type +``` + +Rules: + + +* Conflict detection is case-insensitive within the same module type +* Display preserves original casing + +--- + + +## 10. Motion & UX Doctrine + + +Structural motion only: + +* Viewport‑anchored reflow (FLIP pattern) +* Item stays visually fixed; UI reflows around it + +Motion is: + + +* Structural, not decorative +* Respectful of reduced‑motion settings + + +--- + +## 11. Engineering & UX Constraints + +### 11.1 Legacy WWCompanion Adapters (Removal Mandate) + +Any of the following **must be removed**, not adapted: + + +* Legacy config adapters + +* Compatibility shims +* Auto-migration layers that preserve old semantics at runtime + + +Migration is **one-time and destructive** at the semantic level. + + +Allowed: + + +* Data migration scripts + +* Explicit renaming / reshaping of stored config + + +Forbidden: + +* Dual-mode logic + +* Runtime branching on legacy flags + + +--- + +### 11.2 Settings UI Structure (Authoritative) + +The Settings UI contains the following top-level sections: + +#### 1. Global Configuration + +Acts as the **configuration baseline**. + +Contains: + +* Global appearance (theme, toolbar positioning, auto-hide on unknown sites) +* API Keys +* API Configurations +* Expandable / foldable lists of: + + * Environments + * Profiles + * Tasks + * Presets + +Each configuration entry: + +* Is configuration-only (no execution) +* Has an enable / disable toggle + +Also includes: + + +* Expandable / foldable list of **sites inheriting directly from global** + +--- + +#### 2. Workspaces + + +Contains a list of workspace sections (all visible in sidebar TOC; TOC supports folding). + + +Each workspace section contains: + + +* Name +* Appearance overrides (inherits from global on creation) +* API configurations shown as **enable/disable toggles only** +* Expandable / foldable lists of: + + * Environments + * Profiles + * Tasks + * Presets + +Each list is split into: + +* **Inherited** (toggle-based enable/disable) +* **Workspace-specific** (full configuration UI) + +Also includes: + +* Expandable / foldable list of **sites inheriting from this workspace** + +--- + +#### 3. Sites + +Mirrors the Workspace structure, minus the list of sites. + + +Rules: + +* Lower tiers inherit both enabled/disabled state and defaults from parents +* On creation or parent change, inherited defaults must be refreshed +* If a site/workspace config name conflicts with an enabled inherited one: + + * The inherited one is automatically disabled + * Helper text must explain the override + +--- + + +### 11.3 Duplication Semantics + +For all non-API configurations, duplication must be offered as: + +* **Duplicate to Workspace** (with selector) +* **Duplicate to Site** (with selector) + +No generic duplicate buttons are allowed. + +--- + +### 11.4 Naming & Conflict Detection + +Naming rules are **case-insensitive and module-specific**. + + +Rules: + +* Conflicts are detected case-insensitively +* Display preserves original casing +* Different module types may share the same name + +--- + +### 11.5 Error Checking & Messaging + +Error checking is **mandatory and non-removable**. + +Agents: + +* May refactor validation logic +* May strengthen invariants +* May change *where* errors are surfaced in the UI + + +Agents may **not**: + +* Silence errors +* Convert errors into warnings +* Auto-recover from invalid state without user action + +Errors must: + + +* Be explicit +* Be attributable to a concrete user action or config +* Not block unrelated UI inspection + +--- + +### 11.6 UI Design Theme + +Respect the existing SiteCompanion UI doctrine: + +* Minimal surface area +* WWCompanion layout parity where applicable + +* No instructional text in execution paths +* No decorative UI + +* No icon-first navigation + +UI exists to expose state, not to explain it. + +--- + + +## 12. Migration Checklist (Agent‑Facing) + +An agent migrating WWCompanion must: + +* [ ] Rename concepts using the mapping table + +* [ ] Ensure constructed prompts use `Profile` (never `Resume`) as the header +* [ ] Introduce workspaces as hard namespaces + +* [ ] Introduce the `global` workspace for capability config +* [ ] Split global state into workspace‑owned state +* [ ] Separate tasks from presets +* [ ] Ensure only presets execute +* [ ] Make env & profile selectors always visible +* [ ] Remove any auto‑execution logic +* [ ] Remove all legacy config adapters +* [ ] Enforce case‑insensitive, module‑local naming rules + +* [ ] Constrain automation to context binding only +* [ ] Keep execution pipeline intact + + +If any step requires inventing behavior, stop. + + +--- + +## 13. Closing Doctrine + +SiteCompanion is intentionally **narrow**. + + +It restores **clarity of intent** by enforcing boundaries: + +* Context may be bound automatically +* Intent may only cross the boundary via an explicit click + +Legacy convenience is not a justification for ambiguity. + + +> The tool must always make it obvious *what will run*, *with what*, and *why*. + +Agents exist to preserve this clarity — not to smooth it away. + diff --git a/sitecompanion/background.js b/sitecompanion/background.js new file mode 100644 index 0000000..36bacc9 --- /dev/null +++ b/sitecompanion/background.js @@ -0,0 +1,683 @@ +const DEFAULT_TASKS = [ + { + id: "task-generic-fit", + name: "Generic Fit", + text: + "You should evaluate for my fit to the job. You don't need to suggest interview prep, we'll leave those for later. a bit of tuning to your answers: please keep things more compact, a single section for the evaluation is enough, you don't need to analyze every bullet point in the posting." + }, + { + id: "task-ratings-only", + name: "Ratings Only", + text: + "Give ratings out of 10 with headings and do not include any other text.\n\n1. Fit evaluation: my fit to the role.\n2. Company status: how well this company offers career development for me.\n3. Pay: use $25 CAD per hour as a baseline; rate the compensation." + } +]; + +const DEFAULT_SETTINGS = { + apiKey: "", + apiKeys: [], + activeApiKeyId: "", + apiConfigs: [], + activeApiConfigId: "", + envConfigs: [], + activeEnvConfigId: "", + profiles: [], + apiBaseUrl: "https://api.openai.com/v1", + apiKeyHeader: "Authorization", + apiKeyPrefix: "Bearer ", + model: "gpt-4o-mini", + systemPrompt: + "You are a precise, honest assistant. Be concise and avoid inventing details, be critical about evaluations. You should put in a small summary of all the sections at the end. You should answer in no longer than 3 sections including the summary. And remember to bold or italicize key points.", + resume: "", + tasks: DEFAULT_TASKS, + theme: "system" +}; + +const OUTPUT_STORAGE_KEY = "lastOutput"; +const AUTO_RUN_KEY = "autoRunDefaultTask"; +let activeAbortController = null; +let keepalivePort = null; +const streamState = { + active: false, + outputText: "", + subscribers: new Set() +}; + +function resetAbort() { + if (activeAbortController) { + activeAbortController.abort(); + activeAbortController = null; + } + closeKeepalive(); +} + +function openKeepalive(tabId) { + if (!tabId || keepalivePort) return; + try { + keepalivePort = chrome.tabs.connect(tabId, { name: "wwcompanion-keepalive" }); + keepalivePort.onDisconnect.addListener(() => { + keepalivePort = null; + }); + } catch { + keepalivePort = null; + } +} + +function closeKeepalive() { + if (!keepalivePort) return; + try { + keepalivePort.disconnect(); + } catch { + // Ignore disconnect failures. + } + keepalivePort = null; +} + +chrome.runtime.onInstalled.addListener(async () => { + const stored = await chrome.storage.local.get(Object.keys(DEFAULT_SETTINGS)); + const updates = {}; + + for (const [key, value] of Object.entries(DEFAULT_SETTINGS)) { + const existing = stored[key]; + const missing = + existing === undefined || + existing === null || + (key === "tasks" && !Array.isArray(existing)); + + 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); + } +}); + +chrome.runtime.onConnect.addListener((port) => { + if (port.name !== "analysis") return; + + streamState.subscribers.add(port); + port.onDisconnect.addListener(() => { + streamState.subscribers.delete(port); + }); + + if (streamState.active) { + safePost(port, { + type: "SYNC", + text: streamState.outputText, + streaming: true + }); + } + + port.onMessage.addListener((message) => { + if (message?.type === "START_ANALYSIS") { + streamState.outputText = ""; + resetAbort(); + const controller = new AbortController(); + activeAbortController = controller; + const request = handleAnalysisRequest(port, message.payload, controller.signal); + void request + .catch((error) => { + if (error?.name === "AbortError") { + safePost(port, { type: "ABORTED" }); + return; + } + safePost(port, { + type: "ERROR", + message: error?.message || "Unknown error during analysis." + }); + }) + .finally(() => { + if (activeAbortController === controller) { + activeAbortController = null; + } + }); + return; + } + + if (message?.type === "ABORT_ANALYSIS") { + resetAbort(); + } + }); + +}); + +chrome.runtime.onMessage.addListener((message) => { + if (message?.type !== "RUN_DEFAULT_TASK") return; + void chrome.storage.local.set({ [AUTO_RUN_KEY]: Date.now() }); + if (chrome.action?.openPopup) { + void chrome.action.openPopup().catch(() => {}); + } +}); + +function buildUserMessage(resume, resumeType, task, posting) { + const header = resumeType === "Profile" ? "=== PROFILE ===" : "=== RESUME ==="; + return [ + header, + resume || "", + "", + "=== TASK ===", + task || "", + "", + "=== JOB POSTING ===", + posting || "" + ].join("\n"); +} + +function safePost(port, message) { + try { + port.postMessage(message); + } catch { + // Port can disconnect when the popup closes; ignore post failures. + } +} + +function broadcast(message) { + for (const port of streamState.subscribers) { + safePost(port, message); + } +} + +async function handleAnalysisRequest(port, payload, signal) { + streamState.outputText = ""; + streamState.active = true; + + const { + apiKey, + apiMode, + apiUrl, + requestTemplate, + apiBaseUrl, + apiKeyHeader, + apiKeyPrefix, + model, + systemPrompt, + resume, + resumeType, + taskText, + postingText, + 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; + } + + if (apiKeyHeader && !apiKey) { + safePost(port, { type: "ERROR", message: "Missing API key." }); + return; + } + + if (!model) { + safePost(port, { type: "ERROR", message: "Missing model name." }); + return; + } + } + + if (!postingText) { + safePost(port, { type: "ERROR", message: "No job posting text provided." }); + return; + } + + if (!taskText) { + safePost(port, { type: "ERROR", message: "No task prompt selected." }); + return; + } + + const userMessage = buildUserMessage( + resume, + resumeType, + taskText, + postingText + ); + + await chrome.storage.local.set({ [OUTPUT_STORAGE_KEY]: "" }); + 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, + apiKeyHeader, + apiKeyPrefix, + model, + systemPrompt: systemPrompt || "", + userMessage, + signal, + onDelta: (text) => { + streamState.outputText += text; + broadcast({ type: "DELTA", text }); + } + }); + } + + broadcast({ type: "DONE" }); + } finally { + streamState.active = false; + await chrome.storage.local.set({ [OUTPUT_STORAGE_KEY]: streamState.outputText }); + closeKeepalive(); + } +} + +function buildChatUrl(apiBaseUrl) { + const trimmed = (apiBaseUrl || "").trim().replace(/\/+$/, ""); + if (!trimmed) return ""; + if (trimmed.endsWith("/chat/completions")) return trimmed; + return `${trimmed}/chat/completions`; +} + +function buildAuthHeader(apiKeyHeader, apiKeyPrefix, apiKey) { + if (!apiKeyHeader) return null; + return { + name: apiKeyHeader, + value: `${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, + apiKeyHeader, + apiKeyPrefix, + model, + systemPrompt, + userMessage, + signal, + onDelta +}) { + const chatUrl = buildChatUrl(apiBaseUrl); + if (!chatUrl) { + throw new Error("Invalid API base URL."); + } + + const headers = { + "Content-Type": "application/json" + }; + + const authHeader = buildAuthHeader(apiKeyHeader, apiKeyPrefix, apiKey); + if (authHeader) { + headers[authHeader.name] = authHeader.value; + } + + const response = await fetch(chatUrl, { + method: "POST", + headers, + body: JSON.stringify({ + model, + stream: true, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: userMessage } + ] + }), + signal + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`API error ${response.status}: ${errorText}`); + } + + 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); +} diff --git a/sitecompanion/content.js b/sitecompanion/content.js new file mode 100644 index 0000000..0a4d308 --- /dev/null +++ b/sitecompanion/content.js @@ -0,0 +1,140 @@ +const HEADER_LINES = new Set([ + "OVERVIEW", + "PRE-SCREENING", + "WORK TERM RATINGS", + "JOB POSTING INFORMATION", + "APPLICATION INFORMATION", + "COMPANY INFORMATION", + "SERVICE TEAM" +]); + +const ACTION_BAR_SELECTOR = "nav.floating--action-bar"; +const INJECTED_ATTR = "data-wwcompanion-default-task"; +const DEFAULT_TASK_LABEL = "Default WWCompanion Task"; + +function isJobPostingOpen() { + return document.getElementsByClassName("modal__content").length > 0; +} + +function sanitizePostingText(text) { + let cleaned = text.replaceAll("fiber_manual_record", ""); + const lines = cleaned.split(/\r?\n/); + const filtered = lines.filter((line) => { + const trimmed = line.trim(); + if (!trimmed) return true; + return !HEADER_LINES.has(trimmed.toUpperCase()); + }); + + cleaned = filtered.join("\n"); + cleaned = cleaned.replace(/[ \t]+/g, " "); + cleaned = cleaned.replace(/\n{3,}/g, "\n\n"); + return cleaned.trim(); +} + +function buildDefaultTaskButton(templateButton) { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = DEFAULT_TASK_LABEL; + button.className = templateButton.className; + button.setAttribute(INJECTED_ATTR, "true"); + button.setAttribute("aria-label", DEFAULT_TASK_LABEL); + button.addEventListener("click", () => { + document.dispatchEvent(new CustomEvent("WWCOMPANION_RUN_DEFAULT_TASK")); + chrome.runtime.sendMessage({ type: "RUN_DEFAULT_TASK" }); + }); + return button; +} + +function getActionBars() { + return [...document.querySelectorAll(ACTION_BAR_SELECTOR)]; +} + +function getActionBarButtonCount(bar) { + return bar.querySelectorAll(`button:not([${INJECTED_ATTR}])`).length; +} + +function selectTargetActionBar(bars) { + if (!bars.length) return null; + let best = bars[0]; + let bestCount = getActionBarButtonCount(best); + for (const bar of bars.slice(1)) { + const count = getActionBarButtonCount(bar); + if (count > bestCount) { + best = bar; + bestCount = count; + } + } + return best; +} + +function ensureDefaultTaskButton() { + const bars = getActionBars(); + if (!bars.length) return; + + if (!isJobPostingOpen()) { + for (const bar of bars) { + const injected = bar.querySelector(`[${INJECTED_ATTR}]`); + if (injected) injected.remove(); + } + return; + } + + const toolbar = selectTargetActionBar(bars); + if (!toolbar) return; + + for (const bar of bars) { + if (bar === toolbar) continue; + const injected = bar.querySelector(`[${INJECTED_ATTR}]`); + if (injected) injected.remove(); + } + + const existing = toolbar.querySelector(`[${INJECTED_ATTR}]`); + if (existing) return; + + const templateButton = toolbar.querySelector("button"); + if (!templateButton) return; + + const button = buildDefaultTaskButton(templateButton); + const firstChild = toolbar.firstElementChild; + if (firstChild) { + toolbar.insertBefore(button, firstChild); + } else { + toolbar.appendChild(button); + } +} + +function extractPostingText() { + const contents = [...document.getElementsByClassName("modal__content")]; + if (!contents.length) { + return { ok: false, error: "No modal content found on this page." }; + } + + // WaterlooWorks renders multiple modal containers; choose the longest visible text block. + const el = contents.reduce((best, cur) => + cur.innerText.length > best.innerText.length ? cur : best + ); + + const rawText = el.innerText; + const sanitized = sanitizePostingText(rawText); + + return { ok: true, rawText, sanitized }; +} + +chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + if (message?.type !== "EXTRACT_POSTING") return; + + const result = extractPostingText(); + sendResponse(result); +}); + +chrome.runtime.onConnect.addListener((port) => { + if (port.name !== "wwcompanion-keepalive") return; + port.onDisconnect.addListener(() => {}); +}); + +const observer = new MutationObserver(() => { + ensureDefaultTaskButton(); +}); + +observer.observe(document.documentElement, { childList: true, subtree: true }); +ensureDefaultTaskButton(); diff --git a/sitecompanion/manifest.json b/sitecompanion/manifest.json new file mode 100644 index 0000000..e7d4804 --- /dev/null +++ b/sitecompanion/manifest.json @@ -0,0 +1,26 @@ +{ + "manifest_version": 3, + "name": "WWCompanion", + "version": "0.3.1", + "description": "AI companion for WaterlooWorks job postings.", + "permissions": ["storage", "activeTab"], + "host_permissions": ["https://waterlooworks.uwaterloo.ca/*"], + "action": { + "default_title": "WWCompanion", + "default_popup": "popup.html" + }, + "background": { + "service_worker": "background.js", + "type": "module" + }, + "content_scripts": [ + { + "matches": ["https://waterlooworks.uwaterloo.ca/*"], + "js": ["content.js"] + } + ], + "options_ui": { + "page": "settings.html", + "open_in_tab": true + } +} diff --git a/sitecompanion/popup.css b/sitecompanion/popup.css new file mode 100644 index 0000000..b824e55 --- /dev/null +++ b/sitecompanion/popup.css @@ -0,0 +1,339 @@ +:root { + --ink: #1f1a17; + --muted: #6b5f55; + --accent: #b14d2b; + --accent-deep: #7d321b; + --panel: #fff7ec; + --border: #e4d6c5; + --glow: rgba(177, 77, 43, 0.18); + --output-bg: rgba(255, 255, 255, 0.7); + --code-bg: #f4e7d2; + --page-bg: radial-gradient(circle at top, #fdf2df, #f7ead6 60%, #f1dcc6 100%); + --input-bg: #fffdf9; + --input-fg: var(--ink); + --panel-shadow: 0 12px 30px rgba(122, 80, 47, 0.12); + color-scheme: light; +} + +:root[data-theme="dark"] { + --ink: #abb2bf; + --muted: #8b93a5; + --accent: #61afef; + --accent-deep: #56b6c2; + --panel: #2f343f; + --border: #3e4451; + --glow: rgba(97, 175, 239, 0.2); + --output-bg: rgba(47, 52, 63, 0.85); + --code-bg: #3a3f4b; + --page-bg: radial-gradient(circle at top, #2a2f3a, #21252b 60%, #1b1f25 100%); + --input-bg: #2b303b; + --input-fg: var(--ink); + --panel-shadow: 0 12px 30px rgba(0, 0, 0, 0.35); + color-scheme: dark; +} + +:root[data-theme="light"] { + color-scheme: light; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 12px; + width: 360px; + font-family: system-ui, -apple-system, "Segoe UI", sans-serif; + color: var(--ink); + background: var(--page-bg); +} + +.title-block { + margin-bottom: 6px; +} + +.title-line { + display: flex; + align-items: baseline; + gap: 8px; + flex-wrap: wrap; +} + +.title { + font-size: 18px; + font-weight: 700; + letter-spacing: 0.3px; +} + +.subtitle { + font-size: 11px; + color: var(--muted); +} + +.panel { + padding: 10px; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--panel); + box-shadow: var(--panel-shadow); +} + +.controls-block { + display: grid; + gap: 8px; +} + +.config-block { + display: grid; + gap: 8px; +} + +.field { + margin-top: 8px; + display: grid; + gap: 4px; +} + +.inline-field { + display: flex; + align-items: center; + gap: 8px; +} + +.inline-field label { + margin: 0; + flex: 0 0 auto; +} + +.inline-field select { + flex: 1; +} + +label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.8px; + color: var(--muted); +} + +select { + width: 100%; + padding: 6px 8px; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--input-bg); + color: var(--input-fg); + font-size: 12px; +} + +.selector-row { + display: flex; + align-items: flex-end; + gap: 8px; +} + +.selector-row .selector-field { + flex: 1; + margin: 0; +} + +.task-row { + display: flex; + align-items: flex-end; + gap: 8px; +} + +.task-row button { + padding: 6px 15px; +} + +.task-row .task-field { + flex: 1; + margin: 0; +} + +.task-row select { + min-width: 0; +} + +.hidden { + display: none; +} + +button { + font-family: inherit; + border: none; + border-radius: 10px; + padding: 6px 10px; + cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.15s ease; +} + +button:disabled { + opacity: 0.5; + cursor: not-allowed; + box-shadow: none; +} + +button:active { + transform: translateY(1px); +} + +.primary { + width: 100%; + background: var(--input-bg); + border: 1px solid var(--border); + box-shadow: 0 6px 16px rgba(120, 85, 55, 0.12); +} + +.accent { + background: var(--accent); + color: #fff9f3; + box-shadow: 0 8px 20px var(--glow); +} + +.ghost { + background: transparent; + border: 1px solid var(--border); +} + +.stop-btn { + background: #c0392b; + border-color: #c0392b; + color: #fff6f2; +} + +.meta { + display: flex; + justify-content: space-between; + gap: 6px; + flex-wrap: wrap; + font-size: 11px; + color: var(--muted); + margin-top: 8px; +} + +.status { + font-size: 11px; + color: var(--accent-deep); +} + +.output { + margin-top: 8px; + border: 1px dashed var(--border); + border-radius: 12px; + padding: 8px; + background: var(--output-bg); + min-height: 210px; + max-height: 360px; + overflow: hidden; +} + +.output-body { + margin: 0; + word-break: break-word; + max-height: 340px; + overflow-y: auto; + font-size: 11px; + line-height: 1.45; +} + +.output-body h1, +.output-body h2, +.output-body h3, +.output-body h4 { + margin: 0 0 6px; + font-size: 13px; + letter-spacing: 0.3px; +} + +.output-body p { + margin: 0 0 8px; +} + +.output-body ul, +.output-body ol { + margin: 0 0 8px 18px; + padding: 0; +} + +.output-body li { + margin-bottom: 4px; +} + +.output-body blockquote { + margin: 0 0 8px; + padding-left: 10px; + border-left: 2px solid var(--border); + color: var(--muted); +} + +.output-body hr { + border: none; + border-top: 1px solid var(--border); + margin: 8px 0; +} + +.output-body pre { + margin: 0 0 10px; + padding: 8px; + border-radius: 10px; + background: var(--code-bg); + overflow-x: auto; +} + +.output-body code { + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + font-size: 11px; + background: var(--code-bg); + padding: 1px 4px; + border-radius: 6px; +} + +.output-body a { + color: var(--accent-deep); +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme]), + :root[data-theme="system"] { + --ink: #abb2bf; + --muted: #8b93a5; + --accent: #61afef; + --accent-deep: #56b6c2; + --panel: #2f343f; + --border: #3e4451; + --glow: rgba(97, 175, 239, 0.2); + --output-bg: rgba(47, 52, 63, 0.85); + --code-bg: #3a3f4b; + --page-bg: radial-gradient(circle at top, #2a2f3a, #21252b 60%, #1b1f25 100%); + --input-bg: #2b303b; + --input-fg: var(--ink); + --panel-shadow: 0 12px 30px rgba(0, 0, 0, 0.35); + color-scheme: dark; + } +} + +.footer { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 6px; + gap: 8px; +} + +.footer-left { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.link { + padding: 4px 6px; + background: none; + border-radius: 8px; + color: var(--accent-deep); + font-size: 11px; +} diff --git a/sitecompanion/popup.html b/sitecompanion/popup.html new file mode 100644 index 0000000..b91f578 --- /dev/null +++ b/sitecompanion/popup.html @@ -0,0 +1,62 @@ + + + + + + WWCompanion + + + +
+
+ WWCompanion + AI companion for WaterlooWorks. +
+
+ +
+
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+ + +
+
+
+
+ Posting: 0 chars + Prompt: 0 chars + Idle +
+
+ +
+
+
+ + + + + + diff --git a/sitecompanion/popup.js b/sitecompanion/popup.js new file mode 100644 index 0000000..a4ab4f1 --- /dev/null +++ b/sitecompanion/popup.js @@ -0,0 +1,844 @@ +const runBtn = document.getElementById("runBtn"); +const abortBtn = document.getElementById("abortBtn"); +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"); +const promptCountEl = document.getElementById("promptCount"); +const settingsBtn = document.getElementById("settingsBtn"); +const copyRenderedBtn = document.getElementById("copyRenderedBtn"); +const copyRawBtn = document.getElementById("copyRawBtn"); +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, + selectedTaskId: "", + selectedEnvId: "", + selectedProfileId: "" +}; + +function getStorage(keys) { + return new Promise((resolve) => chrome.storage.local.get(keys, resolve)); +} + +function buildUserMessage(resume, resumeType, task, posting) { + const header = resumeType === "Profile" ? "=== PROFILE ===" : "=== RESUME ==="; + return [ + header, + resume || "", + "", + "=== TASK ===", + task || "", + "", + "=== JOB POSTING ===", + posting || "" + ].join("\n"); +} + +function escapeHtml(text) { + return text + .replace(/&/g, "&") + .replace(//g, ">"); +} + +function escapeAttribute(text) { + return text.replace(/&/g, "&").replace(/"/g, """); +} + +function sanitizeUrl(url) { + const trimmed = url.trim().replace(/&/g, "&"); + if (/^https?:\/\//i.test(trimmed)) return trimmed; + return ""; +} + +function applyInline(text) { + if (!text) return ""; + const codeSpans = []; + let output = text.replace(/`([^`]+)`/g, (_match, code) => { + const id = codeSpans.length; + codeSpans.push(code); + return `@@CODESPAN${id}@@`; + }); + + output = output.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, label, url) => { + const safeUrl = sanitizeUrl(url); + if (!safeUrl) return label; + return `${label}`; + }); + + output = output.replace(/\*\*([^*]+)\*\*/g, "$1"); + output = output.replace(/\*([^*]+)\*/g, "$1"); + output = output.replace(/_([^_]+)_/g, "$1"); + + output = output.replace(/@@CODESPAN(\d+)@@/g, (_match, id) => { + const code = codeSpans[Number(id)] || ""; + return `${code}`; + }); + + return output; +} + +function renderMarkdown(rawText) { + const { text, blocks } = (() => { + const escaped = escapeHtml(rawText || ""); + const codeBlocks = []; + const replaced = escaped.replace(/```([\s\S]*?)```/g, (_match, code) => { + let content = code; + if (content.startsWith("\n")) content = content.slice(1); + const firstLine = content.split("\n")[0] || ""; + if (/^[a-z0-9+.#-]+$/i.test(firstLine.trim()) && content.includes("\n")) { + content = content.split("\n").slice(1).join("\n"); + } + const id = codeBlocks.length; + codeBlocks.push(content); + return `@@CODEBLOCK${id}@@`; + }); + return { text: replaced, blocks: codeBlocks }; + })(); + + const lines = text.split(/\r?\n/); + const result = []; + let paragraph = []; + let listType = null; + let inBlockquote = false; + let quoteLines = []; + + const flushParagraph = () => { + if (!paragraph.length) return; + result.push(`

${applyInline(paragraph.join("
"))}

`); + paragraph = []; + }; + + const closeList = () => { + if (!listType) return; + result.push(``); + listType = null; + }; + + const openList = (type) => { + if (listType === type) return; + if (listType) result.push(``); + listType = type; + result.push(`<${type}>`); + }; + + const closeBlockquote = () => { + if (!inBlockquote) return; + result.push(`
${applyInline(quoteLines.join("
"))}
`); + inBlockquote = false; + quoteLines = []; + }; + + for (const line of lines) { + const trimmed = line.trim(); + const isQuoteLine = /^\s*>\s?/.test(line); + + if (trimmed === "") { + flushParagraph(); + closeList(); + closeBlockquote(); + continue; + } + + if (inBlockquote && !isQuoteLine) { + closeBlockquote(); + } + + if (/^@@CODEBLOCK\d+@@$/.test(trimmed)) { + flushParagraph(); + closeList(); + closeBlockquote(); + result.push(trimmed); + continue; + } + + const headingMatch = line.match(/^(#{1,6})\s+(.*)$/); + if (headingMatch) { + flushParagraph(); + closeList(); + closeBlockquote(); + const level = headingMatch[1].length; + result.push(`${applyInline(headingMatch[2])}`); + continue; + } + + if (/^(\s*[-*_])\1{2,}\s*$/.test(line)) { + flushParagraph(); + closeList(); + closeBlockquote(); + result.push("
"); + continue; + } + + if (isQuoteLine) { + if (!inBlockquote) { + flushParagraph(); + closeList(); + inBlockquote = true; + quoteLines = []; + } + quoteLines.push(line.replace(/^\s*>\s?/, "")); + continue; + } + + const unorderedMatch = line.match(/^[-*+]\s+(.+)$/); + if (unorderedMatch) { + flushParagraph(); + closeBlockquote(); + openList("ul"); + result.push(`
  • ${applyInline(unorderedMatch[1])}
  • `); + continue; + } + + const orderedMatch = line.match(/^\d+\.\s+(.+)$/); + if (orderedMatch) { + flushParagraph(); + closeBlockquote(); + openList("ol"); + result.push(`
  • ${applyInline(orderedMatch[1])}
  • `); + continue; + } + + paragraph.push(line); + } + + flushParagraph(); + closeList(); + closeBlockquote(); + + return result + .join("\n") + .replace(/@@CODEBLOCK(\d+)@@/g, (_match, id) => { + const code = blocks[Number(id)] || ""; + return `
    ${code}
    `; + }); +} + +function renderOutput() { + outputEl.innerHTML = renderMarkdown(state.outputRaw); + outputEl.scrollTop = outputEl.scrollHeight; +} + +function persistOutputNow() { + return chrome.storage.local.set({ [OUTPUT_STORAGE_KEY]: state.outputRaw }); +} + +function setStatus(message) { + statusEl.textContent = message; +} + +function applyTheme(theme) { + const value = theme || "system"; + document.documentElement.dataset.theme = value; +} + +function setAnalyzing(isAnalyzing) { + state.isAnalyzing = isAnalyzing; + runBtn.disabled = isAnalyzing; + abortBtn.disabled = !isAnalyzing; + runBtn.classList.toggle("hidden", isAnalyzing); + abortBtn.classList.toggle("hidden", !isAnalyzing); + updateTaskSelectState(); + updateEnvSelectState(); + updateProfileSelectState(); +} + +function updatePostingCount() { + postingCountEl.textContent = `Posting: ${state.postingText.length} chars`; +} + +function updatePromptCount(count) { + promptCountEl.textContent = `Prompt: ${count} chars`; +} + +function renderTasks(tasks) { + state.tasks = tasks; + taskSelect.innerHTML = ""; + + if (!tasks.length) { + const option = document.createElement("option"); + option.textContent = "No tasks configured"; + option.value = ""; + taskSelect.appendChild(option); + updateTaskSelectState(); + return; + } + + for (const task of tasks) { + const option = document.createElement("option"); + option.value = task.id; + option.textContent = task.name || "Untitled task"; + taskSelect.appendChild(option); + } + 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"; + } catch { + return false; + } +} + +function sendToActiveTab(message) { + return new Promise((resolve, reject) => { + chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { + const tab = tabs[0]; + if (!tab?.id) { + reject(new Error("No active tab found.")); + return; + } + + if (!isWaterlooWorksUrl(tab.url || "")) { + reject(new Error("Open waterlooworks.uwaterloo.ca to use this.")); + return; + } + + chrome.tabs.sendMessage(tab.id, message, (response) => { + const error = chrome.runtime.lastError; + if (error) { + const msg = + error.message && error.message.includes("Receiving end does not exist") + ? "Couldn't reach the page. Try refreshing WaterlooWorks and retry." + : error.message; + reject(new Error(msg)); + return; + } + resolve(response); + }); + }); + }); +} + +function ensurePort() { + if (state.port) return state.port; + + const port = chrome.runtime.connect({ name: "analysis" }); + port.onMessage.addListener((message) => { + if (message?.type === "DELTA") { + state.outputRaw += message.text; + renderOutput(); + return; + } + + if (message?.type === "SYNC") { + state.outputRaw = message.text || ""; + renderOutput(); + if (message.streaming) { + setAnalyzing(true); + setStatus("Analyzing..."); + } + return; + } + + if (message?.type === "DONE") { + setAnalyzing(false); + setStatus("Done"); + return; + } + + if (message?.type === "ABORTED") { + setAnalyzing(false); + setStatus("Aborted."); + return; + } + + if (message?.type === "ERROR") { + setAnalyzing(false); + setStatus(message.message || "Error during analysis."); + } + }); + + port.onDisconnect.addListener(() => { + state.port = null; + setAnalyzing(false); + }); + + state.port = port; + return port; +} + +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(); +} + +async function loadTheme() { + const { theme = "system" } = await getStorage(["theme"]); + applyTheme(theme); +} + +async function handleExtract() { + setStatus("Extracting..."); + try { + const response = await sendToActiveTab({ type: "EXTRACT_POSTING" }); + if (!response?.ok) { + setStatus(response?.error || "No posting detected."); + return false; + } + + state.postingText = response.sanitized || ""; + updatePostingCount(); + updatePromptCount(0); + setStatus("Posting extracted."); + return true; + } catch (error) { + setStatus(error.message || "Unable to extract posting."); + return false; + } +} + +async function handleAnalyze() { + if (!state.postingText) { + setStatus("Extract a job posting first."); + return; + } + + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const tab = tabs[0]; + if (!tab?.url || !isWaterlooWorksUrl(tab.url)) { + setStatus("Open waterlooworks.uwaterloo.ca to run tasks."); + return; + } + + const taskId = taskSelect.value; + const task = state.tasks.find((item) => item.id === taskId); + if (!task) { + setStatus("Select a task prompt."); + return; + } + + 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" + ]); + + 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; + } + + 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; + } + } + + const promptText = buildUserMessage( + resumeText, + resumeType, + task.text || "", + state.postingText + ); + updatePromptCount(promptText.length); + + state.outputRaw = ""; + renderOutput(); + setAnalyzing(true); + setStatus("Analyzing..."); + + const port = ensurePort(); + port.postMessage({ + type: "START_ANALYSIS", + payload: { + apiKey, + 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 + } + }); +} + +async function handleExtractAndAnalyze() { + const extracted = await handleExtract(); + if (!extracted) return; + await handleAnalyze(); +} + +function handleAbort() { + if (!state.port) return; + state.port.postMessage({ type: "ABORT_ANALYSIS" }); + setAnalyzing(false); + setStatus("Aborted."); +} + +async function handleClearOutput() { + state.outputRaw = ""; + renderOutput(); + await persistOutputNow(); + setStatus("Output cleared."); +} + +async function copyTextToClipboard(text, label) { + try { + await navigator.clipboard.writeText(text); + setStatus(`${label} copied.`); + } catch (error) { + setStatus(`Unable to copy ${label.toLowerCase()}.`); + } +} + +function handleCopyRendered() { + const text = outputEl.innerText || ""; + if (!text.trim()) { + setStatus("Nothing to copy."); + return; + } + void copyTextToClipboard(text, "Output"); +} + +function handleCopyRaw() { + const text = state.outputRaw || ""; + if (!text.trim()) { + setStatus("Nothing to copy."); + return; + } + void copyTextToClipboard(text, "Markdown"); +} + +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); +loadConfig(); +loadTheme(); + +async function loadSavedOutput() { + const stored = await getStorage([OUTPUT_STORAGE_KEY]); + state.outputRaw = stored[OUTPUT_STORAGE_KEY] || ""; + renderOutput(); +} + +async function loadAutoRunRequest() { + const stored = await getStorage([AUTO_RUN_KEY]); + if (stored[AUTO_RUN_KEY]) { + state.autoRunPending = true; + await chrome.storage.local.remove(AUTO_RUN_KEY); + } + maybeRunDefaultTask(); +} + +function maybeRunDefaultTask() { + if (!state.autoRunPending) return; + if (state.isAnalyzing) return; + if (!state.tasks.length) return; + selectTask(state.tasks[0].id, { resetEnv: true }); + void persistSelections(); + state.autoRunPending = false; + void handleExtractAndAnalyze(); +} + +loadSavedOutput(); +loadAutoRunRequest(); +ensurePort(); + +chrome.storage.onChanged.addListener((changes) => { + if (changes[AUTO_RUN_KEY]?.newValue) { + state.autoRunPending = true; + void chrome.storage.local.remove(AUTO_RUN_KEY); + maybeRunDefaultTask(); + } + + if (changes[OUTPUT_STORAGE_KEY]?.newValue !== undefined) { + if (!state.isAnalyzing || !state.port) { + state.outputRaw = changes[OUTPUT_STORAGE_KEY].newValue || ""; + renderOutput(); + } + } + + if (changes.theme) { + applyTheme(changes.theme.newValue || "system"); + } +}); diff --git a/sitecompanion/settings.css b/sitecompanion/settings.css new file mode 100644 index 0000000..fc89b3f --- /dev/null +++ b/sitecompanion/settings.css @@ -0,0 +1,450 @@ +:root { + --ink: #221b15; + --muted: #6b5f55; + --accent: #b14d2b; + --panel: #fffaf1; + --border: #eadbc8; + --bg: #f5ead7; + --input-bg: #fffdf9; + --input-fg: var(--ink); + --card-bg: #fffefb; + --panel-shadow: 0 18px 40px rgba(120, 85, 55, 0.12); + color-scheme: light; +} + +:root[data-theme="dark"] { + --ink: #abb2bf; + --muted: #8b93a5; + --accent: #61afef; + --panel: #2f343f; + --border: #3e4451; + --bg: linear-gradient(160deg, #2a2f3a, #1f232b); + --input-bg: #2b303b; + --input-fg: var(--ink); + --card-bg: #2b303b; + --panel-shadow: 0 18px 40px rgba(0, 0, 0, 0.35); + color-scheme: dark; +} + +:root[data-theme="light"] { + color-scheme: light; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 24px; + font-family: system-ui, -apple-system, "Segoe UI", sans-serif; + color: var(--ink); + 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; +} + +.page-bar { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 12px; + margin-bottom: 16px; +} + +.title { + font-size: 26px; + font-weight: 700; +} + +.subtitle { + font-size: 13px; + color: var(--muted); +} + +.panel { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 16px; + padding: 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; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.row-actions { + display: flex; + gap: 8px; +} + +.row-title { + display: flex; + align-items: baseline; + gap: 8px; + flex-wrap: wrap; +} + +h2 { + margin: 0; + font-size: 16px; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--muted); +} + +.hint { + font-size: 12px; + text-transform: none; + letter-spacing: 0; + color: var(--muted); +} + +.hint-accent { + color: var(--accent); +} + +.field { + display: grid; + gap: 6px; + margin-bottom: 12px; +} + +label { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--muted); +} + +input, +textarea, +select { + width: 100%; + padding: 10px 12px; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--input-bg); + color: var(--input-fg); + font-family: inherit; + font-size: 13px; +} + +textarea { + resize: vertical; + min-height: 120px; +} + +.inline { + display: flex; + gap: 8px; +} + +button { + font-family: inherit; + border: none; + border-radius: 10px; + padding: 8px 12px; + cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.15s ease; +} + +button:active { + transform: translateY(1px); +} + +.accent { + background: var(--accent); + color: #fff9f3; + box-shadow: 0 8px 20px rgba(177, 77, 43, 0.2); +} + +.ghost { + background: transparent; + border: 1px solid var(--border); +} + +.status { + font-size: 12px; + color: var(--accent); +} + +.tasks { + display: grid; + gap: 12px; +} + +.task-card { + padding: 12px; + border-radius: 12px; + border: 1px solid var(--border); + background: var(--card-bg); + display: grid; + 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"] { + --ink: #abb2bf; + --muted: #8b93a5; + --accent: #61afef; + --panel: #2f343f; + --border: #3e4451; + --bg: linear-gradient(160deg, #2a2f3a, #1f232b); + --input-bg: #2b303b; + --input-fg: var(--ink); + --card-bg: #2b303b; + --panel-shadow: 0 18px 40px rgba(0, 0, 0, 0.35); + color-scheme: dark; + } +} + +.task-actions { + display: flex; + gap: 6px; + justify-content: flex-end; +} + +@media (max-width: 720px) { + .settings-layout { + flex-direction: column; + } + + .toc { + width: 100%; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + } +} diff --git a/sitecompanion/settings.html b/sitecompanion/settings.html new file mode 100644 index 0000000..5c9188c --- /dev/null +++ b/sitecompanion/settings.html @@ -0,0 +1,156 @@ + + + + + + WWCompanion Settings + + + +
    +
    WWCompanion Settings
    +
    Configure prompts, resume, and API access
    +
    +
    +
    + +
    + +
    + +
    +
    + + +

    Appearance

    +
    +
    +
    + + +
    +
    +
    + +
    + + +

    API KEYS

    +
    +
    +
    +
    + +
    +
    +
    +
    + +
    + + +

    API

    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    + +
    + + +
    +

    Environment

    + API configuration and system prompt go here +
    +
    +
    +
    +
    + +
    +
    +
    +
    + +
    + + +
    +

    My Profiles

    + Text to your resumes or generic profiles goes here +
    +
    +
    +
    +
    + +
    +
    +
    +
    + +
    + + +
    +

    Task Presets

    + Top task is the default +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    + + + + diff --git a/sitecompanion/settings.js b/sitecompanion/settings.js new file mode 100644 index 0000000..f3f1d30 --- /dev/null +++ b/sitecompanion/settings.js @@ -0,0 +1,1812 @@ +const saveBtn = document.getElementById("saveBtn"); +const saveBtnSidebar = document.getElementById("saveBtnSidebar"); +const addApiConfigBtn = document.getElementById("addApiConfigBtn"); +const apiConfigsContainer = document.getElementById("apiConfigs"); +const addApiKeyBtn = document.getElementById("addApiKeyBtn"); +const apiKeysContainer = document.getElementById("apiKeys"); +const addEnvConfigBtn = document.getElementById("addEnvConfigBtn"); +const envConfigsContainer = document.getElementById("envConfigs"); +const addTaskBtn = document.getElementById("addTaskBtn"); +const tasksContainer = document.getElementById("tasks"); +const addProfileBtn = document.getElementById("addProfileBtn"); +const profilesContainer = document.getElementById("profiles"); +const statusEl = document.getElementById("status"); +const statusSidebarEl = document.getElementById("statusSidebar"); +const sidebarErrorsEl = document.getElementById("sidebarErrors"); +const themeSelect = document.getElementById("themeSelect"); + +const OPENAI_DEFAULTS = { + apiBaseUrl: "https://api.openai.com/v1", + apiKeyHeader: "Authorization", + apiKeyPrefix: "Bearer " +}; +const DEFAULT_MODEL = "gpt-4o-mini"; +const DEFAULT_SYSTEM_PROMPT = + "You are a precise, honest assistant. Be concise and avoid inventing details, be critical about evaluations. You should put in a small summary of all the sections at the end. You should answer in no longer than 3 sections including the summary. And remember to bold or italicize key points."; + +function getStorage(keys) { + return new Promise((resolve) => chrome.storage.local.get(keys, resolve)); +} + +function setStatus(message) { + statusEl.textContent = message; + if (statusSidebarEl) statusSidebarEl.textContent = message; + if (!message) return; + setTimeout(() => { + if (statusEl.textContent === message) statusEl.textContent = ""; + if (statusSidebarEl?.textContent === message) statusSidebarEl.textContent = ""; + }, 2000); +} + +let sidebarErrorFrame = null; +function scheduleSidebarErrors() { + if (!sidebarErrorsEl) return; + if (sidebarErrorFrame) return; + sidebarErrorFrame = requestAnimationFrame(() => { + sidebarErrorFrame = null; + updateSidebarErrors(); + }); +} + +function applyTheme(theme) { + const value = theme || "system"; + document.documentElement.dataset.theme = value; +} + +function newTaskId() { + if (crypto?.randomUUID) return crypto.randomUUID(); + return `task-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; +} + +function newApiKeyId() { + if (crypto?.randomUUID) return crypto.randomUUID(); + return `key-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; +} + +function newApiConfigId() { + if (crypto?.randomUUID) return crypto.randomUUID(); + return `config-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; +} + +function newEnvConfigId() { + if (crypto?.randomUUID) return crypto.randomUUID(); + return `env-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; +} + +function newProfileId() { + if (crypto?.randomUUID) return crypto.randomUUID(); + return `profile-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; +} + +function buildChatUrlFromBase(baseUrl) { + const trimmed = (baseUrl || "").trim().replace(/\/+$/, ""); + if (!trimmed) return ""; + if (trimmed.endsWith("/chat/completions")) return trimmed; + return `${trimmed}/chat/completions`; +} + +function collectNames(container, selector) { + if (!container) return []; + return [...container.querySelectorAll(selector)] + .map((input) => (input.value || "").trim()) + .filter(Boolean); +} + +function buildUniqueDefaultName(names) { + const lower = new Set(names.map((name) => name.toLowerCase())); + if (!lower.has("default")) return "Default"; + let index = 2; + while (lower.has(`default-${index}`)) { + index += 1; + } + return `Default-${index}`; +} + +function ensureUniqueName(desired, existingNames) { + const trimmed = (desired || "").trim(); + const lowerNames = existingNames.map((name) => name.toLowerCase()); + if (trimmed && !lowerNames.includes(trimmed.toLowerCase())) { + return trimmed; + } + return buildUniqueDefaultName(existingNames); +} + +function getTopEnvId() { + return collectEnvConfigs()[0]?.id || ""; +} + +function getTopProfileId() { + return collectProfiles()[0]?.id || ""; +} + +function setApiConfigAdvanced(card, isAdvanced) { + card.classList.toggle("is-advanced", isAdvanced); + card.dataset.mode = isAdvanced ? "advanced" : "basic"; + + const basicFields = card.querySelectorAll( + ".basic-only input, .basic-only textarea" + ); + const advancedFields = card.querySelectorAll( + ".advanced-only input, .advanced-only textarea" + ); + basicFields.forEach((field) => { + field.disabled = isAdvanced; + }); + advancedFields.forEach((field) => { + field.disabled = !isAdvanced; + }); + + const resetBtn = card.querySelector(".reset-openai"); + if (resetBtn) resetBtn.disabled = false; + + const advancedBtn = card.querySelector(".advanced-toggle"); + if (advancedBtn) advancedBtn.disabled = isAdvanced; +} + +function readApiConfigFromCard(card) { + const nameInput = card.querySelector(".api-config-name"); + const keySelect = card.querySelector(".api-config-key-select"); + const baseInput = card.querySelector(".api-config-base"); + const headerInput = card.querySelector(".api-config-header"); + const prefixInput = card.querySelector(".api-config-prefix"); + const modelInput = card.querySelector(".api-config-model"); + const urlInput = card.querySelector(".api-config-url"); + const templateInput = card.querySelector(".api-config-template"); + const isAdvanced = card.classList.contains("is-advanced"); + + return { + id: card.dataset.id || newApiConfigId(), + name: (nameInput?.value || "Default").trim(), + apiKeyId: keySelect?.value || "", + apiBaseUrl: (baseInput?.value || "").trim(), + apiKeyHeader: (headerInput?.value || "").trim(), + apiKeyPrefix: prefixInput?.value || "", + model: (modelInput?.value || "").trim(), + apiUrl: (urlInput?.value || "").trim(), + requestTemplate: (templateInput?.value || "").trim(), + advanced: isAdvanced + }; +} + +function buildApiConfigCard(config) { + const card = document.createElement("div"); + card.className = "api-config-card"; + card.dataset.id = config.id || newApiConfigId(); + const isAdvanced = Boolean(config.advanced); + + const nameField = document.createElement("div"); + nameField.className = "field"; + const nameLabel = document.createElement("label"); + nameLabel.textContent = "Name"; + const nameInput = document.createElement("input"); + nameInput.type = "text"; + nameInput.value = config.name || ""; + nameInput.className = "api-config-name"; + nameField.appendChild(nameLabel); + nameField.appendChild(nameInput); + + const keyField = document.createElement("div"); + keyField.className = "field"; + const keyLabel = document.createElement("label"); + keyLabel.textContent = "API Key"; + const keySelect = document.createElement("select"); + keySelect.className = "api-config-key-select"; + keySelect.dataset.preferred = config.apiKeyId || ""; + keyField.appendChild(keyLabel); + keyField.appendChild(keySelect); + + const baseField = document.createElement("div"); + baseField.className = "field basic-only"; + const baseLabel = document.createElement("label"); + baseLabel.textContent = "API Base URL"; + const baseInput = document.createElement("input"); + baseInput.type = "text"; + baseInput.placeholder = OPENAI_DEFAULTS.apiBaseUrl; + baseInput.value = config.apiBaseUrl || ""; + baseInput.className = "api-config-base"; + baseField.appendChild(baseLabel); + baseField.appendChild(baseInput); + + const headerField = document.createElement("div"); + headerField.className = "field advanced-only"; + const headerLabel = document.createElement("label"); + headerLabel.textContent = "API Key Header"; + const headerInput = document.createElement("input"); + headerInput.type = "text"; + headerInput.placeholder = OPENAI_DEFAULTS.apiKeyHeader; + headerInput.value = config.apiKeyHeader || ""; + headerInput.className = "api-config-header"; + headerField.appendChild(headerLabel); + headerField.appendChild(headerInput); + + const prefixField = document.createElement("div"); + prefixField.className = "field advanced-only"; + const prefixLabel = document.createElement("label"); + prefixLabel.textContent = "API Key Prefix"; + const prefixInput = document.createElement("input"); + prefixInput.type = "text"; + prefixInput.placeholder = OPENAI_DEFAULTS.apiKeyPrefix; + prefixInput.value = config.apiKeyPrefix || ""; + prefixInput.className = "api-config-prefix"; + prefixField.appendChild(prefixLabel); + prefixField.appendChild(prefixInput); + + const modelField = document.createElement("div"); + modelField.className = "field basic-only"; + const modelLabel = document.createElement("label"); + modelLabel.textContent = "Model name"; + const modelInput = document.createElement("input"); + modelInput.type = "text"; + modelInput.placeholder = DEFAULT_MODEL; + modelInput.value = config.model || ""; + modelInput.className = "api-config-model"; + modelField.appendChild(modelLabel); + modelField.appendChild(modelInput); + + const urlField = document.createElement("div"); + urlField.className = "field advanced-only"; + const urlLabel = document.createElement("label"); + urlLabel.textContent = "API URL"; + const urlInput = document.createElement("input"); + urlInput.type = "text"; + urlInput.placeholder = "https://api.example.com/v1/chat/completions"; + urlInput.value = config.apiUrl || ""; + urlInput.className = "api-config-url"; + urlField.appendChild(urlLabel); + urlField.appendChild(urlInput); + + const templateField = document.createElement("div"); + templateField.className = "field advanced-only"; + const templateLabel = document.createElement("label"); + templateLabel.textContent = "Request JSON template"; + const templateInput = document.createElement("textarea"); + templateInput.rows = 8; + templateInput.placeholder = [ + "{", + " \"stream\": true,", + " \"messages\": [", + " { \"role\": \"system\", \"content\": \"SYSTEM_PROMPT_GOES_HERE\" },", + " { \"role\": \"user\", \"content\": \"PROMPT_GOES_HERE\" }", + " ],", + " \"api_key\": \"API_KEY_GOES_HERE\"", + "}" + ].join("\n"); + templateInput.value = config.requestTemplate || ""; + templateInput.className = "api-config-template"; + templateField.appendChild(templateLabel); + templateField.appendChild(templateInput); + + const actions = document.createElement("div"); + actions.className = "api-config-actions"; + const leftActions = document.createElement("div"); + leftActions.className = "api-config-actions-left"; + const rightActions = document.createElement("div"); + rightActions.className = "api-config-actions-right"; + const moveTopBtn = document.createElement("button"); + moveTopBtn.type = "button"; + moveTopBtn.className = "ghost move-top"; + moveTopBtn.textContent = "Top"; + const moveUpBtn = document.createElement("button"); + moveUpBtn.type = "button"; + moveUpBtn.className = "ghost move-up"; + moveUpBtn.textContent = "Up"; + const moveDownBtn = document.createElement("button"); + moveDownBtn.type = "button"; + moveDownBtn.className = "ghost move-down"; + moveDownBtn.textContent = "Down"; + const addBelowBtn = document.createElement("button"); + addBelowBtn.type = "button"; + addBelowBtn.className = "ghost add-below"; + addBelowBtn.textContent = "Add"; + + moveTopBtn.addEventListener("click", () => { + const first = apiConfigsContainer.firstElementChild; + if (!first || first === card) return; + apiConfigsContainer.insertBefore(card, first); + updateApiConfigControls(); + updateEnvApiOptions(); + }); + + moveUpBtn.addEventListener("click", () => { + const previous = card.previousElementSibling; + if (!previous) return; + apiConfigsContainer.insertBefore(card, previous); + updateApiConfigControls(); + updateEnvApiOptions(); + }); + + moveDownBtn.addEventListener("click", () => { + const next = card.nextElementSibling; + if (!next) return; + apiConfigsContainer.insertBefore(card, next.nextElementSibling); + updateApiConfigControls(); + updateEnvApiOptions(); + }); + + addBelowBtn.addEventListener("click", () => { + const name = buildUniqueDefaultName( + collectNames(apiConfigsContainer, ".api-config-name") + ); + const newCard = buildApiConfigCard({ + id: newApiConfigId(), + name, + apiBaseUrl: OPENAI_DEFAULTS.apiBaseUrl, + apiKeyHeader: OPENAI_DEFAULTS.apiKeyHeader, + apiKeyPrefix: OPENAI_DEFAULTS.apiKeyPrefix, + model: DEFAULT_MODEL, + apiUrl: "", + requestTemplate: "", + advanced: false + }); + card.insertAdjacentElement("afterend", newCard); + updateApiConfigKeyOptions(); + updateEnvApiOptions(); + updateApiConfigControls(); + }); + + const advancedBtn = document.createElement("button"); + advancedBtn.type = "button"; + advancedBtn.className = "ghost advanced-toggle"; + advancedBtn.textContent = "Advanced Mode"; + advancedBtn.addEventListener("click", () => { + if (card.classList.contains("is-advanced")) return; + urlInput.value = buildChatUrlFromBase(baseInput.value); + templateInput.value = [ + "{", + ` \"model\": \"${modelInput.value || DEFAULT_MODEL}\",`, + " \"stream\": true,", + " \"messages\": [", + " { \"role\": \"system\", \"content\": \"SYSTEM_PROMPT_GOES_HERE\" },", + " { \"role\": \"user\", \"content\": \"PROMPT_GOES_HERE\" }", + " ],", + " \"api_key\": \"API_KEY_GOES_HERE\"", + "}" + ].join("\n"); + setApiConfigAdvanced(card, true); + updateEnvApiOptions(); + }); + const duplicateBtn = document.createElement("button"); + duplicateBtn.type = "button"; + duplicateBtn.className = "ghost duplicate"; + duplicateBtn.textContent = "Duplicate"; + duplicateBtn.addEventListener("click", () => { + const names = collectNames(apiConfigsContainer, ".api-config-name"); + const copy = readApiConfigFromCard(card); + copy.id = newApiConfigId(); + copy.name = ensureUniqueName(`${copy.name || "Default"} Copy`, names); + const newCard = buildApiConfigCard(copy); + card.insertAdjacentElement("afterend", newCard); + updateApiConfigKeyOptions(); + updateEnvApiOptions(); + }); + const resetBtn = document.createElement("button"); + resetBtn.type = "button"; + resetBtn.className = "ghost reset-openai"; + resetBtn.textContent = "Reset to OpenAI"; + resetBtn.addEventListener("click", () => { + baseInput.value = OPENAI_DEFAULTS.apiBaseUrl; + headerInput.value = OPENAI_DEFAULTS.apiKeyHeader; + prefixInput.value = OPENAI_DEFAULTS.apiKeyPrefix; + modelInput.value = DEFAULT_MODEL; + urlInput.value = ""; + templateInput.value = ""; + setApiConfigAdvanced(card, false); + updateEnvApiOptions(); + }); + const deleteBtn = document.createElement("button"); + deleteBtn.type = "button"; + deleteBtn.className = "ghost delete"; + deleteBtn.textContent = "Delete"; + deleteBtn.addEventListener("click", () => { + card.remove(); + updateEnvApiOptions(); + updateApiConfigControls(); + }); + + const updateSelect = () => updateEnvApiOptions(); + nameInput.addEventListener("input", updateSelect); + baseInput.addEventListener("input", updateSelect); + headerInput.addEventListener("input", updateSelect); + prefixInput.addEventListener("input", updateSelect); + modelInput.addEventListener("input", updateSelect); + urlInput.addEventListener("input", updateSelect); + templateInput.addEventListener("input", updateSelect); + + rightActions.appendChild(moveTopBtn); + rightActions.appendChild(moveUpBtn); + rightActions.appendChild(moveDownBtn); + rightActions.appendChild(addBelowBtn); + rightActions.appendChild(duplicateBtn); + rightActions.appendChild(deleteBtn); + + leftActions.appendChild(advancedBtn); + leftActions.appendChild(resetBtn); + + actions.appendChild(leftActions); + actions.appendChild(rightActions); + + card.appendChild(nameField); + card.appendChild(keyField); + card.appendChild(baseField); + card.appendChild(headerField); + card.appendChild(prefixField); + card.appendChild(modelField); + card.appendChild(urlField); + card.appendChild(templateField); + card.appendChild(actions); + + setApiConfigAdvanced(card, isAdvanced); + + return card; +} + +function collectApiConfigs() { + const cards = [...apiConfigsContainer.querySelectorAll(".api-config-card")]; + return cards.map((card) => readApiConfigFromCard(card)); +} + +function updateApiConfigControls() { + const cards = [...apiConfigsContainer.querySelectorAll(".api-config-card")]; + cards.forEach((card, index) => { + const moveTopBtn = card.querySelector(".move-top"); + const moveUpBtn = card.querySelector(".move-up"); + const moveDownBtn = card.querySelector(".move-down"); + if (moveTopBtn) moveTopBtn.disabled = index === 0; + if (moveUpBtn) moveUpBtn.disabled = index === 0; + if (moveDownBtn) moveDownBtn.disabled = index === cards.length - 1; + }); + scheduleSidebarErrors(); +} + +function buildApiKeyCard(entry) { + const card = document.createElement("div"); + card.className = "api-key-card"; + card.dataset.id = entry.id || newApiKeyId(); + + const nameField = document.createElement("div"); + nameField.className = "field"; + const nameLabel = document.createElement("label"); + nameLabel.textContent = "Name"; + const nameInput = document.createElement("input"); + nameInput.type = "text"; + nameInput.value = entry.name || ""; + nameInput.className = "api-key-name"; + nameField.appendChild(nameLabel); + nameField.appendChild(nameInput); + + const keyField = document.createElement("div"); + keyField.className = "field"; + const keyLabel = document.createElement("label"); + keyLabel.textContent = "Key"; + const keyInline = document.createElement("div"); + keyInline.className = "inline"; + const keyInput = document.createElement("input"); + keyInput.type = "password"; + keyInput.autocomplete = "off"; + keyInput.placeholder = "sk-..."; + keyInput.value = entry.key || ""; + keyInput.className = "api-key-value"; + const showBtn = document.createElement("button"); + showBtn.type = "button"; + showBtn.className = "ghost"; + showBtn.textContent = "Show"; + showBtn.addEventListener("click", () => { + const isPassword = keyInput.type === "password"; + keyInput.type = isPassword ? "text" : "password"; + showBtn.textContent = isPassword ? "Hide" : "Show"; + }); + keyInline.appendChild(keyInput); + keyInline.appendChild(showBtn); + keyField.appendChild(keyLabel); + keyField.appendChild(keyInline); + + const actions = document.createElement("div"); + actions.className = "api-key-actions"; + const moveTopBtn = document.createElement("button"); + moveTopBtn.type = "button"; + moveTopBtn.className = "ghost move-top"; + moveTopBtn.textContent = "Top"; + const moveUpBtn = document.createElement("button"); + moveUpBtn.type = "button"; + moveUpBtn.className = "ghost move-up"; + moveUpBtn.textContent = "Up"; + const moveDownBtn = document.createElement("button"); + moveDownBtn.type = "button"; + moveDownBtn.className = "ghost move-down"; + moveDownBtn.textContent = "Down"; + const addBelowBtn = document.createElement("button"); + addBelowBtn.type = "button"; + addBelowBtn.className = "ghost add-below"; + addBelowBtn.textContent = "Add"; + + moveTopBtn.addEventListener("click", () => { + const first = apiKeysContainer.firstElementChild; + if (!first || first === card) return; + apiKeysContainer.insertBefore(card, first); + updateApiKeyControls(); + updateApiConfigKeyOptions(); + }); + + moveUpBtn.addEventListener("click", () => { + const previous = card.previousElementSibling; + if (!previous) return; + apiKeysContainer.insertBefore(card, previous); + updateApiKeyControls(); + updateApiConfigKeyOptions(); + }); + + moveDownBtn.addEventListener("click", () => { + const next = card.nextElementSibling; + if (!next) return; + apiKeysContainer.insertBefore(card, next.nextElementSibling); + updateApiKeyControls(); + updateApiConfigKeyOptions(); + }); + + addBelowBtn.addEventListener("click", () => { + const name = buildUniqueDefaultName( + collectNames(apiKeysContainer, ".api-key-name") + ); + const newCard = buildApiKeyCard({ id: newApiKeyId(), name, key: "" }); + card.insertAdjacentElement("afterend", newCard); + updateApiConfigKeyOptions(); + updateApiKeyControls(); + }); + + actions.appendChild(moveTopBtn); + actions.appendChild(moveUpBtn); + actions.appendChild(moveDownBtn); + actions.appendChild(addBelowBtn); + const deleteBtn = document.createElement("button"); + deleteBtn.type = "button"; + deleteBtn.className = "ghost delete"; + deleteBtn.textContent = "Delete"; + deleteBtn.addEventListener("click", () => { + card.remove(); + updateApiConfigKeyOptions(); + updateApiKeyControls(); + }); + actions.appendChild(deleteBtn); + + const updateSelect = () => updateApiConfigKeyOptions(); + nameInput.addEventListener("input", updateSelect); + keyInput.addEventListener("input", updateSelect); + + card.appendChild(nameField); + card.appendChild(keyField); + card.appendChild(actions); + + return card; +} + +function collectApiKeys() { + const cards = [...apiKeysContainer.querySelectorAll(".api-key-card")]; + return cards.map((card) => { + const nameInput = card.querySelector(".api-key-name"); + const keyInput = card.querySelector(".api-key-value"); + return { + id: card.dataset.id || newApiKeyId(), + name: (nameInput?.value || "Default").trim(), + key: (keyInput?.value || "").trim() + }; + }); +} + +function updateApiKeyControls() { + const cards = [...apiKeysContainer.querySelectorAll(".api-key-card")]; + cards.forEach((card, index) => { + const moveTopBtn = card.querySelector(".move-top"); + const moveUpBtn = card.querySelector(".move-up"); + const moveDownBtn = card.querySelector(".move-down"); + if (moveTopBtn) moveTopBtn.disabled = index === 0; + if (moveUpBtn) moveUpBtn.disabled = index === 0; + if (moveDownBtn) moveDownBtn.disabled = index === cards.length - 1; + }); + scheduleSidebarErrors(); +} + +function updateApiConfigKeyOptions() { + const keys = collectApiKeys(); + const selects = apiConfigsContainer.querySelectorAll(".api-config-key-select"); + selects.forEach((select) => { + const preferred = select.dataset.preferred || select.value; + select.innerHTML = ""; + if (!keys.length) { + const option = document.createElement("option"); + option.value = ""; + option.textContent = "No keys configured"; + select.appendChild(option); + select.disabled = true; + return; + } + + select.disabled = false; + for (const key of keys) { + const option = document.createElement("option"); + option.value = key.id; + option.textContent = key.name || "Default"; + select.appendChild(option); + } + + if (preferred && keys.some((key) => key.id === preferred)) { + select.value = preferred; + } else { + select.value = keys[0].id; + } + + select.dataset.preferred = select.value; + }); +} + +function buildEnvConfigCard(config) { + const card = document.createElement("div"); + card.className = "env-config-card"; + card.dataset.id = config.id || newEnvConfigId(); + + const nameField = document.createElement("div"); + nameField.className = "field"; + const nameLabel = document.createElement("label"); + nameLabel.textContent = "Name"; + const nameInput = document.createElement("input"); + nameInput.type = "text"; + nameInput.value = config.name || ""; + nameInput.className = "env-config-name"; + nameField.appendChild(nameLabel); + nameField.appendChild(nameInput); + + const apiField = document.createElement("div"); + apiField.className = "field"; + const apiLabel = document.createElement("label"); + apiLabel.textContent = "API config"; + const apiSelect = document.createElement("select"); + apiSelect.className = "env-config-api-select"; + apiSelect.dataset.preferred = config.apiConfigId || ""; + apiField.appendChild(apiLabel); + apiField.appendChild(apiSelect); + + const promptField = document.createElement("div"); + promptField.className = "field"; + const promptLabel = document.createElement("label"); + promptLabel.textContent = "System prompt"; + const promptInput = document.createElement("textarea"); + promptInput.rows = 8; + promptInput.value = config.systemPrompt || ""; + promptInput.className = "env-config-prompt"; + promptField.appendChild(promptLabel); + promptField.appendChild(promptInput); + + const actions = document.createElement("div"); + actions.className = "env-config-actions"; + const moveTopBtn = document.createElement("button"); + moveTopBtn.type = "button"; + moveTopBtn.className = "ghost move-top"; + moveTopBtn.textContent = "Top"; + const moveUpBtn = document.createElement("button"); + moveUpBtn.type = "button"; + moveUpBtn.className = "ghost move-up"; + moveUpBtn.textContent = "Up"; + const moveDownBtn = document.createElement("button"); + moveDownBtn.type = "button"; + moveDownBtn.className = "ghost move-down"; + moveDownBtn.textContent = "Down"; + const addBelowBtn = document.createElement("button"); + addBelowBtn.type = "button"; + addBelowBtn.className = "ghost add-below"; + addBelowBtn.textContent = "Add"; + + moveTopBtn.addEventListener("click", () => { + const first = envConfigsContainer.firstElementChild; + if (!first || first === card) return; + envConfigsContainer.insertBefore(card, first); + updateEnvControls(); + updateTaskEnvOptions(); + }); + + moveUpBtn.addEventListener("click", () => { + const previous = card.previousElementSibling; + if (!previous) return; + envConfigsContainer.insertBefore(card, previous); + updateEnvControls(); + updateTaskEnvOptions(); + }); + + moveDownBtn.addEventListener("click", () => { + const next = card.nextElementSibling; + if (!next) return; + envConfigsContainer.insertBefore(card, next.nextElementSibling); + updateEnvControls(); + updateTaskEnvOptions(); + }); + + actions.appendChild(moveTopBtn); + actions.appendChild(moveUpBtn); + actions.appendChild(moveDownBtn); + actions.appendChild(addBelowBtn); + + addBelowBtn.addEventListener("click", () => { + const name = buildUniqueDefaultName( + collectNames(envConfigsContainer, ".env-config-name") + ); + const fallbackApiConfigId = collectApiConfigs()[0]?.id || ""; + const newCard = buildEnvConfigCard({ + id: newEnvConfigId(), + name, + apiConfigId: fallbackApiConfigId, + systemPrompt: DEFAULT_SYSTEM_PROMPT + }); + card.insertAdjacentElement("afterend", newCard); + updateEnvApiOptions(); + updateEnvControls(); + updateTaskEnvOptions(); + }); + + const duplicateBtn = document.createElement("button"); + duplicateBtn.type = "button"; + duplicateBtn.className = "ghost duplicate"; + duplicateBtn.textContent = "Duplicate"; + duplicateBtn.addEventListener("click", () => { + const names = collectNames(envConfigsContainer, ".env-config-name"); + const copy = collectEnvConfigs().find((entry) => entry.id === card.dataset.id) || { + id: card.dataset.id, + name: nameInput.value || "Default", + apiConfigId: apiSelect.value || "", + systemPrompt: promptInput.value || "" + }; + const newCard = buildEnvConfigCard({ + id: newEnvConfigId(), + name: ensureUniqueName(`${copy.name || "Default"} Copy`, names), + apiConfigId: copy.apiConfigId, + systemPrompt: copy.systemPrompt + }); + card.insertAdjacentElement("afterend", newCard); + updateEnvApiOptions(); + updateEnvControls(); + updateTaskEnvOptions(); + }); + + const deleteBtn = document.createElement("button"); + deleteBtn.type = "button"; + deleteBtn.className = "ghost delete"; + deleteBtn.textContent = "Delete"; + deleteBtn.addEventListener("click", () => { + card.remove(); + updateEnvControls(); + updateTaskEnvOptions(); + }); + + actions.appendChild(duplicateBtn); + actions.appendChild(deleteBtn); + nameInput.addEventListener("input", () => updateEnvApiOptions()); + + card.appendChild(nameField); + card.appendChild(apiField); + card.appendChild(promptField); + card.appendChild(actions); + + return card; +} + +function updateEnvControls() { + const cards = [...envConfigsContainer.querySelectorAll(".env-config-card")]; + cards.forEach((card, index) => { + const moveTopBtn = card.querySelector(".move-top"); + const moveUpBtn = card.querySelector(".move-up"); + const moveDownBtn = card.querySelector(".move-down"); + if (moveTopBtn) moveTopBtn.disabled = index === 0; + if (moveUpBtn) moveUpBtn.disabled = index === 0; + if (moveDownBtn) moveDownBtn.disabled = index === cards.length - 1; + }); + scheduleSidebarErrors(); +} + +function updateTaskEnvOptions() { + const envs = collectEnvConfigs(); + const selects = tasksContainer.querySelectorAll(".task-env-select"); + selects.forEach((select) => { + const preferred = select.dataset.preferred || select.value; + select.innerHTML = ""; + if (!envs.length) { + const option = document.createElement("option"); + option.value = ""; + option.textContent = "No environments configured"; + select.appendChild(option); + select.disabled = true; + return; + } + + select.disabled = false; + for (const env of envs) { + const option = document.createElement("option"); + option.value = env.id; + option.textContent = env.name || "Default"; + select.appendChild(option); + } + + if (preferred && envs.some((env) => env.id === preferred)) { + select.value = preferred; + } else { + select.value = envs[0].id; + } + + select.dataset.preferred = select.value; + }); + scheduleSidebarErrors(); +} + +function collectEnvConfigs() { + const cards = [...envConfigsContainer.querySelectorAll(".env-config-card")]; + return cards.map((card) => { + const nameInput = card.querySelector(".env-config-name"); + const apiSelect = card.querySelector(".env-config-api-select"); + const promptInput = card.querySelector(".env-config-prompt"); + return { + id: card.dataset.id || newEnvConfigId(), + name: (nameInput?.value || "Default").trim(), + apiConfigId: apiSelect?.value || "", + systemPrompt: (promptInput?.value || "").trim() + }; + }); +} + +function buildProfileCard(profile) { + const card = document.createElement("div"); + card.className = "profile-card"; + card.dataset.id = profile.id || newProfileId(); + + const nameField = document.createElement("div"); + nameField.className = "field"; + const nameLabel = document.createElement("label"); + nameLabel.textContent = "Name"; + const nameInput = document.createElement("input"); + nameInput.type = "text"; + nameInput.value = profile.name || ""; + nameInput.className = "profile-name"; + nameField.appendChild(nameLabel); + nameField.appendChild(nameInput); + + const typeField = document.createElement("div"); + typeField.className = "field"; + const typeLabel = document.createElement("label"); + typeLabel.textContent = "Type"; + const typeSelect = document.createElement("select"); + typeSelect.className = "profile-type"; + const resumeOption = document.createElement("option"); + resumeOption.value = "Resume"; + resumeOption.textContent = "Resume"; + const profileOption = document.createElement("option"); + profileOption.value = "Profile"; + profileOption.textContent = "Profile"; + typeSelect.appendChild(resumeOption); + typeSelect.appendChild(profileOption); + typeSelect.value = profile.type === "Profile" ? "Profile" : "Resume"; + typeField.appendChild(typeLabel); + typeField.appendChild(typeSelect); + + const textField = document.createElement("div"); + textField.className = "field"; + const textLabel = document.createElement("label"); + textLabel.textContent = "Profile text"; + const textArea = document.createElement("textarea"); + textArea.rows = 8; + textArea.value = profile.text || ""; + textArea.className = "profile-text"; + textField.appendChild(textLabel); + textField.appendChild(textArea); + + const actions = document.createElement("div"); + actions.className = "profile-actions"; + const moveTopBtn = document.createElement("button"); + moveTopBtn.type = "button"; + moveTopBtn.className = "ghost move-top"; + moveTopBtn.textContent = "Top"; + const moveUpBtn = document.createElement("button"); + moveUpBtn.type = "button"; + moveUpBtn.className = "ghost move-up"; + moveUpBtn.textContent = "Up"; + const moveDownBtn = document.createElement("button"); + moveDownBtn.type = "button"; + moveDownBtn.className = "ghost move-down"; + moveDownBtn.textContent = "Down"; + const addBelowBtn = document.createElement("button"); + addBelowBtn.type = "button"; + addBelowBtn.className = "ghost add-below"; + addBelowBtn.textContent = "Add"; + + moveTopBtn.addEventListener("click", () => { + const first = profilesContainer.firstElementChild; + if (!first || first === card) return; + profilesContainer.insertBefore(card, first); + updateProfileControls(); + updateTaskProfileOptions(); + }); + + moveUpBtn.addEventListener("click", () => { + const previous = card.previousElementSibling; + if (!previous) return; + profilesContainer.insertBefore(card, previous); + updateProfileControls(); + updateTaskProfileOptions(); + }); + + moveDownBtn.addEventListener("click", () => { + const next = card.nextElementSibling; + if (!next) return; + profilesContainer.insertBefore(card, next.nextElementSibling); + updateProfileControls(); + updateTaskProfileOptions(); + }); + + actions.appendChild(moveTopBtn); + actions.appendChild(moveUpBtn); + actions.appendChild(moveDownBtn); + actions.appendChild(addBelowBtn); + + addBelowBtn.addEventListener("click", () => { + const name = buildUniqueDefaultName( + collectNames(profilesContainer, ".profile-name") + ); + const newCard = buildProfileCard({ + id: newProfileId(), + name, + text: "", + type: "Resume" + }); + card.insertAdjacentElement("afterend", newCard); + updateProfileControls(); + updateTaskProfileOptions(); + }); + + const duplicateBtn = document.createElement("button"); + duplicateBtn.type = "button"; + duplicateBtn.className = "ghost duplicate"; + duplicateBtn.textContent = "Duplicate"; + duplicateBtn.addEventListener("click", () => { + const names = collectNames(profilesContainer, ".profile-name"); + const copy = collectProfiles().find((entry) => entry.id === card.dataset.id) || { + id: card.dataset.id, + name: nameInput.value || "Default", + text: textArea.value || "", + type: typeSelect.value || "Resume" + }; + const newCard = buildProfileCard({ + id: newProfileId(), + name: ensureUniqueName(`${copy.name || "Default"} Copy`, names), + text: copy.text, + type: copy.type || "Resume" + }); + card.insertAdjacentElement("afterend", newCard); + updateProfileControls(); + updateTaskProfileOptions(); + }); + + const deleteBtn = document.createElement("button"); + deleteBtn.type = "button"; + deleteBtn.className = "ghost delete"; + deleteBtn.textContent = "Delete"; + deleteBtn.addEventListener("click", () => { + card.remove(); + updateProfileControls(); + updateTaskProfileOptions(); + }); + + actions.appendChild(duplicateBtn); + actions.appendChild(deleteBtn); + + nameInput.addEventListener("input", () => updateTaskProfileOptions()); + + card.appendChild(nameField); + card.appendChild(typeField); + card.appendChild(textField); + card.appendChild(actions); + + return card; +} + +function collectProfiles() { + const cards = [...profilesContainer.querySelectorAll(".profile-card")]; + return cards.map((card) => { + const nameInput = card.querySelector(".profile-name"); + const textArea = card.querySelector(".profile-text"); + const typeSelect = card.querySelector(".profile-type"); + return { + id: card.dataset.id || newProfileId(), + name: (nameInput?.value || "Default").trim(), + text: (textArea?.value || "").trim(), + type: typeSelect?.value || "Resume" + }; + }); +} + +function updateProfileControls() { + const cards = [...profilesContainer.querySelectorAll(".profile-card")]; + cards.forEach((card, index) => { + const moveTopBtn = card.querySelector(".move-top"); + const moveUpBtn = card.querySelector(".move-up"); + const moveDownBtn = card.querySelector(".move-down"); + if (moveTopBtn) moveTopBtn.disabled = index === 0; + if (moveUpBtn) moveUpBtn.disabled = index === 0; + if (moveDownBtn) moveDownBtn.disabled = index === cards.length - 1; + }); + scheduleSidebarErrors(); +} + +function updateTaskProfileOptions() { + const profiles = collectProfiles(); + const selects = tasksContainer.querySelectorAll(".task-profile-select"); + selects.forEach((select) => { + const preferred = select.dataset.preferred || select.value; + select.innerHTML = ""; + if (!profiles.length) { + const option = document.createElement("option"); + option.value = ""; + option.textContent = "No profiles configured"; + select.appendChild(option); + select.disabled = true; + return; + } + + select.disabled = false; + for (const profile of profiles) { + const option = document.createElement("option"); + option.value = profile.id; + option.textContent = profile.name || "Default"; + select.appendChild(option); + } + + if (preferred && profiles.some((profile) => profile.id === preferred)) { + select.value = preferred; + } else { + select.value = profiles[0].id; + } + + select.dataset.preferred = select.value; + }); + scheduleSidebarErrors(); +} + +function updateEnvApiOptions() { + const apiConfigs = collectApiConfigs(); + const selects = envConfigsContainer.querySelectorAll(".env-config-api-select"); + selects.forEach((select) => { + const preferred = select.dataset.preferred || select.value; + select.innerHTML = ""; + if (!apiConfigs.length) { + const option = document.createElement("option"); + option.value = ""; + option.textContent = "No API configs configured"; + select.appendChild(option); + select.disabled = true; + return; + } + + select.disabled = false; + for (const config of apiConfigs) { + const option = document.createElement("option"); + option.value = config.id; + option.textContent = config.name || "Default"; + select.appendChild(option); + } + + if (preferred && apiConfigs.some((config) => config.id === preferred)) { + select.value = preferred; + } else { + select.value = apiConfigs[0].id; + } + + select.dataset.preferred = select.value; + }); + updateTaskEnvOptions(); +} + +function buildTaskCard(task) { + const card = document.createElement("div"); + card.className = "task-card"; + card.dataset.id = task.id || newTaskId(); + + const nameField = document.createElement("div"); + nameField.className = "field"; + const nameLabel = document.createElement("label"); + nameLabel.textContent = "Name"; + const nameInput = document.createElement("input"); + nameInput.type = "text"; + nameInput.value = task.name || ""; + nameInput.className = "task-name"; + nameField.appendChild(nameLabel); + nameField.appendChild(nameInput); + + const envField = document.createElement("div"); + envField.className = "field"; + const envLabel = document.createElement("label"); + envLabel.textContent = "Default environment"; + const envSelect = document.createElement("select"); + envSelect.className = "task-env-select"; + envSelect.dataset.preferred = task.defaultEnvId || ""; + envField.appendChild(envLabel); + envField.appendChild(envSelect); + + const profileField = document.createElement("div"); + profileField.className = "field"; + const profileLabel = document.createElement("label"); + profileLabel.textContent = "Default profile"; + const profileSelect = document.createElement("select"); + profileSelect.className = "task-profile-select"; + profileSelect.dataset.preferred = task.defaultProfileId || ""; + profileField.appendChild(profileLabel); + profileField.appendChild(profileSelect); + + const textField = document.createElement("div"); + textField.className = "field"; + const textLabel = document.createElement("label"); + textLabel.textContent = "Task prompt"; + const textArea = document.createElement("textarea"); + textArea.rows = 6; + textArea.value = task.text || ""; + textArea.className = "task-text"; + textField.appendChild(textLabel); + textField.appendChild(textArea); + + const actions = document.createElement("div"); + actions.className = "task-actions"; + const moveTopBtn = document.createElement("button"); + moveTopBtn.type = "button"; + moveTopBtn.className = "ghost move-top"; + moveTopBtn.textContent = "Top"; + moveTopBtn.setAttribute("aria-label", "Move task to top"); + moveTopBtn.setAttribute("title", "Move to top"); + const moveUpBtn = document.createElement("button"); + moveUpBtn.type = "button"; + moveUpBtn.className = "ghost move-up"; + moveUpBtn.textContent = "Up"; + moveUpBtn.setAttribute("aria-label", "Move task up"); + moveUpBtn.setAttribute("title", "Move up"); + const moveDownBtn = document.createElement("button"); + moveDownBtn.type = "button"; + moveDownBtn.className = "ghost move-down"; + moveDownBtn.textContent = "Down"; + moveDownBtn.setAttribute("aria-label", "Move task down"); + moveDownBtn.setAttribute("title", "Move down"); + const addBelowBtn = document.createElement("button"); + addBelowBtn.type = "button"; + addBelowBtn.className = "ghost add-below"; + addBelowBtn.textContent = "Add"; + addBelowBtn.setAttribute("aria-label", "Add task below"); + addBelowBtn.setAttribute("title", "Add below"); + const duplicateBtn = document.createElement("button"); + duplicateBtn.type = "button"; + duplicateBtn.className = "ghost duplicate"; + duplicateBtn.textContent = "Duplicate"; + duplicateBtn.setAttribute("aria-label", "Duplicate task"); + duplicateBtn.setAttribute("title", "Duplicate"); + const deleteBtn = document.createElement("button"); + deleteBtn.type = "button"; + deleteBtn.className = "ghost delete"; + deleteBtn.textContent = "Delete"; + deleteBtn.setAttribute("aria-label", "Delete task"); + deleteBtn.setAttribute("title", "Delete"); + + moveTopBtn.addEventListener("click", () => { + const first = tasksContainer.firstElementChild; + if (!first || first === card) return; + tasksContainer.insertBefore(card, first); + updateTaskControls(); + }); + + moveUpBtn.addEventListener("click", () => { + const previous = card.previousElementSibling; + if (!previous) return; + tasksContainer.insertBefore(card, previous); + updateTaskControls(); + }); + + moveDownBtn.addEventListener("click", () => { + const next = card.nextElementSibling; + if (!next) return; + tasksContainer.insertBefore(card, next.nextElementSibling); + updateTaskControls(); + }); + + addBelowBtn.addEventListener("click", () => { + const name = buildUniqueDefaultName( + collectNames(tasksContainer, ".task-name") + ); + const newCard = buildTaskCard({ + id: newTaskId(), + name, + text: "", + defaultEnvId: getTopEnvId(), + defaultProfileId: getTopProfileId() + }); + card.insertAdjacentElement("afterend", newCard); + updateTaskControls(); + updateTaskEnvOptions(); + updateTaskProfileOptions(); + }); + + duplicateBtn.addEventListener("click", () => { + const copy = { + id: newTaskId(), + name: ensureUniqueName( + `${nameInput.value || "Untitled"} Copy`, + collectNames(tasksContainer, ".task-name") + ), + text: textArea.value, + defaultEnvId: envSelect.value || "", + defaultProfileId: profileSelect.value || "" + }; + const newCard = buildTaskCard(copy); + card.insertAdjacentElement("afterend", newCard); + updateTaskControls(); + updateTaskEnvOptions(); + updateTaskProfileOptions(); + }); + + deleteBtn.addEventListener("click", () => { + card.remove(); + updateTaskControls(); + }); + + actions.appendChild(moveTopBtn); + actions.appendChild(moveUpBtn); + actions.appendChild(moveDownBtn); + actions.appendChild(addBelowBtn); + actions.appendChild(duplicateBtn); + actions.appendChild(deleteBtn); + + card.appendChild(nameField); + card.appendChild(envField); + card.appendChild(profileField); + card.appendChild(textField); + card.appendChild(actions); + + return card; +} + +function updateTaskControls() { + const cards = [...tasksContainer.querySelectorAll(".task-card")]; + cards.forEach((card, index) => { + const moveTopBtn = card.querySelector(".move-top"); + const moveUpBtn = card.querySelector(".move-up"); + const moveDownBtn = card.querySelector(".move-down"); + if (moveTopBtn) moveTopBtn.disabled = index === 0; + if (moveUpBtn) moveUpBtn.disabled = index === 0; + if (moveDownBtn) moveDownBtn.disabled = index === cards.length - 1; + }); + scheduleSidebarErrors(); +} + +function collectTasks() { + const cards = [...tasksContainer.querySelectorAll(".task-card")]; + return cards.map((card) => { + const nameInput = card.querySelector(".task-name"); + const textArea = card.querySelector(".task-text"); + const envSelect = card.querySelector(".task-env-select"); + const profileSelect = card.querySelector(".task-profile-select"); + return { + id: card.dataset.id || newTaskId(), + name: (nameInput?.value || "Untitled Task").trim(), + text: (textArea?.value || "").trim(), + defaultEnvId: envSelect?.value || "", + defaultProfileId: profileSelect?.value || "" + }; + }); +} + +function updateSidebarErrors() { + if (!sidebarErrorsEl) return; + const errors = []; + + const tasks = collectTasks(); + const envs = collectEnvConfigs(); + const profiles = collectProfiles(); + const apiConfigs = collectApiConfigs(); + const apiKeys = collectApiKeys(); + + const checkNameInputs = (container, selector, label) => { + if (!container) return; + const inputs = [...container.querySelectorAll(selector)]; + if (!inputs.length) return; + const seen = new Map(); + let hasEmpty = false; + for (const input of inputs) { + const name = (input.value || "").trim(); + if (!name) { + hasEmpty = true; + continue; + } + const lower = name.toLowerCase(); + seen.set(lower, (seen.get(lower) || 0) + 1); + } + if (hasEmpty) { + errors.push(`${label} has empty names.`); + } + for (const [name, count] of seen.entries()) { + if (count > 1) { + errors.push(`${label} has duplicate name: ${name}.`); + } + } + }; + + checkNameInputs(tasksContainer, ".task-name", "Task presets"); + checkNameInputs(envConfigsContainer, ".env-config-name", "Environments"); + checkNameInputs(profilesContainer, ".profile-name", "Profiles"); + checkNameInputs(apiConfigsContainer, ".api-config-name", "API configs"); + checkNameInputs(apiKeysContainer, ".api-key-name", "API keys"); + + if (!tasks.length) errors.push("No task presets configured."); + if (!envs.length) errors.push("No environments configured."); + if (!profiles.length) errors.push("No profiles configured."); + if (!apiConfigs.length) errors.push("No API configs configured."); + if (!apiKeys.length) errors.push("No API keys configured."); + + if (tasks.length) { + const defaultTask = tasks[0]; + if (!defaultTask.text) errors.push("Default task prompt is empty."); + + const defaultEnv = + envs.find((env) => env.id === defaultTask.defaultEnvId) || envs[0]; + if (!defaultEnv) { + errors.push("Default task environment is missing."); + } + + const defaultProfile = + profiles.find((profile) => profile.id === defaultTask.defaultProfileId) || + profiles[0]; + if (!defaultProfile) { + errors.push("Default task profile is missing."); + } else if (!defaultProfile.text) { + errors.push("Default profile text is empty."); + } + + const defaultApiConfig = defaultEnv + ? apiConfigs.find((config) => config.id === defaultEnv.apiConfigId) + : null; + if (!defaultApiConfig) { + errors.push("Default environment is missing an API config."); + } else if (defaultApiConfig.advanced) { + if (!defaultApiConfig.apiUrl) { + errors.push("Default API config is missing an API URL."); + } + if (!defaultApiConfig.requestTemplate) { + errors.push("Default API config is missing a request template."); + } + } else { + if (!defaultApiConfig.apiBaseUrl) { + errors.push("Default API config is missing a base URL."); + } + if (!defaultApiConfig.model) { + errors.push("Default API config is missing a model name."); + } + } + + const needsKey = + Boolean(defaultApiConfig?.apiKeyHeader) || + Boolean( + defaultApiConfig?.requestTemplate?.includes("API_KEY_GOES_HERE") + ); + if (needsKey) { + const key = apiKeys.find((entry) => entry.id === defaultApiConfig?.apiKeyId); + if (!key || !key.key) { + errors.push("Default API config is missing an API key."); + } + } + } + + if (!errors.length) { + sidebarErrorsEl.classList.add("hidden"); + sidebarErrorsEl.textContent = ""; + return; + } + + sidebarErrorsEl.textContent = errors.map((error) => `- ${error}`).join("\n"); + sidebarErrorsEl.classList.remove("hidden"); +} + +async function loadSettings() { + const { + apiKey = "", + apiKeys = [], + activeApiKeyId = "", + apiConfigs = [], + activeApiConfigId = "", + envConfigs = [], + activeEnvConfigId = "", + profiles = [], + apiBaseUrl = "", + apiKeyHeader = "", + apiKeyPrefix = "", + model = "", + systemPrompt = "", + resume = "", + tasks = [], + theme = "system" + } = await getStorage([ + "apiKey", + "apiKeys", + "activeApiKeyId", + "apiConfigs", + "activeApiConfigId", + "envConfigs", + "activeEnvConfigId", + "profiles", + "apiBaseUrl", + "apiKeyHeader", + "apiKeyPrefix", + "model", + "systemPrompt", + "resume", + "tasks", + "theme" + ]); + + themeSelect.value = theme; + applyTheme(theme); + + let resolvedKeys = Array.isArray(apiKeys) ? apiKeys : []; + let resolvedActiveId = activeApiKeyId; + + if (!resolvedKeys.length && apiKey) { + const migrated = { id: newApiKeyId(), name: "Default", key: apiKey }; + resolvedKeys = [migrated]; + resolvedActiveId = migrated.id; + await chrome.storage.local.set({ + apiKeys: resolvedKeys, + activeApiKeyId: resolvedActiveId + }); + } else if (resolvedKeys.length) { + const hasActive = resolvedKeys.some((entry) => entry.id === resolvedActiveId); + if (!hasActive) { + resolvedActiveId = resolvedKeys[0].id; + await chrome.storage.local.set({ activeApiKeyId: resolvedActiveId }); + } + } + + apiKeysContainer.innerHTML = ""; + if (!resolvedKeys.length) { + apiKeysContainer.appendChild( + buildApiKeyCard({ id: newApiKeyId(), name: "", key: "" }) + ); + } else { + for (const entry of resolvedKeys) { + apiKeysContainer.appendChild(buildApiKeyCard(entry)); + } + } + updateApiKeyControls(); + + let resolvedConfigs = Array.isArray(apiConfigs) ? apiConfigs : []; + let resolvedActiveConfigId = activeApiConfigId; + + if (!resolvedConfigs.length) { + const migrated = { + id: newApiConfigId(), + name: "Default", + apiBaseUrl: apiBaseUrl || OPENAI_DEFAULTS.apiBaseUrl, + apiKeyHeader: apiKeyHeader || OPENAI_DEFAULTS.apiKeyHeader, + apiKeyPrefix: apiKeyPrefix || OPENAI_DEFAULTS.apiKeyPrefix, + model: model || DEFAULT_MODEL, + apiKeyId: resolvedActiveId || resolvedKeys[0]?.id || "", + apiUrl: "", + requestTemplate: "", + advanced: false + }; + resolvedConfigs = [migrated]; + resolvedActiveConfigId = migrated.id; + await chrome.storage.local.set({ + apiConfigs: resolvedConfigs, + activeApiConfigId: resolvedActiveConfigId + }); + } else { + const fallbackKeyId = resolvedActiveId || resolvedKeys[0]?.id || ""; + const withKeys = resolvedConfigs.map((config) => ({ + ...config, + apiKeyId: config.apiKeyId || fallbackKeyId, + apiUrl: config.apiUrl || "", + requestTemplate: config.requestTemplate || "", + advanced: Boolean(config.advanced) + })); + if (withKeys.some((config, index) => config.apiKeyId !== resolvedConfigs[index].apiKeyId)) { + resolvedConfigs = withKeys; + await chrome.storage.local.set({ apiConfigs: resolvedConfigs }); + } + const hasActive = resolvedConfigs.some( + (config) => config.id === resolvedActiveConfigId + ); + if (!hasActive) { + resolvedActiveConfigId = resolvedConfigs[0].id; + await chrome.storage.local.set({ activeApiConfigId: resolvedActiveConfigId }); + } + } + + apiConfigsContainer.innerHTML = ""; + for (const config of resolvedConfigs) { + apiConfigsContainer.appendChild(buildApiConfigCard(config)); + } + updateApiConfigKeyOptions(); + updateApiConfigControls(); + + let resolvedEnvConfigs = Array.isArray(envConfigs) ? envConfigs : []; + const fallbackApiConfigId = + resolvedActiveConfigId || resolvedConfigs[0]?.id || ""; + + if (!resolvedEnvConfigs.length) { + const migrated = { + id: newEnvConfigId(), + name: "Default", + apiConfigId: fallbackApiConfigId, + systemPrompt: systemPrompt || DEFAULT_SYSTEM_PROMPT + }; + resolvedEnvConfigs = [migrated]; + await chrome.storage.local.set({ + envConfigs: resolvedEnvConfigs, + activeEnvConfigId: migrated.id + }); + } else { + const withDefaults = resolvedEnvConfigs.map((config) => ({ + ...config, + apiConfigId: config.apiConfigId || fallbackApiConfigId, + systemPrompt: config.systemPrompt ?? "" + })); + const needsUpdate = withDefaults.some((config, index) => { + const original = resolvedEnvConfigs[index]; + return ( + config.apiConfigId !== original.apiConfigId || + (config.systemPrompt || "") !== (original.systemPrompt || "") + ); + }); + if (needsUpdate) { + resolvedEnvConfigs = withDefaults; + await chrome.storage.local.set({ envConfigs: resolvedEnvConfigs }); + } + const hasActive = resolvedEnvConfigs.some( + (config) => config.id === activeEnvConfigId + ); + if (!hasActive && resolvedEnvConfigs.length) { + await chrome.storage.local.set({ + activeEnvConfigId: resolvedEnvConfigs[0].id + }); + } + } + + envConfigsContainer.innerHTML = ""; + for (const config of resolvedEnvConfigs) { + envConfigsContainer.appendChild(buildEnvConfigCard(config)); + } + updateEnvApiOptions(); + updateEnvControls(); + + let resolvedProfiles = Array.isArray(profiles) ? profiles : []; + if (!resolvedProfiles.length) { + const migrated = { + id: newProfileId(), + name: "Default", + text: resume || "", + type: "Resume" + }; + resolvedProfiles = [migrated]; + await chrome.storage.local.set({ profiles: resolvedProfiles }); + } else { + const normalized = resolvedProfiles.map((profile) => ({ + ...profile, + text: profile.text ?? "", + type: profile.type === "Profile" ? "Profile" : "Resume" + })); + const needsUpdate = normalized.some( + (profile, index) => + (profile.text || "") !== (resolvedProfiles[index]?.text || "") || + (profile.type || "Resume") !== (resolvedProfiles[index]?.type || "Resume") + ); + if (needsUpdate) { + resolvedProfiles = normalized; + await chrome.storage.local.set({ profiles: resolvedProfiles }); + } + } + + profilesContainer.innerHTML = ""; + for (const profile of resolvedProfiles) { + profilesContainer.appendChild(buildProfileCard(profile)); + } + updateProfileControls(); + + tasksContainer.innerHTML = ""; + const defaultEnvId = resolvedEnvConfigs[0]?.id || ""; + const defaultProfileId = resolvedProfiles[0]?.id || ""; + const normalizedTasks = Array.isArray(tasks) + ? tasks.map((task) => ({ + ...task, + defaultEnvId: task.defaultEnvId || defaultEnvId, + defaultProfileId: task.defaultProfileId || defaultProfileId + })) + : []; + if ( + normalizedTasks.length && + normalizedTasks.some( + (task, index) => + task.defaultEnvId !== tasks[index]?.defaultEnvId || + task.defaultProfileId !== tasks[index]?.defaultProfileId + ) + ) { + await chrome.storage.local.set({ tasks: normalizedTasks }); + } + + if (!normalizedTasks.length) { + tasksContainer.appendChild( + buildTaskCard({ + id: newTaskId(), + name: "", + text: "", + defaultEnvId, + defaultProfileId + }) + ); + updateTaskControls(); + updateTaskEnvOptions(); + updateTaskProfileOptions(); + return; + } + + for (const task of normalizedTasks) { + tasksContainer.appendChild(buildTaskCard(task)); + } + updateTaskControls(); + updateTaskEnvOptions(); + updateTaskProfileOptions(); + updateSidebarErrors(); +} + +async function saveSettings() { + const tasks = collectTasks(); + const apiKeys = collectApiKeys(); + const apiConfigs = collectApiConfigs(); + const envConfigs = collectEnvConfigs(); + const profiles = collectProfiles(); + const activeEnvConfigId = envConfigs[0]?.id || ""; + const activeEnv = envConfigs[0]; + const activeApiConfigId = + activeEnv?.apiConfigId || apiConfigs[0]?.id || ""; + const activeConfig = apiConfigs.find((entry) => entry.id === activeApiConfigId); + const activeApiKeyId = + activeConfig?.apiKeyId || + apiKeys[0]?.id || + ""; + await chrome.storage.local.set({ + apiKeys, + activeApiKeyId, + apiConfigs, + activeApiConfigId, + envConfigs, + activeEnvConfigId, + systemPrompt: activeEnv?.systemPrompt || "", + profiles, + resume: profiles[0]?.text || "", + tasks, + theme: themeSelect.value + }); + setStatus("Saved."); +} + +saveBtn.addEventListener("click", () => void saveSettings()); +if (saveBtnSidebar) { + saveBtnSidebar.addEventListener("click", () => void saveSettings()); +} +addTaskBtn.addEventListener("click", () => { + const name = buildUniqueDefaultName( + collectNames(tasksContainer, ".task-name") + ); + const newCard = buildTaskCard({ + id: newTaskId(), + name, + text: "", + defaultEnvId: getTopEnvId(), + defaultProfileId: getTopProfileId() + }); + const first = tasksContainer.firstElementChild; + if (first) { + tasksContainer.insertBefore(newCard, first); + } else { + tasksContainer.appendChild(newCard); + } + updateTaskControls(); + updateTaskEnvOptions(); + updateTaskProfileOptions(); +}); + +addApiKeyBtn.addEventListener("click", () => { + const name = buildUniqueDefaultName( + collectNames(apiKeysContainer, ".api-key-name") + ); + const newCard = buildApiKeyCard({ id: newApiKeyId(), name, key: "" }); + const first = apiKeysContainer.firstElementChild; + if (first) { + apiKeysContainer.insertBefore(newCard, first); + } else { + apiKeysContainer.appendChild(newCard); + } + updateApiConfigKeyOptions(); + updateApiKeyControls(); +}); + +addApiConfigBtn.addEventListener("click", () => { + const name = buildUniqueDefaultName( + collectNames(apiConfigsContainer, ".api-config-name") + ); + const newCard = buildApiConfigCard({ + id: newApiConfigId(), + name, + apiBaseUrl: OPENAI_DEFAULTS.apiBaseUrl, + apiKeyHeader: OPENAI_DEFAULTS.apiKeyHeader, + apiKeyPrefix: OPENAI_DEFAULTS.apiKeyPrefix, + model: DEFAULT_MODEL, + apiUrl: "", + requestTemplate: "", + advanced: false + }); + const first = apiConfigsContainer.firstElementChild; + if (first) { + apiConfigsContainer.insertBefore(newCard, first); + } else { + apiConfigsContainer.appendChild(newCard); + } + updateApiConfigKeyOptions(); + updateEnvApiOptions(); + updateApiConfigControls(); +}); + +addEnvConfigBtn.addEventListener("click", () => { + const name = buildUniqueDefaultName( + collectNames(envConfigsContainer, ".env-config-name") + ); + const fallbackApiConfigId = collectApiConfigs()[0]?.id || ""; + const newCard = buildEnvConfigCard({ + id: newEnvConfigId(), + name, + apiConfigId: fallbackApiConfigId, + systemPrompt: DEFAULT_SYSTEM_PROMPT + }); + const first = envConfigsContainer.firstElementChild; + if (first) { + envConfigsContainer.insertBefore(newCard, first); + } else { + envConfigsContainer.appendChild(newCard); + } + updateEnvApiOptions(); + updateEnvControls(); + updateTaskEnvOptions(); +}); + +addProfileBtn.addEventListener("click", () => { + const name = buildUniqueDefaultName( + collectNames(profilesContainer, ".profile-name") + ); + const newCard = buildProfileCard({ + id: newProfileId(), + name, + text: "", + type: "Resume" + }); + const first = profilesContainer.firstElementChild; + if (first) { + profilesContainer.insertBefore(newCard, first); + } else { + profilesContainer.appendChild(newCard); + } + updateProfileControls(); + updateTaskProfileOptions(); +}); + +themeSelect.addEventListener("change", () => applyTheme(themeSelect.value)); +themeSelect.addEventListener("change", () => applyTheme(themeSelect.value)); + +loadSettings(); + +document.querySelectorAll(".toc a").forEach((link) => { + link.addEventListener("click", (event) => { + const href = link.getAttribute("href"); + if (!href || !href.startsWith("#")) return; + const target = document.querySelector(href); + if (target && target.tagName === "DETAILS") { + target.open = true; + } + }); +}); + +document.addEventListener("input", scheduleSidebarErrors); +document.addEventListener("change", scheduleSidebarErrors);