diff --git a/src/components/ApiSettings.tsx b/src/components/ApiSettings.tsx
new file mode 100644
index 0000000..59f8cab
--- /dev/null
+++ b/src/components/ApiSettings.tsx
@@ -0,0 +1,72 @@
+import { useEffect, useState } from "react";
+
+const ApiSettings = () => {
+ const [apiKey, setApiKey] = useState("");
+ const [isSaved, setIsSaved] = useState(false);
+
+ useEffect(() => {
+ chrome.storage.local.get("genai_api_key", (result) => {
+ if (result.genai_api_key) {
+ setApiKey(result.genai_api_key as string);
+ }
+ });
+ }, []);
+
+ const handleSave = () => {
+ chrome.storage.local.set({ genai_api_key: apiKey }, () => {
+ setIsSaved(true);
+ setTimeout(() => setIsSaved(false), 2000);
+ });
+ };
+
+ return (
+
+
API & Security
+
+
+
+
Bring Your Own Key (BYOK)
+
+ To use Lexiflow, you must provide your own Google Gemini API key.
+ This key is stored securely in your browser's local storage and is never sent to our servers.
+
+
+
+
+
+ Google Gemini API Key
+
+
setApiKey(e.target.value)}
+ />
+
+ You can get a free API key from Google AI Studio.
+
+
+
+
+
+ Save Key
+
+ {isSaved && (
+
+
+
+
+ Saved successfully
+
+ )}
+
+
+
+ );
+};
+
+export default ApiSettings;
diff --git a/src/components/SettingsPage.tsx b/src/components/SettingsPage.tsx
index d73c806..55e4433 100644
--- a/src/components/SettingsPage.tsx
+++ b/src/components/SettingsPage.tsx
@@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
import GeneralSettings from "./GeneralSettings";
import Integrations from "./Integrations";
import GlossarySettings from "./GlossarySettings";
+import ApiSettings from "./ApiSettings";
import { LexiFlowSettingsProvider } from "../context/LexiFlowSettingsContext";
const SettingsPage = () => {
@@ -20,6 +21,8 @@ const SettingsPage = () => {
switch (activeTab) {
case "#general":
return ;
+ case "#api":
+ return ;
case "#integrations":
return ;
case "#glossary":
@@ -54,6 +57,19 @@ const SettingsPage = () => {
>
General Settings
+ {
+ setActiveTab("#api");
+ window.location.hash = "#api";
+ }}
+ className={`w-full text-left px-4 py-2 rounded-md text-sm font-medium ${
+ activeTab === "#api"
+ ? "bg-pink-100 text-pink-700"
+ : "text-gray-600 hover:bg-gray-50"
+ }`}
+ >
+ API & Security
+
{
setActiveTab("#integrations");
diff --git a/src/services/api/HttpService.ts b/src/services/api/HttpService.ts
new file mode 100644
index 0000000..d489136
--- /dev/null
+++ b/src/services/api/HttpService.ts
@@ -0,0 +1,98 @@
+import { LexiflowNetworkError, LexiflowTimeoutError, LexiflowRateLimitError } from "../../shared/errors/LexiflowErrors";
+
+export interface HttpRequestOptions extends RequestInit {
+ timeoutMs?: number;
+}
+
+export class HttpService {
+ /**
+ * Performs an HTTP POST request with built-in timeout and custom error handling.
+ */
+ static async post(url: string, body: any, options: HttpRequestOptions = {}): Promise {
+ const { timeoutMs = 15000, ...fetchOptions } = options;
+
+ const controller = new AbortController();
+ const id = setTimeout(() => controller.abort(), timeoutMs);
+
+ try {
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...(fetchOptions.headers || {})
+ },
+ body: JSON.stringify(body),
+ signal: controller.signal,
+ ...fetchOptions
+ });
+
+ clearTimeout(id);
+
+ if (!response.ok) {
+ if (response.status === 429) {
+ throw new LexiflowRateLimitError();
+ }
+ throw new LexiflowNetworkError(`HTTP ${response.status}: ${response.statusText}`);
+ }
+
+ return await response.json() as T;
+ } catch (error: any) {
+ clearTimeout(id);
+
+ if (error.name === 'AbortError') {
+ throw new LexiflowTimeoutError();
+ }
+
+ if (error instanceof LexiflowNetworkError || error instanceof LexiflowRateLimitError) {
+ throw error;
+ }
+
+ throw new LexiflowNetworkError(error.message);
+ }
+ }
+
+ /**
+ * Performs an HTTP GET request with built-in timeout and custom error handling.
+ */
+ static async get(url: string, options: HttpRequestOptions = {}): Promise {
+ const { timeoutMs = 15000, ...fetchOptions } = options;
+
+ const controller = new AbortController();
+ const id = setTimeout(() => controller.abort(), timeoutMs);
+
+ try {
+ const response = await fetch(url, {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...(fetchOptions.headers || {})
+ },
+ signal: controller.signal,
+ ...fetchOptions
+ });
+
+ clearTimeout(id);
+
+ if (!response.ok) {
+ if (response.status === 429) {
+ throw new LexiflowRateLimitError();
+ }
+ throw new LexiflowNetworkError(`HTTP ${response.status}: ${response.statusText}`);
+ }
+
+ return await response.json() as T;
+ } catch (error: any) {
+ clearTimeout(id);
+
+ if (error.name === 'AbortError') {
+ throw new LexiflowTimeoutError();
+ }
+
+ if (error instanceof LexiflowNetworkError || error instanceof LexiflowRateLimitError) {
+ throw error;
+ }
+
+ throw new LexiflowNetworkError(error.message);
+ }
+ }
+}
diff --git a/src/services/cache/CacheService.ts b/src/services/cache/CacheService.ts
new file mode 100644
index 0000000..109923f
--- /dev/null
+++ b/src/services/cache/CacheService.ts
@@ -0,0 +1,77 @@
+export class CacheService {
+ private static MAX_SIZE = 50;
+
+ /**
+ * Generates a deterministic cache key.
+ */
+ static generateKey(text: string, mode: string, targetLang?: string): string {
+ const normalizedText = text.trim();
+ return `${mode}_${targetLang || 'none'}_${normalizedText}`;
+ }
+
+ private static async getCacheData(): Promise> {
+ return new Promise((resolve) => {
+ chrome.storage.session.get('lexiflow_cache', (result) => {
+ if (result.lexiflow_cache && typeof result.lexiflow_cache === 'string') {
+ try {
+ resolve(new Map(JSON.parse(result.lexiflow_cache)));
+ } catch (e) {
+ resolve(new Map());
+ }
+ } else {
+ resolve(new Map());
+ }
+ });
+ });
+ }
+
+ private static async saveCacheData(cache: Map): Promise {
+ const serialized = JSON.stringify(Array.from(cache.entries()));
+ return new Promise((resolve) => {
+ chrome.storage.session.set({ lexiflow_cache: serialized }, () => resolve());
+ });
+ }
+
+ /**
+ * Retrieves a value from the cache asynchronously.
+ */
+ static async get(key: string): Promise {
+ const cache = await this.getCacheData();
+ if (cache.has(key)) {
+ // Move to end to mark as recently used
+ const value = cache.get(key)!;
+ cache.delete(key);
+ cache.set(key, value);
+ await this.saveCacheData(cache);
+ return value;
+ }
+ return null;
+ }
+
+ /**
+ * Sets a value in the cache asynchronously, enforcing the MAX_SIZE LRU limit.
+ */
+ static async set(key: string, value: string): Promise {
+ const cache = await this.getCacheData();
+ if (cache.has(key)) {
+ cache.delete(key);
+ } else if (cache.size >= this.MAX_SIZE) {
+ // Delete the first (oldest) entry
+ const oldestKey = cache.keys().next().value;
+ if (oldestKey) {
+ cache.delete(oldestKey);
+ }
+ }
+ cache.set(key, value);
+ await this.saveCacheData(cache);
+ }
+
+ /**
+ * Clears the entire cache asynchronously.
+ */
+ static async clear(): Promise {
+ return new Promise((resolve) => {
+ chrome.storage.session.remove('lexiflow_cache', () => resolve());
+ });
+ }
+}
diff --git a/src/services/gemini.provider.ts b/src/services/gemini.provider.ts
new file mode 100644
index 0000000..e1218a6
--- /dev/null
+++ b/src/services/gemini.provider.ts
@@ -0,0 +1,97 @@
+import { LLMProvider, ProviderCapabilities, LLMRequestOptions, TranslationOptions, DictionaryResult } from '../shared/types';
+import { HttpService } from './api/HttpService';
+import { PromptBuilder } from './prompts/templates';
+
+const DEFAULT_MODEL = "gemini-2.5-flash";
+
+export class GeminiProvider implements LLMProvider {
+ public capabilities: ProviderCapabilities = {
+ supportsStreaming: false,
+ supportsStructuredOutput: true,
+ supportsVision: false
+ };
+
+ private apiKey: string;
+
+ constructor(apiKey: string) {
+ if (!apiKey) {
+ throw new Error("Gemini API key is not configured.");
+ }
+ this.apiKey = apiKey;
+ }
+
+ async refineText(text: string, options: LLMRequestOptions): Promise {
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/${DEFAULT_MODEL}:generateContent?key=${this.apiKey}`;
+ const prompt = PromptBuilder.buildRefinementPrompt(text, options.mode, options.targetLang);
+
+ const body = {
+ contents: [{ parts: [{ text: prompt }] }],
+ generationConfig: {
+ temperature: options.temperature ?? 0.4,
+ maxOutputTokens: options.maxTokens ?? 1500
+ }
+ };
+
+ const response = await HttpService.post(url, body, { timeoutMs: 15000 });
+
+ if (Array.isArray(response?.candidates) && response.candidates.length > 0) {
+ return response.candidates[0]?.content?.parts?.[0]?.text?.trim() || "";
+ }
+
+ throw new Error("No response generated from Gemini.");
+ }
+
+ async translateRaw(options: TranslationOptions): Promise {
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/${DEFAULT_MODEL}:generateContent?key=${this.apiKey}`;
+ const prompt = PromptBuilder.buildTranslationPrompt(options);
+
+ const body = {
+ contents: [{ parts: [{ text: prompt }] }],
+ generationConfig: {
+ temperature: 0.1,
+ maxOutputTokens: 1500
+ }
+ };
+
+ const response = await HttpService.post(url, body, { timeoutMs: 15000 });
+
+ if (Array.isArray(response?.candidates) && response.candidates.length > 0) {
+ return response.candidates[0]?.content?.parts?.[0]?.text?.trim() || "";
+ }
+
+ throw new Error("No response generated from Gemini.");
+ }
+
+ async translateDictionary(options: TranslationOptions): Promise {
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/${DEFAULT_MODEL}:generateContent?key=${this.apiKey}`;
+ const prompt = PromptBuilder.buildDictionaryPrompt(options);
+
+ const body = {
+ contents: [{ parts: [{ text: prompt }] }],
+ generationConfig: {
+ temperature: 0.1,
+ maxOutputTokens: 1500
+ }
+ };
+
+ const response = await HttpService.post(url, body, { timeoutMs: 15000 });
+
+ if (Array.isArray(response?.candidates) && response.candidates.length > 0) {
+ const resultText = response.candidates[0]?.content?.parts?.[0]?.text || "";
+
+ try {
+ const cleaned = resultText.replace(/```json/g, '').replace(/```/g, '').trim();
+ return JSON.parse(cleaned) as DictionaryResult;
+ } catch (e) {
+ // Fallback to returning raw text in the meaning field if parsing fails
+ return {
+ meaning: resultText,
+ synonyms: [],
+ examples: { source: "", target: "" }
+ } as DictionaryResult;
+ }
+ }
+
+ throw new Error("No response generated from Gemini.");
+ }
+}
diff --git a/src/services/llm.factory.ts b/src/services/llm.factory.ts
new file mode 100644
index 0000000..1c86794
--- /dev/null
+++ b/src/services/llm.factory.ts
@@ -0,0 +1,18 @@
+import { LLMProvider } from '../shared/types';
+import { GeminiProvider } from './gemini.provider';
+
+export type AIProviderName = 'gemini' | 'openai' | 'claude';
+
+export class LLMFactory {
+ static createProvider(providerName: AIProviderName | string, apiKey: string): LLMProvider {
+ switch (providerName) {
+ case 'gemini':
+ return new GeminiProvider(apiKey);
+ case 'openai':
+ case 'claude':
+ throw new Error(`Provider ${providerName} is not yet implemented.`);
+ default:
+ throw new Error(`Unknown provider ${providerName}.`);
+ }
+ }
+}
diff --git a/src/services/prompts/templates.ts b/src/services/prompts/templates.ts
new file mode 100644
index 0000000..83f7df7
--- /dev/null
+++ b/src/services/prompts/templates.ts
@@ -0,0 +1,131 @@
+import { RefinementMode, TranslationOptions } from "../../shared/types";
+import { languages } from "../../utils/languages";
+
+export class PromptBuilder {
+ private static getLangName(code: string): string {
+ const lang = languages.find(l => l.code === code);
+ return lang ? lang.name : code;
+ }
+
+ private static buildGlossaryInstruction(glossary?: Record): string {
+ if (!glossary || Object.keys(glossary).length === 0) return "";
+ const rules = Object.entries(glossary)
+ .map(([source, target]) => `"${source}" -> "${target}"`)
+ .join("\n");
+ return `\nIMPORTANT GLOSSARY RULES - You MUST translate the following terms exactly as specified:\n${rules}\n`;
+ }
+
+ /**
+ * Builds the prompt for AI writing refinement.
+ */
+ static buildRefinementPrompt(text: string, mode: RefinementMode | string, targetLang?: string): string {
+ const languageInstruction = targetLang ? ` Translate the refined text to ${this.getLangName(targetLang)}.` : ` Maintain the original language of the text.`;
+
+ let modeInstruction = "";
+ switch (mode) {
+ case RefinementMode.FIX_GRAMMAR:
+ case 'grammar':
+ modeInstruction = "Fix any grammatical, spelling, or punctuation errors without changing the original meaning.";
+ break;
+ case RefinementMode.PROFESSIONAL:
+ case 'professional':
+ modeInstruction = "Rewrite the text to sound highly professional, suitable for a corporate or business environment.";
+ break;
+ case RefinementMode.CASUAL:
+ case 'casual':
+ modeInstruction = "Rewrite the text to sound relaxed and conversational.";
+ break;
+ case RefinementMode.FRIENDLY:
+ case 'friendly':
+ modeInstruction = "Rewrite the text to sound warm, polite, and approachable.";
+ break;
+ case RefinementMode.FORMAL:
+ case 'formal':
+ modeInstruction = "Rewrite the text to sound strict, objective, and formal.";
+ break;
+ case RefinementMode.SHORTER:
+ case 'shorter':
+ modeInstruction = "Summarize and make the text significantly shorter and more concise.";
+ break;
+ case RefinementMode.LONGER:
+ case 'longer':
+ modeInstruction = "Elaborate on the ideas and make the text longer and more detailed.";
+ break;
+ case RefinementMode.SIMPLIFY:
+ case 'simplify':
+ modeInstruction = "Rewrite the text so it is extremely easy to understand, at a 5th-grade reading level.";
+ break;
+ case RefinementMode.EXPLAIN:
+ case 'explain':
+ modeInstruction = "Provide a clear explanation of what the text means. You may use markdown for this explanation.";
+ break;
+ case 'improve':
+ modeInstruction = "Improve the overall writing quality, flow, and readability.";
+ break;
+ default:
+ modeInstruction = `Rewrite the text using the '${mode}' style.`;
+ }
+
+ return `You are an expert editor and writer. Your task is to process the provided text.
+Instruction: ${modeInstruction}
+${languageInstruction}
+
+Rules:
+1. Output ONLY the finalized text.
+2. Do NOT include markdown formatting unless explicitly told to explain.
+3. Do NOT include conversational filler like "Here is the text" or "Sure, I can help".
+4. Do NOT wrap the output in quotation marks.
+
+
+${text}
+ `;
+ }
+
+ /**
+ * Builds the prompt for standard raw translation.
+ */
+ static buildTranslationPrompt(options: TranslationOptions): string {
+ const sourceName = options.sourceLang !== 'Detect language' ? this.getLangName(options.sourceLang) : 'the detected language';
+ const targetName = this.getLangName(options.targetLang);
+ const glossaryRule = this.buildGlossaryInstruction(options.glossary);
+
+ return `Translate the following text from ${sourceName} to ${targetName}.
+${glossaryRule}
+
+Rules:
+1. Output ONLY the raw translated text.
+2. Do NOT include markdown formatting.
+3. Do NOT include conversational filler.
+4. Do NOT wrap the output in quotation marks.
+
+
+${options.text}
+ `;
+ }
+
+ /**
+ * Builds the prompt for dictionary translation.
+ */
+ static buildDictionaryPrompt(options: TranslationOptions): string {
+ const sourceName = options.sourceLang !== 'Detect language' ? this.getLangName(options.sourceLang) : 'the detected language';
+ const targetName = this.getLangName(options.targetLang);
+ const glossaryRule = this.buildGlossaryInstruction(options.glossary);
+
+ return `Act as an expert bilingual dictionary. Analyze the provided text translating from ${sourceName} to ${targetName}.
+${glossaryRule}
+
+Provide the output strictly as a valid JSON object matching this schema exactly, with no markdown code blocks (\`\`\`), no prefix, and no suffix:
+{
+ "meaning": "The direct translation and brief explanation of the selected word/phrase",
+ "synonyms": ["Synonym 1", "Synonym 2"],
+ "examples": {
+ "source": "An example sentence using the text in ${sourceName}",
+ "target": "The translation of the example sentence in ${targetName}"
+ }
+}
+
+
+${options.text}
+ `;
+ }
+}