base for AI migration/extension of WWCompanion
This commit is contained in:
882
AGENTS.md
Normal file
882
AGENTS.md
Normal file
@@ -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.
|
||||
|
||||
683
sitecompanion/background.js
Normal file
683
sitecompanion/background.js
Normal file
@@ -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);
|
||||
}
|
||||
140
sitecompanion/content.js
Normal file
140
sitecompanion/content.js
Normal file
@@ -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();
|
||||
26
sitecompanion/manifest.json
Normal file
26
sitecompanion/manifest.json
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
339
sitecompanion/popup.css
Normal file
339
sitecompanion/popup.css
Normal file
@@ -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;
|
||||
}
|
||||
62
sitecompanion/popup.html
Normal file
62
sitecompanion/popup.html
Normal file
@@ -0,0 +1,62 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>WWCompanion</title>
|
||||
<link rel="stylesheet" href="popup.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="title-block">
|
||||
<div class="title-line">
|
||||
<span class="title">WWCompanion</span>
|
||||
<span class="subtitle">AI companion for WaterlooWorks.</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<div class="controls-block">
|
||||
<div class="config-block">
|
||||
<div class="selector-row">
|
||||
<div class="field selector-field">
|
||||
<label for="envSelect">Environment</label>
|
||||
<select id="envSelect"></select>
|
||||
</div>
|
||||
<div class="field selector-field">
|
||||
<label for="profileSelect">Profile</label>
|
||||
<select id="profileSelect"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="task-row">
|
||||
<div class="field inline-field task-field">
|
||||
<label for="taskSelect">Task</label>
|
||||
<select id="taskSelect"></select>
|
||||
</div>
|
||||
<button id="runBtn" class="accent">Run</button>
|
||||
<button id="abortBtn" class="ghost stop-btn hidden" disabled>Stop</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<span id="postingCount">Posting: 0 chars</span>
|
||||
<span id="promptCount">Prompt: 0 chars</span>
|
||||
<span id="status" class="status">Idle</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="output">
|
||||
<div id="output" class="output-body" aria-live="polite"></div>
|
||||
</section>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="footer-left">
|
||||
<button id="copyRenderedBtn" class="ghost" type="button">Copy</button>
|
||||
<button id="copyRawBtn" class="ghost" type="button">Copy Markdown</button>
|
||||
<button id="clearOutputBtn" class="ghost" type="button">Clear</button>
|
||||
</div>
|
||||
<button id="settingsBtn" class="link">Open Settings</button>
|
||||
</footer>
|
||||
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
844
sitecompanion/popup.js
Normal file
844
sitecompanion/popup.js
Normal file
@@ -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, "<")
|
||||
.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 `<a href="${escapeAttribute(
|
||||
safeUrl
|
||||
)}" target="_blank" rel="noreferrer">${label}</a>`;
|
||||
});
|
||||
|
||||
output = output.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
||||
output = output.replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
||||
output = output.replace(/_([^_]+)_/g, "<em>$1</em>");
|
||||
|
||||
output = output.replace(/@@CODESPAN(\d+)@@/g, (_match, id) => {
|
||||
const code = codeSpans[Number(id)] || "";
|
||||
return `<code>${code}</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(`<p>${applyInline(paragraph.join("<br>"))}</p>`);
|
||||
paragraph = [];
|
||||
};
|
||||
|
||||
const closeList = () => {
|
||||
if (!listType) return;
|
||||
result.push(`</${listType}>`);
|
||||
listType = null;
|
||||
};
|
||||
|
||||
const openList = (type) => {
|
||||
if (listType === type) return;
|
||||
if (listType) result.push(`</${listType}>`);
|
||||
listType = type;
|
||||
result.push(`<${type}>`);
|
||||
};
|
||||
|
||||
const closeBlockquote = () => {
|
||||
if (!inBlockquote) return;
|
||||
result.push(`<blockquote>${applyInline(quoteLines.join("<br>"))}</blockquote>`);
|
||||
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(`<h${level}>${applyInline(headingMatch[2])}</h${level}>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^(\s*[-*_])\1{2,}\s*$/.test(line)) {
|
||||
flushParagraph();
|
||||
closeList();
|
||||
closeBlockquote();
|
||||
result.push("<hr>");
|
||||
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(`<li>${applyInline(unorderedMatch[1])}</li>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const orderedMatch = line.match(/^\d+\.\s+(.+)$/);
|
||||
if (orderedMatch) {
|
||||
flushParagraph();
|
||||
closeBlockquote();
|
||||
openList("ol");
|
||||
result.push(`<li>${applyInline(orderedMatch[1])}</li>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
paragraph.push(line);
|
||||
}
|
||||
|
||||
flushParagraph();
|
||||
closeList();
|
||||
closeBlockquote();
|
||||
|
||||
return result
|
||||
.join("\n")
|
||||
.replace(/@@CODEBLOCK(\d+)@@/g, (_match, id) => {
|
||||
const code = blocks[Number(id)] || "";
|
||||
return `<pre><code>${code}</code></pre>`;
|
||||
});
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
});
|
||||
450
sitecompanion/settings.css
Normal file
450
sitecompanion/settings.css
Normal file
@@ -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));
|
||||
}
|
||||
}
|
||||
156
sitecompanion/settings.html
Normal file
156
sitecompanion/settings.html
Normal file
@@ -0,0 +1,156 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>WWCompanion Settings</title>
|
||||
<link rel="stylesheet" href="settings.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="title-block">
|
||||
<div class="title">WWCompanion Settings</div>
|
||||
<div class="subtitle">Configure prompts, resume, and API access</div>
|
||||
</header>
|
||||
<div class="page-bar">
|
||||
<div id="status" class="status"></div>
|
||||
<button id="saveBtn" class="accent">Save Settings</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-layout">
|
||||
<nav class="toc" aria-label="Settings table of contents">
|
||||
<div class="toc-heading">WWCompanion Settings</div>
|
||||
<button id="saveBtnSidebar" class="accent" type="button">Save Settings</button>
|
||||
<div id="statusSidebar" class="status"> </div>
|
||||
<div class="toc-title">Sections</div>
|
||||
<div class="toc-links">
|
||||
<a href="#appearance-panel">Appearance</a>
|
||||
<a href="#api-keys-panel">API Keys</a>
|
||||
<a href="#api-panel">API</a>
|
||||
<a href="#environment-panel">Environment</a>
|
||||
<a href="#profiles-panel">My Profiles</a>
|
||||
<a href="#tasks-panel">Task Presets</a>
|
||||
</div>
|
||||
<div id="sidebarErrors" class="sidebar-errors hidden"></div>
|
||||
</nav>
|
||||
<main class="settings-main">
|
||||
<details class="panel" id="appearance-panel">
|
||||
<summary class="panel-summary">
|
||||
<span class="panel-caret" aria-hidden="true">
|
||||
<span class="caret-closed">▸</span>
|
||||
<span class="caret-open">▾</span>
|
||||
</span>
|
||||
<h2>Appearance</h2>
|
||||
</summary>
|
||||
<div class="panel-body">
|
||||
<div class="field">
|
||||
<label for="themeSelect">Theme</label>
|
||||
<select id="themeSelect">
|
||||
<option value="system">System</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="panel" id="api-keys-panel">
|
||||
<summary class="panel-summary">
|
||||
<span class="panel-caret" aria-hidden="true">
|
||||
<span class="caret-closed">▸</span>
|
||||
<span class="caret-open">▾</span>
|
||||
</span>
|
||||
<h2>API KEYS</h2>
|
||||
</summary>
|
||||
<div class="panel-body">
|
||||
<div class="row">
|
||||
<div></div>
|
||||
<button id="addApiKeyBtn" class="ghost" type="button">Add Key</button>
|
||||
</div>
|
||||
<div id="apiKeys" class="api-keys"></div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="panel" id="api-panel">
|
||||
<summary class="panel-summary">
|
||||
<span class="panel-caret" aria-hidden="true">
|
||||
<span class="caret-closed">▸</span>
|
||||
<span class="caret-open">▾</span>
|
||||
</span>
|
||||
<h2>API</h2>
|
||||
</summary>
|
||||
<div class="panel-body">
|
||||
<div class="row">
|
||||
<div></div>
|
||||
<div class="row-actions">
|
||||
<button id="addApiConfigBtn" class="ghost" type="button">Add Config</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="apiConfigs" class="api-configs"></div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="panel" id="environment-panel">
|
||||
<summary class="panel-summary">
|
||||
<span class="panel-caret" aria-hidden="true">
|
||||
<span class="caret-closed">▸</span>
|
||||
<span class="caret-open">▾</span>
|
||||
</span>
|
||||
<div class="row-title">
|
||||
<h2>Environment</h2>
|
||||
<span class="hint hint-accent">API configuration and system prompt go here</span>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="panel-body">
|
||||
<div class="row">
|
||||
<div></div>
|
||||
<button id="addEnvConfigBtn" class="ghost" type="button">Add Config</button>
|
||||
</div>
|
||||
<div id="envConfigs" class="env-configs"></div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="panel" id="profiles-panel">
|
||||
<summary class="panel-summary">
|
||||
<span class="panel-caret" aria-hidden="true">
|
||||
<span class="caret-closed">▸</span>
|
||||
<span class="caret-open">▾</span>
|
||||
</span>
|
||||
<div class="row-title">
|
||||
<h2>My Profiles</h2>
|
||||
<span class="hint hint-accent">Text to your resumes or generic profiles goes here</span>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="panel-body">
|
||||
<div class="row">
|
||||
<div></div>
|
||||
<button id="addProfileBtn" class="ghost" type="button">Add Profile</button>
|
||||
</div>
|
||||
<div id="profiles" class="profiles"></div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="panel" id="tasks-panel">
|
||||
<summary class="panel-summary">
|
||||
<span class="panel-caret" aria-hidden="true">
|
||||
<span class="caret-closed">▸</span>
|
||||
<span class="caret-open">▾</span>
|
||||
</span>
|
||||
<div class="row-title">
|
||||
<h2>Task Presets</h2>
|
||||
<span class="hint hint-accent">Top task is the default</span>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="panel-body">
|
||||
<div class="row">
|
||||
<div></div>
|
||||
<button id="addTaskBtn" class="ghost" type="button">Add Task</button>
|
||||
</div>
|
||||
<div id="tasks" class="tasks"></div>
|
||||
</div>
|
||||
</details>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="settings.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1812
sitecompanion/settings.js
Normal file
1812
sitecompanion/settings.js
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user