import { Action, ActionPanel, Alert, Icon, List, Toast, confirmAlert, openExtensionPreferences, showToast } from "@vicinae/api"; import { useCallback, useEffect, useState, type ReactNode } from "react"; import { clearAllChats, clearOrphanedSnips, runAutoDelete } from "./lib/cleanup"; import { usePreferences } from "./lib/hooks/usePreferences"; import { clearModelSelection, describeAutoDelete, describeMaxToolRounds, describeResumeTimeout, needsSetup, type ResolvedPreferences, } 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, reload } = usePreferences(); // The screen does nothing until the model is known, because half of what it // reports is about that model: whether it can see images, and whether it is the // one from preferences or from the Models command. Rendering it with the // preference value and correcting it a moment later would flash a wrong answer // at the user. if (!preferences) { return ( ); } return ; } interface SetupScreenProps { preferences: ResolvedPreferences; /** Re-reads preferences after this screen changes the model selection. */ reloadPreferences: () => void; } function SetupScreen({ preferences, reloadPreferences }: SetupScreenProps) { 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]); // Opening any Lam command is a moment to collect expired conversations, and // this is one of them. Nothing is open here, so nothing is exempt. useEffect(() => { void runAutoDelete({ maxAgeMs: preferences.autoDeleteMs }); }, [preferences.autoDeleteMs]); const openPreferences = useCallback(() => { void openExtensionPreferences(); }, []); /** * Hands the model back to the `Model` preference. * * The escape hatch for the one cost of keeping a selection: while one is set, * editing the preference has no effect, and the only way to notice is this * screen. Confirmed rather than immediate because it is a change the user * cannot see the result of until the next question they ask. */ const resetModel = useCallback(async () => { await clearModelSelection(); reloadPreferences(); await showToast({ style: Toast.Style.Success, title: "Model reset to the preference", message: preferences.preferenceModel, }); }, [reloadPreferences, preferences.preferenceModel]); /** * Confirms, then runs a destructive tidy-up. * * Both actions on this screen delete things that cannot be recovered: there is * no trash to undo them from, and a conversation's screenshots go with it. That * is worth one extra keystroke, and the alert says what will go rather than * asking "are you sure" about nothing in particular. */ const confirmThen = useCallback( async (options: { title: string; message: string }, run: () => Promise) => { const confirmed = await confirmAlert({ title: options.title, message: options.message, primaryAction: { title: "Delete", style: Alert.ActionStyle.Destructive }, }); if (confirmed) await run(); }, [], ); /** * The action panel for a maintenance row. * * The row's own action comes first, because it is why the user is looking at * the row, and the two read-only actions sit in their own section below so they * can never be the default that Return fires. */ const maintenanceActions = (primary: ReactNode) => ( {primary} void testConnection()} /> ); 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 ( void resetModel()} /> void testConnection()} /> ) : ( settingsActions ) } /> {/* * Maintenance sits below the settings it acts on. Both rows are destructive * rather than settings, so each builds its own action panel instead of * sharing the one above: these are the only two things on this screen that * delete anything, and neither should be one keystroke away from a row the * user was only reading. */} void confirmThen( { title: "Delete unattached photos?", message: "Screenshots that belong to no conversation will be deleted. Photos still attached to a chat are kept.", }, clearOrphanedSnips, ) } />, )} /> void confirmThen( { title: "Delete every conversation?", message: "Every conversation and every screenshot it captured will be deleted. This cannot be undone.", }, clearAllChats, ) } />, )} /> {error ? ( ) : null} {missingKey ? ( } /> ) : null} {models.length > 0 ? ( {models.map((model) => ( } /> ))} ) : null} ); }