Compare commits
2
Commits
35d88f635c
...
51c16b678f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51c16b678f | ||
|
|
400f81fa6c |
@@ -122,6 +122,8 @@ vicinae 'vicinae://launch/lam-agent/chat?fallbackText=summarise%20this'
|
||||
| **Ollama API Key** | None | Required. Stored by Vicinae as a password preference. |
|
||||
| **Model** | None | Required. Any model your key can reach. |
|
||||
| **Ollama Host** | `https://ollama.com` | Set to `http://localhost:11434` for a local server. |
|
||||
| **Location** | None | The place Lam should treat as yours, for example `Cebu City, Philippines`. Optional. The only exact location available, and the only one that stops anything being inferred. |
|
||||
| **IP Geolocation** | off | Off by default. When on, and only when **Location** is empty, your IP is sent to ipapi.co to resolve a rough city. |
|
||||
| **System Prompt** | Lam's default | Replaces the built-in prompt entirely. The default asks for plain prose: no em dashes, no generated-text tells, no restating the question. |
|
||||
| **Web Tools** | on | Enables `web_search` and `web_fetch`. |
|
||||
| **Reasoning Effort** | Off | `low`/`medium`/`high` for models that support thinking. |
|
||||
@@ -136,11 +138,19 @@ Lam calls these on its own when they would improve an answer. All four are read-
|
||||
| Tool | What it does |
|
||||
| --- | --- |
|
||||
| `get_datetime` | Current local date, time, weekday, timezone, and UTC offset. |
|
||||
| `get_location` | Timezone, UTC offset, locale, region, and city. |
|
||||
| `get_location` | The place you configured, or a rough city from your IP if you enabled that, plus timezone, UTC offset, locale and region. |
|
||||
| `web_search` | Searches the web; returns titles, URLs, and snippets. |
|
||||
| `web_fetch` | Fetches one page and returns its content as markdown. |
|
||||
|
||||
`get_location` is derived from your device timezone rather than GPS or an IP lookup, so no third-party geolocation service sees your address. Treat it as "which region am I in", not "where am I".
|
||||
`get_location` reports the **Location** you set, if you set one, because that is the only answer here that is actually about where you are. Everything else is inferred from your device, and the inference is coarser than it looks.
|
||||
|
||||
**A timezone is not a city.** This is the trap the tool used to fall into: it reported `City: Manila`, taken from the last segment of the IANA zone name. That name is the database's representative place, not yours, and a single zone is often a whole country. `Asia/Manila` is the Philippines, `Asia/Kolkata` is India, `Europe/Oslo` is Norway, `Australia/Brisbane` is Queensland. Reporting the zone's namesake as your city was wrong for anyone outside it, and confidently so, because a model asked about the weather will use it without hesitating. The zone's label is still reported, as `Timezone area`, and named for what it is.
|
||||
|
||||
The **region** comes from your locale, which is a declaration rather than an inference: a locale of `en-PH` says the Philippines because that is what you set. It costs nothing and it cannot be wrong the way a zone name can.
|
||||
|
||||
If you turn on **IP Geolocation**, and only then, `get_location` resolves a rough city from your IP address via [ipapi.co](https://ipapi.co). That is a third party learning approximately where you are, so it is off by default, it is skipped entirely whenever you have set a Location (the better answer is already there, and there is no reason to spend your privacy on a worse one), and the tool output names the provider and says the result is city-level rather than exact. The answer is cached for 6 hours rather than looked up per question, because a city does not change between questions and every lookup hands over another copy of your address. `Set Up Lam Agent` clears both that cache and the capability cache when you re-test.
|
||||
|
||||
This extension does not use GPS. That would mean CoreLocation on macOS, which has no command-line interface, or GeoClue on Linux, whose usual provider resolves nearby Wi-Fi networks through a Mozilla service anyway. Neither is worth it for "is it late where you are".
|
||||
|
||||
Web search results and fetched pages can be large. Lam truncates them before they reach the model, and gives up after 5 rounds of tool calls rather than looping.
|
||||
|
||||
|
||||
@@ -65,6 +65,22 @@
|
||||
"type": "textfield",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "location",
|
||||
"title": "Location",
|
||||
"description": "The place Lam should treat as yours, for example Cebu City, Philippines. Optional. Without it Lam falls back to your device timezone and locale, which are accurate to a region and not to a city, so anything that depends on exactly where you are (weather, opening hours) is worth filling in.",
|
||||
"type": "textfield",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "ipGeolocation",
|
||||
"title": "IP Geolocation",
|
||||
"label": "Look up my city from my IP address",
|
||||
"description": "Off by default. When this is on, and the Location preference above is empty, Lam sends your IP address to ipapi.co to work out your city. That is a third party learning roughly where you are, which is why it is opt-in, and why setting Location makes it unnecessary.",
|
||||
"type": "checkbox",
|
||||
"required": false,
|
||||
"default": false
|
||||
},
|
||||
{
|
||||
"name": "webSearchEnabled",
|
||||
"title": "Web Tools",
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { __resetLocalStorage } from "../__mocks__/@vicinae/api";
|
||||
import { GEO_TTL_MS, buildPlace, forgetGeoPlace, ipPlace } from "../lib/geo";
|
||||
|
||||
/**
|
||||
* The only code in the extension that sends anything about the user to a third
|
||||
* party, so the parts worth pinning down are that it caches (rather than leaking
|
||||
* the address on every question), that it never throws, and that a failure is
|
||||
* reported instead of being served as a coarser answer.
|
||||
*/
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
function answer(payload: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const CEBU = {
|
||||
city: "Cebu City",
|
||||
region: "Central Visayas",
|
||||
country_name: "Philippines",
|
||||
latitude: 10.3167,
|
||||
longitude: 123.8907,
|
||||
timezone: "Asia/Manila",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
__resetLocalStorage();
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("buildPlace", () => {
|
||||
it("joins the parts that exist", () => {
|
||||
expect(buildPlace({ city: "Cebu City", region: "Central Visayas", country: "Philippines" })).toBe(
|
||||
"Cebu City, Central Visayas, Philippines",
|
||||
);
|
||||
});
|
||||
|
||||
it("drops repeats, which providers return for city-states", () => {
|
||||
// "Singapore, Singapore, Singapore" reads like a bug rather than a place.
|
||||
expect(buildPlace({ city: "Singapore", region: "Singapore", country: "Singapore" })).toBe("Singapore");
|
||||
});
|
||||
|
||||
it("ignores repeats that differ only in case", () => {
|
||||
expect(buildPlace({ city: "Manila", region: "manila" })).toBe("Manila");
|
||||
});
|
||||
|
||||
it("drops empty and whitespace parts", () => {
|
||||
expect(buildPlace({ city: " ", region: "", country: "Philippines" })).toBe("Philippines");
|
||||
});
|
||||
|
||||
it("returns nothing when there is nothing", () => {
|
||||
expect(buildPlace({})).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ipPlace", () => {
|
||||
it("resolves a place from a provider answer", async () => {
|
||||
fetchMock.mockResolvedValueOnce(answer(CEBU));
|
||||
|
||||
const result = await ipPlace({ now: 1_000 });
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
if (result.status !== "ok") return;
|
||||
|
||||
expect(result.place.place).toBe("Cebu City, Central Visayas, Philippines");
|
||||
expect(result.place.latitude).toBe(10.3167);
|
||||
expect(result.place.timezone).toBe("Asia/Manila");
|
||||
expect(result.place.source).toBe("ipapi.co");
|
||||
});
|
||||
|
||||
it("asks for JSON with no cache-busting that a cache would defeat", async () => {
|
||||
fetchMock.mockResolvedValueOnce(answer(CEBU));
|
||||
await ipPlace({ now: 1_000 });
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("https://ipapi.co/json/");
|
||||
expect(init.headers.Accept).toBe("application/json");
|
||||
});
|
||||
|
||||
it("serves a repeat from the cache instead of re-sending the address", async () => {
|
||||
fetchMock.mockResolvedValueOnce(answer(CEBU));
|
||||
|
||||
await ipPlace({ now: 1_000 });
|
||||
const second = await ipPlace({ now: 2_000 });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(second.status).toBe("ok");
|
||||
});
|
||||
|
||||
it("re-fetches once the cached place is stale", async () => {
|
||||
fetchMock.mockResolvedValueOnce(answer(CEBU));
|
||||
await ipPlace({ now: 1_000 });
|
||||
|
||||
fetchMock.mockResolvedValueOnce(answer({ ...CEBU, city: "Davao City" }));
|
||||
const later = await ipPlace({ now: 1_000 + GEO_TTL_MS + 1 });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(later.status === "ok" && later.place.city).toBe("Davao City");
|
||||
});
|
||||
|
||||
it("treats exactly the TTL as still fresh", async () => {
|
||||
fetchMock.mockResolvedValueOnce(answer(CEBU));
|
||||
await ipPlace({ now: 1_000 });
|
||||
|
||||
await ipPlace({ now: 1_000 + GEO_TTL_MS });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("ignores the cache when asked to refresh", async () => {
|
||||
fetchMock.mockResolvedValueOnce(answer(CEBU));
|
||||
await ipPlace({ now: 1_000 });
|
||||
|
||||
fetchMock.mockResolvedValueOnce(answer({ ...CEBU, city: "Davao City" }));
|
||||
const refreshed = await ipPlace({ now: 1_001, refresh: true });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(refreshed.status === "ok" && refreshed.place.city).toBe("Davao City");
|
||||
});
|
||||
|
||||
it("reports a rate limit by status rather than pretending to know the place", async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response("slow down", { status: 429 }));
|
||||
|
||||
const result = await ipPlace({ now: 1_000 });
|
||||
|
||||
expect(result).toEqual({ status: "failed", message: "ipapi.co answered HTTP 429" });
|
||||
});
|
||||
|
||||
it("reports a provider that answers with error: true", async () => {
|
||||
// ipapi.co signals failure in the body while still answering 200.
|
||||
fetchMock.mockResolvedValueOnce(answer({ error: true, reason: "RateLimited", message: "Too many" }));
|
||||
|
||||
const result = await ipPlace({ now: 1_000 });
|
||||
|
||||
expect(result).toEqual({ status: "failed", message: "Too many" });
|
||||
});
|
||||
|
||||
it("reports a body that is not JSON", async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response("<html>nope</html>", { status: 200 }));
|
||||
|
||||
const result = await ipPlace({ now: 1_000 });
|
||||
|
||||
expect(result.status).toBe("failed");
|
||||
});
|
||||
|
||||
it("reports an answer with no usable place", async () => {
|
||||
fetchMock.mockResolvedValueOnce(answer({ city: null, region: null, country_name: null }));
|
||||
|
||||
const result = await ipPlace({ now: 1_000 });
|
||||
|
||||
expect(result).toEqual({ status: "failed", message: "ipapi.co returned no usable place" });
|
||||
});
|
||||
|
||||
it("survives the network being down without throwing", async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error("offline"));
|
||||
|
||||
const result = await ipPlace({ now: 1_000 });
|
||||
|
||||
expect(result.status).toBe("failed");
|
||||
});
|
||||
|
||||
it("does not cache a failure", async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error("offline"));
|
||||
await ipPlace({ now: 1_000 });
|
||||
|
||||
fetchMock.mockResolvedValueOnce(answer(CEBU));
|
||||
const second = await ipPlace({ now: 1_001 });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(second.status).toBe("ok");
|
||||
});
|
||||
|
||||
it("ignores a corrupt cache entry rather than failing", async () => {
|
||||
const { LocalStorage } = await import("../__mocks__/@vicinae/api");
|
||||
await LocalStorage.setItem("lam.geo", "{not json");
|
||||
|
||||
fetchMock.mockResolvedValueOnce(answer(CEBU));
|
||||
|
||||
await expect(ipPlace({ now: 1_000 })).resolves.toMatchObject({ status: "ok" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("forgetGeoPlace", () => {
|
||||
it("drops the cached place so a move is not pinned for hours", async () => {
|
||||
fetchMock.mockResolvedValueOnce(answer(CEBU));
|
||||
await ipPlace({ now: 1_000 });
|
||||
|
||||
await forgetGeoPlace();
|
||||
|
||||
fetchMock.mockResolvedValueOnce(answer({ ...CEBU, city: "Davao City" }));
|
||||
const after = await ipPlace({ now: 1_001 });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(after.status === "ok" && after.place.city).toBe("Davao City");
|
||||
});
|
||||
});
|
||||
@@ -1,21 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describeTimezone, getLocationContext, utcOffsetLabel, weekdayName } from "../lib/location";
|
||||
import {
|
||||
describeTimezoneArea,
|
||||
getLocationContext,
|
||||
localeRegion,
|
||||
utcOffsetLabel,
|
||||
weekdayName,
|
||||
} from "../lib/location";
|
||||
|
||||
describe("describeTimezone", () => {
|
||||
it("splits a simple zone into region and city", () => {
|
||||
expect(describeTimezone("Asia/Manila")).toEqual({ region: "Asia", city: "Manila" });
|
||||
describe("describeTimezoneArea", () => {
|
||||
it("keeps the zone's own label", () => {
|
||||
expect(describeTimezoneArea("Asia/Manila")).toEqual({ area: "Manila" });
|
||||
});
|
||||
|
||||
it("handles zones with more than two segments", () => {
|
||||
expect(describeTimezone("America/Argentina/Salta")).toEqual({ region: "America", city: "Salta" });
|
||||
expect(describeTimezoneArea("America/Argentina/Salta")).toEqual({ area: "Salta" });
|
||||
});
|
||||
|
||||
it("underscores become spaces", () => {
|
||||
expect(describeTimezone("America/New_York")).toEqual({ region: "America", city: "New York" });
|
||||
it("turns underscores into spaces", () => {
|
||||
expect(describeTimezoneArea("America/New_York")).toEqual({ area: "New York" });
|
||||
});
|
||||
|
||||
it("drops the continent grouping, which answers a question nobody asked", () => {
|
||||
// "Asia" beside a locale region of "Philippines" reads as two answers to
|
||||
// the same question.
|
||||
expect(describeTimezoneArea("Asia/Manila")).not.toHaveProperty("region");
|
||||
});
|
||||
|
||||
it("returns nothing for a bare zone like UTC", () => {
|
||||
expect(describeTimezone("UTC")).toEqual({});
|
||||
expect(describeTimezoneArea("UTC")).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("localeRegion", () => {
|
||||
it("reads the region the user configured, rather than inferring one", () => {
|
||||
expect(localeRegion("en-PH")).toEqual({ code: "PH", name: "Philippines" });
|
||||
});
|
||||
|
||||
it("handles other regions", () => {
|
||||
expect(localeRegion("de-DE")?.name).toBe("Germany");
|
||||
expect(localeRegion("en-US")?.code).toBe("US");
|
||||
});
|
||||
|
||||
it("has nothing to say for a locale with no region", () => {
|
||||
expect(localeRegion("en")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not throw on a malformed locale", () => {
|
||||
expect(localeRegion("not a locale")).toBeUndefined();
|
||||
expect(localeRegion("")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,10 +86,11 @@ describe("getLocationContext", () => {
|
||||
const context = getLocationContext(new Date("2026-01-02T03:04:05Z"), "Asia/Manila");
|
||||
|
||||
expect(context.timezone).toBe("Asia/Manila");
|
||||
expect(context.city).toBe("Manila");
|
||||
expect(context.region).toBe("Asia");
|
||||
expect(context.utcOffset).toContain("+08:00");
|
||||
expect(context.locale.length).toBeGreaterThan(0);
|
||||
// The zone's label is reported as such, never as the user's city.
|
||||
expect(context.timezoneArea).toBe("Manila");
|
||||
expect(context).not.toHaveProperty("city");
|
||||
});
|
||||
|
||||
it("still returns a usable context for an unknown zone", () => {
|
||||
@@ -65,4 +98,15 @@ describe("getLocationContext", () => {
|
||||
expect(context.timezone).toBe("Not/AReal_Zone");
|
||||
expect(context.utcOffset).toBe("UTC");
|
||||
});
|
||||
|
||||
it("carries the configured place through verbatim", () => {
|
||||
const context = getLocationContext(new Date(), "Asia/Manila", " Cebu City, Philippines ");
|
||||
expect(context.place).toBe("Cebu City, Philippines");
|
||||
});
|
||||
|
||||
it("treats an empty or whitespace place as unset", () => {
|
||||
expect(getLocationContext(new Date(), "Asia/Manila", "").place).toBeUndefined();
|
||||
expect(getLocationContext(new Date(), "Asia/Manila", " ").place).toBeUndefined();
|
||||
expect(getLocationContext(new Date(), "Asia/Manila").place).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,6 +53,26 @@ describe("resolvePreferences", () => {
|
||||
expect(resolved.showReasoning).toBe(true);
|
||||
expect(resolved.think).toBeUndefined();
|
||||
expect(resolved.resumeTimeoutMs).toBe(HOUR);
|
||||
// An empty string rather than undefined, so callers never branch on it.
|
||||
expect(resolved.place).toBe("");
|
||||
});
|
||||
|
||||
it("leaves IP geolocation off unless it was switched on", () => {
|
||||
// The one setting that sends something about the user to a third party is
|
||||
// opted into, never defaulted into.
|
||||
expect(resolvePreferences({}).ipGeolocation).toBe(false);
|
||||
expect(resolvePreferences({ ipGeolocation: true }).ipGeolocation).toBe(true);
|
||||
expect(resolvePreferences({ ipGeolocation: false }).ipGeolocation).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the configured location, trimmed", () => {
|
||||
expect(resolvePreferences({ location: " Cebu City, Philippines " }).place).toBe(
|
||||
"Cebu City, Philippines",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats a whitespace-only location as unset", () => {
|
||||
expect(resolvePreferences({ location: " " }).place).toBe("");
|
||||
});
|
||||
|
||||
it("honours the display toggles being switched off", () => {
|
||||
|
||||
+108
-2
@@ -225,13 +225,119 @@ describe("local tools", () => {
|
||||
expect(result.content).toContain("Current date and time:");
|
||||
});
|
||||
|
||||
it("describes location as a region rather than an address", async () => {
|
||||
it("describes location as a region rather than an address when nothing is configured", async () => {
|
||||
const result = await executeTool("get_location", {}, context);
|
||||
|
||||
expect(result.isError).toBe(false);
|
||||
expect(result.content).toContain("Timezone:");
|
||||
expect(result.content).toContain("Locale:");
|
||||
expect(result.content).toContain("not a precise address");
|
||||
expect(result.content).toContain("no Location is set in preferences");
|
||||
expect(result.content).toContain("Ask which city they are in");
|
||||
});
|
||||
|
||||
it("never claims a city derived from the timezone name", async () => {
|
||||
// `Asia/Manila` is one zone covering the whole Philippines, so reporting
|
||||
// "City: Manila" was wrong for anyone in Cebu and confidently so.
|
||||
const result = await executeTool("get_location", {}, context);
|
||||
|
||||
expect(result.content).not.toMatch(/^City:/m);
|
||||
expect(result.content).toContain("The timezone area is the timezone's own name, not the user's city.");
|
||||
});
|
||||
|
||||
it("puts the configured place first and says to prefer it", async () => {
|
||||
const result = await executeTool("get_location", {}, {
|
||||
...context,
|
||||
place: "Cebu City, Philippines",
|
||||
});
|
||||
|
||||
expect(result.content).toContain("Place: Cebu City, Philippines");
|
||||
expect(result.content).toContain("prefer it");
|
||||
expect(result.content).not.toContain("no Location is set");
|
||||
expect(result.content.indexOf("Place:")).toBeLessThan(result.content.indexOf("Timezone:"));
|
||||
});
|
||||
|
||||
it("still reports the timezone alongside a configured place, for local time questions", async () => {
|
||||
const result = await executeTool("get_location", {}, { ...context, place: "Cebu City" });
|
||||
|
||||
expect(result.content).toContain("Timezone:");
|
||||
expect(result.content).toContain("UTC offset:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("get_location with IP geolocation", () => {
|
||||
const resolved = {
|
||||
status: "ok" as const,
|
||||
place: {
|
||||
place: "Cebu City, Central Visayas, Philippines",
|
||||
city: "Cebu City",
|
||||
region: "Central Visayas",
|
||||
country: "Philippines",
|
||||
timezone: "Asia/Manila",
|
||||
source: "ipapi.co",
|
||||
fetchedAt: 1_000,
|
||||
},
|
||||
};
|
||||
|
||||
it("reports the resolved place and says how approximate it is", async () => {
|
||||
const ipPlace = vi.fn(async () => resolved);
|
||||
|
||||
const result = await executeTool("get_location", {}, { ...context, ipPlace });
|
||||
|
||||
expect(result.isError).toBe(false);
|
||||
expect(result.content).toContain("Place: Cebu City, Central Visayas, Philippines");
|
||||
expect(result.content).toContain("city-level rather than exact");
|
||||
expect(result.content).toContain("ipapi.co");
|
||||
});
|
||||
|
||||
it("does not look the address up at all when the user has said where they are", async () => {
|
||||
// The preference is both more accurate and free, so sending the IP anyway
|
||||
// would be a privacy cost for nothing.
|
||||
const ipPlace = vi.fn(async () => resolved);
|
||||
|
||||
const result = await executeTool("get_location", {}, {
|
||||
...context,
|
||||
place: "Cebu City, Philippines",
|
||||
ipPlace,
|
||||
});
|
||||
|
||||
expect(ipPlace).not.toHaveBeenCalled();
|
||||
expect(result.content).toContain("Place: Cebu City, Philippines");
|
||||
expect(result.content).not.toContain("ipapi.co");
|
||||
});
|
||||
|
||||
it("surfaces a failed lookup instead of quietly serving the region answer", async () => {
|
||||
const ipPlace = vi.fn(async () => ({ status: "failed" as const, message: "ipapi.co answered HTTP 429" }));
|
||||
|
||||
const result = await executeTool("get_location", {}, { ...context, ipPlace });
|
||||
|
||||
expect(result.content).toContain("IP geolocation is switched on but did not answer");
|
||||
expect(result.content).toContain("HTTP 429");
|
||||
// The region answer is still there, so the tool stays useful.
|
||||
expect(result.content).toContain("Timezone:");
|
||||
});
|
||||
|
||||
it("flags an IP timezone that disagrees with the clock, which usually means a VPN", async () => {
|
||||
const ipPlace = vi.fn(async () => ({
|
||||
status: "ok" as const,
|
||||
place: { ...resolved.place, timezone: "America/New_York" },
|
||||
}));
|
||||
|
||||
const result = await executeTool("get_location", {}, { ...context, ipPlace });
|
||||
|
||||
expect(result.content).toContain("differs from the device timezone");
|
||||
});
|
||||
|
||||
it("says nothing about a mismatch when the timezones agree", async () => {
|
||||
const ipPlace = vi.fn(async () => resolved);
|
||||
const result = await executeTool("get_location", {}, { ...context, ipPlace });
|
||||
|
||||
// The test host's own zone varies, so this asserts the absence of the note
|
||||
// only for the case where the provider echoed the same value.
|
||||
if (result.content.includes("IP-reported timezone")) {
|
||||
expect(result.content).toContain("differs");
|
||||
} else {
|
||||
expect(result.content).not.toContain("IP-reported timezone");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -114,6 +114,7 @@ export async function runAgent(options: AgentOptions): Promise<AgentResult> {
|
||||
const result = await executeTool(name, args, {
|
||||
host: options.toolContext.host,
|
||||
apiKey: options.toolContext.apiKey,
|
||||
place: options.toolContext.place,
|
||||
now: options.toolContext.now,
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
import { LocalStorage } from "@vicinae/api";
|
||||
import { z } from "zod";
|
||||
import { errorMessage } from "./utils";
|
||||
|
||||
/**
|
||||
* IP geolocation, opt-in.
|
||||
*
|
||||
* This is the one place in the extension that tells a third party anything about
|
||||
* the user. The README said no geolocation service ever saw the user's address,
|
||||
* and this is what makes that conditional, so it is off unless asked for, it is
|
||||
* skipped entirely when the user has already said where they are, and the tool
|
||||
* output names the provider so the model can qualify what it repeats.
|
||||
*
|
||||
* The answer is cached for hours rather than looked up per call. A city does not
|
||||
* change between questions, and every lookup hands the provider another copy of
|
||||
* the user's IP address.
|
||||
*
|
||||
* Imports Vicinae for `LocalStorage` only, which is the same footing as
|
||||
* `sessions.ts` and `vision.ts`. Everything else about it is plain `fetch`, so
|
||||
* the parsing and the cache are unit tested without a runtime.
|
||||
*/
|
||||
|
||||
/** Where the lookup goes. Swapping providers means changing this and the parser below. */
|
||||
export const GEO_PROVIDER = "https://ipapi.co/json/";
|
||||
export const GEO_PROVIDER_NAME = "ipapi.co";
|
||||
|
||||
/** How long a resolved place is trusted. Long enough to not re-leak the IP on every question. */
|
||||
export const GEO_TTL_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
/** An IP lookup is a nice-to-have, so it must never hold up an answer. */
|
||||
const GEO_TIMEOUT_MS = 8_000;
|
||||
|
||||
const STORAGE_KEY = "lam.geo";
|
||||
|
||||
export interface GeoPlace {
|
||||
/** Ready to show, e.g. "Cebu City, Central Visayas, Philippines". */
|
||||
place: string;
|
||||
city?: string;
|
||||
region?: string;
|
||||
country?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
timezone?: string;
|
||||
/** Named in the tool output, so the model can say where this came from. */
|
||||
source: string;
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
export type GeoLookup = { status: "ok"; place: GeoPlace } | { status: "failed"; message: string };
|
||||
|
||||
export interface GeoOptions {
|
||||
signal?: AbortSignal;
|
||||
/** Injected so the cache is deterministic under test. */
|
||||
now?: number;
|
||||
/** Ignores a cached answer. */
|
||||
refresh?: boolean;
|
||||
}
|
||||
|
||||
const CachedSchema = z.object({
|
||||
place: z.string(),
|
||||
city: z.string().optional(),
|
||||
region: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
latitude: z.number().optional(),
|
||||
longitude: z.number().optional(),
|
||||
timezone: z.string().optional(),
|
||||
source: z.string(),
|
||||
fetchedAt: z.number(),
|
||||
});
|
||||
|
||||
/** The subset of ipapi.co's response this needs, with every field optional so a shape change degrades instead of throwing. */
|
||||
const ResponseSchema = z.object({
|
||||
error: z.unknown().optional(),
|
||||
reason: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
city: z.string().nullable().optional(),
|
||||
region: z.string().nullable().optional(),
|
||||
country_name: z.string().nullable().optional(),
|
||||
latitude: z.number().nullable().optional(),
|
||||
longitude: z.number().nullable().optional(),
|
||||
timezone: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Joins the parts of a place, dropping duplicates.
|
||||
*
|
||||
* Providers often return the city twice (once as `city`, once as `region`) for
|
||||
* city-states and single-city regions, and "Singapore, Singapore, Singapore"
|
||||
* reads like a bug.
|
||||
*/
|
||||
export function buildPlace(parts: { city?: string; region?: string; country?: string }): string {
|
||||
const seen = new Set<string>();
|
||||
const kept: string[] = [];
|
||||
|
||||
for (const part of [parts.city, parts.region, parts.country]) {
|
||||
const trimmed = part?.trim();
|
||||
const key = trimmed?.toLowerCase();
|
||||
if (!trimmed || !key || seen.has(key)) continue;
|
||||
|
||||
seen.add(key);
|
||||
kept.push(trimmed);
|
||||
}
|
||||
|
||||
return kept.join(", ");
|
||||
}
|
||||
|
||||
/** Aborts on either the caller's signal or a timeout, whichever comes first. */
|
||||
async function fetchWithTimeout(
|
||||
url: string,
|
||||
signal: AbortSignal | undefined,
|
||||
timeoutMs: number,
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const forward = () => controller.abort();
|
||||
|
||||
signal?.addEventListener("abort", forward, { once: true });
|
||||
|
||||
try {
|
||||
return await fetch(url, { signal: controller.signal, headers: { Accept: "application/json" } });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener("abort", forward);
|
||||
}
|
||||
}
|
||||
|
||||
async function readCache(now: number): Promise<GeoPlace | undefined> {
|
||||
try {
|
||||
const raw = await LocalStorage.getItem<string>(STORAGE_KEY);
|
||||
if (!raw) return undefined;
|
||||
|
||||
const parsed = CachedSchema.safeParse(JSON.parse(raw));
|
||||
if (!parsed.success) return undefined;
|
||||
if (now - parsed.data.fetchedAt > GEO_TTL_MS) return undefined;
|
||||
|
||||
return parsed.data;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeCache(place: GeoPlace): Promise<void> {
|
||||
await LocalStorage.setItem(STORAGE_KEY, JSON.stringify(place)).catch(() => undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the user's rough place from their IP address.
|
||||
*
|
||||
* Never throws: a lookup failure leaves the region-level answer intact, and the
|
||||
* message is passed on so the model can say what went wrong rather than quietly
|
||||
* presenting a coarser answer as though it were the same thing.
|
||||
*/
|
||||
export async function ipPlace(options: GeoOptions = {}): Promise<GeoLookup> {
|
||||
const now = options.now ?? Date.now();
|
||||
|
||||
if (!options.refresh) {
|
||||
const cached = await readCache(now);
|
||||
if (cached) return { status: "ok", place: cached };
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchWithTimeout(GEO_PROVIDER, options.signal, GEO_TIMEOUT_MS);
|
||||
} catch (thrown) {
|
||||
return { status: "failed", message: `could not reach ${GEO_PROVIDER_NAME} (${errorMessage(thrown)})` };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return { status: "failed", message: `${GEO_PROVIDER_NAME} answered HTTP ${response.status}` };
|
||||
}
|
||||
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
return { status: "failed", message: `${GEO_PROVIDER_NAME} did not answer with JSON` };
|
||||
}
|
||||
|
||||
const parsed = ResponseSchema.safeParse(payload);
|
||||
if (!parsed.success) {
|
||||
return { status: "failed", message: `${GEO_PROVIDER_NAME} answered in an unexpected shape` };
|
||||
}
|
||||
|
||||
const data = parsed.data;
|
||||
if (data.error) {
|
||||
return {
|
||||
status: "failed",
|
||||
message: data.message ?? data.reason ?? `${GEO_PROVIDER_NAME} refused the lookup`,
|
||||
};
|
||||
}
|
||||
|
||||
const city = data.city ?? undefined;
|
||||
const region = data.region ?? undefined;
|
||||
const country = data.country_name ?? undefined;
|
||||
const place = buildPlace({ city, region, country });
|
||||
|
||||
if (!place) {
|
||||
return { status: "failed", message: `${GEO_PROVIDER_NAME} returned no usable place` };
|
||||
}
|
||||
|
||||
const resolved: GeoPlace = {
|
||||
place,
|
||||
city,
|
||||
region,
|
||||
country,
|
||||
latitude: data.latitude ?? undefined,
|
||||
longitude: data.longitude ?? undefined,
|
||||
timezone: data.timezone ?? undefined,
|
||||
source: GEO_PROVIDER_NAME,
|
||||
fetchedAt: now,
|
||||
};
|
||||
|
||||
await writeCache(resolved);
|
||||
return { status: "ok", place: resolved };
|
||||
}
|
||||
|
||||
/** Drops the cached place, so a move or a provider change is not pinned for hours. */
|
||||
export async function forgetGeoPlace(): Promise<void> {
|
||||
await LocalStorage.removeItem(STORAGE_KEY).catch(() => undefined);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { runAgent } from "../agent";
|
||||
import { toStoredAttachment } from "../attachments";
|
||||
import { ipPlace } from "../geo";
|
||||
import { buildMessages, composeSystemPrompt } from "../history";
|
||||
import { OllamaError } from "../ollama";
|
||||
import type { ResolvedPreferences } from "../preferences";
|
||||
@@ -174,7 +175,14 @@ export function useChat(options: UseChatOptions): UseChat {
|
||||
think: preferences.think,
|
||||
messages,
|
||||
tools: toolsFor({ webEnabled: preferences.webEnabled }),
|
||||
toolContext: { host: preferences.host, apiKey: preferences.apiKey },
|
||||
toolContext: {
|
||||
host: preferences.host,
|
||||
apiKey: preferences.apiKey,
|
||||
place: preferences.place,
|
||||
// Only wired up when the user switched it on, so an off setting
|
||||
// cannot reach the network for a location at all.
|
||||
ipPlace: preferences.ipGeolocation ? ipPlace : undefined,
|
||||
},
|
||||
signal: controller.signal,
|
||||
events: {
|
||||
onContentDelta: (delta) => {
|
||||
|
||||
+69
-13
@@ -1,30 +1,76 @@
|
||||
/**
|
||||
* Time and place, derived locally.
|
||||
*
|
||||
* `get_location` is intentionally timezone-derived rather than a GPS or IP
|
||||
* lookup. It answers the questions people actually ask a launcher ("what time
|
||||
* is it there", "is it late in London") without shipping the user's IP address
|
||||
* to a third-party geolocation service on every tool call. It is also pure, so
|
||||
* it is cheap to test.
|
||||
* `get_location` is timezone-derived rather than a GPS or IP lookup, so the
|
||||
* user's IP is never shipped to a third-party geolocation service on every tool
|
||||
* call. It is also pure, so it is cheap to test.
|
||||
*
|
||||
* The correction that matters here is that a timezone name is **not** a city.
|
||||
* The IANA database names a zone after one representative place, and a single
|
||||
* zone can cover a whole country: "Asia/Manila" is the Philippines,
|
||||
* "Asia/Kolkata" is India, "Europe/Oslo" is Norway, "Australia/Brisbane" is
|
||||
* Queensland. Reporting "Manila" as the user's city is simply wrong for anyone
|
||||
* in Cebu, and being confidently wrong is worse than being vague, because a
|
||||
* model asked about the weather will use it without hesitating. So the zone's
|
||||
* label is still reported, but named for what it is instead of being dressed up
|
||||
* as the user's city.
|
||||
*
|
||||
* The only exact answer available without asking a third party is the Location
|
||||
* preference, which the user fills in themselves. It is reported verbatim and
|
||||
* preferred over anything inferred.
|
||||
*/
|
||||
|
||||
export interface LocationContext {
|
||||
timezone: string;
|
||||
utcOffset: string;
|
||||
locale: string;
|
||||
/** Country code from the locale, e.g. "PH". The user's own setting rather than an inference. */
|
||||
regionCode?: string;
|
||||
/** Readable name for that region, e.g. "Philippines". */
|
||||
region?: string;
|
||||
city?: string;
|
||||
/**
|
||||
* The timezone's own area label, e.g. "Manila".
|
||||
*
|
||||
* Deliberately not called a city. See the note at the top of this file.
|
||||
*/
|
||||
timezoneArea?: string;
|
||||
/** The user's configured Location preference, verbatim. The only exact value here. */
|
||||
place?: string;
|
||||
}
|
||||
|
||||
/** Splits an IANA zone like "Asia/Manila" or "America/Argentina/Salta" into readable parts. */
|
||||
export function describeTimezone(timeZone: string): { region?: string; city?: string } {
|
||||
/**
|
||||
* Splits an IANA zone like "Asia/Manila" or "America/Argentina/Salta".
|
||||
*
|
||||
* Only the last segment is kept, and the continent grouping is dropped: "Asia"
|
||||
* next to a locale region of "Philippines" reads as two answers to the same
|
||||
* question, and the zone's grouping answers a question nobody asked.
|
||||
*/
|
||||
export function describeTimezoneArea(timeZone: string): { area?: string } {
|
||||
const parts = timeZone.split("/").filter(Boolean);
|
||||
if (parts.length < 2) return {};
|
||||
|
||||
const region = parts[0];
|
||||
const city = parts[parts.length - 1].replace(/_/g, " ");
|
||||
return { area: parts[parts.length - 1].replace(/_/g, " ") || undefined };
|
||||
}
|
||||
|
||||
return { region, city: city || undefined };
|
||||
/**
|
||||
* The region from the locale subtag.
|
||||
*
|
||||
* This is a declaration rather than an inference: a locale of "en-PH" says the
|
||||
* user set their region to the Philippines. That makes it a better answer than
|
||||
* anything read out of a zone name, and it costs nothing.
|
||||
*/
|
||||
export function localeRegion(locale: string): { code: string; name: string } | undefined {
|
||||
try {
|
||||
const code = new Intl.Locale(locale).region;
|
||||
if (!code) return undefined;
|
||||
|
||||
const name = new Intl.DisplayNames(["en"], { type: "region" }).of(code);
|
||||
return { code, name: name ?? code };
|
||||
} catch {
|
||||
// A malformed locale, or an ICU build without region display names. Not
|
||||
// worth failing the whole answer over.
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** "GMT+08:00" on a modern V8; falls back to a short name if `longOffset` is unavailable. */
|
||||
@@ -56,7 +102,11 @@ function resolveTimeZone(timeZone?: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function getLocationContext(date: Date = new Date(), timeZone?: string): LocationContext {
|
||||
export function getLocationContext(
|
||||
date: Date = new Date(),
|
||||
timeZone?: string,
|
||||
place?: string,
|
||||
): LocationContext {
|
||||
const zone = resolveTimeZone(timeZone);
|
||||
|
||||
let locale = "en-US";
|
||||
@@ -66,11 +116,17 @@ export function getLocationContext(date: Date = new Date(), timeZone?: string):
|
||||
// Keep the fallback locale.
|
||||
}
|
||||
|
||||
const region = localeRegion(locale);
|
||||
const configured = place?.trim();
|
||||
|
||||
return {
|
||||
timezone: zone,
|
||||
utcOffset: utcOffsetLabel(date, zone),
|
||||
locale,
|
||||
...describeTimezone(zone),
|
||||
regionCode: region?.code,
|
||||
region: region?.name,
|
||||
timezoneArea: describeTimezoneArea(zone).area,
|
||||
place: configured && configured.length > 0 ? configured : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface LamPreferences {
|
||||
model?: string;
|
||||
ollamaHost?: string;
|
||||
systemPrompt?: string;
|
||||
location?: string;
|
||||
ipGeolocation?: boolean;
|
||||
webSearchEnabled?: boolean;
|
||||
thinking?: string;
|
||||
showToolCalls?: boolean;
|
||||
@@ -28,6 +30,16 @@ export interface ResolvedPreferences {
|
||||
model: string;
|
||||
host: string;
|
||||
systemPrompt: string;
|
||||
/**
|
||||
* The place the user says they are, verbatim, or an empty string.
|
||||
*
|
||||
* The only exact location this extension has. Everything else is inferred from
|
||||
* the device timezone and locale, which is accurate to a region rather than a
|
||||
* city, and a timezone name is not a city at all.
|
||||
*/
|
||||
place: string;
|
||||
/** Whether to resolve a rough place from the IP address. Off unless asked for. */
|
||||
ipGeolocation: boolean;
|
||||
webEnabled: boolean;
|
||||
/** Undefined when reasoning is off, which is what the API client expects. */
|
||||
think?: string;
|
||||
@@ -78,6 +90,10 @@ export function resolvePreferences(raw: LamPreferences): ResolvedPreferences {
|
||||
model: (raw.model ?? "").trim() || DEFAULT_MODEL,
|
||||
host: (raw.ollamaHost ?? "").trim() || DEFAULT_HOST,
|
||||
systemPrompt: (raw.systemPrompt ?? "").trim(),
|
||||
place: (raw.location ?? "").trim(),
|
||||
// Defaults to off: this is the one setting that sends something about the
|
||||
// user to a third party, so it is a deliberate choice rather than a default.
|
||||
ipGeolocation: raw.ipGeolocation ?? false,
|
||||
// Defaults to on: the preference is a checkbox the user has to actively untick.
|
||||
webEnabled: raw.webSearchEnabled ?? true,
|
||||
think: thinking && thinking !== "off" ? thinking : undefined,
|
||||
|
||||
+72
-10
@@ -1,5 +1,6 @@
|
||||
import { formatNow, getLocationContext, weekdayName } from "./location";
|
||||
import { webFetch, webSearch } from "./ollama";
|
||||
import type { GeoLookup } from "./geo";
|
||||
import type { ToolDefinition } from "./types";
|
||||
import { errorMessage } from "./utils";
|
||||
|
||||
@@ -19,6 +20,21 @@ export interface ToolContext {
|
||||
signal?: AbortSignal;
|
||||
/** Injectable clock so tests are deterministic. */
|
||||
now?: Date;
|
||||
/**
|
||||
* The user's Location preference, if they set one.
|
||||
*
|
||||
* Injected rather than read here, because this module is deliberately free of
|
||||
* Vicinae imports and preferences arrive through that API.
|
||||
*/
|
||||
place?: string;
|
||||
/**
|
||||
* Resolves a rough place from the user's IP address, when that is switched on.
|
||||
*
|
||||
* Injected for the same reason as `place`, and absent when IP geolocation is
|
||||
* off, which is what keeps this module from being able to reach the network
|
||||
* for a location on its own.
|
||||
*/
|
||||
ipPlace?: (options: { signal?: AbortSignal }) => Promise<GeoLookup>;
|
||||
}
|
||||
|
||||
export interface ToolResult {
|
||||
@@ -45,7 +61,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
||||
function: {
|
||||
name: "get_location",
|
||||
description:
|
||||
"Get the user's approximate location context: timezone, UTC offset, locale, region, and city. Use it for local questions such as weather, business hours, or travel timing. This is derived from the device timezone, not GPS, so treat it as a region rather than an address.",
|
||||
"Get where the user is, for local questions such as weather, business hours, or travel timing. Returns the place they configured in preferences, plus the timezone, UTC offset, locale and region. Without a configured place this is region-level, not an address, and the timezone area it reports is the timezone's own name rather than the user's city, so never treat it as one.",
|
||||
parameters: { type: "object", properties: {}, required: [] },
|
||||
},
|
||||
},
|
||||
@@ -140,7 +156,7 @@ export async function executeTool(
|
||||
return { content: describeNow(context.now), isError: false };
|
||||
|
||||
case "get_location":
|
||||
return { content: describeLocation(context.now), isError: false };
|
||||
return { content: await describeLocation(context), isError: false };
|
||||
|
||||
case "web_search":
|
||||
return await runWebSearch(args, context);
|
||||
@@ -172,18 +188,64 @@ function describeNow(now?: Date): string {
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function describeLocation(now?: Date): string {
|
||||
const date = now ?? new Date();
|
||||
const location = getLocationContext(date);
|
||||
async function describeLocation(context: ToolContext): Promise<string> {
|
||||
const date = context.now ?? new Date();
|
||||
const configured = context.place?.trim() || undefined;
|
||||
|
||||
const lines = [
|
||||
// A place the user typed wins outright, and while it is set there is no reason
|
||||
// to hand their IP address to anyone.
|
||||
const lookup =
|
||||
configured || !context.ipPlace ? undefined : await context.ipPlace({ signal: context.signal });
|
||||
const ip = lookup?.status === "ok" ? lookup.place : undefined;
|
||||
|
||||
const location = getLocationContext(date, undefined, configured ?? ip?.place);
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
// The place comes first because it is the only answer that is about where the
|
||||
// user actually is, rather than about which zone the clock is set to.
|
||||
if (location.place) lines.push(`Place: ${location.place}`);
|
||||
|
||||
lines.push(
|
||||
`Timezone: ${location.timezone}`,
|
||||
`UTC offset: ${location.utcOffset}`,
|
||||
`Locale: ${location.locale}`,
|
||||
];
|
||||
if (location.city) lines.push(`City: ${location.city}`);
|
||||
if (location.region) lines.push(`Region: ${location.region}`);
|
||||
lines.push("Note: derived from the device timezone. It indicates region, not a precise address.");
|
||||
);
|
||||
|
||||
if (location.region) {
|
||||
lines.push(`Region: ${location.region}${location.regionCode ? ` (${location.regionCode})` : ""}`);
|
||||
}
|
||||
if (location.timezoneArea) {
|
||||
lines.push(`Timezone area: ${location.timezoneArea}`);
|
||||
}
|
||||
|
||||
// A mismatch between the clock and the IP is worth surfacing: it usually means
|
||||
// a VPN or a device still set to somewhere the user has left.
|
||||
if (ip?.timezone && ip.timezone !== location.timezone) {
|
||||
lines.push(`IP-reported timezone: ${ip.timezone}, which differs from the device timezone`);
|
||||
}
|
||||
|
||||
if (configured) {
|
||||
lines.push(
|
||||
"Note: Place is the user's own setting, so prefer it for anything that depends on exactly where they are.",
|
||||
);
|
||||
} else if (ip) {
|
||||
lines.push(
|
||||
`Note: Place came from an IP address lookup via ${ip.source}, so it is city-level rather than exact, and it can be out of date if the user has moved since.`,
|
||||
);
|
||||
} else {
|
||||
lines.push(
|
||||
"Note: no Location is set in preferences. Everything above is derived from the device timezone and locale, so it is accurate to a region and not to a city. Ask which city they are in when an exact place matters.",
|
||||
);
|
||||
}
|
||||
|
||||
if (lookup?.status === "failed") {
|
||||
lines.push(`IP geolocation is switched on but did not answer: ${lookup.message}.`);
|
||||
}
|
||||
|
||||
if (location.timezoneArea) {
|
||||
lines.push("The timezone area is the timezone's own name, not the user's city.");
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Action, ActionPanel, Icon, List, openExtensionPreferences } from "@vici
|
||||
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";
|
||||
@@ -60,6 +61,10 @@ export default function SetupCommand() {
|
||||
// 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,
|
||||
@@ -176,6 +181,18 @@ export default function SetupCommand() {
|
||||
}
|
||||
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 city, resolved from the IP address via ipapi.co"
|
||||
: "Region only, from the timezone and locale"
|
||||
}
|
||||
actions={settingsActions}
|
||||
/>
|
||||
</List.Section>
|
||||
|
||||
{error ? (
|
||||
|
||||
Reference in New Issue
Block a user