"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { useElectronAPI } from "@/hooks/use-platform"; import { ensureTokensFromElectron, getBearerToken } from "@/lib/auth-utils"; type SSEEvent = | { type: "text-delta"; id: string; delta: string } | { type: "text-start"; id: string } | { type: "text-end"; id: string } | { type: "start"; messageId: string } | { type: "finish" } | { type: "error"; errorText: string } | { type: "data-thinking-step"; data: { id: string; title: string; status: string; items: string[] }; } | { type: "data-suggestions"; data: { options: string[] }; }; interface AgentStep { id: string; title: string; status: string; items: string[]; } type FriendlyError = { message: string; isSetup?: boolean }; function friendlyError(raw: string | number): FriendlyError { if (typeof raw === "number") { if (raw === 401) return { message: "Please sign in to use suggestions." }; if (raw === 403) return { message: "You don\u2019t have permission for this." }; if (raw === 404) return { message: "Suggestion service not found. Is the backend running?" }; if (raw >= 500) return { message: "Something went wrong on the server. Try again." }; return { message: "Something went wrong. Try again." }; } const lower = raw.toLowerCase(); if (lower.includes("not authenticated") || lower.includes("unauthorized")) return { message: "Please sign in to use suggestions." }; if (lower.includes("no vision llm configured") || lower.includes("no llm configured")) return { message: "Configure a vision-capable model (e.g. GPT-4o, Gemini) to enable autocomplete.", isSetup: true, }; if (lower.includes("does not support vision")) return { message: "The selected model doesn\u2019t support vision. Choose a vision-capable model.", isSetup: true, }; if (lower.includes("fetch") || lower.includes("network") || lower.includes("econnrefused")) return { message: "Can\u2019t reach the server. Check your connection." }; return { message: "Something went wrong. Try again." }; } const AUTO_DISMISS_MS = 3000; function StepIcon({ status }: { status: string }) { if (status === "complete") { return ( ); } return ; } export default function SuggestionPage() { const api = useElectronAPI(); const [options, setOptions] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [steps, setSteps] = useState([]); const [expandedOption, setExpandedOption] = useState(null); const abortRef = useRef(null); const isDesktop = !!api?.onAutocompleteContext; useEffect(() => { if (!api?.onAutocompleteContext) { setIsLoading(false); } }, [api]); useEffect(() => { if (!error || error.isSetup) return; const timer = setTimeout(() => { api?.dismissSuggestion?.(); }, AUTO_DISMISS_MS); return () => clearTimeout(timer); }, [error, api]); useEffect(() => { if (isLoading || error || options.length > 0) return; const timer = setTimeout(() => { api?.dismissSuggestion?.(); }, AUTO_DISMISS_MS); return () => clearTimeout(timer); }, [isLoading, error, options, api]); const fetchSuggestion = useCallback( async (screenshot: string, searchSpaceId: string, appName?: string, windowTitle?: string) => { abortRef.current?.abort(); const controller = new AbortController(); abortRef.current = controller; setIsLoading(true); setOptions([]); setError(null); setSteps([]); setExpandedOption(null); let token = getBearerToken(); if (!token) { await ensureTokensFromElectron(); token = getBearerToken(); } if (!token) { setError(friendlyError("not authenticated")); setIsLoading(false); return; } const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000"; try { const response = await fetch(`${backendUrl}/api/v1/autocomplete/vision/stream`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify({ screenshot, search_space_id: parseInt(searchSpaceId, 10), app_name: appName || "", window_title: windowTitle || "", }), signal: controller.signal, }); if (!response.ok) { setError(friendlyError(response.status)); setIsLoading(false); return; } if (!response.body) { setError(friendlyError("network error")); setIsLoading(false); return; } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const events = buffer.split(/\r?\n\r?\n/); buffer = events.pop() || ""; for (const event of events) { const lines = event.split(/\r?\n/); for (const line of lines) { if (!line.startsWith("data: ")) continue; const data = line.slice(6).trim(); if (!data || data === "[DONE]") continue; try { const parsed: SSEEvent = JSON.parse(data); if (parsed.type === "data-suggestions") { setOptions(parsed.data.options); } else if (parsed.type === "error") { setError(friendlyError(parsed.errorText)); } else if (parsed.type === "data-thinking-step") { const { id, title, status, items } = parsed.data; setSteps((prev) => { const existing = prev.findIndex((s) => s.id === id); if (existing >= 0) { const updated = [...prev]; updated[existing] = { id, title, status, items }; return updated; } return [...prev, { id, title, status, items }]; }); } } catch {} } } } } catch (err) { if (err instanceof DOMException && err.name === "AbortError") return; setError(friendlyError("network error")); } finally { setIsLoading(false); } }, [] ); useEffect(() => { if (!api?.onAutocompleteContext) return; const cleanup = api.onAutocompleteContext((data) => { const searchSpaceId = data.searchSpaceId || "1"; if (data.screenshot) { fetchSuggestion(data.screenshot, searchSpaceId, data.appName, data.windowTitle); } }); return cleanup; }, [fetchSuggestion, api]); if (!isDesktop) { return (
This page is only available in the SurfSense desktop app.
); } if (error) { if (error.isSetup) { return (
Vision Model Required {error.message} Settings → Vision Models
); } return (
{error.message}
); } const showLoading = isLoading && options.length === 0; if (showLoading) { return (
{steps.length === 0 && (
Preparing…
)} {steps.length > 0 && (
{steps.map((step) => (
{step.title} {step.items.length > 0 && ( · {step.items[0]} )}
))}
)}
); } const handleSelect = (text: string) => { api?.acceptSuggestion?.(text); }; const handleDismiss = () => { api?.dismissSuggestion?.(); }; const TRUNCATE_LENGTH = 120; if (options.length === 0) { return (
No suggestions available.
); } return (
{options.map((option, index) => { const isExpanded = expandedOption === index; const needsTruncation = option.length > TRUNCATE_LENGTH; const displayText = needsTruncation && !isExpanded ? option.slice(0, TRUNCATE_LENGTH) + "…" : option; return ( )} ); })}
); }