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:
@@ -9,9 +9,12 @@ import {
|
||||
} from '@lib/actions';
|
||||
|
||||
describe('context-menu registry', () => {
|
||||
it('contains 5 parents + 5x5 style children = 30 entries (parity with old loops)', () => {
|
||||
expect(CONTEXT_MENU_ENTRIES).toHaveLength(30);
|
||||
it('contains one parent per action + style children for all actions except prompt', () => {
|
||||
const styledActions = ACTIONS.filter((a) => a !== 'prompt');
|
||||
expect(CONTEXT_MENU_ENTRIES).toHaveLength(ACTIONS.length + styledActions.length * CONTEXT_MENU_STYLES.length);
|
||||
expect(CONTEXT_MENU_ENTRIES.filter((e) => !e.parentId)).toHaveLength(ACTIONS.length);
|
||||
// prompt is a single item — its parameters live in the Prompt Builder dialog
|
||||
expect(CONTEXT_MENU_ENTRIES.filter((e) => e.parentId === 'lexai-prompt')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('every parent precedes its children (contextMenus.create ordering)', () => {
|
||||
@@ -31,6 +34,7 @@ describe('context-menu registry', () => {
|
||||
expect(parent).toMatchObject({ action, style: 'Default' });
|
||||
expect(parent!.title).toBe(`⚡ LexAI: ${ACTION_LABELS[action]}`);
|
||||
|
||||
if (action === 'prompt') continue; // no style children — see registry comment
|
||||
for (const style of CONTEXT_MENU_STYLES) {
|
||||
const child = findContextMenuEntry(`lexai-${action}-${style.toLowerCase()}`);
|
||||
expect(child).toMatchObject({ action, style, parentId: `lexai-${action}`, title: style });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import nacl from 'tweetnacl';
|
||||
import {
|
||||
bytesToBase64,
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
generateEncKey,
|
||||
encryptApiKey,
|
||||
decryptApiKey,
|
||||
migratePlaintextApiKey,
|
||||
} from '@lib/crypto';
|
||||
|
||||
// Reproduces the ORIGINAL inline implementations verbatim (Options.tsx encrypt,
|
||||
@@ -68,6 +69,60 @@ describe('encrypt/decrypt roundtrip', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('migratePlaintextApiKey', () => {
|
||||
// Stateful chrome.storage.local mock so the migration's read/write/remove
|
||||
// sequence operates on a real store instead of the default empty stub.
|
||||
function stubStorage(initial: Record<string, unknown>): Record<string, unknown> {
|
||||
const store: Record<string, unknown> = { ...initial };
|
||||
global.chrome.storage.local.get = vi.fn((keys: string[], cb: (r: Record<string, unknown>) => void) => {
|
||||
const out: Record<string, unknown> = {};
|
||||
keys.forEach((k) => { if (k in store) out[k] = store[k]; });
|
||||
cb(out);
|
||||
}) as any;
|
||||
global.chrome.storage.local.set = vi.fn((data: Record<string, unknown>, cb?: () => void) => {
|
||||
Object.assign(store, data);
|
||||
cb?.();
|
||||
}) as any;
|
||||
global.chrome.storage.local.remove = vi.fn((key: string, cb?: () => void) => {
|
||||
delete store[key];
|
||||
cb?.();
|
||||
}) as any;
|
||||
return store;
|
||||
}
|
||||
|
||||
it('is a no-op when no plaintext key exists', async () => {
|
||||
const store = stubStorage({ apiKeyEnc: 'x', encKey: 'y' });
|
||||
expect(await migratePlaintextApiKey()).toBe(false);
|
||||
expect(store).toEqual({ apiKeyEnc: 'x', encKey: 'y' });
|
||||
});
|
||||
|
||||
it('encrypts a plaintext-only key and removes the plaintext', async () => {
|
||||
const store = stubStorage({ apiKey: 'sk-legacy-key' });
|
||||
expect(await migratePlaintextApiKey()).toBe(true);
|
||||
expect(store.apiKey).toBeUndefined();
|
||||
expect(typeof store.apiKeyEnc).toBe('string');
|
||||
expect(typeof store.encKey).toBe('string');
|
||||
expect(decryptApiKey(store.encKey as string, store.apiKeyEnc as string)).toBe('sk-legacy-key');
|
||||
});
|
||||
|
||||
it('removes stale plaintext when a valid encrypted key already exists', async () => {
|
||||
const key = generateEncKey();
|
||||
const enc = encryptApiKey('sk-current', key);
|
||||
const store = stubStorage({ apiKey: 'sk-stale', apiKeyEnc: enc, encKey: bytesToBase64(key) });
|
||||
expect(await migratePlaintextApiKey()).toBe(true);
|
||||
expect(store.apiKey).toBeUndefined();
|
||||
expect(decryptApiKey(store.encKey as string, store.apiKeyEnc as string)).toBe('sk-current');
|
||||
});
|
||||
|
||||
it('keeps the plaintext fallback when the encrypted key does not decrypt', async () => {
|
||||
const key = generateEncKey();
|
||||
const enc = encryptApiKey('sk-current', key);
|
||||
const store = stubStorage({ apiKey: 'sk-fallback', apiKeyEnc: enc, encKey: bytesToBase64(generateEncKey()) });
|
||||
expect(await migratePlaintextApiKey()).toBe(false);
|
||||
expect(store.apiKey).toBe('sk-fallback');
|
||||
});
|
||||
});
|
||||
|
||||
describe('backward compatibility with the pre-extraction inline code', () => {
|
||||
it('decrypts a value encrypted by the OLD Options.tsx code path', () => {
|
||||
const key = nacl.randomBytes(32);
|
||||
|
||||
@@ -39,11 +39,21 @@ describe('callProvider request shapes (parity with old implementations)', () =>
|
||||
{ role: 'system', content: 'SYS' },
|
||||
{ role: 'user', content: 'hello' },
|
||||
],
|
||||
max_tokens: 1024,
|
||||
max_completion_tokens: 1024,
|
||||
temperature: 0.7,
|
||||
});
|
||||
});
|
||||
|
||||
it('OpenAI reasoning models (o-series / gpt-5): max_completion_tokens, NO temperature', async () => {
|
||||
const fetch = mockFetchOnce(openAiResponse);
|
||||
await callProvider({ provider: 'openai', apiKey: 'sk-x', model: 'gpt-5-mini' }, 'hello', 'SYS', { maxTokens: 1024 });
|
||||
|
||||
const body = JSON.parse(fetch.mock.calls[0][1].body);
|
||||
expect(body.max_completion_tokens).toBe(1024);
|
||||
expect(body.max_tokens).toBeUndefined();
|
||||
expect(body.temperature).toBeUndefined();
|
||||
});
|
||||
|
||||
it('Anthropic: x-api-key + version headers, top-level system, no temperature', async () => {
|
||||
const fetch = mockFetchOnce(anthropicResponse);
|
||||
const res = await callProvider({ provider: 'anthropic', apiKey: 'sk-ant' }, 'hello', 'SYS', { maxTokens: 1024 });
|
||||
@@ -126,6 +136,16 @@ describe('callProvider error handling (parity with old implementations)', () =>
|
||||
expect(res).toEqual({ error: 'OpenRouter returned an empty response.' });
|
||||
});
|
||||
|
||||
it('returns a clean HTTP error when the error body is not JSON (e.g. HTML 502)', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 502,
|
||||
json: async () => { throw new SyntaxError('Unexpected token < in JSON'); },
|
||||
}));
|
||||
const res = await callProvider({ provider: 'openai', apiKey: 'k' }, 'x', 'SYS');
|
||||
expect(res).toEqual({ error: 'OpenAI error: HTTP 502' });
|
||||
});
|
||||
|
||||
it('rejects unknown providers without fetching', async () => {
|
||||
const fetch = mockFetchOnce({});
|
||||
const res = await callProvider({ provider: 'bogus', apiKey: 'k' }, 'x', 'SYS');
|
||||
@@ -155,6 +175,31 @@ describe('getSystemPrompt', () => {
|
||||
expect(getSystemPrompt('rephrase', 'Default')).not.toContain('style.');
|
||||
expect(getSystemPrompt('rephrase')).not.toContain('Write in a');
|
||||
});
|
||||
|
||||
it("'prompt' uses the prompt-engineer prompt with a prompt-directed style modifier", () => {
|
||||
expect(getSystemPrompt('prompt')).toContain('expert prompt engineer');
|
||||
expect(getSystemPrompt('prompt', 'Formal')).toMatch(/instruct the model to respond in a formal style\.$/);
|
||||
expect(getSystemPrompt('prompt', 'Formal')).not.toContain('Write in a');
|
||||
expect(getSystemPrompt('prompt', 'Default')).toBe(getSystemPrompt('prompt'));
|
||||
});
|
||||
|
||||
it('Prompt Builder params add instructions; Auto adds nothing', () => {
|
||||
const base = getSystemPrompt('prompt');
|
||||
expect(getSystemPrompt('prompt', undefined, { promptStyle: 'Auto', persona: 'Auto', format: 'Auto' })).toBe(base);
|
||||
|
||||
const full = getSystemPrompt('prompt', undefined, {
|
||||
promptStyle: 'Few-shot',
|
||||
persona: 'Data Analyst',
|
||||
format: 'JSON',
|
||||
});
|
||||
expect(full).toContain('few-shot');
|
||||
expect(full).toContain('persona of Data Analyst');
|
||||
expect(full).toContain('final output as json');
|
||||
|
||||
expect(getSystemPrompt('prompt', undefined, { persona: 'None' })).toContain('Do not assign a persona');
|
||||
// Params are prompt-action-only — other actions ignore them.
|
||||
expect(getSystemPrompt('rephrase', undefined, { persona: 'Teacher' })).toBe(getSystemPrompt('rephrase'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultMaxTokens', () => {
|
||||
|
||||
Reference in New Issue
Block a user