Files
lam-agent-vicinae/src/setup.tsx
T
adrianbonpin af325b4a62 fix: ipapi.co stopped answering, so the lookup never resolved
Turning IP Geolocation on changed nothing: every lookup failed, and the
tool output still said "no Location is set in preferences", which is a
different problem and sent the user looking in the wrong place.

ipapi.co no longer serves an unauthenticated caller. Verified directly:

  no User-Agent    -> HTTP 429 {"reason":"RateLimited",
                                 "message":"Please sign up for a paid plan"}
  browser UA       -> HTTP 403, a Cloudflare "Just a moment..." challenge

So the provider is now ipwho.is, which answers 200 without a key. It
signals refusal with success: false on an HTTP 200, so the body is checked
as well as the status, and it nests the timezone under an object, which the
parser now accepts alongside a bare string. A named User-Agent is sent:
node's default is bare, and "who is asking" is the one thing a geolocation
provider has a fair interest in.

The failure branch is also fixed. A failed lookup and an unset preference
are different problems, and the wording no longer conflates them. The note
tells the model to pass the failure on, because the model is the only
channel that reaches the user and it had been paraphrasing the failure away.

Accuracy is documented honestly rather than as "city-level". An IP locates
the ISP's egress, not the person: measured from one machine, an IPv4 query
resolved to Cebu City and an IPv6 query moments later to Talisay in
Calabarzon, about 500km apart. That is now in the README, the preference
description and the tool output, and it is the argument for setting
Location instead.

Verified live with the exact request and field access the extension uses:
HTTP 200, "Talisay, Calabarzon (Region IV-A), Philippines", Asia/Manila.
344 tests, tsc, eslint, prettier, and vici build.
2026-10-04 15:59:09 +08:00

248 lines
8.7 KiB
TypeScript

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<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]);
const openPreferences = useCallback(() => {
void openExtensionPreferences();
}, []);
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.model}
actions={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={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>
{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>
);
}