diff --git a/apps/extension/biome.json b/apps/extension/biome.json new file mode 100644 index 0000000..096a95f --- /dev/null +++ b/apps/extension/biome.json @@ -0,0 +1,4 @@ +{ + "root": false, + "extends": "//" +} diff --git a/apps/extension/package.json b/apps/extension/package.json index ad4ee83..5acb9bd 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -19,7 +19,8 @@ "preact": "^10.28.4", "radix-ui": "^1.4.3", "react": "19.2.3", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "sonner": "^2.0.7" }, "devDependencies": { "@crxjs/vite-plugin": "^2.0.0-beta.25", diff --git a/apps/extension/public/manifest.json b/apps/extension/public/manifest.json index d8574ff..96c0da5 100644 --- a/apps/extension/public/manifest.json +++ b/apps/extension/public/manifest.json @@ -39,8 +39,12 @@ ], "permissions": [ "storage", - "identity" + "identity", + "sidePanel" ], + "side_panel": { + "default_path": "src/sidepanel/index.html" + }, "optional_host_permissions": [ "https://*/*", "http://*/*" diff --git a/apps/extension/src/background/firebase-user.ts b/apps/extension/src/background/firebase-user.ts new file mode 100644 index 0000000..128ddc3 --- /dev/null +++ b/apps/extension/src/background/firebase-user.ts @@ -0,0 +1,29 @@ +import { AuthManager } from "./auth"; +import { + getFirebaseAuth, + isFirebaseConfigured, + signInToFirebase, +} from "./firebase"; + +export async function getFirebaseUserId() { + if (!isFirebaseConfigured()) { + throw new Error( + "Firebase is not configured in this build. Add the VITE_FIREBASE_* environment variables before enabling Cloud Sync.", + ); + } + + const firebaseAuth = getFirebaseAuth(); + const cachedUser = await AuthManager.getCachedUser(); + + if ( + firebaseAuth.currentUser && + cachedUser?.email && + firebaseAuth.currentUser.email === cachedUser.email + ) { + return firebaseAuth.currentUser.uid; + } + + const token = await AuthManager.getAuthToken(false); + const credential = await signInToFirebase(token); + return credential.user.uid; +} diff --git a/apps/extension/src/background/prompt-gallery.ts b/apps/extension/src/background/prompt-gallery.ts new file mode 100644 index 0000000..46139e1 --- /dev/null +++ b/apps/extension/src/background/prompt-gallery.ts @@ -0,0 +1,576 @@ +import type { PublicPrompt, SavedPrompt } from "@plenz/types"; +import { + collection, + deleteDoc, + doc, + getDoc, + getDocs, + limit, + orderBy, + query, + serverTimestamp, + setDoc, +} from "firebase/firestore/lite"; +import { AuthManager } from "./auth"; +import { getFirestoreDb, isFirebaseConfigured } from "./firebase"; +import { getFirebaseUserId } from "./firebase-user"; + +const PROMPT_CATALOG_COLLECTION = "prompt_catalog"; +const SAVED_PROMPTS_COLLECTION = "saved_prompts"; +// const PUBLIC_PROMPT_LIMIT = 24; +const firebaseProjectId = readEnvVar("VITE_FIREBASE_PROJECT_ID"); +const firebaseApiKey = readEnvVar("VITE_FIREBASE_API_KEY"); + +type PublicPromptSort = "trending" | "newest"; + +interface PromptCatalogDocument { + title?: string; + prompt?: string; + slug?: string; + category?: unknown; + trendScore?: number; + createdAt?: unknown; + updatedAt?: unknown; + shareEnabled?: boolean; +} + +interface SavedPromptDocument { + title?: string; + prompt?: string; + category?: unknown; + sourceType?: "catalog" | "custom"; + catalogPromptId?: string | null; + catalogSlug?: string | null; + savedAt?: unknown; + createdAt?: unknown; + updatedAt?: unknown; +} + +interface FirestoreFieldValue { + stringValue?: string; + integerValue?: string; + doubleValue?: number; + booleanValue?: boolean; + timestampValue?: string; + arrayValue?: { + values?: FirestoreFieldValue[]; + }; +} + +interface FirestoreDocument { + name: string; + fields?: Record; +} + +function assertFirebaseConfigured() { + if (!isFirebaseConfigured()) { + throw new Error( + "Firebase is not configured in this build. Add the VITE_FIREBASE_* environment variables before using the prompt gallery.", + ); + } +} + +function readEnvVar(key: keyof ImportMetaEnv) { + const value = import.meta.env[key]; + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : ""; +} + +function normalizeText(value: string, field: string) { + const nextValue = value.trim(); + + if (!nextValue) { + throw new Error(`${field} is required.`); + } + + return nextValue; +} + +function toIsoString(value: unknown): string | null { + if (!value) { + return null; + } + + if ( + typeof value === "object" && + "toDate" in value && + typeof (value as { toDate: () => Date }).toDate === "function" + ) { + return (value as { toDate: () => Date }).toDate().toISOString(); + } + + if (value instanceof Date) { + return value.toISOString(); + } + + return typeof value === "string" ? value : null; +} + +function toCategoryList(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + + return value + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +} + +function toPublicPrompt( + id: string, + data: PromptCatalogDocument, +): PublicPrompt | null { + if (typeof data.title !== "string" || typeof data.prompt !== "string") { + return null; + } + + return { + id, + title: data.title, + prompt: data.prompt, + slug: + typeof data.slug === "string" && data.slug.trim().length > 0 + ? data.slug.trim() + : id, + category: toCategoryList(data.category), + trendScore: typeof data.trendScore === "number" ? data.trendScore : null, + createdAt: toIsoString(data.createdAt), + updatedAt: toIsoString(data.updatedAt), + canShare: data.shareEnabled !== false, + }; +} + +function toSavedPrompt( + id: string, + data: SavedPromptDocument, +): SavedPrompt | null { + if (typeof data.title !== "string" || typeof data.prompt !== "string") { + return null; + } + + const catalogSlug = + typeof data.catalogSlug === "string" && data.catalogSlug.trim().length > 0 + ? data.catalogSlug.trim() + : null; + + return { + id, + title: data.title, + prompt: data.prompt, + category: toCategoryList(data.category), + sourceType: data.sourceType === "catalog" ? "catalog" : "custom", + catalogPromptId: + typeof data.catalogPromptId === "string" && + data.catalogPromptId.trim().length > 0 + ? data.catalogPromptId.trim() + : null, + catalogSlug, + savedAt: toIsoString(data.savedAt), + createdAt: toIsoString(data.createdAt), + updatedAt: toIsoString(data.updatedAt), + canShare: !!catalogSlug, + }; +} + +async function requireSignedInUser(actionLabel: string) { + const user = await AuthManager.getAuthStatus(); + + if (!user) { + throw new Error(`Sign in with Google to ${actionLabel}.`); + } + + return getFirebaseUserId(); +} + +function getSavedPromptRef(userId: string, savedPromptId: string) { + return doc( + getFirestoreDb(), + "users", + userId, + SAVED_PROMPTS_COLLECTION, + savedPromptId, + ); +} + +function getSavedPromptsCollection(userId: string) { + return collection( + getFirestoreDb(), + "users", + userId, + SAVED_PROMPTS_COLLECTION, + ); +} + +function buildFirestoreRestUrl(path: string) { + if (!firebaseProjectId) { + return ""; + } + + const baseUrl = `https://firestore.googleapis.com/v1/projects/${firebaseProjectId}/databases/(default)/documents`; + const keyQuery = firebaseApiKey ? `?key=${firebaseApiKey}` : ""; + return `${baseUrl}/${path}${keyQuery}`; +} + +function getFirestoreDocumentId(documentName: string) { + return documentName.split("/").at(-1) ?? ""; +} + +function getFirestoreStringField( + document: FirestoreDocument, + fieldName: string, +): string | undefined { + return document.fields?.[fieldName]?.stringValue; +} + +function getFirestoreNumberField( + document: FirestoreDocument, + fieldName: string, +): number | undefined { + const fieldValue = document.fields?.[fieldName]; + + if (typeof fieldValue?.doubleValue === "number") { + return fieldValue.doubleValue; + } + + if (typeof fieldValue?.integerValue === "string") { + const numericValue = Number(fieldValue.integerValue); + return Number.isFinite(numericValue) ? numericValue : undefined; + } + + return undefined; +} + +function getFirestoreBooleanField( + document: FirestoreDocument, + fieldName: string, +): boolean | undefined { + const fieldValue = document.fields?.[fieldName]?.booleanValue; + return typeof fieldValue === "boolean" ? fieldValue : undefined; +} + +function getFirestoreTimestampField( + document: FirestoreDocument, + fieldName: string, +): string | undefined { + return document.fields?.[fieldName]?.timestampValue; +} + +function getFirestoreStringArrayField( + document: FirestoreDocument, + fieldName: string, +): string[] { + const values = document.fields?.[fieldName]?.arrayValue?.values; + + if (!Array.isArray(values)) { + return []; + } + + return values + .map((value) => value.stringValue?.trim() ?? "") + .filter((value) => value.length > 0); +} + +function toPublicPromptFromFirestoreDocument(document: FirestoreDocument) { + return toPublicPrompt(getFirestoreDocumentId(document.name), { + title: getFirestoreStringField(document, "title"), + prompt: getFirestoreStringField(document, "prompt"), + slug: getFirestoreStringField(document, "slug"), + category: getFirestoreStringArrayField(document, "category"), + trendScore: getFirestoreNumberField(document, "trendScore"), + createdAt: getFirestoreTimestampField(document, "createdAt"), + updatedAt: getFirestoreTimestampField(document, "updatedAt"), + shareEnabled: getFirestoreBooleanField(document, "shareEnabled"), + }); +} + +function isPermissionError(error: unknown) { + return ( + error instanceof Error && + /insufficient permissions|permission[- ]denied/i.test(error.message) + ); +} + +async function listPublicPromptsViaRest( + category: PublicPromptSort, +): Promise { + const queryUrl = buildFirestoreRestUrl(":runQuery"); + + if (!queryUrl) { + throw new Error("Prompt catalog project is not configured."); + } + + const sortField = category === "trending" ? "trendScore" : "createdAt"; + const response = await fetch(queryUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + structuredQuery: { + from: [{ collectionId: PROMPT_CATALOG_COLLECTION }], + orderBy: [ + { + field: { fieldPath: sortField }, + direction: "DESCENDING", + }, + ], + // limit: PUBLIC_PROMPT_LIMIT, + }, + }), + }); + + if (!response.ok) { + let errorMessage = `Failed to fetch public prompts (${response.status}).`; + + try { + const errorPayload = (await response.json()) as { + error?: { message?: string }; + }; + if (typeof errorPayload.error?.message === "string") { + errorMessage = errorPayload.error.message; + } + } catch { + // Keep the HTTP status fallback when Firestore does not return JSON. + } + + throw new Error(errorMessage); + } + + const queryResults = (await response.json()) as Array<{ + document?: FirestoreDocument; + }>; + + return queryResults + .map((result) => + result.document + ? toPublicPromptFromFirestoreDocument(result.document) + : null, + ) + .filter((prompt): prompt is PublicPrompt => !!prompt); +} + +async function listPublicPromptsViaFirestore( + category: PublicPromptSort, +): Promise { + const sortField = category === "trending" ? "trendScore" : "createdAt"; + const promptQuery = query( + collection(getFirestoreDb(), PROMPT_CATALOG_COLLECTION), + orderBy(sortField, "desc"), + // limit(PUBLIC_PROMPT_LIMIT), + ); + const snapshot = await getDocs(promptQuery); + const prompts: PublicPrompt[] = []; + + snapshot.forEach((promptDoc) => { + const prompt = toPublicPrompt( + promptDoc.id, + promptDoc.data() as PromptCatalogDocument, + ); + + if (prompt) { + prompts.push(prompt); + } + }); + + return prompts; +} + +export const PromptGalleryManager = { + async listPublicPrompts(category: PublicPromptSort): Promise { + assertFirebaseConfigured(); + + try { + return await listPublicPromptsViaRest(category); + } catch (restError) { + console.warn( + "Prompt gallery: public catalog REST fetch failed, falling back to Firestore Lite.", + restError, + ); + } + + try { + return await listPublicPromptsViaFirestore(category); + } catch (firestoreError) { + if (isPermissionError(firestoreError)) { + throw new Error( + "Public prompts could not be loaded. Confirm the deployed Firestore rules allow reads on `prompt_catalog`.", + ); + } + + throw firestoreError; + } + }, + + async listSavedPrompts(): Promise { + assertFirebaseConfigured(); + + const userId = await requireSignedInUser("view saved prompts"); + const savedQuery = query( + getSavedPromptsCollection(userId), + orderBy("savedAt", "desc"), + ); + const snapshot = await getDocs(savedQuery); + const prompts: SavedPrompt[] = []; + + snapshot.forEach((promptDoc) => { + const prompt = toSavedPrompt( + promptDoc.id, + promptDoc.data() as SavedPromptDocument, + ); + + if (prompt) { + prompts.push(prompt); + } + }); + + return prompts; + }, + + async createCustomPrompt(input: { + title: string; + prompt: string; + }): Promise { + assertFirebaseConfigured(); + + const userId = await requireSignedInUser("save prompts"); + const title = normalizeText(input.title, "Title"); + const promptText = normalizeText(input.prompt, "Prompt"); + const savedPromptsCollection = getSavedPromptsCollection(userId); + const promptDoc = doc(savedPromptsCollection); + + await setDoc(promptDoc, { + title, + prompt: promptText, + category: [], + sourceType: "custom", + catalogPromptId: null, + catalogSlug: null, + savedAt: serverTimestamp(), + createdAt: serverTimestamp(), + updatedAt: serverTimestamp(), + }); + + const snapshot = await getDoc(promptDoc); + const savedPrompt = toSavedPrompt( + snapshot.id, + snapshot.data() as SavedPromptDocument, + ); + + if (!savedPrompt) { + throw new Error("Saved prompt could not be loaded."); + } + + return savedPrompt; + }, + + async updateSavedPrompt(input: { + id: string; + title: string; + prompt: string; + }): Promise { + assertFirebaseConfigured(); + + const userId = await requireSignedInUser("edit saved prompts"); + const savedPromptId = normalizeText(input.id, "Prompt id"); + const title = normalizeText(input.title, "Title"); + const promptText = normalizeText(input.prompt, "Prompt"); + const savedPromptRef = getSavedPromptRef(userId, savedPromptId); + const existingSnapshot = await getDoc(savedPromptRef); + + if (!existingSnapshot.exists()) { + throw new Error("Saved prompt could not be found."); + } + + const existingPrompt = toSavedPrompt( + existingSnapshot.id, + existingSnapshot.data() as SavedPromptDocument, + ); + + if (!existingPrompt) { + throw new Error("Saved prompt could not be loaded."); + } + + if (existingPrompt.sourceType !== "custom") { + throw new Error("Only private prompts can be edited right now."); + } + + await setDoc( + savedPromptRef, + { + title, + prompt: promptText, + updatedAt: serverTimestamp(), + }, + { merge: true }, + ); + + const snapshot = await getDoc(savedPromptRef); + const savedPrompt = toSavedPrompt( + snapshot.id, + snapshot.data() as SavedPromptDocument, + ); + + if (!savedPrompt) { + throw new Error("Saved prompt could not be loaded."); + } + + return savedPrompt; + }, + + async savePublicPrompt(prompt: PublicPrompt): Promise { + assertFirebaseConfigured(); + + const userId = await requireSignedInUser("save prompts"); + const title = normalizeText(prompt.title, "Title"); + const promptText = normalizeText(prompt.prompt, "Prompt"); + const savedPromptId = `catalog_${prompt.id}`; + const savedPromptRef = getSavedPromptRef(userId, savedPromptId); + const existingSnapshot = await getDoc(savedPromptRef); + const timestampFields = existingSnapshot.exists() + ? { + savedAt: serverTimestamp(), + updatedAt: serverTimestamp(), + } + : { + savedAt: serverTimestamp(), + createdAt: serverTimestamp(), + updatedAt: serverTimestamp(), + }; + + await setDoc( + savedPromptRef, + { + title, + prompt: promptText, + category: prompt.category, + sourceType: "catalog", + catalogPromptId: prompt.id, + catalogSlug: normalizeText(prompt.slug, "Share link"), + ...timestampFields, + }, + { merge: true }, + ); + + const snapshot = await getDoc(savedPromptRef); + const savedPrompt = toSavedPrompt( + snapshot.id, + snapshot.data() as SavedPromptDocument, + ); + + if (!savedPrompt) { + throw new Error("Saved prompt could not be loaded."); + } + + return savedPrompt; + }, + + async deleteSavedPrompt(savedPromptId: string): Promise { + assertFirebaseConfigured(); + + const userId = await requireSignedInUser("remove saved prompts"); + const nextSavedPromptId = normalizeText(savedPromptId, "Prompt id"); + await deleteDoc(getSavedPromptRef(userId, nextSavedPromptId)); + }, +}; diff --git a/apps/extension/src/background/prompt-orchestrator.ts b/apps/extension/src/background/prompt-orchestrator.ts new file mode 100644 index 0000000..ab272cd --- /dev/null +++ b/apps/extension/src/background/prompt-orchestrator.ts @@ -0,0 +1,221 @@ +import { providers } from "@plenz/providers"; +import { + buildSystemPrompt, + EntityExtractor, + IntentDetector, + SuggestionValidationError, + validateSuggestions, +} from "@plenz/core"; +import type { + AnalysisResult, + ProviderAdapter, + ProviderAnalyzeContext, + ProviderConfig, +} from "@plenz/types"; +import { StorageManager } from "./storage"; + +const ANALYSIS_TIMEOUT_MS = 12_000; + +type AttemptInput = { + provider: ProviderAdapter; + config: ProviderConfig; + prompt: string; + systemPrompt: string; + context?: ProviderAnalyzeContext; +}; + +type OrchestratedAnalysisResult = Pick & { + providerId: string; +}; + +function getErrorStatus(error: unknown) { + return typeof error === "object" && + error !== null && + "status" in error && + typeof (error as { status?: unknown }).status === "number" + ? (error as { status: number }).status + : undefined; +} + +function getErrorMessage(error: unknown) { + if (error instanceof Error) return error.message; + return "Unknown analysis error"; +} + +function isAbortError(error: unknown) { + return error instanceof DOMException && error.name === "AbortError"; +} + +function isRetryableError(error: unknown) { + if (error instanceof SuggestionValidationError) return false; + if (isAbortError(error)) return true; + + const status = getErrorStatus(error); + if (status === 429 || (typeof status === "number" && status >= 500)) { + return true; + } + + const message = getErrorMessage(error).toLowerCase(); + return ( + message.includes("failed to fetch") || + message.includes("network") || + message.includes("timeout") || + message.includes("timed out") + ); +} + +function getContextText(context?: ProviderAnalyzeContext) { + const conversation = context?.conversation; + if (!conversation) return ""; + + return [ + conversation.rollingSummary, + ...conversation.recentMessages.map((message) => message.text), + ] + .filter(Boolean) + .join("\n"); +} + +async function analyzeWithTimeout(input: AttemptInput): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), ANALYSIS_TIMEOUT_MS); + + try { + const result = await input.provider.analyze( + input.prompt, + input.systemPrompt, + input.config, + input.context, + { signal: controller.signal }, + ); + const suggestions = validateSuggestions(result.suggestions, 5, input.prompt); + return { + ...result, + suggestions, + }; + } finally { + clearTimeout(timeoutId); + } +} + +function logAttemptFailure(providerId: string, error: unknown) { + console.warn("plenz: provider analysis attempt failed", { + providerId, + status: getErrorStatus(error), + message: getErrorMessage(error), + }); +} + +async function getFallbackAttempts(activeProviderId: string | null) { + const configsResult = await StorageManager.getAllModelConfigs(); + const configs = configsResult.data ?? {}; + const availableProviders: ProviderAdapter[] = providers; + + return availableProviders + .filter((provider) => provider.id !== activeProviderId) + .map((provider) => ({ + provider, + config: configs[provider.id], + })) + .filter( + (attempt): attempt is { provider: ProviderAdapter; config: ProviderConfig } => + !!attempt.config?.apiKey && !!attempt.config?.model, + ); +} + +export async function analyzePromptWithOrchestration( + prompt: string, + context?: ProviderAnalyzeContext, +): Promise { + const prefs = await StorageManager.getPreferences(); + + const activeConfigResult = await StorageManager.getActiveModelConfig(); + const activeConfig = activeConfigResult.data; + + if (!activeConfig?.apiKey || !activeConfig.model) { + return { + error: + "LLM Provider not configured. Please set an API key and model in the extension options.", + }; + } + + const activeProvider = providers.find((provider) => provider.id === prefs.activeProviderId); + if (!activeProvider) { + return { + error: "Active LLM Provider not found. Please review your settings.", + }; + } + + const analysisText = [prompt, getContextText(context)].filter(Boolean).join("\n\n"); + const intentDetector = new IntentDetector(); + const entityExtractor = new EntityExtractor(); + const intentMatch = intentDetector.detect(analysisText); + const entities = entityExtractor.extract(analysisText, context); + const systemPrompt = buildSystemPrompt(intentMatch, entities, context?.conversation); + const failedErrors: unknown[] = []; + + try { + const activeResult = await analyzeWithTimeout({ + provider: activeProvider, + config: activeConfig, + prompt, + systemPrompt, + context, + }); + + return { + suggestions: activeResult.suggestions, + latencyMs: activeResult.latencyMs, + providerId: activeProvider.id, + }; + } catch (error) { + failedErrors.push(error); + logAttemptFailure(activeProvider.id, error); + + if (isRetryableError(error)) { + try { + const retryResult = await analyzeWithTimeout({ + provider: activeProvider, + config: activeConfig, + prompt, + systemPrompt, + context, + }); + + return { + suggestions: retryResult.suggestions, + latencyMs: retryResult.latencyMs, + providerId: activeProvider.id, + }; + } catch (retryError) { + failedErrors.push(retryError); + logAttemptFailure(activeProvider.id, retryError); + } + } + } + + const fallbackAttempts = await getFallbackAttempts(prefs.activeProviderId); + for (const attempt of fallbackAttempts) { + try { + const fallbackResult = await analyzeWithTimeout({ + provider: attempt.provider, + config: attempt.config, + prompt, + systemPrompt, + context, + }); + + return { + suggestions: fallbackResult.suggestions, + latencyMs: fallbackResult.latencyMs, + providerId: attempt.provider.id, + }; + } catch (error) { + failedErrors.push(error); + logAttemptFailure(attempt.provider.id, error); + } + } + + const lastError = failedErrors[failedErrors.length - 1]; + return { error: getErrorMessage(lastError) || "Failed to analyze prompt." }; +} diff --git a/apps/extension/src/background/router.ts b/apps/extension/src/background/router.ts index 04c636e..652287b 100644 --- a/apps/extension/src/background/router.ts +++ b/apps/extension/src/background/router.ts @@ -1,11 +1,8 @@ import { StorageManager } from "./storage"; import { providers } from "@plenz/providers"; -import { - buildSystemPrompt, - IntentDetector, - EntityExtractor, -} from "@plenz/core"; import { AuthManager } from "./auth"; +import { PromptGalleryManager } from "./prompt-gallery"; +import { analyzePromptWithOrchestration } from "./prompt-orchestrator"; export async function handleMessage( message: any, @@ -34,6 +31,13 @@ export async function handleMessage( case "GET_STORAGE_SETTINGS": return await StorageManager.getStorageSettings(); + case "GET_PREFERENCES": + return await StorageManager.getPreferences(); + + case "SET_PREFERENCES": + await StorageManager.setPreferences(message.payload?.preferences ?? {}); + return { success: true, preferences: await StorageManager.getPreferences() }; + case "SET_STORAGE_BACKEND": { const nextBackend = message.payload?.backend; @@ -79,43 +83,8 @@ export async function handleMessage( case "ANALYZE_PROMPT": { const { prompt, context } = message.payload; - const intentDetector = new IntentDetector(); - const entityExtractor = new EntityExtractor(); - - const intentMatch = intentDetector.detect(prompt); - const entities = entityExtractor.extract(prompt, context); - const systemPrompt = buildSystemPrompt(intentMatch, entities); - try { - const configResult = await StorageManager.getActiveModelConfig(); - const config = configResult.data; - const prefs = await StorageManager.getPreferences(); - - if (!config || !config.apiKey) { - return { - error: - "LLM Provider not configured. Please set an API key in the extension options.", - }; - } - - const provider = providers.find((p) => p.id === prefs.activeProviderId); - if (!provider) { - return { - error: - "Active LLM Provider not found. Please review your settings.", - }; - } - - const remoteResult = await provider.analyze( - prompt, - systemPrompt, - config, - context, - ); - return { - suggestions: remoteResult.suggestions.slice(0, 5), - latencyMs: remoteResult.latencyMs, - }; + return await analyzePromptWithOrchestration(prompt, context); } catch (e: any) { console.error("Remote analysis failed:", e); return { error: e.message || "Failed to analyze prompt." }; @@ -131,6 +100,55 @@ export async function handleMessage( case "GET_AUTH_STATUS": return await AuthManager.getAuthStatus(); + case "OPEN_PROMPT_GALLERY_PANEL": { + const windowId = message.payload?.windowId; + + if (typeof windowId !== "number") { + return { success: false, error: "Current window is unavailable." }; + } + + await chrome.sidePanel.open({ windowId }); + return { success: true }; + } + + case "LIST_PUBLIC_PROMPTS": + return { + prompts: await PromptGalleryManager.listPublicPrompts( + message.payload?.category === "newest" ? "newest" : "trending", + ), + }; + + case "LIST_SAVED_PROMPTS": + return { + prompts: await PromptGalleryManager.listSavedPrompts(), + }; + + case "CREATE_SAVED_PROMPT": + return { + prompt: await PromptGalleryManager.createCustomPrompt({ + title: message.payload?.title ?? "", + prompt: message.payload?.prompt ?? "", + }), + }; + + case "UPDATE_SAVED_PROMPT": + return { + prompt: await PromptGalleryManager.updateSavedPrompt({ + id: message.payload?.id ?? "", + title: message.payload?.title ?? "", + prompt: message.payload?.prompt ?? "", + }), + }; + + case "SAVE_PUBLIC_PROMPT": + return { + prompt: await PromptGalleryManager.savePublicPrompt(message.payload?.prompt), + }; + + case "DELETE_SAVED_PROMPT": + await PromptGalleryManager.deleteSavedPrompt(message.payload?.id ?? ""); + return { success: true }; + default: console.warn("Unknown message type:", message.type); return null; diff --git a/apps/extension/src/background/storage-backend.ts b/apps/extension/src/background/storage-backend.ts index 4cac0bf..d57b9f2 100644 --- a/apps/extension/src/background/storage-backend.ts +++ b/apps/extension/src/background/storage-backend.ts @@ -18,11 +18,10 @@ import { type FirestoreProviderConfig, } from "./encryption"; import { - getFirebaseAuth, getFirestoreDb, isFirebaseConfigured, - signInToFirebase, } from "./firebase"; +import { getFirebaseUserId } from "./firebase-user"; export const MODEL_CONFIG_KEY_PREFIX = "model_config_"; export type StorageBackendPreference = "chrome-sync" | "firebase"; @@ -252,34 +251,12 @@ export class ChromeSyncBackend implements StorageBackend { export class FirebaseBackend implements StorageBackend { private readonly syncFallback = new ChromeSyncBackend(); - private async getFirebaseUserId() { - if (!isFirebaseConfigured()) { - throw new Error( - "Firebase is not configured in this build. Add the VITE_FIREBASE_* environment variables before enabling Cloud Sync.", - ); - } - - const firebaseAuth = getFirebaseAuth(); - const cachedUser = await AuthManager.getCachedUser(); - if ( - firebaseAuth.currentUser && - cachedUser?.email && - firebaseAuth.currentUser.email === cachedUser.email - ) { - return firebaseAuth.currentUser.uid; - } - - const token = await AuthManager.getAuthToken(false); - const credential = await signInToFirebase(token); - return credential.user.uid; - } - private async withFirebaseFallback( operation: (userId: string) => Promise>, fallback: () => Promise>, ): Promise> { try { - const userId = await this.getFirebaseUserId(); + const userId = await getFirebaseUserId(); return await operation(userId); } catch (error) { return appendFallbackMessage(await fallback(), error); diff --git a/apps/extension/src/background/storage.ts b/apps/extension/src/background/storage.ts index d131a46..e419e46 100644 --- a/apps/extension/src/background/storage.ts +++ b/apps/extension/src/background/storage.ts @@ -23,6 +23,8 @@ export interface UserPreferences { activeProviderId: string | null; theme: "light" | "dark" | "system"; storageBackend: StorageBackendPreference; + suggestionsEnabled: boolean; + chatContextEnabled: boolean; } const DEFAULT_PREFERENCES: UserPreferences = { @@ -31,6 +33,8 @@ const DEFAULT_PREFERENCES: UserPreferences = { activeProviderId: null, theme: "system", storageBackend: "chrome-sync", + suggestionsEnabled: true, + chatContextEnabled: false, }; function getErrorMessage(error: unknown) { @@ -62,6 +66,14 @@ function normalizePreferences(value: unknown): UserPreferences { : DEFAULT_PREFERENCES.debounceTime; const storageBackend = prefs.storageBackend === "firebase" ? "firebase" : "chrome-sync"; + const suggestionsEnabled = + typeof prefs.suggestionsEnabled === "boolean" + ? prefs.suggestionsEnabled + : DEFAULT_PREFERENCES.suggestionsEnabled; + const chatContextEnabled = + typeof prefs.chatContextEnabled === "boolean" + ? prefs.chatContextEnabled + : DEFAULT_PREFERENCES.chatContextEnabled; return { debounceTime, @@ -69,6 +81,8 @@ function normalizePreferences(value: unknown): UserPreferences { activeProviderId, theme, storageBackend, + suggestionsEnabled, + chatContextEnabled, }; } diff --git a/apps/extension/src/content/index.ts b/apps/extension/src/content/index.ts index f52610b..2fb8fee 100644 --- a/apps/extension/src/content/index.ts +++ b/apps/extension/src/content/index.ts @@ -2,13 +2,17 @@ import { getActivePlatform, PlatformConfig } from "./platforms/registry"; import { SuggestionOverlay } from "./ui/SuggestionOverlay"; import { BadgeIndicator } from "./ui/BadgeIndicator"; import { GhostOverlay } from "./ui/GhostOverlay"; -import { Suggestion } from "@plenz/types"; +import { buildConversationContext } from "@plenz/core"; +import type { ChatMessage, ConversationContext, Suggestion } from "@plenz/types"; const PREFERENCES_STORAGE_KEY = "preferences"; const DEFAULT_DEBOUNCE_MS = 500; type Preferences = { debounceMs?: number; + debounceTime?: number; + suggestionsEnabled?: boolean; + chatContextEnabled?: boolean; }; function sanitizeDebounceMs(value: unknown) { @@ -31,7 +35,11 @@ class ExtensionContentScript { private readonly boundHandleInput = this.handleInput.bind(this); private debounceTimer: number | null = null; private debounceMs = DEFAULT_DEBOUNCE_MS; + private suggestionsEnabled = true; + private chatContextEnabled = false; private activeAnalysisRequestId = 0; + private rollingSummary = ""; + private conversationUrl = window.location.href; constructor() { this.platform = getActivePlatform(); @@ -69,11 +77,15 @@ class ExtensionContentScript { private async loadPreferences() { try { + const runtimePreferences = await this.sendRuntimeMessage({ + type: "GET_PREFERENCES", + }); const stored = await chrome.storage.local.get(PREFERENCES_STORAGE_KEY); - const preferences = stored[PREFERENCES_STORAGE_KEY] as + const localPreferences = stored[PREFERENCES_STORAGE_KEY] as | Preferences | undefined; - this.debounceMs = sanitizeDebounceMs(preferences?.debounceMs); + const preferences = runtimePreferences ?? localPreferences; + this.applyPreferences(preferences); } catch (error) { console.warn("plenz: failed to load preferences", error); this.debounceMs = DEFAULT_DEBOUNCE_MS; @@ -89,12 +101,25 @@ class ExtensionContentScript { const updatedPreferences = changes[PREFERENCES_STORAGE_KEY].newValue as | Preferences | undefined; - this.debounceMs = sanitizeDebounceMs( - updatedPreferences?.debounceMs, - ); + this.applyPreferences(updatedPreferences); }); } + private applyPreferences(preferences: Preferences | undefined) { + this.debounceMs = sanitizeDebounceMs( + preferences?.debounceMs ?? preferences?.debounceTime, + ); + this.suggestionsEnabled = preferences?.suggestionsEnabled !== false; + this.chatContextEnabled = preferences?.chatContextEnabled === true; + + if (!this.suggestionsEnabled) { + this.currentSuggestions = []; + this.overlay?.hide(); + this.badge?.hide(); + this.ghostOverlay?.hide(); + } + } + private detachInput() { if (this.inputElement) { this.inputElement.removeEventListener("input", this.boundHandleInput); @@ -103,6 +128,7 @@ class ExtensionContentScript { this.inputElement = null; this.overlay?.hide(); this.badge?.hide(); + this.badge?.destroy(); this.ghostOverlay?.hide(); this.overlay = null; this.badge = null; @@ -163,6 +189,14 @@ class ExtensionContentScript { return; } + if (!this.suggestionsEnabled) { + this.currentSuggestions = []; + this.badge?.hide(); + this.overlay?.hide(); + this.ghostOverlay?.hide(); + return; + } + // Clear existing timer if (this.debounceTimer) { window.clearTimeout(this.debounceTimer); @@ -179,6 +213,9 @@ class ExtensionContentScript { private async analyzePrompt(text: string) { const requestId = ++this.activeAnalysisRequestId; + const conversation = this.chatContextEnabled + ? this.collectConversationContext(text) + : undefined; try { this.badge?.showSaving(); @@ -191,7 +228,7 @@ class ExtensionContentScript { prompt: text, context: { active_website: window.location.hostname, - // More context could be added here if needed + conversation, } }, }); @@ -236,6 +273,71 @@ class ExtensionContentScript { } } + private getElementVisibleText(element: Element) { + const rect = element.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return ""; + return (element.textContent || "").replace(/\s+/g, " ").trim(); + } + + private collectRawChatMessages() { + if (!this.platform) return []; + + const entries: Array = []; + const seenElements = new Set(); + + for (const { selector, role } of this.platform.messageSelectors) { + for (const element of Array.from(document.querySelectorAll(selector))) { + if (seenElements.has(element) || element === this.inputElement) continue; + seenElements.add(element); + + const text = this.getElementVisibleText(element); + if (!text) continue; + + entries.push({ + role, + text, + timestamp: Date.now(), + element, + }); + } + } + + return entries + .sort((a, b) => + a.element.compareDocumentPosition(b.element) & + Node.DOCUMENT_POSITION_PRECEDING + ? 1 + : -1, + ) + .map(({ element, ...message }) => message); + } + + private collectConversationContext( + currentDraft: string, + ): ConversationContext | undefined { + if (!this.platform) return undefined; + + if (this.conversationUrl !== window.location.href) { + this.conversationUrl = window.location.href; + this.rollingSummary = ""; + } + + const messages = this.collectRawChatMessages(); + const conversation = buildConversationContext({ + platform: this.platform.name, + activeWebsite: window.location.hostname, + currentDraft, + messages, + previousSummary: this.rollingSummary, + }); + + this.rollingSummary = conversation.rollingSummary ?? ""; + + return conversation.recentMessages.length > 0 || conversation.rollingSummary + ? conversation + : undefined; + } + private async sendRuntimeMessage(message: unknown): Promise { const runtime = globalThis.chrome?.runtime; if (!runtime?.id || typeof runtime.sendMessage !== "function") { diff --git a/apps/extension/src/content/platforms/registry.ts b/apps/extension/src/content/platforms/registry.ts index f77155d..52a0402 100644 --- a/apps/extension/src/content/platforms/registry.ts +++ b/apps/extension/src/content/platforms/registry.ts @@ -2,6 +2,10 @@ export interface PlatformConfig { name: string hostPatterns: string[] inputSelectors: string[] + messageSelectors: Array<{ + selector: string + role: "user" | "assistant" | "unknown" + }> submitButtonSelectors: string[] containerSelector: string inputType: "contenteditable" | "textarea" | "prosemirror" @@ -12,6 +16,10 @@ export const PLATFORM_REGISTRY: PlatformConfig[] = [ name: "chatgpt", hostPatterns: ["chatgpt.com", "chat.openai.com"], inputSelectors: ["#prompt-textarea", '[data-id="root"] textarea'], + messageSelectors: [ + { selector: '[data-message-author-role="user"]', role: "user" }, + { selector: '[data-message-author-role="assistant"]', role: "assistant" }, + ], submitButtonSelectors: ['[data-testid="send-button"]'], containerSelector: "form", inputType: "contenteditable", @@ -23,6 +31,11 @@ export const PLATFORM_REGISTRY: PlatformConfig[] = [ '[contenteditable="true"].ProseMirror', "fieldset .ProseMirror", ], + messageSelectors: [ + { selector: '[data-testid="user-message"]', role: "user" }, + { selector: '[data-testid="assistant-message"]', role: "assistant" }, + { selector: ".font-claude-message", role: "assistant" }, + ], submitButtonSelectors: ['button[aria-label="Send Message"]'], containerSelector: "fieldset", inputType: "prosemirror", @@ -31,6 +44,11 @@ export const PLATFORM_REGISTRY: PlatformConfig[] = [ name: "gemini", hostPatterns: ["gemini.google.com"], inputSelectors: ["rich-textarea .ql-editor", ".text-input-field textarea"], + messageSelectors: [ + { selector: ".user-query-container", role: "user" }, + { selector: ".model-response-text", role: "assistant" }, + { selector: "message-content", role: "assistant" }, + ], submitButtonSelectors: [ "button.send-button", '[aria-label="Send message"]', @@ -42,6 +60,11 @@ export const PLATFORM_REGISTRY: PlatformConfig[] = [ name: "perplexity", hostPatterns: ["perplexity.ai"], inputSelectors: ['textarea[placeholder*="Ask"]', "textarea.overflow-auto"], + messageSelectors: [ + { selector: '[data-testid="user-message"]', role: "user" }, + { selector: '[data-testid="answer"]', role: "assistant" }, + { selector: "main article", role: "unknown" }, + ], submitButtonSelectors: ['button[aria-label="Submit"]'], containerSelector: "form", inputType: "textarea", diff --git a/apps/extension/src/content/ui/BadgeIndicator.ts b/apps/extension/src/content/ui/BadgeIndicator.ts index 440adcc..f31355e 100644 --- a/apps/extension/src/content/ui/BadgeIndicator.ts +++ b/apps/extension/src/content/ui/BadgeIndicator.ts @@ -4,14 +4,23 @@ const ZAP_ICON = ``; export class BadgeIndicator { + private anchorElement: HTMLElement; private shadowHost: HTMLDivElement; private shadowRoot: ShadowRoot; private badge: HTMLDivElement; private visible: boolean = false; + private resizeObserver: ResizeObserver; + private readonly boundUpdatePosition: () => void; constructor(anchorElement: HTMLElement) { + this.anchorElement = anchorElement; + this.boundUpdatePosition = this.updatePosition.bind(this); this.shadowHost = document.createElement("div"); this.shadowHost.id = "plenz-badge-host"; + this.shadowHost.style.position = "fixed"; + this.shadowHost.style.zIndex = "10001"; + this.shadowHost.style.display = "inline-block"; + this.shadowHost.style.pointerEvents = "none"; // We use closed shadow DOM to protect styles this.shadowRoot = this.shadowHost.attachShadow({ mode: "closed" }); @@ -25,18 +34,46 @@ export class BadgeIndicator { this.badge.style.display = "none"; this.shadowRoot.appendChild(this.badge); - // Append to anchor's parent to avoid messing with input value/layout if possible - // But for positioning "inside" input, we might need to be absolute relative to input container - const parent = anchorElement.parentElement; - if (parent) { - const computedStyle = window.getComputedStyle(parent); - if (computedStyle.position === "static") { - parent.style.position = "relative"; - } - parent.appendChild(this.shadowHost); - } else { - anchorElement.appendChild(this.shadowHost); - } + document.body.appendChild(this.shadowHost); + + this.resizeObserver = new ResizeObserver(this.boundUpdatePosition); + this.resizeObserver.observe(anchorElement); + window.addEventListener("resize", this.boundUpdatePosition); + window.addEventListener("scroll", this.boundUpdatePosition, true); + } + + private updatePosition() { + if (!this.visible || !this.anchorElement.isConnected) return; + + const anchorRect = this.anchorElement.getBoundingClientRect(); + const hostRect = this.shadowHost.getBoundingClientRect(); + const viewportPadding = 8; + const gap = 6; + const right = Math.max( + viewportPadding, + window.innerWidth - anchorRect.right + viewportPadding, + ); + const spaceAbove = anchorRect.top - gap; + const top = + spaceAbove >= hostRect.height + viewportPadding + ? anchorRect.top - hostRect.height - gap + : anchorRect.bottom + gap; + + const maxTop = Math.max( + viewportPadding, + window.innerHeight - hostRect.height - viewportPadding, + ); + + this.shadowHost.style.right = `${right}px`; + this.shadowHost.style.top = `${Math.min(maxTop, Math.max(viewportPadding, top))}px`; + } + + private reveal() { + this.badge.style.display = "flex"; + this.badge.style.alignItems = "center"; + this.visible = true; + this.updatePosition(); + requestAnimationFrame(this.boundUpdatePosition); } public show(count: number) { @@ -46,28 +83,22 @@ export class BadgeIndicator { } this.badge.classList.remove("error"); this.badge.innerHTML = `${ZAP_ICON} ${count}`; - this.badge.style.display = "flex"; - this.badge.style.alignItems = "center"; this.badge.title = ""; - this.visible = true; + this.reveal(); } public showSaving() { this.badge.classList.remove("error"); this.badge.innerHTML = `${ZAP_ICON} ...`; - this.badge.style.display = "flex"; - this.badge.style.alignItems = "center"; this.badge.title = ""; - this.visible = true; + this.reveal(); } public showError(message?: string) { this.badge.classList.add("error"); this.badge.innerHTML = `${ALERT_ICON}`; - this.badge.style.display = "flex"; - this.badge.style.alignItems = "center"; this.badge.title = message || "An error occurred"; - this.visible = true; + this.reveal(); } public hide() { @@ -78,5 +109,12 @@ export class BadgeIndicator { public setOnClick(handler: () => void) { this.badge.addEventListener("click", handler); } + + public destroy() { + this.resizeObserver.disconnect(); + window.removeEventListener("resize", this.boundUpdatePosition); + window.removeEventListener("scroll", this.boundUpdatePosition, true); + this.shadowHost.remove(); + } } diff --git a/apps/extension/src/content/ui/styles.css b/apps/extension/src/content/ui/styles.css index de3ca8f..ef4a8df 100644 --- a/apps/extension/src/content/ui/styles.css +++ b/apps/extension/src/content/ui/styles.css @@ -26,10 +26,7 @@ } .pl-badge { - position: absolute; - right: 8px; - top: 50%; - transform: translateY(-50%); + position: static; background: color-mix(in srgb, var(--pl-accent) 12%, white); color: var(--pl-accent); padding: 3px 8px; @@ -43,8 +40,11 @@ align-items: center; gap: 4px; cursor: pointer; + pointer-events: auto; + white-space: nowrap; transition: background-color 0.15s ease; border: 1px solid color-mix(in srgb, var(--pl-accent) 45%, white); + box-shadow: 0 2px 8px rgb(0 0 0 / 0.12); } .pl-badge:hover { diff --git a/apps/extension/src/index.css b/apps/extension/src/index.css index 702fef6..43edb5a 100644 --- a/apps/extension/src/index.css +++ b/apps/extension/src/index.css @@ -95,3 +95,28 @@ color: var(--background); } +.plenz-popup-scrollbar { + scrollbar-width: thin; + scrollbar-color: color-mix(in oklch, var(--muted-foreground) 38%, transparent) + transparent; +} + +.plenz-popup-scrollbar::-webkit-scrollbar { + width: 6px; +} + +.plenz-popup-scrollbar::-webkit-scrollbar-track { + background: transparent; +} + +.plenz-popup-scrollbar::-webkit-scrollbar-thumb { + background: color-mix(in oklch, var(--muted-foreground) 32%, transparent); + border: 2px solid transparent; + border-radius: 999px; + background-clip: content-box; +} + +.plenz-popup-scrollbar:hover::-webkit-scrollbar-thumb { + background: color-mix(in oklch, var(--muted-foreground) 54%, transparent); + background-clip: content-box; +} diff --git a/apps/extension/src/options/App.tsx b/apps/extension/src/options/App.tsx index ba79389..56e9aa3 100644 --- a/apps/extension/src/options/App.tsx +++ b/apps/extension/src/options/App.tsx @@ -19,6 +19,9 @@ const DEFAULT_DEBOUNCE_MS = 500; type Preferences = { debounceMs?: number; + debounceTime?: number; + suggestionsEnabled?: boolean; + chatContextEnabled?: boolean; }; function sanitizeDebounceMs(value: unknown) { @@ -33,6 +36,8 @@ export function App() { const [debounceInput, setDebounceInput] = useState( String(DEFAULT_DEBOUNCE_MS), ); + const [suggestionsEnabled, setSuggestionsEnabled] = useState(true); + const [chatContextEnabled, setChatContextEnabled] = useState(false); const [preferencesState, setPreferencesState] = useState<{ status: "idle" | "saving" | "saved" | "error"; message?: string; @@ -41,11 +46,23 @@ export function App() { useEffect(() => { const loadPreferences = async () => { try { + const runtimePreferences = (await chrome.runtime.sendMessage({ + type: "GET_PREFERENCES", + })) as Preferences | undefined; const stored = await chrome.storage.local.get(PREFERENCES_STORAGE_KEY); - const preferences = stored[PREFERENCES_STORAGE_KEY] as + const localPreferences = stored[PREFERENCES_STORAGE_KEY] as | Preferences | undefined; - setDebounceInput(String(sanitizeDebounceMs(preferences?.debounceMs))); + const preferences = runtimePreferences ?? localPreferences; + setDebounceInput( + String( + sanitizeDebounceMs( + preferences?.debounceMs ?? preferences?.debounceTime, + ), + ), + ); + setSuggestionsEnabled(preferences?.suggestionsEnabled !== false); + setChatContextEnabled(preferences?.chatContextEnabled === true); } catch (error) { setPreferencesState({ status: "error", @@ -66,12 +83,27 @@ export function App() { const stored = await chrome.storage.local.get(PREFERENCES_STORAGE_KEY); const currentPreferences = (stored[PREFERENCES_STORAGE_KEY] as Preferences | undefined) ?? {}; + const nextPreferences = { + ...currentPreferences, + debounceMs: nextDebounceMs, + debounceTime: nextDebounceMs, + suggestionsEnabled, + chatContextEnabled, + }; + + await chrome.runtime.sendMessage({ + type: "SET_PREFERENCES", + payload: { + preferences: { + debounceTime: nextDebounceMs, + suggestionsEnabled, + chatContextEnabled, + }, + }, + }); await chrome.storage.local.set({ - [PREFERENCES_STORAGE_KEY]: { - ...currentPreferences, - debounceMs: nextDebounceMs, - }, + [PREFERENCES_STORAGE_KEY]: nextPreferences, }); setDebounceInput(String(nextDebounceMs)); @@ -147,6 +179,46 @@ export function App() { } /> + +
+

+ Browse trending prompts, review newly added ideas, and keep your + saved prompts in one side panel. +

+ + +

Links diff --git a/apps/extension/src/sidepanel/App.tsx b/apps/extension/src/sidepanel/App.tsx new file mode 100644 index 0000000..7bbc7c2 --- /dev/null +++ b/apps/extension/src/sidepanel/App.tsx @@ -0,0 +1,55 @@ +import { Card } from "@plenz/ui/components/card" +import { Toaster } from "sonner" +import { PromptGalleryProvider } from "./PromptGalleryProvider" +import { + PromptGalleryContent, + PromptGalleryHeader, + PromptGallerySaveDialog, + PromptGalleryToasts, + PromptGalleryToolbar, +} from "./PromptGallerySections" + +const PROMPT_GALLERY_TOAST_MAX_WIDTH = 356 + +export function App() { + return ( + + + + ) +} + +function PromptGalleryScreen() { + return ( +

+
+ + +
+ + + + + +
+
+ + + + +
+ ) +} diff --git a/apps/extension/src/sidepanel/PromptGalleryProvider.tsx b/apps/extension/src/sidepanel/PromptGalleryProvider.tsx new file mode 100644 index 0000000..d8543a1 --- /dev/null +++ b/apps/extension/src/sidepanel/PromptGalleryProvider.tsx @@ -0,0 +1,547 @@ +import type { PublicPrompt, SavedPrompt } from "@plenz/types" +import { + createContext, + useContext, + useEffect, + useState, + type ReactNode, +} from "react" +import { promptGalleryRuntime } from "./prompt-gallery.runtime" +import type { + ActionState, + GalleryFilter, + PromptGalleryContextValue, + PromptGalleryState, + PromptDraftMode, + PromptQueryState, +} from "./prompt-gallery.types" +import { + buildCatalogSaveLookup, + createPromptQueryState, + getCatalogShareSlug, + getSearchablePromptCollection, + isUserInfo, + matchesSearch, + PUBLIC_SHARE_BASE_URL, +} from "./prompt-gallery.utils" +import type { UserInfo } from "../background/auth" + +const initialState: PromptGalleryState = { + activeFilter: "trending", + actionState: null, + authLoading: true, + busyPromptId: null, + draft: { + savedPromptId: null, + prompt: "", + title: "", + }, + expandedPromptIds: {}, + promptDraftMode: "create", + promptModalOpen: false, + publicNewestPrompts: createPromptQueryState("loading"), + publicTrendingPrompts: createPromptQueryState("loading"), + savedPrompts: createPromptQueryState("idle"), + submittingPromptDraft: false, + searchTerm: "", + user: null, +} + +const PromptGalleryContext = createContext(null) + +export function PromptGalleryProvider({ + children, +}: { + children: ReactNode +}) { + const [state, setState] = useState(initialState) + + useEffect(() => { + void loadAuthStatus() + void Promise.all([loadPublicPrompts("trending"), loadPublicPrompts("newest")]) + }, []) + + useEffect(() => { + const handleAuthChange = (event: Event) => { + const detail = (event as CustomEvent<{ user: UserInfo | null }>).detail + const nextUser = detail?.user ?? null + + setState((current) => ({ + ...current, + authLoading: false, + savedPrompts: nextUser + ? current.savedPrompts + : createPromptQueryState("idle"), + user: nextUser, + })) + + if (nextUser) { + void loadSavedPrompts() + } + } + + window.addEventListener( + "plenz-auth-status-changed", + handleAuthChange as EventListener, + ) + + return () => { + window.removeEventListener( + "plenz-auth-status-changed", + handleAuthChange as EventListener, + ) + } + }, []) + + async function loadAuthStatus() { + try { + const response = await promptGalleryRuntime.getAuthStatus() + + setState((current) => ({ + ...current, + authLoading: false, + user: isUserInfo(response) ? response : null, + })) + + if (isUserInfo(response)) { + await loadSavedPrompts() + } + } catch (error) { + console.error("Prompt gallery: failed to read auth status", error) + + setState((current) => ({ + ...current, + authLoading: false, + user: null, + })) + } + } + + async function loadPublicPrompts(category: "trending" | "newest") { + const stateKey = + category === "trending" ? "publicTrendingPrompts" : "publicNewestPrompts" + + setState((current) => ({ + ...current, + [stateKey]: { + ...current[stateKey], + error: null, + status: "loading", + }, + })) + + try { + const response = await promptGalleryRuntime.listPublicPrompts(category) + + setState((current) => ({ + ...current, + [stateKey]: { + data: response.prompts, + error: null, + status: "ready", + } satisfies PromptQueryState, + })) + } catch (error) { + setState((current) => ({ + ...current, + [stateKey]: { + data: [], + error: (error as Error).message, + status: "error", + } satisfies PromptQueryState, + })) + } + } + + async function loadSavedPrompts() { + setState((current) => ({ + ...current, + savedPrompts: { + ...current.savedPrompts, + error: null, + status: "loading", + }, + })) + + try { + const response = await promptGalleryRuntime.listSavedPrompts() + + setState((current) => ({ + ...current, + savedPrompts: { + data: response.prompts, + error: null, + status: "ready", + }, + })) + } catch (error) { + setState((current) => ({ + ...current, + savedPrompts: { + data: [], + error: (error as Error).message, + status: "error", + }, + })) + } + } + + function setActionState(actionState: ActionState | null) { + setState((current) => ({ + ...current, + actionState, + })) + } + + function clearActionState() { + setActionState(null) + } + + function setSearchTerm(value: string) { + setState((current) => ({ + ...current, + searchTerm: value, + })) + } + + function setActiveFilter(filter: GalleryFilter) { + setState((current) => ({ + ...current, + activeFilter: filter, + })) + } + + function togglePromptExpanded(promptId: string) { + setState((current) => ({ + ...current, + expandedPromptIds: { + ...current.expandedPromptIds, + [promptId]: !current.expandedPromptIds[promptId], + }, + })) + } + + function openPromptModal(mode: PromptDraftMode, prompt?: SavedPrompt) { + setState((current) => { + if (!current.user) { + return { + ...current, + actionState: { + kind: "error", + title: "Sign in required", + message: "Sign in with Google to save your own prompts.", + }, + activeFilter: "saved", + } + } + + if (mode === "edit") { + if (!prompt || prompt.sourceType !== "custom") { + return { + ...current, + actionState: { + kind: "error", + title: "Only private prompts can be edited right now.", + message: "Only private prompts can be edited right now.", + }, + } + } + + return { + ...current, + draft: { + savedPromptId: prompt.id, + prompt: prompt.prompt, + title: prompt.title, + }, + promptDraftMode: "edit", + promptModalOpen: true, + } + } + + return { + ...current, + draft: { + savedPromptId: null, + prompt: "", + title: "", + }, + promptDraftMode: "create", + promptModalOpen: true, + } + }) + } + + function openCreatePromptModal() { + openPromptModal("create") + } + + function openEditSavedPromptModal(prompt: SavedPrompt) { + openPromptModal("edit", prompt) + } + + function closePromptModal() { + setState((current) => ({ + ...current, + draft: { + savedPromptId: null, + prompt: "", + title: "", + }, + promptDraftMode: "create", + promptModalOpen: false, + })) + } + + function setDraftTitle(value: string) { + setState((current) => ({ + ...current, + draft: { + ...current.draft, + title: value, + }, + })) + } + + function setDraftPrompt(value: string) { + setState((current) => ({ + ...current, + draft: { + ...current.draft, + prompt: value, + }, + })) + } + + async function copyPrompt(prompt: PublicPrompt | SavedPrompt) { + try { + await navigator.clipboard.writeText(prompt.prompt) + setActionState({ + kind: "success", + title: "Prompt copied", + }) + } catch (error) { + setActionState({ + kind: "error", + title: "Prompt copy failed", + message: (error as Error).message || "Prompt could not be copied.", + }) + } + } + + async function sharePrompt(prompt: PublicPrompt | SavedPrompt) { + const shareSlug = getCatalogShareSlug(prompt) + + if (!prompt.canShare || !shareSlug) { + setActionState({ + kind: "error", + title: "Prompt share failed", + message: "Only editorial prompts can be shared right now.", + }) + return + } + + try { + await navigator.clipboard.writeText(`${PUBLIC_SHARE_BASE_URL}/${shareSlug}`) + setActionState({ + kind: "success", + title: "Prompt shared", + message: `Copied a share link for "${prompt.title}".`, + }) + } catch (error) { + setActionState({ + kind: "error", + title: "Prompt share failed", + message: (error as Error).message || "Share link could not be copied.", + }) + } + } + + async function toggleCatalogSave(prompt: PublicPrompt) { + const savedCatalogPrompts = buildCatalogSaveLookup(state.savedPrompts.data) + const existingSavedPrompt = savedCatalogPrompts[prompt.id] + + setState((current) => ({ + ...current, + busyPromptId: prompt.id, + })) + + try { + if (existingSavedPrompt) { + await promptGalleryRuntime.deleteSavedPrompt(existingSavedPrompt.id) + setActionState({ + kind: "success", + title: "Prompt removed", + // message: `Removed "${prompt.title}" from your saved prompts.`, + }) + } else { + await promptGalleryRuntime.savePublicPrompt(prompt) + setActionState({ + kind: "success", + title: "Prompt saved", + message: `Saved "${prompt.title}" to your prompt library.`, + }) + } + + await loadSavedPrompts() + } catch (error) { + setActionState({ + kind: "error", + title: "Prompt update failed", + message: (error as Error).message || "Prompt could not be updated.", + }) + } finally { + setState((current) => ({ + ...current, + busyPromptId: null, + })) + } + } + + async function deleteSavedPrompt(prompt: SavedPrompt) { + setState((current) => ({ + ...current, + busyPromptId: prompt.id, + })) + + try { + await promptGalleryRuntime.deleteSavedPrompt(prompt.id) + setActionState({ + kind: "success", + title: "Prompt removed", + // message: `Removed "${prompt.title}" from your saved prompts.`, + }) + await loadSavedPrompts() + } catch (error) { + setActionState({ + kind: "error", + title: "Prompt removal failed", + message: (error as Error).message || "Prompt could not be removed.", + }) + } finally { + setState((current) => ({ + ...current, + busyPromptId: null, + })) + } + } + + async function submitPromptDraft() { + if (state.submittingPromptDraft) { + return + } + + setState((current) => ({ + ...current, + submittingPromptDraft: true, + })) + + try { + const isEditing = state.promptDraftMode === "edit" + const actionLabel = isEditing ? "updated" : "saved" + + if (isEditing) { + await promptGalleryRuntime.updateSavedPrompt( + state.draft.savedPromptId ?? "", + state.draft.title, + state.draft.prompt, + ) + } else { + await promptGalleryRuntime.createSavedPrompt( + state.draft.title, + state.draft.prompt, + ) + } + + setState((current) => ({ + ...current, + actionState: { + kind: "success", + title: `Prompt ${actionLabel}`, + // message: `Private prompt ${actionLabel} in your library.`, + }, + activeFilter: "saved", + draft: { + savedPromptId: null, + prompt: "", + title: "", + }, + promptDraftMode: "create", + promptModalOpen: false, + })) + + await loadSavedPrompts() + } catch (error) { + setActionState({ + kind: "error", + title: "Prompt save failed", + message: (error as Error).message || "Prompt could not be saved.", + }) + } finally { + setState((current) => ({ + ...current, + submittingPromptDraft: false, + })) + } + } + + const selectedPrompts = getSearchablePromptCollection( + state.activeFilter, + state.publicTrendingPrompts.data, + state.publicNewestPrompts.data, + state.savedPrompts.data, + ).filter((prompt) => matchesSearch(prompt, state.searchTerm)) + + const savedCatalogPrompts = buildCatalogSaveLookup(state.savedPrompts.data) + const activeQueryState = + state.activeFilter === "trending" + ? state.publicTrendingPrompts + : state.activeFilter === "newest" + ? state.publicNewestPrompts + : state.savedPrompts + const isSavedViewLocked = + state.activeFilter === "saved" && !state.authLoading && !state.user + + const value: PromptGalleryContextValue = { + actions: { + clearActionState, + closePromptModal, + copyPrompt, + deleteSavedPrompt, + openCreatePromptModal, + openEditSavedPromptModal, + submitPromptDraft, + setActiveFilter, + setDraftPrompt, + setDraftTitle, + setSearchTerm, + sharePrompt, + toggleCatalogSave, + togglePromptExpanded, + }, + state, + view: { + activeError: activeQueryState.error, + activeLoading: activeQueryState.status === "loading", + isSavedViewLocked, + savedCatalogPrompts, + selectedPrompts, + }, + } + + return ( + + {children} + + ) +} + +export function usePromptGallery() { + const context = useContext(PromptGalleryContext) + + if (!context) { + throw new Error("usePromptGallery must be used within PromptGalleryProvider.") + } + + return context +} diff --git a/apps/extension/src/sidepanel/PromptGallerySections.tsx b/apps/extension/src/sidepanel/PromptGallerySections.tsx new file mode 100644 index 0000000..f43d2c8 --- /dev/null +++ b/apps/extension/src/sidepanel/PromptGallerySections.tsx @@ -0,0 +1,521 @@ +import type { PublicPrompt, SavedPrompt } from "@plenz/types" +import { useEffect, useRef } from "react" +import { AuthStatus } from "../components/AuthStatus" +import { Badge } from "@plenz/ui/components/badge" +import { Button } from "@plenz/ui/components/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@plenz/ui/components/card" +import { Input } from "@plenz/ui/components/input" +import { Skeleton } from "@plenz/ui/components/skeleton" +import { + CalendarIcon, + ChevronDownIcon, + ChevronUpIcon, + CopyIcon, + Plus, + Search, + Share2Icon, + SquarePenIcon, + Trash2Icon, +} from "lucide-react" +import { toast } from "sonner" +import { usePromptGallery } from "./PromptGalleryProvider" +import type { GalleryFilter, PromptGalleryActions } from "./prompt-gallery.types" +import { + formatDateLabel, + formatPromptPreview, + isPublicPrompt, +} from "./prompt-gallery.utils" +import { cn } from "@plenz/ui/index" + +const FILTER_OPTIONS: Array<{ + id: GalleryFilter + label: string +}> = [ + { id: "trending", label: "Trending" }, + { id: "newest", label: "Newly added" }, + { id: "saved", label: "Saved" }, + ] + +export function PromptGalleryHeader() { + const { actions } = usePromptGallery() + + return ( + + +
+

+ Prompt gallery +

+ +
+
+ + + +
+ ) +} + +export function PromptGalleryToolbar() { + const { actions, state } = usePromptGallery() + + return ( + +
+ + + actions.setSearchTerm((event.target as HTMLInputElement).value) + } + placeholder="Search by title or prompt text" + className="pl-9" + /> +
+ +
+ {FILTER_OPTIONS.map((filterOption) => ( + + ))} +
+
+ ) +} + +function PromptGalleryFilterButton({ + filter, + label, +}: { + filter: GalleryFilter + label: string +}) { + const { actions, state } = usePromptGallery() + const count = + filter === "trending" + ? state.publicTrendingPrompts.data.length + : filter === "newest" + ? state.publicNewestPrompts.data.length + : state.savedPrompts.data.length + + return ( + + ) +} + +export function PromptGalleryToasts() { + const { actions, state } = usePromptGallery() + const lastToastKeyRef = useRef(null) + + useEffect(() => { + if (!state.actionState) { + lastToastKeyRef.current = null + return + } + + const toastKey = `${state.actionState.kind}:${state.actionState.title}:${state.actionState.message}` + + if (lastToastKeyRef.current === toastKey) { + return + } + + lastToastKeyRef.current = toastKey + + if (state.actionState.kind === "success") { + toast.success(state.actionState.title, { description: state.actionState.message }) + } else { + toast.error(state.actionState.title, { description: state.actionState.message }) + } + + actions.clearActionState() + }, [actions, state.actionState]) + + return null +} + +export function PromptGalleryContent() { + const { state, view } = usePromptGallery() + + if (view.activeLoading) { + return + } + + if (view.activeError) { + return ( + + ) + } + + if (view.isSavedViewLocked) { + return ( + + ) + } + + if (view.selectedPrompts.length === 0) { + return ( + + ) + } + + return ( +
+ {view.selectedPrompts.map((prompt) => ( + + ))} +
+ ) +} + +function PromptGalleryLoadingState() { + return ( +
+ + + +
+ ) +} + +function PromptGalleryMessageCard({ + description, + title, + tone = "default", +}: { + description: string + title: string + tone?: "default" | "error" +}) { + return ( + + +

+ {title} +

+

+ {description} +

+
+
+ ) +} + +function PromptGalleryPromptCard({ + prompt, +}: { + prompt: PublicPrompt | SavedPrompt +}) { + const { actions, state, view } = usePromptGallery() + const expanded = !!state.expandedPromptIds[prompt.id] + const promptText = formatPromptPreview(prompt.prompt, expanded) + const isExpandable = prompt.prompt.length > 240 + + return ( + + + +
+

+ {prompt.title} +

+ +
+
+ + {formatDateLabel( + "savedAt" in prompt + ? prompt.savedAt || prompt.updatedAt + : prompt.createdAt || prompt.updatedAt, + )} +
+ +
+
+ + isExpandable && actions.togglePromptExpanded(prompt.id)} + > +

+ {promptText} +

+ +
+ {isExpandable ? ( + expanded ? () : () + ) : null} +
+
+
+ ) +} + +function PromptGalleryPromptActions({ + prompt, actions +}: { + prompt: PublicPrompt | SavedPrompt + actions: PromptGalleryActions +}) { + return ( +
+ {isPublicPrompt(prompt) ? ( + + ) : ( + + )} + + + + +
+ ) + +} + +function PromptGalleryPromptBadges({ + prompt, +}: { + prompt: PublicPrompt | SavedPrompt +}) { + const categories = prompt.category + + return ( +
+ {categories.map((entry) => ( + + {entry} + + ))} + + {"savedAt" in prompt && prompt.sourceType === "custom" ? ( + + Private + + ) : null} +
+ ) +} + +function CatalogPromptActions({ + prompt, +}: { + prompt: PublicPrompt +}) { + const { actions, state, view } = usePromptGallery() + const savedPrompt = view.savedCatalogPrompts[prompt.id] + const buttonLabel = + state.busyPromptId === prompt.id + ? "Working..." + : savedPrompt + ? "Saved" + : "Save" + + return ( + + ) +} + +function SavedPromptActions({ + prompt, +}: { + prompt: SavedPrompt +}) { + const { actions, state } = usePromptGallery() + const isBusy = state.busyPromptId === prompt.id + + return ( + <> + + + {prompt.sourceType === "custom" ? ( + + ) : null} + + ) +} + +export function PromptGallerySaveDialog() { + const { actions, state } = usePromptGallery() + const isEditing = state.promptDraftMode === "edit" + + if (!state.promptModalOpen) { + return null + } + + return ( +
+ + +

+ {isEditing ? "Edit a private prompt" : "Save a private prompt"} +

+ + {isEditing + ? "Update a prompt in your saved library" + : "Add a prompt to your saved library"} + + + Saved prompts are private to your Google account and appear only + under your Saved filter. + +
+ + +
+ + + actions.setDraftTitle( + (event.target as HTMLInputElement).value, + ) + } + placeholder="Example: Launch announcement prompt" + /> +
+ +
+ +