import { Action, ActionPanel, Icon, List, openExtensionPreferences } from "@vicinae/api"; import { useCallback, useEffect, useMemo, useState } from "react"; import { describeResumeTimeout, getLamPreferences, needsSetup } from "./lib/preferences"; import { listModels } from "./lib/ollama"; import { forgetGeoPlace } from "./lib/geo"; import { forgetVisionSupport, visionSupport, type VisionSupport } from "./lib/vision"; import type { ModelInfo } from "./lib/types"; import { errorMessage } from "./lib/utils"; /** * Setup and diagnostics. * * Two things are worth understanding about how configuration works here. * * First, Vicinae asks for `required` preferences with no default *before* the * command starts. The API key and the model are both declared that way in the * manifest, so the two-step "key, then model" onboarding happens on the very * first launch without any code in this file. * * Second, an extension can read preferences but cannot write them: there is no * `setPreferenceValues`. So this command does not save anything. It verifies * the connection, lists the models the key can actually reach, and hands the * exact model name to the clipboard so it can be pasted into settings. That is * more useful than a form that silently fights the launcher's own settings UI, * and it means the model list can never drift out of sync with the real one. */ /** Shows enough of a key to recognise it without printing the whole thing. */ function maskKey(apiKey: string): string { if (apiKey.length === 0) return "Not set"; if (apiKey.length <= 8) return "•".repeat(apiKey.length); return `${apiKey.slice(0, 4)}${"•".repeat(6)}${apiKey.slice(-4)}`; } export default function SetupCommand() { const preferences = useMemo(() => getLamPreferences(), []); const [models, setModels] = useState([]); const [error, setError] = useState(undefined); const [isLoading, setIsLoading] = useState(false); const [hasTested, setHasTested] = useState(false); const [vision, setVision] = useState("unknown"); const missingKey = needsSetup(preferences); const testConnection = useCallback(async () => { setIsLoading(true); setError(undefined); try { const result = await listModels({ host: preferences.host, apiKey: preferences.apiKey }); setModels(result); } catch (thrown) { setModels([]); setError(errorMessage(thrown)); } finally { setHasTested(true); setIsLoading(false); } // A model can gain or lose image support without its name changing (a // re-pull, a retagged build), so re-testing drops the cached answer that the // chat view gates the snip action on rather than leaving a stale "no". await forgetVisionSupport(); // Same reasoning for the IP-derived place: a re-test is the moment to stop // trusting an answer that was resolved hours ago, possibly in another city. await forgetGeoPlace(); setVision( await visionSupport({ host: preferences.host, apiKey: preferences.apiKey, model: preferences.model, refresh: true, }), ); }, [preferences]); // Verifying on open means the user sees something real immediately rather // than having to guess whether their key works. useEffect(() => { if (!missingKey) void testConnection(); }, [missingKey, testConnection]); const openPreferences = useCallback(() => { void openExtensionPreferences(); }, []); const settingsActions = ( void testConnection()} /> ); const statusText = (() => { if (missingKey) return "No API key yet"; if (isLoading) return "Checking…"; if (error) return "Connection failed"; if (hasTested) return `Connected · ${models.length} models`; return "Not checked"; })(); const statusIcon = error || missingKey ? Icon.QuestionMarkCircle : Icon.SpeechBubble; return ( {error ? ( ) : null} {missingKey ? ( } /> ) : null} {models.length > 0 ? ( {models.map((model) => ( } /> ))} ) : null} ); }