feat: implement API key resolution and live model listing for providers
All checks were successful
CI — Test & Build / Test & Build (push) Successful in 59s
All checks were successful
CI — Test & Build / Test & Build (push) Successful in 59s
This commit is contained in:
72
.claude/agents/lexai-extension-dev.md
Normal file
72
.claude/agents/lexai-extension-dev.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
---
|
||||||
|
name: lexai-extension-dev
|
||||||
|
description: >-
|
||||||
|
Specialist for the LexAI Chrome extension (WXT + React + Manifest V3, BYO-LLM-key).
|
||||||
|
Use for any work on entrypoints/ (content script, background service worker, options,
|
||||||
|
popup), the multi-provider LLM proxy, chrome.storage + tweetnacl key handling, message
|
||||||
|
passing between contexts, selection/replace DOM logic, or the Gitea CI / Chrome Web Store
|
||||||
|
release flow. Knows this repo's conventions (inline styles, data-lexai guard, snapshot
|
||||||
|
pattern, dual message shapes) and verifies changes with typecheck/tests/build.
|
||||||
|
tools: Read, Edit, Write, Grep, Glob, Bash, Skill
|
||||||
|
model: sonnet
|
||||||
|
---
|
||||||
|
|
||||||
|
You are the LexAI extension specialist. LexAI is a Grammarly-like **Manifest V3 Chrome
|
||||||
|
extension** built with **WXT + React + TypeScript**. It has **no backend** — the background
|
||||||
|
service worker calls the user's own LLM provider (OpenAI / Anthropic / Groq / OpenRouter)
|
||||||
|
with the user's own API key. Read `CLAUDE.md` at the repo root first; it is the source of
|
||||||
|
truth for architecture and conventions.
|
||||||
|
|
||||||
|
## Your operating rules
|
||||||
|
|
||||||
|
1. **Respect the three-context model.** Content script ⇄ background ⇄ React pages talk only
|
||||||
|
via `chrome.runtime` messages. Never make a provider `fetch` from the content script or a
|
||||||
|
React page — CORS and key handling belong in `entrypoints/background.ts`. Route through
|
||||||
|
`ANALYZE_TEXT` or `COPY_AS`.
|
||||||
|
|
||||||
|
2. **Preserve the message contract.** `ANALYZE_TEXT` must accept both `{ payload: {...} }`
|
||||||
|
and flat `{ text, action, style }`. The `onMessage` listener must `return true`. Actions
|
||||||
|
are `grammar|rephrase|shorten|expand|explain`; `fix` normalizes to `grammar`.
|
||||||
|
|
||||||
|
3. **Don't break the selection/replace pipeline** in `content.ts`. Selection is captured
|
||||||
|
eagerly (mouseup + button mousedown) and snapshotted before any `await`, because focus
|
||||||
|
and the live selection are gone by the time a response returns. Handle **both** paths:
|
||||||
|
textarea/input (`selectionStart/End`) and contenteditable/DOM (`Range` API). Keep the
|
||||||
|
`data-lexai="true"` attribute on every injected node.
|
||||||
|
|
||||||
|
4. **Key security is non-negotiable.** Prefer the encrypted path (`apiKeyEnc` + `encKey`,
|
||||||
|
tweetnacl `secretbox`); plaintext `apiKey` is back-compat only. Never log the key, never
|
||||||
|
send it anywhere except the user's selected provider endpoint. Keep the plaintext fallback
|
||||||
|
unless you write a migration.
|
||||||
|
|
||||||
|
5. **Styling is inline.** Tailwind is installed but inactive. Match the existing dark
|
||||||
|
Catppuccin-ish palette and inline `Object.assign(el.style, {...})` / `style={{...}}`
|
||||||
|
pattern. Don't introduce Tailwind classes unless the task is explicitly to wire up PostCSS.
|
||||||
|
|
||||||
|
6. **When you add or change a provider,** remember each provider is duplicated as `callX`
|
||||||
|
and `callXWithPrompt`. Update both, and keep error handling uniform (network error →
|
||||||
|
friendly string; `!res.ok` → provider error message; empty result → explicit message).
|
||||||
|
|
||||||
|
## Verify before you finish
|
||||||
|
|
||||||
|
Run what the change touches, and report actual output:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install # if node_modules is absent
|
||||||
|
npm run typecheck
|
||||||
|
npm test -- --run
|
||||||
|
npm run build # for behavior changes; confirms the MV3 bundle builds
|
||||||
|
```
|
||||||
|
|
||||||
|
For DOM/selection/replace changes, `npm run build` and state that a real-page manual check is
|
||||||
|
needed (load unpacked from `.output/chrome-mv3`) — unit tests do not cover DOM timing. Use the
|
||||||
|
`verify` and `run` skills when driving the built extension would confirm behavior.
|
||||||
|
|
||||||
|
## Release awareness
|
||||||
|
|
||||||
|
CI is **Gitea** (`.gitea/workflows/`), not GitHub Actions. Version lives in **both**
|
||||||
|
`package.json` and `wxt.config.ts`; a `v*.*.*` tag triggers the Chrome Web Store deploy. Flag
|
||||||
|
any change that would require a version bump or a manifest permission change.
|
||||||
|
|
||||||
|
Be surgical: match existing style, keep diffs minimal, and explain any change that affects the
|
||||||
|
message contract, storage schema, manifest permissions, or the key-handling path.
|
||||||
119
CLAUDE.md
Normal file
119
CLAUDE.md
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# CLAUDE.md — LexAI
|
||||||
|
|
||||||
|
Guidance for Claude Code when working in this repository.
|
||||||
|
|
||||||
|
## What LexAI is
|
||||||
|
|
||||||
|
A Grammarly-like **Chrome Extension (Manifest V3)** that provides AI writing assistance
|
||||||
|
(grammar fix, rephrase, shorten, expand, explain) on any webpage. Users bring **their own
|
||||||
|
LLM API key** — there is no LexAI backend. The extension's service worker calls the user's
|
||||||
|
chosen provider directly.
|
||||||
|
|
||||||
|
- **Providers:** OpenAI, Anthropic, Groq, OpenRouter (all configured in `entrypoints/background.ts`).
|
||||||
|
- **No subscription, no server.** The API key lives encrypted in `chrome.storage.local`.
|
||||||
|
|
||||||
|
## Tech stack
|
||||||
|
|
||||||
|
- **WXT** `^0.20` — extension framework (wraps Vite). Entrypoints live in `entrypoints/`.
|
||||||
|
- **React 18** + TypeScript — used only for the Options and Popup pages.
|
||||||
|
- **Zustand** — a dependency, but state is currently local; not yet wired into a store.
|
||||||
|
- **tweetnacl** / **tweetnacl-util** — `secretbox` symmetric encryption for the API key.
|
||||||
|
- **Vitest** (jsdom) for unit tests, **Playwright** for e2e.
|
||||||
|
- **Tailwind** is in devDependencies but **not active** — all UI uses inline style objects
|
||||||
|
(WXT PostCSS was never wired up). Do not assume Tailwind classes work.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install # first-time setup (node_modules is gitignored; not present by default)
|
||||||
|
npm run dev # WXT dev server with hot reload
|
||||||
|
npm run build # production build → .output/chrome-mv3/
|
||||||
|
npm run zip # package for Chrome Web Store
|
||||||
|
npm test # vitest (add `-- --run` for one-shot, non-watch)
|
||||||
|
npm run test:e2e # Playwright (requires a prior `npm run build`)
|
||||||
|
npm run typecheck # tsc --noEmit
|
||||||
|
```
|
||||||
|
|
||||||
|
**Prerequisite:** Node. CI pins **Node 22** (`node:22-bookworm`). Run `npm install` before any
|
||||||
|
`npm run *` script — the binaries (`tsc`, `vitest`) come from `node_modules/.bin`.
|
||||||
|
|
||||||
|
**Load unpacked in Chrome:** `npm run build` → `chrome://extensions` → Developer Mode →
|
||||||
|
Load unpacked → select `.output/chrome-mv3`.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Three cooperating contexts, message-passed over `chrome.runtime`:
|
||||||
|
|
||||||
|
```
|
||||||
|
entrypoints/content.ts (content script, injected into <all_urls>)
|
||||||
|
• Detects text selection: textarea/input (selectionStart/End) vs contenteditable/DOM (Range API)
|
||||||
|
• Renders the floating toolbar + result modal + toasts (all inline-styled, appended to document.body)
|
||||||
|
• Snapshots selection state BEFORE any async call, then Replace uses the snapshot
|
||||||
|
• Sends { type: 'ANALYZE_TEXT', payload: {text, action, style} } to the background
|
||||||
|
|
||||||
|
entrypoints/background.ts (service worker — the LLM proxy)
|
||||||
|
• onMessage: ANALYZE_TEXT and COPY_AS
|
||||||
|
• Reads provider/apiKey/apiKeyEnc/encKey/model from chrome.storage.local
|
||||||
|
• Decrypts the key (tweetnacl secretbox), routes to the correct provider's fetch call
|
||||||
|
• Registers right-click context menus (action × style) on install
|
||||||
|
|
||||||
|
entrypoints/options/Options.tsx (settings page, React)
|
||||||
|
• Provider + model + API key form; encrypts the key and writes apiKeyEnc/encKey to storage
|
||||||
|
|
||||||
|
entrypoints/popup/Popup.tsx (toolbar popup, React)
|
||||||
|
• Standalone text box → same ANALYZE_TEXT flow; shows config status; links to Options
|
||||||
|
```
|
||||||
|
|
||||||
|
Content script and popup **must not** call provider APIs directly — CORS and key handling
|
||||||
|
belong in the background service worker. Route everything through `ANALYZE_TEXT`/`COPY_AS`.
|
||||||
|
|
||||||
|
### Message contract
|
||||||
|
|
||||||
|
- `ANALYZE_TEXT` accepts **both** `{ payload: {text, action, style} }` (content/popup) and
|
||||||
|
flat `{ text, action, style }`. Keep both shapes working if you touch the handler.
|
||||||
|
- `action` values: `grammar`, `rephrase`, `shorten`, `expand`, `explain`. The context menu
|
||||||
|
and popup emit `fix`, which `getSystemPrompt` normalizes to `grammar`.
|
||||||
|
- The listener returns `true` to keep the async channel open — **required**; removing it
|
||||||
|
silently breaks every response.
|
||||||
|
|
||||||
|
## Key conventions & gotchas
|
||||||
|
|
||||||
|
- **`data-lexai="true"`** is set on every LexAI-injected DOM node. Selection/click handlers
|
||||||
|
check `target.closest('[data-lexai="true"]')` to avoid self-triggering. Preserve it on any
|
||||||
|
new injected element.
|
||||||
|
- **Selection is captured eagerly** (on `mouseup` and on button `mousedown`) because focus
|
||||||
|
shifts and the live selection is gone by the time an async response returns. When editing
|
||||||
|
content.ts, keep the snapshot-before-await pattern intact.
|
||||||
|
- **`z-index: 2147483647`** (max) on toolbar/modal so they sit above host-page UI.
|
||||||
|
- **Provider code is duplicated**: each provider has a `callX` (system-prompt from action)
|
||||||
|
and a `callXWithPrompt` (arbitrary system prompt, used by COPY_AS). A change to request
|
||||||
|
shape usually needs to be made in both. See "Recommendations" below — this is a known smell.
|
||||||
|
- **API-key handling:** prefer the encrypted path (`apiKeyEnc` + `encKey`); plaintext `apiKey`
|
||||||
|
is legacy/back-compat only. Never log the key. Never add code that transmits it anywhere
|
||||||
|
except the user's chosen provider endpoint.
|
||||||
|
- **Backward compat:** don't drop the plaintext `apiKey` fallback without a migration.
|
||||||
|
- Console `[LexAI …]` debug logs exist in content.ts's replace path — intentional for now.
|
||||||
|
|
||||||
|
## Testing notes
|
||||||
|
|
||||||
|
- `tests/unit/setup.ts` mocks `global.chrome`. Unit tests currently exercise storage mocks
|
||||||
|
rather than importing the real handlers — see Recommendations for the gap.
|
||||||
|
- Playwright e2e loads the built extension via `--load-extension=.output/chrome-mv3`; the
|
||||||
|
test files still contain `[EXTENSION_ID]` placeholders and won't pass as-is.
|
||||||
|
|
||||||
|
## CI / release (Gitea, not GitHub Actions)
|
||||||
|
|
||||||
|
Workflows live in `.gitea/workflows/`:
|
||||||
|
- `ci.yml` — typecheck → test → build → publish zip to Gitea package registry (on push to main/develop, PRs).
|
||||||
|
- `deploy-chrome.yml` — on `v*.*.*` tag: build → upload → publish to Chrome Web Store.
|
||||||
|
- Both send Telegram notifications. Secrets: `GITEATOKEN`, `CWS_*`, `TELEGRAM_*`.
|
||||||
|
|
||||||
|
**Version bumps:** update `version` in **both** `package.json` and `wxt.config.ts` (the
|
||||||
|
manifest version comes from wxt.config.ts). A `v*.*.*` git tag triggers the store deploy.
|
||||||
|
|
||||||
|
## When making changes
|
||||||
|
|
||||||
|
- After editing an entrypoint, run `npm run typecheck` and `npm test -- --run`.
|
||||||
|
- For behavior changes, `npm run build` and load unpacked to verify in a real page — the
|
||||||
|
selection/replace logic is DOM-timing-sensitive and unit tests don't cover it.
|
||||||
|
- Keep UI styling inline (no Tailwind) unless you're intentionally wiring PostCSS.
|
||||||
81
RECOMMENDATIONS.md
Normal file
81
RECOMMENDATIONS.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# LexAI — Improvement Recommendations
|
||||||
|
|
||||||
|
Prioritized findings from a full read of the codebase (2026-07-13). Grouped by theme;
|
||||||
|
each item notes rough effort (S/M/L) and why it matters.
|
||||||
|
|
||||||
|
## 🔒 Security & privacy
|
||||||
|
|
||||||
|
1. **Narrow host permissions (M).** The manifest requests `<all_urls>` host permission and
|
||||||
|
injects the content script into every frame of every site — including banking, email, and
|
||||||
|
internal apps. Consider `activeTab` + on-demand injection, or a user-configurable
|
||||||
|
allowlist. This is also the #1 thing that slows Chrome Web Store review.
|
||||||
|
2. **The encryption is obfuscation, not protection (M).** `encKey` sits in
|
||||||
|
`chrome.storage.local` right next to `apiKeyEnc`; anyone who can read storage can decrypt.
|
||||||
|
Be honest in the UI ("stored locally, obscured") or derive the key from something not
|
||||||
|
co-located (e.g. `chrome.storage.session` for the key, WebCrypto, or a passphrase). At
|
||||||
|
minimum, don't over-promise "encrypted" security to users.
|
||||||
|
3. **Strip debug logging from production (S).** `content.ts` logs selection text and element
|
||||||
|
values (`[LexAI captureForButton]`, `[LexAI Replace]`, etc.) to the page console — visible
|
||||||
|
to the host page. Gate behind a `__DEV__`/`import.meta.env.DEV` flag.
|
||||||
|
|
||||||
|
## 🧹 Code quality & maintainability
|
||||||
|
|
||||||
|
4. **Collapse the duplicated provider layer (M).** Each provider exists twice —
|
||||||
|
`callOpenAI`/`callOpenAIWithPrompt`, etc. — 8 near-identical functions. Refactor to one
|
||||||
|
`callProvider(config, messages | systemPrompt, text)` with a small per-provider adapter
|
||||||
|
describing `{ url, headers(config), body(model, system, text), extract(data) }`. Cuts
|
||||||
|
`background.ts` roughly in half and removes the "update both copies" trap noted in CLAUDE.md.
|
||||||
|
5. **Extract shared UI/styling (M).** The Catppuccin palette and button styles are re-declared
|
||||||
|
inline across content.ts, Options.tsx, Popup.tsx. Move colors/spacing into a shared
|
||||||
|
`src/ui/theme.ts` (and reusable style factories) so a palette change is one edit.
|
||||||
|
6. **Remove dead dependencies (S).** `zustand` is installed but no store exists; `tailwindcss`
|
||||||
|
+ `autoprefixer` are present but inactive. Either wire them up or drop them to shrink the
|
||||||
|
install and remove confusion.
|
||||||
|
7. **Centralize provider/model config (S).** The provider list, default models, and endpoints
|
||||||
|
live in both `Options.tsx` (UI) and `background.ts` (calls). Put them in one shared module
|
||||||
|
so the picker and the caller can't drift.
|
||||||
|
|
||||||
|
## ✅ Testing (biggest gap)
|
||||||
|
|
||||||
|
8. **Unit tests don't test real code (M).** `tests/unit/background.test.ts` only exercises the
|
||||||
|
`chrome.storage` mock — it never imports `getSystemPrompt`, `decryptApiKey`, or the provider
|
||||||
|
router. Extract those pure functions and test them directly (prompt normalization,
|
||||||
|
`fix`→`grammar`, encrypt→decrypt round-trip, provider routing, error extraction).
|
||||||
|
9. **Fix or quarantine the e2e tests (S).** `tests/e2e/extension.test.ts` hard-codes
|
||||||
|
`chrome-extension://[EXTENSION_ID]/...` — it cannot pass. Resolve the extension ID at
|
||||||
|
runtime (read it from the service-worker target) or mark the suite `.skip` until fixed so
|
||||||
|
CI green means something.
|
||||||
|
10. **Add a content-script DOM test (L).** The selection→snapshot→replace logic is the app's
|
||||||
|
riskiest code and has zero coverage. A jsdom or Playwright test over textarea and
|
||||||
|
contenteditable replace paths would catch regressions the current tests can't.
|
||||||
|
|
||||||
|
## ✨ Product / UX
|
||||||
|
|
||||||
|
11. **Make `max_tokens` adaptive (S).** It's hard-coded to `1024` everywhere; "Expand" on a
|
||||||
|
long paragraph will truncate mid-sentence. Scale with input length or expose it in settings.
|
||||||
|
12. **Add response streaming (L).** Non-streaming means the user stares at "thinking…" for the
|
||||||
|
full latency. Streaming tokens into the modal is the single biggest perceived-speed win.
|
||||||
|
13. **Accessibility (M).** Toolbar/modal buttons lack `aria-label`s, focus management, and
|
||||||
|
keyboard navigation; the modal doesn't trap focus. Add roles/labels and Esc/Tab handling
|
||||||
|
(Esc is partially handled already).
|
||||||
|
14. **React error boundaries + graceful storage failures (S).** Options/Popup call
|
||||||
|
`createRoot(...).render()` with no error boundary; a throw yields a blank page.
|
||||||
|
|
||||||
|
## 🚀 Build / release
|
||||||
|
|
||||||
|
15. **Pin the toolchain (S).** Add an `.nvmrc`/`engines` field for Node 22 to match CI, and a
|
||||||
|
`package.json` `packageManager` field. Local `npm run *` currently fails with no
|
||||||
|
`node_modules` and no version guard.
|
||||||
|
16. **Version bump is a two-file manual step (S).** `version` must be edited in both
|
||||||
|
`package.json` and `wxt.config.ts`. Add a script (or read one from the other) so a release
|
||||||
|
can't ship mismatched versions — this has already caused churn in the git history.
|
||||||
|
17. **CI clones instead of checking out (S).** Both Gitea workflows `git clone` the repo into
|
||||||
|
`/tmp` rather than using the checked-out workspace, and disable TLS verification
|
||||||
|
(`http.sslVerify false`). Worth revisiting for speed and security once the runner setup
|
||||||
|
allows a normal checkout.
|
||||||
|
|
||||||
|
## Suggested order
|
||||||
|
|
||||||
|
Quick wins first: **3, 6, 9, 11, 15, 16** (all S, mostly independent). Then the structural
|
||||||
|
refactors **4, 5, 8**, which make everything after them easier. Tackle **1/2** (permissions +
|
||||||
|
key story) before any serious Chrome Web Store push. Save **10, 12, 13** for a focused Phase 2.
|
||||||
@@ -80,6 +80,18 @@ async function decryptApiKey(encKeyB64: string, apiKeyEncB64: string): Promise<s
|
|||||||
return new TextDecoder().decode(decrypted);
|
return new TextDecoder().decode(decrypted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve the usable API key from stored config: prefer the encrypted path,
|
||||||
|
// fall back to plaintext for backward compat. Returns null if none is set.
|
||||||
|
async function resolveApiKey(config: LexAIConfig): Promise<string | null> {
|
||||||
|
let apiKey = config.apiKey;
|
||||||
|
if (config.apiKeyEnc && config.encKey) {
|
||||||
|
const decrypted = await decryptApiKey(config.encKey, config.apiKeyEnc);
|
||||||
|
if (decrypted) apiKey = decrypted;
|
||||||
|
}
|
||||||
|
if (!apiKey || apiKey.trim() === '') return null;
|
||||||
|
return apiKey.trim();
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Provider implementations ─────────────────────────────────────────────────
|
// ─── Provider implementations ─────────────────────────────────────────────────
|
||||||
|
|
||||||
async function callOpenAI(payload: AnalyzePayload, config: LexAIConfig): Promise<LexAIResponse> {
|
async function callOpenAI(payload: AnalyzePayload, config: LexAIConfig): Promise<LexAIResponse> {
|
||||||
@@ -353,26 +365,70 @@ async function callOpenRouterWithPrompt(payload: AnalyzePayload, config: LexAICo
|
|||||||
return { result: result.trim() };
|
return { result: result.trim() };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Live model listing ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const MODEL_LIST_ENDPOINTS: Record<string, string> = {
|
||||||
|
openai: 'https://api.openai.com/v1/models',
|
||||||
|
groq: 'https://api.groq.com/openai/v1/models',
|
||||||
|
openrouter: 'https://openrouter.ai/api/v1/models',
|
||||||
|
anthropic: 'https://api.anthropic.com/v1/models',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Drop non-chat models (embeddings, audio, image, etc.) so the picker stays useful.
|
||||||
|
const NON_CHAT_MODEL_RE = /embedding|whisper|tts|dall-e|audio|realtime|moderation|image|guard|transcribe|speech|rerank/i;
|
||||||
|
|
||||||
|
async function listModels(provider: string, apiKey?: string): Promise<{ models?: string[]; error?: string }> {
|
||||||
|
const url = MODEL_LIST_ENDPOINTS[provider];
|
||||||
|
if (!url) return { error: `Unknown provider: "${provider}". Please check LexAI settings.` };
|
||||||
|
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
|
if (provider === 'anthropic') {
|
||||||
|
if (!apiKey) return { error: 'Anthropic requires an API key to list models.' };
|
||||||
|
headers['x-api-key'] = apiKey;
|
||||||
|
headers['anthropic-version'] = '2023-06-01';
|
||||||
|
// Allow the extension origin to call Anthropic directly (avoids a CORS 403).
|
||||||
|
headers['anthropic-dangerous-direct-browser-access'] = 'true';
|
||||||
|
} else if (apiKey) {
|
||||||
|
// OpenRouter's list is public, so the key is optional there; OpenAI/Groq require it.
|
||||||
|
headers['Authorization'] = `Bearer ${apiKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetchWithTimeout(url, { method: 'GET', headers }, 15000);
|
||||||
|
} catch (err) {
|
||||||
|
return { error: `Network error reaching ${provider}: ${String(err)}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg = data?.error?.message ?? data?.error ?? `HTTP ${res.status}`;
|
||||||
|
return { error: `${provider} error: ${msg}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = Array.isArray(data?.data) ? data.data : [];
|
||||||
|
const ids = raw
|
||||||
|
.map((m: any) => (typeof m === 'string' ? m : m?.id))
|
||||||
|
.filter((id: unknown): id is string => typeof id === 'string' && id.length > 0)
|
||||||
|
.filter((id: string) => !NON_CHAT_MODEL_RE.test(id))
|
||||||
|
.sort((a: string, b: string) => a.localeCompare(b));
|
||||||
|
|
||||||
|
if (ids.length === 0) return { error: `No models returned by ${provider}.` };
|
||||||
|
return { models: ids };
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Main handler ─────────────────────────────────────────────────────────────
|
// ─── Main handler ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function handleAnalyzeText(payload: AnalyzePayload): Promise<LexAIResponse> {
|
async function handleAnalyzeText(payload: AnalyzePayload): Promise<LexAIResponse> {
|
||||||
const stored = await chrome.storage.local.get(['provider', 'apiKey', 'apiKeyEnc', 'encKey', 'model']);
|
const stored = await chrome.storage.local.get(['provider', 'apiKey', 'apiKeyEnc', 'encKey', 'model']);
|
||||||
const config = stored as LexAIConfig;
|
const config = stored as LexAIConfig;
|
||||||
|
|
||||||
// Resolve API key — prefer encrypted path, fall back to plaintext for backward compat
|
const apiKey = await resolveApiKey(config);
|
||||||
let apiKey = config.apiKey;
|
if (!apiKey) {
|
||||||
if (config.apiKeyEnc && config.encKey) {
|
|
||||||
const decrypted = await decryptApiKey(config.encKey, config.apiKeyEnc);
|
|
||||||
if (decrypted) {
|
|
||||||
apiKey = decrypted;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!apiKey || apiKey.trim() === '') {
|
|
||||||
return { error: 'No API key configured. Please open LexAI settings (click the extension icon).' };
|
return { error: 'No API key configured. Please open LexAI settings (click the extension icon).' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedConfig: LexAIConfig = { ...config, apiKey: apiKey.trim() };
|
const resolvedConfig: LexAIConfig = { ...config, apiKey };
|
||||||
const provider = config.provider || 'openai';
|
const provider = config.provider || 'openai';
|
||||||
|
|
||||||
switch (provider) {
|
switch (provider) {
|
||||||
@@ -460,16 +516,12 @@ export default defineBackground(() => {
|
|||||||
chrome.storage.local.get(['provider', 'apiKey', 'apiKeyEnc', 'encKey', 'model'])
|
chrome.storage.local.get(['provider', 'apiKey', 'apiKeyEnc', 'encKey', 'model'])
|
||||||
.then(async (stored) => {
|
.then(async (stored) => {
|
||||||
const config = stored as LexAIConfig;
|
const config = stored as LexAIConfig;
|
||||||
let apiKey = config.apiKey;
|
const apiKey = await resolveApiKey(config);
|
||||||
if (config.apiKeyEnc && config.encKey) {
|
if (!apiKey) {
|
||||||
const decrypted = await decryptApiKey(config.encKey, config.apiKeyEnc);
|
|
||||||
if (decrypted) apiKey = decrypted;
|
|
||||||
}
|
|
||||||
if (!apiKey || apiKey.trim() === '') {
|
|
||||||
sendResponse({ error: 'No API key configured. Please open LexAI settings.' });
|
sendResponse({ error: 'No API key configured. Please open LexAI settings.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const resolvedConfig: LexAIConfig = { ...config, apiKey: apiKey.trim() };
|
const resolvedConfig: LexAIConfig = { ...config, apiKey };
|
||||||
const systemPrompt = `Reformat the following text as ${format}. Return only the reformatted result, no explanation.`;
|
const systemPrompt = `Reformat the following text as ${format}. Return only the reformatted result, no explanation.`;
|
||||||
return callProvider(resolvedConfig, text, systemPrompt);
|
return callProvider(resolvedConfig, text, systemPrompt);
|
||||||
})
|
})
|
||||||
@@ -477,5 +529,25 @@ export default defineBackground(() => {
|
|||||||
.catch((err) => sendResponse({ error: String(err) }));
|
.catch((err) => sendResponse({ error: String(err) }));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (message.type === 'LIST_MODELS') {
|
||||||
|
const provider = (message.provider as string) || 'openai';
|
||||||
|
// Prefer an inline key (freshly typed, not yet saved); else use the stored key.
|
||||||
|
const inlineKey = (message.apiKey as string | undefined)?.trim() || undefined;
|
||||||
|
(async () => {
|
||||||
|
let apiKey = inlineKey;
|
||||||
|
if (!apiKey) {
|
||||||
|
const stored = await chrome.storage.local.get(['apiKey', 'apiKeyEnc', 'encKey']);
|
||||||
|
apiKey = (await resolveApiKey(stored as LexAIConfig)) ?? undefined;
|
||||||
|
}
|
||||||
|
if (!apiKey && provider !== 'openrouter') {
|
||||||
|
return { error: 'No API key found. Enter your API key above, then click Load models.' };
|
||||||
|
}
|
||||||
|
return listModels(provider, apiKey);
|
||||||
|
})()
|
||||||
|
.then(sendResponse)
|
||||||
|
.catch((err) => sendResponse({ error: String(err) }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,33 +4,30 @@ import nacl from 'tweetnacl';
|
|||||||
|
|
||||||
// ─── Provider config ──────────────────────────────────────────────────────────
|
// ─── Provider config ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Model lists are fetched live per provider (see loadModels) — no static lists here.
|
||||||
const PROVIDERS = [
|
const PROVIDERS = [
|
||||||
{
|
{
|
||||||
id: 'openai',
|
id: 'openai',
|
||||||
name: 'OpenAI',
|
name: 'OpenAI',
|
||||||
placeholder: 'sk-...',
|
placeholder: 'sk-...',
|
||||||
models: ['gpt-4o', 'gpt-4o-mini', 'gpt-3.5-turbo'],
|
|
||||||
docsUrl: 'https://platform.openai.com/api-keys',
|
docsUrl: 'https://platform.openai.com/api-keys',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'anthropic',
|
id: 'anthropic',
|
||||||
name: 'Anthropic (Claude)',
|
name: 'Anthropic (Claude)',
|
||||||
placeholder: 'sk-ant-...',
|
placeholder: 'sk-ant-...',
|
||||||
models: ['claude-3-5-sonnet-20241022', 'claude-3-5-haiku-20241022', 'claude-3-opus-20240229'],
|
|
||||||
docsUrl: 'https://console.anthropic.com/keys',
|
docsUrl: 'https://console.anthropic.com/keys',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'groq',
|
id: 'groq',
|
||||||
name: 'Groq (Free tier)',
|
name: 'Groq (Free tier)',
|
||||||
placeholder: 'gsk_...',
|
placeholder: 'gsk_...',
|
||||||
models: ['llama-3.3-70b-versatile', 'llama-3.1-8b-instant', 'mixtral-8x7b-32768'],
|
|
||||||
docsUrl: 'https://console.groq.com/keys',
|
docsUrl: 'https://console.groq.com/keys',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'openrouter',
|
id: 'openrouter',
|
||||||
name: 'OpenRouter (100+ models)',
|
name: 'OpenRouter (100+ models)',
|
||||||
placeholder: 'sk-or-...',
|
placeholder: 'sk-or-...',
|
||||||
models: ['openai/gpt-4o-mini', 'anthropic/claude-3-5-haiku', 'meta-llama/llama-3.3-70b-instruct:free'],
|
|
||||||
docsUrl: 'https://openrouter.ai/keys',
|
docsUrl: 'https://openrouter.ai/keys',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -193,19 +190,63 @@ function safeStorageSet(data: Record<string, string>, callback?: () => void) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function safeSendMessage(message: Record<string, unknown>): Promise<any> {
|
||||||
|
try {
|
||||||
|
if (typeof chrome === 'undefined' || !chrome.runtime?.id) return null;
|
||||||
|
return await chrome.runtime.sendMessage(message);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('LexAI: sendMessage failed', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Component ────────────────────────────────────────────────────────────────
|
// ─── Component ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function OptionsPage() {
|
function OptionsPage() {
|
||||||
const [provider, setProvider] = useState('openai');
|
const [provider, setProvider] = useState('openai');
|
||||||
const [apiKey, setApiKey] = useState('');
|
const [apiKey, setApiKey] = useState('');
|
||||||
const [model, setModel] = useState('gpt-4o-mini');
|
const [model, setModel] = useState('');
|
||||||
|
const [models, setModels] = useState<string[]>([]);
|
||||||
|
const [modelsStatus, setModelsStatus] = useState<'idle' | 'loading' | 'error'>('idle');
|
||||||
|
const [modelsError, setModelsError] = useState('');
|
||||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||||
const [showKey, setShowKey] = useState(false);
|
const [showKey, setShowKey] = useState(false);
|
||||||
const [isEncrypted, setIsEncrypted] = useState(false);
|
const [isEncrypted, setIsEncrypted] = useState(false);
|
||||||
const apiKeyRef = useRef<HTMLInputElement>(null);
|
const apiKeyRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Fetch the live model list from the provider (via the background worker, which
|
||||||
|
// owns key decryption). Uses a freshly typed key if present, else the stored key.
|
||||||
|
const loadModels = async (providerOverride?: string, keyOverride?: string) => {
|
||||||
|
const prov = providerOverride ?? provider;
|
||||||
|
const inlineKey = keyOverride ?? (apiKey.trim() || undefined);
|
||||||
|
setModelsStatus('loading');
|
||||||
|
setModelsError('');
|
||||||
|
|
||||||
|
const response = await safeSendMessage({ type: 'LIST_MODELS', provider: prov, apiKey: inlineKey });
|
||||||
|
|
||||||
|
if (!response) {
|
||||||
|
setModels([]);
|
||||||
|
setModelsStatus('error');
|
||||||
|
setModelsError('No response from the extension. Try reloading the page.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (response.error) {
|
||||||
|
setModels([]);
|
||||||
|
setModelsStatus('error');
|
||||||
|
setModelsError(response.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const list: string[] = Array.isArray(response.models) ? response.models : [];
|
||||||
|
setModels(list);
|
||||||
|
setModelsStatus('idle');
|
||||||
|
// Keep the current selection if it's still valid; otherwise force a re-pick.
|
||||||
|
setModel((prev) => (prev && list.includes(prev) ? prev : ''));
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
safeStorageGet(['provider', 'apiKey', 'apiKeyEnc', 'encKey', 'model'], (result) => {
|
safeStorageGet(['provider', 'apiKey', 'apiKeyEnc', 'encKey', 'model'], (result) => {
|
||||||
|
const prov = result.provider || 'openai';
|
||||||
if (result.provider) setProvider(result.provider);
|
if (result.provider) setProvider(result.provider);
|
||||||
if (result.model) setModel(result.model);
|
if (result.model) setModel(result.model);
|
||||||
|
|
||||||
@@ -217,6 +258,11 @@ function OptionsPage() {
|
|||||||
setApiKey(result.apiKey);
|
setApiKey(result.apiKey);
|
||||||
setIsEncrypted(false);
|
setIsEncrypted(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If a key is already configured, pull the live model list on open.
|
||||||
|
if (result.apiKeyEnc || result.apiKey || prov === 'openrouter') {
|
||||||
|
loadModels(prov);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -224,11 +270,20 @@ function OptionsPage() {
|
|||||||
|
|
||||||
const handleProviderChange = (newProvider: string) => {
|
const handleProviderChange = (newProvider: string) => {
|
||||||
setProvider(newProvider);
|
setProvider(newProvider);
|
||||||
const p = PROVIDERS.find((p) => p.id === newProvider);
|
setModel('');
|
||||||
if (p) setModel(p.models[0]);
|
setModels([]);
|
||||||
|
setModelsStatus('idle');
|
||||||
|
setModelsError('');
|
||||||
|
loadModels(newProvider);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
|
// Require an explicit model choice — prevents sending a stale/mismatched model
|
||||||
|
// id to a provider (the root cause of the OpenRouter failures).
|
||||||
|
if (!model) {
|
||||||
|
setModelsError('Please load and select a model before saving.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
// If key is blank and we already have an encrypted key, don't overwrite
|
// If key is blank and we already have an encrypted key, don't overwrite
|
||||||
if (!apiKey.trim() && !isEncrypted) {
|
if (!apiKey.trim() && !isEncrypted) {
|
||||||
apiKeyRef.current?.focus();
|
apiKeyRef.current?.focus();
|
||||||
@@ -319,18 +374,51 @@ function OptionsPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Model */}
|
{/* Model — fetched live from the selected provider */}
|
||||||
<div style={styles.formGroup}>
|
<div style={styles.formGroup}>
|
||||||
<label style={styles.label}>Model</label>
|
<label style={styles.label}>Model</label>
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
<select
|
<select
|
||||||
style={styles.select}
|
style={{ ...styles.select, flex: 1, opacity: models.length === 0 ? 0.6 : 1 }}
|
||||||
value={model}
|
value={model}
|
||||||
onChange={(e) => setModel(e.target.value)}
|
onChange={(e) => setModel(e.target.value)}
|
||||||
|
disabled={modelsStatus === 'loading' || models.length === 0}
|
||||||
>
|
>
|
||||||
{currentProvider.models.map((m) => (
|
{modelsStatus === 'loading' && <option value="">Loading models…</option>}
|
||||||
|
{modelsStatus !== 'loading' && models.length === 0 && (
|
||||||
|
<option value="">
|
||||||
|
{modelsStatus === 'error' ? 'Failed to load — see below' : 'Load models to choose'}
|
||||||
|
</option>
|
||||||
|
)}
|
||||||
|
{models.length > 0 && <option value="">— Select a model —</option>}
|
||||||
|
{models.map((m) => (
|
||||||
<option key={m} value={m}>{m}</option>
|
<option key={m} value={m}>{m}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => loadModels()}
|
||||||
|
disabled={modelsStatus === 'loading'}
|
||||||
|
title="Fetch the current model list from your provider"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(137,180,250,0.15)',
|
||||||
|
color: '#89b4fa',
|
||||||
|
border: '1px solid rgba(137,180,250,0.3)',
|
||||||
|
borderRadius: '9px',
|
||||||
|
padding: '0 12px',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: modelsStatus === 'loading' ? 'not-allowed' : 'pointer',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{modelsStatus === 'loading' ? '⏳' : '↻ Load'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{modelsStatus === 'error' && modelsError && (
|
||||||
|
<div style={{ ...styles.hint, color: '#f38ba8' }}>⚠ {modelsError}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* API Key */}
|
{/* API Key */}
|
||||||
|
|||||||
Reference in New Issue
Block a user