440 lines
16 KiB
TypeScript
440 lines
16 KiB
TypeScript
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 (
|
|
<List isLoading navigationTitle="Lam Agent Setup" searchBarPlaceholder="Loading preferences…" />
|
|
);
|
|
}
|
|
|
|
return <SetupScreen preferences={preferences} reloadPreferences={reload} />;
|
|
}
|
|
|
|
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<ModelInfo[]>([]);
|
|
const [error, setError] = useState<string | undefined>(undefined);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [hasTested, setHasTested] = useState(false);
|
|
const [vision, setVision] = useState<VisionSupport>("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<void>) => {
|
|
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) => (
|
|
<ActionPanel>
|
|
{primary}
|
|
<ActionPanel.Section>
|
|
<Action title="Open Extension Preferences" icon={Icon.BulletPoints} onAction={openPreferences} />
|
|
<Action
|
|
title="Test Connection"
|
|
icon={Icon.ArrowClockwise}
|
|
onAction={() => void testConnection()}
|
|
/>
|
|
</ActionPanel.Section>
|
|
</ActionPanel>
|
|
);
|
|
|
|
const settingsActions = (
|
|
<ActionPanel>
|
|
<Action title="Open Extension Preferences" icon={Icon.BulletPoints} onAction={openPreferences} />
|
|
<Action
|
|
title="Test Connection"
|
|
icon={Icon.ArrowClockwise}
|
|
onAction={() => void testConnection()}
|
|
/>
|
|
</ActionPanel>
|
|
);
|
|
|
|
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 (
|
|
<List
|
|
isLoading={isLoading}
|
|
navigationTitle="Lam Agent Setup"
|
|
searchBarPlaceholder="Search models…"
|
|
actions={settingsActions}
|
|
>
|
|
<List.Section title="Configuration">
|
|
<List.Item
|
|
icon={statusIcon}
|
|
title="Status"
|
|
subtitle={statusText}
|
|
actions={settingsActions}
|
|
accessories={error ? [{ tag: "Action needed" }] : []}
|
|
/>
|
|
<List.Item
|
|
icon={Icon.Person}
|
|
title="Ollama Host"
|
|
subtitle={preferences.host}
|
|
actions={settingsActions}
|
|
/>
|
|
<List.Item
|
|
icon={Icon.Person}
|
|
title="API Key"
|
|
subtitle={maskKey(preferences.apiKey)}
|
|
actions={settingsActions}
|
|
/>
|
|
<List.Item
|
|
icon={Icon.Stars}
|
|
title="Model"
|
|
subtitle={
|
|
preferences.modelSource === "selection"
|
|
? `${preferences.model} (chosen in Models; the preference says ${preferences.preferenceModel})`
|
|
: preferences.model
|
|
}
|
|
actions={
|
|
preferences.modelSource === "selection" ? (
|
|
<ActionPanel>
|
|
<Action
|
|
title="Reset to the Preference"
|
|
icon={Icon.ArrowCounterClockwise}
|
|
onAction={() => void resetModel()}
|
|
/>
|
|
<ActionPanel.Section>
|
|
<Action
|
|
title="Open Extension Preferences"
|
|
icon={Icon.BulletPoints}
|
|
onAction={openPreferences}
|
|
/>
|
|
<Action
|
|
title="Test Connection"
|
|
icon={Icon.ArrowClockwise}
|
|
onAction={() => void testConnection()}
|
|
/>
|
|
</ActionPanel.Section>
|
|
</ActionPanel>
|
|
) : (
|
|
settingsActions
|
|
)
|
|
}
|
|
/>
|
|
<List.Item
|
|
icon={Icon.BulletPoints}
|
|
title="Web Tools"
|
|
subtitle={preferences.webEnabled ? "Enabled" : "Disabled"}
|
|
actions={settingsActions}
|
|
/>
|
|
<List.Item
|
|
icon={Icon.Clock}
|
|
title="Reasoning Effort"
|
|
subtitle={preferences.think ?? "Off"}
|
|
actions={settingsActions}
|
|
/>
|
|
<List.Item
|
|
icon={Icon.BulletPoints}
|
|
title="Show Tool Calls"
|
|
subtitle={preferences.showToolCalls ? "Shown" : "Hidden"}
|
|
actions={settingsActions}
|
|
/>
|
|
<List.Item
|
|
icon={Icon.SpeechBubble}
|
|
title="Show Reasoning"
|
|
subtitle={preferences.showReasoning ? "Shown" : "Hidden"}
|
|
actions={settingsActions}
|
|
/>
|
|
<List.Item
|
|
icon={Icon.ArrowClockwise}
|
|
title="Start New Chat After"
|
|
subtitle={describeResumeTimeout(preferences.resumeTimeoutMs)}
|
|
actions={settingsActions}
|
|
/>
|
|
<List.Item
|
|
icon={Icon.Trash}
|
|
title="Delete Old Chats After"
|
|
subtitle={describeAutoDelete(preferences.autoDeleteMs)}
|
|
actions={settingsActions}
|
|
/>
|
|
<List.Item
|
|
icon={Icon.BulletPoints}
|
|
title="Tool Call Limit"
|
|
subtitle={describeMaxToolRounds(preferences.maxToolRounds)}
|
|
actions={settingsActions}
|
|
/>
|
|
<List.Item
|
|
icon={vision === "yes" ? Icon.Image : Icon.QuestionMarkCircle}
|
|
title="Image Input"
|
|
subtitle={
|
|
vision === "yes"
|
|
? `Supported by ${preferences.model}. Snip commands are available.`
|
|
: vision === "no"
|
|
? `${preferences.model} cannot see images. Pick a vision model to snip.`
|
|
: "Not checked yet"
|
|
}
|
|
actions={settingsActions}
|
|
/>
|
|
<List.Item
|
|
icon={preferences.place || preferences.ipGeolocation ? Icon.Person : Icon.QuestionMarkCircle}
|
|
title="Location"
|
|
subtitle={
|
|
preferences.place
|
|
? `${preferences.place} (from preferences)`
|
|
: preferences.ipGeolocation
|
|
? "Rough area, resolved from the IP address via ipwho.is"
|
|
: "Region only, from the timezone and locale"
|
|
}
|
|
actions={settingsActions}
|
|
/>
|
|
</List.Section>
|
|
|
|
{/*
|
|
* 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.
|
|
*/}
|
|
<List.Section title="Maintenance">
|
|
<List.Item
|
|
icon={Icon.Image}
|
|
title="Clear Out Unattached Photos"
|
|
subtitle="Delete captures no conversation refers to any more. Screenshots still attached to a chat are kept, and so is one snipped in the last day."
|
|
actions={maintenanceActions(
|
|
<Action
|
|
title="Clear Unattached Photos"
|
|
icon={Icon.Image}
|
|
style="destructive"
|
|
onAction={() =>
|
|
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,
|
|
)
|
|
}
|
|
/>,
|
|
)}
|
|
/>
|
|
<List.Item
|
|
icon={Icon.Trash}
|
|
title="Clear Out All Chats"
|
|
subtitle="Delete every conversation, and every screenshot it captured. This cannot be undone."
|
|
actions={maintenanceActions(
|
|
<Action
|
|
title="Clear All Chats"
|
|
icon={Icon.Trash}
|
|
style="destructive"
|
|
onAction={() =>
|
|
void confirmThen(
|
|
{
|
|
title: "Delete every conversation?",
|
|
message:
|
|
"Every conversation and every screenshot it captured will be deleted. This cannot be undone.",
|
|
},
|
|
clearAllChats,
|
|
)
|
|
}
|
|
/>,
|
|
)}
|
|
/>
|
|
</List.Section>
|
|
|
|
{error ? (
|
|
<List.Section title="Last Error">
|
|
<List.Item
|
|
icon={Icon.QuestionMarkCircle}
|
|
title={error}
|
|
subtitle="Check the API key, then run Test Connection again"
|
|
actions={settingsActions}
|
|
/>
|
|
</List.Section>
|
|
) : null}
|
|
|
|
{missingKey ? (
|
|
<List.Section title="Next Step">
|
|
<List.Item
|
|
icon={Icon.QuestionMarkCircle}
|
|
title="Add your Ollama API key"
|
|
subtitle="Create one at ollama.com/settings/keys, then paste it into the extension preferences"
|
|
actions={
|
|
<ActionPanel>
|
|
<Action title="Open Extension Preferences" icon={Icon.BulletPoints} onAction={openPreferences} />
|
|
<Action.OpenInBrowser title="Create an API Key" url="https://ollama.com/settings/keys" />
|
|
</ActionPanel>
|
|
}
|
|
/>
|
|
</List.Section>
|
|
) : null}
|
|
|
|
{models.length > 0 ? (
|
|
<List.Section title="Available Models" subtitle={`${models.length} reachable with this key`}>
|
|
{models.map((model) => (
|
|
<List.Item
|
|
key={model.name}
|
|
id={model.name}
|
|
icon={Icon.Stars}
|
|
title={model.name}
|
|
subtitle={model.name === preferences.model ? "Currently selected" : undefined}
|
|
accessories={model.parameterSize ? [{ tag: model.parameterSize }] : []}
|
|
actions={
|
|
<ActionPanel>
|
|
<Action.CopyToClipboard title="Copy Model Name" content={model.name} />
|
|
<Action title="Open Extension Preferences" icon={Icon.BulletPoints} onAction={openPreferences} />
|
|
</ActionPanel>
|
|
}
|
|
/>
|
|
))}
|
|
</List.Section>
|
|
) : null}
|
|
</List>
|
|
);
|
|
}
|