feat: Implement Prompt Builder functionality in Popup and Options
Some checks failed
CI — Test & Build / Test & Build (push) Failing after 39s

- Added a new "Prompt Builder" tab in the Popup for generating AI prompts with customizable parameters.
- Introduced new state variables for managing prompt styles, personas, formats, and models.
- Enhanced the Options page to fetch and display models based on the provided API key.
- Updated the actions and types to include the new 'prompt' action and its associated parameters.
- Implemented migration logic for legacy plaintext API keys to encrypted storage.
- Updated the getSystemPrompt function to incorporate prompt parameters for better instruction generation.
- Added tests for the new functionality, including context menu entries and prompt generation logic.
This commit is contained in:
john kevin asprec
2026-07-15 15:27:41 +08:00
parent 0fef9848cb
commit acea99d7ad
40 changed files with 1971 additions and 177 deletions

View File

@@ -1,7 +1,7 @@
import { defineBackground } from 'wxt/utils/define-background';
import type { AnalyzePayload, LexAIConfig, LexAIResponse } from '@lib/types';
import { CONTEXT_MENU_ENTRIES, findContextMenuEntry } from '@lib/actions';
import { decryptApiKey } from '@lib/crypto';
import { CONTEXT_MENU_ENTRIES, findContextMenuEntry, resolvePromptPersona } from '@lib/actions';
import { decryptApiKey, migratePlaintextApiKey } from '@lib/crypto';
import { callProvider, getSystemPrompt, listModels } from '@lib/providers';
// Resolve the usable API key from stored config: prefer the encrypted path,
@@ -22,13 +22,35 @@ async function handleAnalyzeText(payload: AnalyzePayload): Promise<LexAIResponse
const stored = await chrome.storage.local.get(['provider', 'apiKey', 'apiKeyEnc', 'encKey', 'model']);
const config = stored as LexAIConfig;
// Prompt requests from the toolbar/context menu carry no explicit params —
// apply the Prompt Builder settings saved from the popup so all entry
// points behave the same. The popup still overrides by sending its own.
if (payload.action === 'prompt' && !payload.promptParams) {
const saved = (await chrome.storage.local.get([
'promptStyle', 'promptPersona', 'customPersona', 'promptFormat', 'promptModel',
])) as Record<string, string | undefined>;
payload = {
...payload,
promptParams: {
promptStyle: saved.promptStyle,
persona: resolvePromptPersona(saved.promptPersona, saved.customPersona),
format: saved.promptFormat,
},
model: payload.model ?? (saved.promptModel || undefined),
};
}
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 };
const systemPrompt = getSystemPrompt(payload.action, payload.style);
const resolvedConfig: LexAIConfig = {
...config,
apiKey,
...(payload.model ? { model: payload.model } : {}),
};
const systemPrompt = getSystemPrompt(payload.action, payload.style, payload.promptParams);
return callProvider(resolvedConfig, payload.text, systemPrompt);
}
@@ -37,6 +59,10 @@ async function handleAnalyzeText(payload: AnalyzePayload): Promise<LexAIResponse
export default defineBackground(() => {
console.log('LexAI background service worker started');
// Encrypt any legacy plaintext apiKey left by older builds (no-op otherwise).
// The read path keeps its plaintext fallback, so a failed migration is safe.
migratePlaintextApiKey().catch(() => {});
// ─── Context menus ───────────────────────────────────────────────────────
chrome.runtime.onInstalled.addListener(() => {
chrome.contextMenus.removeAll(() => {
@@ -64,7 +90,11 @@ export default defineBackground(() => {
});
// ─── Message handler ─────────────────────────────────────────────────────
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
// Only our own contexts (content scripts, popup, options) may drive the
// key-bearing call path — ignore anything from another extension.
if (sender.id !== chrome.runtime.id) return;
if (message.type === 'ANALYZE_TEXT') {
// Support both { payload: { text, action, style } } (content.ts) and
// { text, action, style } (popup) formats
@@ -72,6 +102,8 @@ export default defineBackground(() => {
text: message.text as string,
action: message.action as string,
style: message.style as string | undefined,
promptParams: message.promptParams,
model: message.model as string | undefined,
};
handleAnalyzeText(payload)
.then(sendResponse)
@@ -99,10 +131,16 @@ export default defineBackground(() => {
}
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 () => {
// Callers that don't know the provider (content script) omit it —
// fall back to the configured one.
let provider = message.provider as string | undefined;
if (!provider) {
const stored = await chrome.storage.local.get('provider');
provider = (stored.provider as string) || 'openai';
}
let apiKey = inlineKey;
if (!apiKey) {
const stored = await chrome.storage.local.get(['apiKey', 'apiKeyEnc', 'encKey']);