Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions src/components/ApiSettings.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="p-6 bg-white w-full">
<h2 className="text-2xl font-normal mb-6">API & Security</h2>

<div className="space-y-6 max-w-2xl">
<div className="bg-blue-50 border border-blue-200 text-blue-800 rounded-md p-4 mb-6 text-sm">
<p className="font-semibold mb-1">Bring Your Own Key (BYOK)</p>
<p>
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.
</p>
</div>

<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Google Gemini API Key
</label>
<input
type="password"
className="w-full p-2 border border-gray-300 rounded-md outline-0 focus:border-pink-500 focus:ring-1 focus:ring-pink-500"
placeholder="AIzaSy..."
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
<p className="text-xs text-gray-500 mt-2">
You can get a free API key from Google AI Studio.
</p>
</div>

<div className="flex items-center gap-4 pt-4">
<button
onClick={handleSave}
className="bg-pink-600 hover:bg-pink-700 text-white px-6 py-2 rounded-md font-medium text-sm transition"
>
Save Key
</button>
{isSaved && (
<span className="text-green-600 text-sm font-medium flex items-center">
<svg className="w-4 h-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Saved successfully
</span>
)}
</div>
</div>
</div>
);
};

export default ApiSettings;
16 changes: 16 additions & 0 deletions src/components/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand All @@ -20,6 +21,8 @@ const SettingsPage = () => {
switch (activeTab) {
case "#general":
return <GeneralSettings />;
case "#api":
return <ApiSettings />;
case "#integrations":
return <Integrations />;
case "#glossary":
Expand Down Expand Up @@ -54,6 +57,19 @@ const SettingsPage = () => {
>
General Settings
</button>
<button
onClick={() => {
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
</button>
<button
onClick={() => {
setActiveTab("#integrations");
Expand Down
98 changes: 98 additions & 0 deletions src/services/api/HttpService.ts
Original file line number Diff line number Diff line change
@@ -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<T>(url: string, body: any, options: HttpRequestOptions = {}): Promise<T> {
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<T>(url: string, options: HttpRequestOptions = {}): Promise<T> {
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);
}
}
}
77 changes: 77 additions & 0 deletions src/services/cache/CacheService.ts
Original file line number Diff line number Diff line change
@@ -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<Map<string, string>> {
return new Promise((resolve) => {
chrome.storage.session.get('lexiflow_cache', (result) => {
if (result.lexiflow_cache && typeof result.lexiflow_cache === 'string') {
try {
resolve(new Map<string, string>(JSON.parse(result.lexiflow_cache)));
} catch (e) {
resolve(new Map<string, string>());
}
} else {
resolve(new Map<string, string>());
}
});
});
}

private static async saveCacheData(cache: Map<string, string>): Promise<void> {
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<string | null> {
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<void> {
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<void> {
return new Promise((resolve) => {
chrome.storage.session.remove('lexiflow_cache', () => resolve());
});
}
}
97 changes: 97 additions & 0 deletions src/services/gemini.provider.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<any>(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<string> {
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<any>(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<DictionaryResult> {
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<any>(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.");
}
}
Loading