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

@@ -4,7 +4,7 @@
// 'fix' is the user-facing id emitted by the context menu and popup; the
// background normalizes it to the 'grammar' prompt.
export const ACTIONS = ['fix', 'rephrase', 'shorten', 'expand', 'explain'] as const;
export const ACTIONS = ['fix', 'rephrase', 'shorten', 'expand', 'explain', 'prompt'] as const;
export type ActionId = (typeof ACTIONS)[number];
export const ACTION_LABELS: Record<ActionId, string> = {
@@ -13,6 +13,7 @@ export const ACTION_LABELS: Record<ActionId, string> = {
shorten: 'Shorten',
expand: 'Expand',
explain: 'Explain',
prompt: 'Make Prompt',
};
// Full list, including the 'Default' pseudo-style (= no style modifier).
@@ -22,6 +23,37 @@ export type WritingStyle = (typeof WRITING_STYLES)[number];
// Context menus omit 'Default' — the parent action item already covers it.
export const CONTEXT_MENU_STYLES = WRITING_STYLES.filter((s) => s !== 'Default');
// ─── Prompt Builder parameters (the 'prompt' action) ─────────────────────────
// 'Auto' always means "let the prompt engineer decide from the input".
export const PROMPT_STYLES = ['Auto', 'Instructional', 'Role-play', 'Step-by-step', 'Few-shot', 'Structured'] as const;
export type PromptStyle = (typeof PROMPT_STYLES)[number];
// 'Custom…' switches the popup to a free-text persona field.
export const PROMPT_PERSONAS = [
'Auto',
'None',
'Expert Developer',
'Copywriter',
'Teacher',
'Data Analyst',
'Business Consultant',
'Researcher',
'Custom…',
] as const;
export type PromptPersona = (typeof PROMPT_PERSONAS)[number];
export const PROMPT_FORMATS = ['Auto', 'Plain text', 'Markdown', 'Bulleted list', 'Numbered steps', 'JSON', 'Table'] as const;
export type PromptFormat = (typeof PROMPT_FORMATS)[number];
// 'Custom…' means "use the free-text persona"; an empty custom text falls
// back to Auto. Shared by the popup and the background (which resolves saved
// Prompt Builder settings for toolbar/context-menu prompt requests).
export function resolvePromptPersona(persona?: string, customPersona?: string): string | undefined {
if (persona === 'Custom…') return customPersona?.trim() || 'Auto';
return persona;
}
// Minimum selection length (chars, after trim) before the floating toolbar
// appears. Kept deliberately above 1-2 chars so accidental double-click
// selections don't trigger the UI.
@@ -49,13 +81,17 @@ export const CONTEXT_MENU_ENTRIES: ContextMenuEntry[] = ACTIONS.flatMap((action)
style: 'Default' as WritingStyle,
title: `⚡ LexAI: ${ACTION_LABELS[action]}`,
},
...CONTEXT_MENU_STYLES.map((style) => ({
id: `lexai-${action}-${style.toLowerCase()}`,
action,
style,
parentId: `lexai-${action}`,
title: style as string,
})),
// 'prompt' opens the Prompt Builder dialog in the page, which has its own
// parameters — writing-style children don't apply to it.
...(action === 'prompt'
? []
: CONTEXT_MENU_STYLES.map((style) => ({
id: `lexai-${action}-${style.toLowerCase()}`,
action,
style,
parentId: `lexai-${action}`,
title: style as string,
}))),
]);
export function findContextMenuEntry(menuItemId: string): ContextMenuEntry | undefined {

View File

@@ -58,6 +58,46 @@ export function encryptApiKey(plaintext: string, key: Uint8Array): string {
return bytesToBase64(combined);
}
// Migrates a legacy plaintext `apiKey` left by older builds to the encrypted
// path, then removes the plaintext copy. Safe to run on every worker start —
// no-op when no plaintext key exists. Write order matters: the encrypted copy
// is persisted (and, when one already exists, verified to decrypt) BEFORE the
// plaintext is removed, so an interruption can never lose the only key.
// Returns true when a migration (or plaintext cleanup) happened.
export async function migratePlaintextApiKey(): Promise<boolean> {
const stored = await new Promise<Record<string, unknown>>((resolve) => {
chrome.storage.local.get(['apiKey', 'apiKeyEnc', 'encKey'], (result) => resolve(result));
});
const plaintext = stored.apiKey as string | undefined;
if (!plaintext) return false;
if (stored.apiKeyEnc && stored.encKey) {
// An encrypted key already exists — drop the stale plaintext copy, but
// only if the ciphertext actually decrypts (else it stays as fallback).
if (decryptApiKey(stored.encKey as string, stored.apiKeyEnc as string) === null) return false;
await new Promise<void>((resolve) => chrome.storage.local.remove('apiKey', resolve));
return true;
}
const key = await getOrCreateEncKey();
const apiKeyEnc = encryptApiKey(plaintext, key);
// Re-check for a concurrent Options save: if an encrypted key appeared while
// we were encrypting, don't clobber it — the next worker start cleans up the
// plaintext via the verified branch above.
const recheck = await new Promise<Record<string, unknown>>((resolve) => {
chrome.storage.local.get(['apiKeyEnc'], (result) => resolve(result));
});
if (recheck.apiKeyEnc) return false;
// Persist the key material together and AWAIT it before removing the
// plaintext, so loss-safety doesn't depend on Chrome's implicit FIFO write
// ordering (getOrCreateEncKey's own set() is fire-and-forget).
await new Promise<void>((resolve) =>
chrome.storage.local.set({ encKey: bytesToBase64(key), apiKeyEnc }, resolve),
);
await new Promise<void>((resolve) => chrome.storage.local.remove('apiKey', resolve));
return true;
}
// Decrypts base64(nonce || ciphertext) with the base64 key.
// Returns null on any tamper/mismatch (secretbox authentication failure).
export function decryptApiKey(encKeyB64: string, apiKeyEncB64: string): string | null {

View File

@@ -4,7 +4,7 @@
// headers, request bodies, default models, and error strings match the old
// implementations — pinned by tests/unit/providers.test.ts.
import type { LexAIConfig, LexAIResponse } from './types';
import type { LexAIConfig, LexAIResponse, PromptParams } from './types';
export async function fetchWithTimeout(
url: string,
@@ -22,7 +22,38 @@ export async function fetchWithTimeout(
// ─── System prompts ───────────────────────────────────────────────────────────
export function getSystemPrompt(action: string, style?: string): string {
// Prompt Builder parameters → extra instructions for the prompt-engineer
// action. 'Auto' (or absence) adds nothing — the base prompt already tells the
// engineer to decide these from the input.
function promptParamModifiers(params?: PromptParams): string {
if (!params) return '';
const parts: string[] = [];
const styleInstructions: Record<string, string> = {
Instructional: ' Compose the engineered prompt as clear, direct instructions.',
'Role-play': ' Compose the engineered prompt as a role-play scenario the model should stay in character for.',
'Step-by-step': ' The engineered prompt should ask the model to work through the task step by step before giving its final answer.',
'Few-shot': ' Include one or two short input→output examples (few-shot) in the engineered prompt.',
Structured: ' Organize the engineered prompt into labeled sections (Context, Task, Constraints, Output format).',
};
if (params.promptStyle && styleInstructions[params.promptStyle]) {
parts.push(styleInstructions[params.promptStyle]);
}
if (params.persona === 'None') {
parts.push(' Do not assign a persona or role in the engineered prompt.');
} else if (params.persona && params.persona !== 'Auto') {
parts.push(` The engineered prompt must assign the model the persona of ${params.persona.trim()}, including the skills and expertise that persona implies.`);
}
if (params.format && params.format !== 'Auto') {
parts.push(` The engineered prompt must require the final output as ${params.format.toLowerCase()}.`);
}
return parts.join('');
}
export function getSystemPrompt(action: string, style?: string, promptParams?: PromptParams): string {
// Normalize 'fix' (used by context menu and popup) to 'grammar'
const normalizedAction = action === 'fix' ? 'grammar' : action;
const prompts: Record<string, string> = {
@@ -46,12 +77,23 @@ export function getSystemPrompt(action: string, style?: string): string {
'You are a helpful teacher. Explain the following text in simple, easy-to-understand language. ' +
'Break down complex terms, jargon, or concepts so anyone can understand. ' +
'Be concise but clear. Return only the explanation, no extra commentary.',
prompt:
'You are an expert prompt engineer. Transform the provided text into one well-crafted prompt that will get the best possible result from an AI model. ' +
'First infer what the user is trying to achieve — the text may be a rough idea, a question, or a description of the output they want. ' +
'Then compose the prompt using only the components that goal actually needs: a persona/role and relevant skills when expertise helps, essential context, a clear task statement, constraints, and the desired output format or structure. ' +
'Make the prompt specific, self-contained, and unambiguous, and phrase it as instructions addressed to an AI model. ' +
'Return ONLY the engineered prompt, ready to paste into an AI chat — no explanations, no surrounding quotes, no preamble.',
};
const base = prompts[normalizedAction] ?? prompts.grammar;
// For the prompt-engineer action, the style describes the output the
// engineered prompt should ask for — not the wording of our response.
const styleModifier = style && style !== 'Default'
? ` Write in a ${style.toLowerCase()} style.`
? normalizedAction === 'prompt'
? ` The engineered prompt should instruct the model to respond in a ${style.toLowerCase()} style.`
: ` Write in a ${style.toLowerCase()} style.`
: '';
return base + styleModifier;
const paramModifiers = normalizedAction === 'prompt' ? promptParamModifiers(promptParams) : '';
return base + styleModifier + paramModifiers;
}
// ─── Adapter table ────────────────────────────────────────────────────────────
@@ -86,6 +128,26 @@ function openAiStyleBody(temperature?: number) {
const extractOpenAiStyle = (data: any): string | undefined => data?.choices?.[0]?.message?.content;
// OpenAI proper rejects the legacy `max_tokens` on newer models ("Use
// 'max_completion_tokens' instead") and reasoning models (o-series, gpt-5
// family) additionally reject any temperature other than the default.
// `max_completion_tokens` is accepted by all current OpenAI chat models, so
// send it unconditionally; temperature stays only for non-reasoning models.
// Groq/OpenRouter still expect `max_tokens` — don't apply this to them.
const OPENAI_REASONING_MODEL_RE = /^(o\d|gpt-5)/;
function openAiBody(model: string, systemPrompt: string, text: string, maxTokens: number) {
return {
model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: text },
],
max_completion_tokens: maxTokens,
...(OPENAI_REASONING_MODEL_RE.test(model) ? {} : { temperature: 0.7 }),
};
}
export const PROVIDER_SPECS: Record<string, ProviderSpec> = {
openai: {
label: 'OpenAI',
@@ -93,7 +155,7 @@ export const PROVIDER_SPECS: Record<string, ProviderSpec> = {
modelsUrl: 'https://api.openai.com/v1/models',
defaultModel: 'gpt-4o-mini',
headers: bearerHeaders,
body: openAiStyleBody(0.7),
body: openAiBody,
extract: extractOpenAiStyle,
},
anthropic: {
@@ -177,7 +239,9 @@ export async function callProvider(
return { error: `Network error reaching ${spec.label}: ${String(err)}` };
}
const data = await res.json();
// Guard the parse — gateways/proxies return HTML error pages (502 etc.)
// which would otherwise surface as a raw SyntaxError to the user.
const data = await res.json().catch(() => null);
if (!res.ok) {
const msg = data?.error?.message ?? `HTTP ${res.status}`;
return { error: `${spec.label} error: ${msg}` };

View File

@@ -2,10 +2,22 @@
// options, popup). This is the single source of truth for the message contract
// and the storage schema.
// Extra parameters for the 'prompt' (prompt-engineer) action. All optional;
// omitted / 'Auto' means the prompt engineer decides from the input itself.
export interface PromptParams {
promptStyle?: string; // see PROMPT_STYLES in actions.ts
persona?: string; // preset from PROMPT_PERSONAS, or free text (Custom)
format?: string; // see PROMPT_FORMATS
}
export interface AnalyzePayload {
text: string;
action: string;
style?: string;
promptParams?: PromptParams;
// Per-request model override (e.g. Prompt Builder's model picker). Falls
// back to the configured model when absent.
model?: string;
}
export interface LexAIConfig {
@@ -36,6 +48,8 @@ export interface AnalyzeTextMessage {
text?: string;
action?: string;
style?: string;
promptParams?: PromptParams;
model?: string;
}
export interface CopyAsMessage {