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,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);