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);
|
||||
}
|
||||
|
||||
// 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 ─────────────────────────────────────────────────
|
||||
|
||||
async function callOpenAI(payload: AnalyzePayload, config: LexAIConfig): Promise<LexAIResponse> {
|
||||
@@ -353,26 +365,70 @@ async function callOpenRouterWithPrompt(payload: AnalyzePayload, config: LexAICo
|
||||
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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleAnalyzeText(payload: AnalyzePayload): Promise<LexAIResponse> {
|
||||
const stored = await chrome.storage.local.get(['provider', 'apiKey', 'apiKeyEnc', 'encKey', 'model']);
|
||||
const config = stored as LexAIConfig;
|
||||
|
||||
// Resolve API key — prefer encrypted path, fall back to plaintext for backward compat
|
||||
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() === '') {
|
||||
const apiKey = await resolveApiKey(config);
|
||||
if (!apiKey) {
|
||||
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';
|
||||
|
||||
switch (provider) {
|
||||
@@ -460,16 +516,12 @@ export default defineBackground(() => {
|
||||
chrome.storage.local.get(['provider', 'apiKey', 'apiKeyEnc', 'encKey', 'model'])
|
||||
.then(async (stored) => {
|
||||
const config = stored as LexAIConfig;
|
||||
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() === '') {
|
||||
const apiKey = await resolveApiKey(config);
|
||||
if (!apiKey) {
|
||||
sendResponse({ error: 'No API key configured. Please open LexAI settings.' });
|
||||
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.`;
|
||||
return callProvider(resolvedConfig, text, systemPrompt);
|
||||
})
|
||||
@@ -477,5 +529,25 @@ export default defineBackground(() => {
|
||||
.catch((err) => sendResponse({ error: String(err) }));
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
|
||||
// Model lists are fetched live per provider (see loadModels) — no static lists here.
|
||||
const PROVIDERS = [
|
||||
{
|
||||
id: 'openai',
|
||||
name: 'OpenAI',
|
||||
placeholder: 'sk-...',
|
||||
models: ['gpt-4o', 'gpt-4o-mini', 'gpt-3.5-turbo'],
|
||||
docsUrl: 'https://platform.openai.com/api-keys',
|
||||
},
|
||||
{
|
||||
id: 'anthropic',
|
||||
name: 'Anthropic (Claude)',
|
||||
placeholder: 'sk-ant-...',
|
||||
models: ['claude-3-5-sonnet-20241022', 'claude-3-5-haiku-20241022', 'claude-3-opus-20240229'],
|
||||
docsUrl: 'https://console.anthropic.com/keys',
|
||||
},
|
||||
{
|
||||
id: 'groq',
|
||||
name: 'Groq (Free tier)',
|
||||
placeholder: 'gsk_...',
|
||||
models: ['llama-3.3-70b-versatile', 'llama-3.1-8b-instant', 'mixtral-8x7b-32768'],
|
||||
docsUrl: 'https://console.groq.com/keys',
|
||||
},
|
||||
{
|
||||
id: 'openrouter',
|
||||
name: 'OpenRouter (100+ models)',
|
||||
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',
|
||||
},
|
||||
];
|
||||
@@ -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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
function OptionsPage() {
|
||||
const [provider, setProvider] = useState('openai');
|
||||
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 [showKey, setShowKey] = useState(false);
|
||||
const [isEncrypted, setIsEncrypted] = useState(false);
|
||||
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(() => {
|
||||
safeStorageGet(['provider', 'apiKey', 'apiKeyEnc', 'encKey', 'model'], (result) => {
|
||||
const prov = result.provider || 'openai';
|
||||
if (result.provider) setProvider(result.provider);
|
||||
if (result.model) setModel(result.model);
|
||||
|
||||
@@ -217,6 +258,11 @@ function OptionsPage() {
|
||||
setApiKey(result.apiKey);
|
||||
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) => {
|
||||
setProvider(newProvider);
|
||||
const p = PROVIDERS.find((p) => p.id === newProvider);
|
||||
if (p) setModel(p.models[0]);
|
||||
setModel('');
|
||||
setModels([]);
|
||||
setModelsStatus('idle');
|
||||
setModelsError('');
|
||||
loadModels(newProvider);
|
||||
};
|
||||
|
||||
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 (!apiKey.trim() && !isEncrypted) {
|
||||
apiKeyRef.current?.focus();
|
||||
@@ -319,18 +374,51 @@ function OptionsPage() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Model */}
|
||||
{/* Model — fetched live from the selected provider */}
|
||||
<div style={styles.formGroup}>
|
||||
<label style={styles.label}>Model</label>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<select
|
||||
style={styles.select}
|
||||
style={{ ...styles.select, flex: 1, opacity: models.length === 0 ? 0.6 : 1 }}
|
||||
value={model}
|
||||
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>
|
||||
))}
|
||||
</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>
|
||||
|
||||
{/* API Key */}
|
||||
|
||||
Reference in New Issue
Block a user