feat: Implement Prompt Builder functionality in Popup and Options
Some checks failed
CI — Test & Build / Test & Build (push) Failing after 39s
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:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}` };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user