Theseus 0.3.122 followups: AI engines page, Pick-AI edit button, Pithos panel, Did-you-mean on DNS failures, LLM picker UI polish, real-brand icon preference
Shipped on top of the 0.3.122 Settings restructure commit. AI engines settings page. New top-level nav entry "AI engines" with its own Default picker, Enabled list, catalog, discover filter and + Add AI engine form. The Search page is now LLM-free — ENGINE_KINDS is `["search"]`, the Search catalog filters out kind="llm" entries, and the Search custom-URL form only adds kind="search" engines. add-engine IPC now accepts a kind: "llm" field (sets defaultLlmId instead of searchEngine for LLM customs). Pick-AI picker. Borders removed, corners rounded (16px), heavier shadow; "Pick AI engines…" footer row dropped. Edit button (pencil icon) added to the header; opens the new AI engines settings page via `llm-picker-settings`, which now routes to the "ai-engines" slug (openSettingsTab falls through to "search" for legacy profiles that lack the section). Icon priority flipped. allEngines() now prefers a fetched favicon (the site's own brand mark, cached per profile) over the bundled generic tile. Bundled tiles still carry the first paint while the fetch runs, and the emit after fetch swaps in the real brand. Offline profiles see the tile indefinitely — same reliability as before. Pithos sidebar panel. bundled-addons/pithos/addon.json declares a `panels` block so sidebarLeftPanels() returns Pithos's panel BEFORE the add-on's on-demand activation. Previously, the Sidebar settings' Theseus native apps card was missing Pithos: it only appeared after first launch, when the add-on's own registerSidebarPanel call ran. Hermes already did this. Did-you-mean on DNS failures. The error page's "Did you mean" row now merges BCNR warm-index matches with ICANN alternative-TLD swaps + history base-name matches. For my.mynymbox.com the suggestions are my.mynymbox.io, mynymbox.io, my.mynymbox.net, mynymbox.net — the subdomain-strip is interleaved so a mistake like typing my.mynymbox.com when the real site is client.mynymbox.io still surfaces mynymbox.io near the top. Both paths are local-only; no network, no leak of what the user is browsing. Sidebar settings tightened. "How panels open" collapsed to one row with two side-by-side option cards; "Reset panel widths" and "AI engine in the sidebar" are one-liners now.
This commit is contained in:
parent
1a31ac0393
commit
aea158ab05
6 changed files with 313 additions and 49 deletions
|
|
@ -7,6 +7,15 @@
|
|||
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjM2RkYzk3Ii8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMWU4ZjZhIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTIyIDZoMjB2NWMwIDItMiAzLTIgNSA4IDMgMTQgMTEgMTQgMjEgMCAxMi05IDIxLTIyIDIxUzEwIDQ5IDEwIDM3YzAtMTAgNi0xOCAxNC0yMSAwLTItMi0zLTItNXoiIGZpbGw9InVybCgjZykiLz48cGF0aCBkPSJNMTQgMzBoMzZNMTMgNDBoMzgiIHN0cm9rZT0iIzBiMWYxOCIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIG9wYWNpdHk9Ii41NSIvPjwvc3ZnPgo=",
|
||||
"main": "index.js",
|
||||
"activation": "on-demand",
|
||||
"panels": [
|
||||
{
|
||||
"id": "main",
|
||||
"title": "Pithos",
|
||||
"page": "ui/index.html",
|
||||
"side": "left",
|
||||
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjM2RkYzk3Ii8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMWU4ZjZhIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTIyIDZoMjB2NWMwIDItMiAzLTIgNSA4IDMgMTQgMTEgMTQgMjEgMCAxMi05IDIxLTIyIDIxUzEwIDQ5IDEwIDM3YzAtMTAgNi0xOCAxNC0yMSAwLTItMi0zLTItNXoiIGZpbGw9InVybCgjZykiLz48cGF0aCBkPSJNMTQgMzBoMzZNMTMgNDBoMzgiIHN0cm9rZT0iIzBiMWYxOCIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIG9wYWNpdHk9Ii41NSIvPjwvc3ZnPgo="
|
||||
}
|
||||
],
|
||||
"capabilities": [
|
||||
"sidebar-panel",
|
||||
"vault-derive",
|
||||
|
|
|
|||
|
|
@ -9,4 +9,7 @@ contextBridge.exposeInMainWorld("errorpage", {
|
|||
registerOnSirius: (host) => ipcRenderer.invoke("error-register", host),
|
||||
openExternal: (url) => ipcRenderer.invoke("error-open-external", url),
|
||||
bnsSimilar: (host) => ipcRenderer.invoke("error-bns-similar", host),
|
||||
// ICANN "did you mean?" — alternative-TLD swaps + history matches for a
|
||||
// host whose DNS failed. See icannAlternatives() in main.js.
|
||||
icannAlternatives: (host) => ipcRenderer.invoke("error-icann-alternatives", host),
|
||||
});
|
||||
|
|
|
|||
32
error.html
32
error.html
|
|
@ -270,16 +270,30 @@
|
|||
actionsBox.appendChild(b);
|
||||
}
|
||||
|
||||
// "Did you mean" — for BCNR-name failures, ask main for near-matches in
|
||||
// the warm shared index (Levenshtein ≤ 2, same TLD). No network — the
|
||||
// index is local, so this works offline too. Clicking a suggestion
|
||||
// retries the load at that host, preserving the original path if any.
|
||||
if ((kind === "name-not-registered" || kind === "name-unreachable") && host && window.errorpage?.bnsSimilar) {
|
||||
window.errorpage.bnsSimilar(host).then((matches) => {
|
||||
if (!Array.isArray(matches) || !matches.length) return;
|
||||
// "Did you mean" — two sources, merged into one row:
|
||||
// • BCNR warm shared index (Levenshtein ≤ 2, same TLD) — for a BCDN name
|
||||
// typo like "games.x" → "game.x". Local index; works offline.
|
||||
// • ICANN alternative-TLD swaps + history base-name matches — a DNS
|
||||
// failure on my.mynymbox.com surfaces my.mynymbox.io, .net, .org, …,
|
||||
// and any related host the user has visited. Local too.
|
||||
// Clicking a suggestion retries at that host, preserving the original path.
|
||||
if ((kind === "name-not-registered" || kind === "name-unreachable") && host) {
|
||||
let origPath = "/";
|
||||
try { const u = new URL(url); origPath = u.pathname + u.search + u.hash; } catch {}
|
||||
const sources = [
|
||||
window.errorpage?.bnsSimilar ? window.errorpage.bnsSimilar(host) : Promise.resolve([]),
|
||||
window.errorpage?.icannAlternatives ? window.errorpage.icannAlternatives(host) : Promise.resolve([]),
|
||||
];
|
||||
Promise.all(sources).then(([bns, icann]) => {
|
||||
const seen = new Set();
|
||||
const matches = [];
|
||||
for (const name of [...(bns || []), ...(icann || [])]) {
|
||||
if (!name || seen.has(name)) continue;
|
||||
seen.add(name); matches.push(name);
|
||||
if (matches.length >= 4) break;
|
||||
}
|
||||
if (!matches.length) return;
|
||||
const row = $("suggestRow");
|
||||
let origPath = "/";
|
||||
try { const u = new URL(url); origPath = u.pathname + u.search + u.hash; } catch {}
|
||||
for (const name of matches) {
|
||||
const a = el("a", null, name);
|
||||
a.href = "#";
|
||||
|
|
|
|||
|
|
@ -19,16 +19,21 @@
|
|||
}
|
||||
.pop {
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--bg); border: 1px solid var(--line); border-radius: 12px;
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,.5), 0 2px 8px rgba(0,0,0,.4);
|
||||
background: var(--bg); border-radius: 16px;
|
||||
box-shadow: 0 16px 48px rgba(0,0,0,.55), 0 4px 12px rgba(0,0,0,.4);
|
||||
overflow: hidden; width: 240px;
|
||||
}
|
||||
.hdr { padding: 10px 14px 6px; font-size: 11px; font-weight: 600;
|
||||
.hdr { padding: 10px 14px 4px; font-size: 11px; font-weight: 600;
|
||||
color: var(--mut); text-transform: uppercase; letter-spacing: .5px;
|
||||
border-bottom: 1px solid var(--line); }
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.hdr .edit { background: transparent; border: 0; padding: 2px; margin: -2px;
|
||||
color: var(--mut); cursor: pointer; border-radius: 6px;
|
||||
display: inline-grid; place-items: center; }
|
||||
.hdr .edit:hover { color: var(--ink); background: var(--card-hi); }
|
||||
.hdr .edit svg { width: 14px; height: 14px; display: block; }
|
||||
.list { display: flex; flex-direction: column; padding: 4px; }
|
||||
.row { display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 10px; border-radius: 8px; cursor: pointer; position: relative;
|
||||
padding: 8px 10px; border-radius: 10px; cursor: pointer; position: relative;
|
||||
transition: background .08s; }
|
||||
.row:hover { background: var(--card-hi); }
|
||||
.row.cur { background: var(--acid-tint); }
|
||||
|
|
@ -41,7 +46,7 @@
|
|||
.name { flex: 1; font-weight: 600; font-size: 13px; color: var(--ink); }
|
||||
.check { flex: none; width: 14px; height: 14px; color: var(--acid); opacity: 0; }
|
||||
.row.cur .check { opacity: 1; }
|
||||
.footer { border-top: 1px solid var(--line); padding: 4px; }
|
||||
.footer { padding: 4px; }
|
||||
.footer .row { color: var(--mut); font-weight: 500; }
|
||||
.footer .ico { background: transparent; }
|
||||
.footer .ico svg { width: 18px; height: 18px; color: var(--mut); }
|
||||
|
|
@ -50,14 +55,8 @@
|
|||
</head>
|
||||
<body>
|
||||
<div class="pop" id="pop">
|
||||
<div class="hdr">AI engine</div>
|
||||
<div class="hdr"><span>AI engine</span><button class="edit" id="editBtn" title="Edit AI engines" aria-label="Edit AI engines"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button></div>
|
||||
<div class="list" id="list"></div>
|
||||
<div class="footer">
|
||||
<div class="row" id="settingsRow">
|
||||
<div class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></div>
|
||||
<div class="name">Pick AI engines…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const api = window.llmPicker;
|
||||
|
|
@ -85,8 +84,10 @@
|
|||
list.appendChild(row);
|
||||
}
|
||||
}
|
||||
document.getElementById("settingsRow").addEventListener("click", () => api.settings());
|
||||
document.addEventListener("keydown", (e) => { if (e.key === "Escape") api.close(); });
|
||||
// Edit button → opens Settings on the dedicated AI engines page (handled
|
||||
// in main via the picker's own `settings` IPC).
|
||||
document.getElementById("editBtn").addEventListener("click", () => api.settings());
|
||||
// Close-on-click-away is driven from main (see closeOnClickAway registration);
|
||||
// relying on a client-side window.blur here closes the picker the moment it
|
||||
// opens if Electron's focus flickers between the strip view and the pop,
|
||||
|
|
|
|||
90
main.js
90
main.js
|
|
@ -310,13 +310,17 @@ function isInstalled(id) {
|
|||
return (settings.installedEngines || DEFAULT_ENABLED).includes(id);
|
||||
}
|
||||
function allEngines() {
|
||||
// Built-in engines without a bundled icon fall back to the same
|
||||
// fetch-and-cache path custom engines use — pulls the site's own
|
||||
// /favicon.ico once and remembers it per profile, so the picker shows
|
||||
// the real brand mark instead of the sym glyph. The emoji sym renders
|
||||
// first and the real icon replaces it on the next emit after fetch.
|
||||
const list = Object.entries(SEARCH_ENGINES).map(([id, e]) =>
|
||||
({ id, name: e.name, sym: e.sym, favicon: bundledIcon(id) || (e.fav ? customFavicon("https://" + e.fav + "/") : null), kind: e.kind || "search", tier: e.tier || "catalog", builtin: true, installed: isInstalled(id), enabled: isEnabled(id) && !e.frozen, frozen: e.frozen || null }));
|
||||
// Icon preference, cheapest first: a cached fetched favicon (the site's own
|
||||
// brand mark, pulled once at /favicon.ico and kept per profile) → the
|
||||
// bundled generic tile → the sym emoji. The fetch is triggered by
|
||||
// customFavicon when the cache misses; the first paint shows the bundled
|
||||
// tile, the next emit after fetch swaps in the real mark. For engines with
|
||||
// no bundled file (today: Phind, frozen) the emoji carries the first paint.
|
||||
const list = Object.entries(SEARCH_ENGINES).map(([id, e]) => {
|
||||
const fetched = e.fav ? customFavicon("https://" + e.fav + "/") : null;
|
||||
const favicon = fetched || bundledIcon(id);
|
||||
return { id, name: e.name, sym: e.sym, favicon, kind: e.kind || "search", tier: e.tier || "catalog", builtin: true, installed: isInstalled(id), enabled: isEnabled(id) && !e.frozen, frozen: e.frozen || null };
|
||||
});
|
||||
for (const c of settings.customEngines || [])
|
||||
list.push({ id: c.id, name: c.name, sym: c.sym || "🔍", favicon: customFavicon(c.url), kind: c.kind || "search", tier: "custom", builtin: false, installed: true, enabled: isEnabled(c.id) });
|
||||
// Apply the user's custom order; ids not in engineOrder keep their natural order (stable sort).
|
||||
|
|
@ -8068,6 +8072,66 @@ function levenshtein(a, b) {
|
|||
}
|
||||
return prev[n];
|
||||
}
|
||||
// "Did you mean?" for ICANN hosts that failed DNS. Two sources, in order:
|
||||
// 1. Hostnames the user has visited whose registrable base matches
|
||||
// (mynymbox.com → suggests my.mynymbox.io if that is in history).
|
||||
// 2. Common TLD swaps for the registrable base — a canned list of the
|
||||
// TLDs most frequently confused with each other (.com ↔ .io ↔ .net
|
||||
// ↔ .org ↔ .co ↔ .app ↔ .dev ↔ .ai).
|
||||
// Returns up to 4 full hostnames the user can click to retry. No network;
|
||||
// no suggestion that leaks what the user is browsing beyond their own box.
|
||||
const COMMON_TLD_SWAPS = ["com", "io", "net", "org", "co", "app", "dev", "ai", "me"];
|
||||
function icannAlternatives(host) {
|
||||
const h = String(host || "").trim().toLowerCase();
|
||||
if (!h || !isLikelyIcannHost(h)) return [];
|
||||
const tld = tldOf(h);
|
||||
if (!tld) return [];
|
||||
// Full base = everything before ".<tld>" ("my.mynymbox" for my.mynymbox.com).
|
||||
// Apex base = just the registrable name ("mynymbox"), used to also suggest
|
||||
// the apex at a different TLD — subdomain typos (my.mynymbox.com actually
|
||||
// being client.mynymbox.io) are common enough that this doubles the hit rate.
|
||||
const fullBase = h.slice(0, -(tld.length + 1));
|
||||
if (!fullBase) return [];
|
||||
const parts = fullBase.split(".");
|
||||
const apexBase = parts[parts.length - 1];
|
||||
const hasSub = parts.length > 1 && apexBase.length >= 2;
|
||||
const seen = new Set([h]);
|
||||
const out = [];
|
||||
const push = (cand) => { if (cand && !seen.has(cand)) { seen.add(cand); out.push(cand); } };
|
||||
// History hits first — a hostname the user has actually visited is a
|
||||
// stronger signal than a canned TLD swap. Match either the full base
|
||||
// (my.mynymbox.*) or the apex base (*.mynymbox.* / mynymbox.*).
|
||||
for (const entry of history || []) {
|
||||
if (out.length >= 4) break;
|
||||
try {
|
||||
const u = new URL(entry.url);
|
||||
const cand = u.hostname.toLowerCase();
|
||||
if (!isLikelyIcannHost(cand)) continue;
|
||||
const candTld = tldOf(cand);
|
||||
if (!candTld) continue;
|
||||
const candFull = cand.slice(0, -(candTld.length + 1));
|
||||
const candApex = candFull.split(".").pop();
|
||||
if (candFull === fullBase || candApex === apexBase) push(cand);
|
||||
} catch {}
|
||||
}
|
||||
// TLD swaps. When the host has a subdomain, interleave the full form
|
||||
// (my.mynymbox.io) with the stripped-subdomain form (mynymbox.io) so
|
||||
// the top suggestions cover BOTH the "wrong-TLD" and the "wrong-TLD-
|
||||
// and-wrong-subdomain" cases — mynymbox.com's real site is actually
|
||||
// client.mynymbox.io, which neither variant alone catches.
|
||||
for (const alt of COMMON_TLD_SWAPS) {
|
||||
if (out.length >= 4) break;
|
||||
if (alt === tld) continue;
|
||||
push(fullBase + "." + alt);
|
||||
if (out.length >= 4) break;
|
||||
if (hasSub) push(apexBase + "." + alt);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
ipcMain.handle("error-icann-alternatives", (e, host) => {
|
||||
if (!isErrorPageSender(e.sender)) return [];
|
||||
return icannAlternatives(host);
|
||||
});
|
||||
ipcMain.handle("error-bns-similar", (e, host) => {
|
||||
if (!isErrorPageSender(e.sender)) return [];
|
||||
const h = String(host || "").trim().toLowerCase();
|
||||
|
|
@ -9151,11 +9215,14 @@ ipcMain.handle("add-engine", async (_e, eng) => {
|
|||
// installs it in both the settings list AND the toolbar dropdown.
|
||||
if (eng && eng.name && eng.url && String(eng.url).includes("%s")) {
|
||||
const id = "custom-" + Date.now().toString(36);
|
||||
const kind = eng.kind === "llm" ? "llm" : "search";
|
||||
settings.customEngines = [...(settings.customEngines || []),
|
||||
{ id, name: String(eng.name).slice(0, 40), sym: String(eng.sym || "🔍").slice(0, 4), url: String(eng.url).slice(0, 400) }];
|
||||
{ id, name: String(eng.name).slice(0, 40), sym: String(eng.sym || (kind === "llm" ? "✨" : "🔍")).slice(0, 4), url: String(eng.url).slice(0, 400), kind }];
|
||||
// Custom engines are auto-installed and enabled.
|
||||
settings.enabledEngines = [...new Set([...(settings.enabledEngines || DEFAULT_ENABLED), id])];
|
||||
settings.searchEngine = id; // select the one just added
|
||||
// LLM customs do NOT become the default search engine — only web-search customs do.
|
||||
if (kind === "search") settings.searchEngine = id;
|
||||
else settings.defaultLlmId = id;
|
||||
saveSettings(); emitEngines();
|
||||
// Settings pulls the list once on return, so give the icon fetch a moment
|
||||
// to land; offline or slow, the row shows the emoji and the toolbar
|
||||
|
|
@ -9993,7 +10060,10 @@ ipcMain.handle("llm-picker-pick", (e, id) => {
|
|||
ipcMain.handle("llm-picker-settings", (e) => {
|
||||
if (!llmPickerPop || e.sender !== llmPickerPop.webContents) return false;
|
||||
hideLlmPicker();
|
||||
openSettingsTab("search");
|
||||
// Opens the dedicated AI engines page added in 0.3.122 — kept separate from
|
||||
// the Search page so the two lists do not share a scroll or a Default
|
||||
// picker. Legacy profiles that lack this section fall through to "search".
|
||||
openSettingsTab("ai-engines");
|
||||
return true;
|
||||
});
|
||||
ipcMain.handle("llm-picker-close", (e) => {
|
||||
|
|
|
|||
199
settings.html
199
settings.html
|
|
@ -315,6 +315,7 @@
|
|||
<a data-sec="general" class="active">General</a>
|
||||
<a data-sec="language">Language</a>
|
||||
<a data-sec="search">Search</a>
|
||||
<a data-sec="ai-engines">AI engines</a>
|
||||
<a data-sec="passwords">Passwords</a>
|
||||
<a data-sec="performance">Performance</a>
|
||||
<a data-sec="privacy">Privacy</a>
|
||||
|
|
@ -790,6 +791,47 @@
|
|||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- AI ENGINES — mirrors the Search layout but shows only kind="llm"
|
||||
engines, so picking an AI launcher and picking a web-search engine
|
||||
live in separate pages. The default-engine picker here sets the
|
||||
defaultLlmId (the sidebar strip's Pick AI target), not the toolbar's
|
||||
search engine. Renderers share engIcon + rowFor; see renderAiEngines
|
||||
below for the paint. -->
|
||||
<section id="ai-engines" hidden>
|
||||
<h1>AI engines</h1>
|
||||
<p class="lede">Which AI launchers show up in the sidebar's Pick-AI dropdown, and which one opens when you click the sparkle button.</p>
|
||||
<div class="row">
|
||||
<div class="txt"><div class="t">Default AI engine</div><div class="d">Opens when you click the sparkle button in the sidebar strip.</div></div>
|
||||
<div class="ctl"><div class="esel" id="defaultLlm" tabindex="0" role="combobox" aria-haspopup="listbox" aria-expanded="false" title="Default AI engine"><span class="eic"></span><span class="enm"></span><span class="caret">▾</span></div></div>
|
||||
</div>
|
||||
<div class="row" style="flex-direction:column;align-items:stretch;gap:10px">
|
||||
<div class="txt" style="display:flex;align-items:center;justify-content:space-between;gap:12px">
|
||||
<div>
|
||||
<div class="t">Enabled AI engines</div>
|
||||
<div class="d">These appear in the sidebar strip's Pick-AI dropdown. Drag to reorder. Toggle off to move an engine back to the catalog.</div>
|
||||
</div>
|
||||
<button id="llmAddBtn" class="btn" type="button">+ Add AI engine</button>
|
||||
</div>
|
||||
<div id="llmEngineList"></div>
|
||||
<div id="llmEngineCatalog" class="engcat" hidden>
|
||||
<div class="ehdr">Add from catalog</div>
|
||||
<div id="llmCatalogList"></div>
|
||||
<div class="ehdr" style="display:flex;align-items:center;justify-content:space-between;gap:8px">
|
||||
<span>Discover more AI engines</span>
|
||||
<input id="llmEngineFilter" type="search" placeholder="Filter by name…" autocomplete="off"
|
||||
style="background:#1b2330;color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:4px 8px;font-size:12px;min-width:auto;width:170px">
|
||||
</div>
|
||||
<div id="llmExtraList"></div>
|
||||
<div class="ehdr">Or add a custom URL</div>
|
||||
<div class="addeng">
|
||||
<input id="llmEngSym" placeholder="✨" style="max-width:52px;text-align:center;flex:none">
|
||||
<input id="llmEngName" placeholder="Name (e.g. My Chat)">
|
||||
<input id="llmEngUrl" placeholder="https://example.com/chat?q=%s">
|
||||
<button id="llmEngAdd" class="btn">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- PASSWORDS -->
|
||||
<section id="passwords" hidden>
|
||||
<h1>Passwords</h1>
|
||||
|
|
@ -1145,25 +1187,30 @@
|
|||
<div class="txt"><div class="t">Show the strip</div><div class="d">A thin vertical column on the left side of every page.</div></div>
|
||||
<label class="sw"><input type="checkbox" id="quickLinksShow"><span class="track"><span class="knob"></span></span></label>
|
||||
</div>
|
||||
<h2 class="sub">How panels open</h2>
|
||||
<div class="row" style="flex-direction:column;align-items:stretch;gap:10px">
|
||||
<div style="display:flex;flex-direction:column;gap:6px">
|
||||
<label class="polrow"><input type="radio" name="sidebarPanelMode" value="overlay"><span><b>Over the page</b> <span class="pmuted">— the panel floats over the left of the page, which keeps its width. A click in the page closes it.</span></span></label>
|
||||
<label class="polrow"><input type="radio" name="sidebarPanelMode" value="push"><span><b>Push the page aside</b> <span class="pmuted">— the page narrows to make room, and the panel stays open until you close it.</span></span></label>
|
||||
<!-- How panels open — compact one-row header + two side-by-side option
|
||||
cards (click a card to select). Radios stay in the DOM so the init
|
||||
code keeps working; the card is really a label wrapping the radio. -->
|
||||
<div class="row" style="flex-direction:column;align-items:stretch;gap:8px">
|
||||
<div class="txt"><div class="t">How panels open</div><div class="d">Choose how the sidebar panel sits with the page.</div></div>
|
||||
<div class="sb-choice">
|
||||
<label class="sb-opt"><input type="radio" name="sidebarPanelMode" value="overlay"><div><b>Over the page</b><div class="pmuted">Panel floats; click page to close.</div></div></label>
|
||||
<label class="sb-opt"><input type="radio" name="sidebarPanelMode" value="push"><div><b>Push the page aside</b><div class="pmuted">Page narrows; panel stays open.</div></div></label>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
.sb-choice { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.sb-opt { display: flex; gap: 10px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; cursor: pointer; background: var(--panel); align-items: flex-start; }
|
||||
.sb-opt:hover { background: var(--card-hi, rgba(255,255,255,.04)); }
|
||||
.sb-opt input { margin-top: 2px; }
|
||||
.sb-opt:has(input:checked) { border-color: var(--acid, #d6ff3d); background: rgba(214,255,61,.08); }
|
||||
@media (max-width: 540px) { .sb-choice { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
<div class="row">
|
||||
<div class="txt">
|
||||
<div class="t">Reset panel widths</div>
|
||||
<div class="d">Each web app and extension panel remembers how wide you dragged it. This blanks every saved width — the next time an app opens it uses its default again.</div>
|
||||
<div class="acts"><button class="btn small ghost" id="sidebarResetWidths">Reset widths</button></div>
|
||||
</div>
|
||||
<div class="txt"><div class="t">Reset panel widths</div><div class="d">Blanks every saved width so each app opens at its default.</div></div>
|
||||
<div class="ctl"><button class="btn small ghost" id="sidebarResetWidths">Reset</button></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="txt">
|
||||
<div class="t">AI engine in the sidebar</div>
|
||||
<div class="d">The sparkle button at the bottom of the strip opens your default AI engine (right-click it to pick from any you've enabled, or set up more under <a data-go="search" style="color:var(--acid-text);cursor:pointer">Search</a>).</div>
|
||||
</div>
|
||||
<div class="txt"><div class="t">AI engine in the sidebar</div><div class="d">Sparkle button opens your default AI; <a data-go="search" style="color:var(--acid-text);cursor:pointer">Search</a> picks it.</div></div>
|
||||
</div>
|
||||
<!-- Card A: Theseus native apps (Hermes, Pithos) — bundled system apps.
|
||||
Rendered from sidebarLeftPanels(); filtered to the native-app ids. -->
|
||||
|
|
@ -1962,9 +2009,10 @@
|
|||
? `<img class="ei" src="${esc(e.favicon)}" onerror="this.replaceWith(Object.assign(document.createElement('span'),{className:'es',textContent:'${sym}'}))">`
|
||||
: `<span class="es">${sym}</span>`;
|
||||
};
|
||||
// The Search page only shows kind=search. LLMs live on their own page
|
||||
// (section #ai-engines), painted below by renderAiEngines.
|
||||
const ENGINE_KINDS = [
|
||||
{ key: "search", label: "Search engines" },
|
||||
{ key: "llm", label: "AI answer engines" },
|
||||
];
|
||||
// Default engine: our own dropdown over the ENABLED engines (the ones in the
|
||||
// toolbar picker), grouped like it, each row with the engine's icon.
|
||||
|
|
@ -2010,7 +2058,9 @@
|
|||
`<span class="enm">${esc(e.name)}</span>` +
|
||||
`<span class="kind">${(e.kind || "search") === "llm" ? "AI" : "Search"}</span>` +
|
||||
(e.frozen ? `<span class="kind warn">Unavailable</span>` : `<button class="add" data-add="${e.id}">+ Add</button>`) + `</div>`;
|
||||
const uninstalled = d.engines.filter((e) => e.builtin && !e.installed);
|
||||
// Search catalog + extras — kind=search only; LLMs live under the
|
||||
// AI engines page's own catalog, below.
|
||||
const uninstalled = d.engines.filter((e) => e.builtin && !e.installed && (e.kind || "search") === "search");
|
||||
const catalogOff = uninstalled.filter((e) => (e.tier || "catalog") === "catalog");
|
||||
const extraOff = uninstalled.filter((e) => e.tier === "extra");
|
||||
if (cat) {
|
||||
|
|
@ -2036,6 +2086,11 @@
|
|||
}
|
||||
// Toggle: pure on/off in the enabled set — the row stays visible either way.
|
||||
list.querySelectorAll('input[type="checkbox"]').forEach((cb) => cb.onchange = () => C.setEngineEnabled(cb.dataset.id, cb.checked).then(renderEngines));
|
||||
// Also paint the AI engines page (kind=llm). Kept intentionally
|
||||
// light-weight: same row/cat markup, no drag, no right-click menu, no
|
||||
// custom URL form — those live on the Search page and are rare for AI
|
||||
// launchers. The Default AI picker is a plain <select>-style button.
|
||||
renderLlmEngines(d);
|
||||
// Right-click any row → context menu with "Remove from list" (moves a
|
||||
// built-in back to the catalog; deletes a custom entirely).
|
||||
list.querySelectorAll(".eng").forEach((row) => {
|
||||
|
|
@ -2140,6 +2195,118 @@
|
|||
sel.onkeydown = (ev) => { if (selMenu) return; if (ev.key === "Enter" || ev.key === " " || ev.key === "ArrowDown" || ev.key === "ArrowUp") { ev.preventDefault(); openDefaultEngineMenu(); } };
|
||||
// The toolbar picker changes the same setting; keep the control in step.
|
||||
if (C.onSettingsUpdate) C.onSettingsUpdate((next) => { if (next && typeof next.searchEngine === "string" && next.searchEngine !== selCurrent) { selCurrent = next.searchEngine; paintDefaultEngine(); } });
|
||||
|
||||
// ---- AI engines page (section #ai-engines) ------------------------------
|
||||
// Mirrors the Search page's shape but limited to kind=llm. The Default
|
||||
// AI engine button writes settings.defaultLlmId (same key the sidebar
|
||||
// strip reads); toggles reuse C.setEngineEnabled. The LLM catalog shows
|
||||
// installable AI launchers; "Discover more" is wired for symmetry even
|
||||
// though no LLM is on the "extra" tier today.
|
||||
const defLlmSel = document.getElementById("defaultLlm");
|
||||
let defLlmId = s.defaultLlmId || null;
|
||||
function paintDefaultLlm(enabledLlms) {
|
||||
if (!defLlmSel) return;
|
||||
const cur = enabledLlms.find((e) => e.id === defLlmId) || enabledLlms[0];
|
||||
defLlmSel.querySelector(".eic").innerHTML = cur ? engIcon(cur) : "";
|
||||
defLlmSel.querySelector(".enm").textContent = cur ? cur.name : "No AI engines enabled";
|
||||
}
|
||||
function renderLlmEngines(d) {
|
||||
const installed = d.engines.filter((e) => e.installed && (e.kind || "search") === "llm");
|
||||
const enabledLlms = d.engines.filter((e) => e.enabled && (e.kind || "search") === "llm");
|
||||
paintDefaultLlm(enabledLlms);
|
||||
const list = document.getElementById("llmEngineList");
|
||||
if (!list) return;
|
||||
list.innerHTML = installed.length
|
||||
? installed.map(rowFor).join("")
|
||||
: `<div class="cempty2">No AI engines in your list. Click + Add AI engine below.</div>`;
|
||||
list.querySelectorAll('input[type="checkbox"]').forEach((cb) => cb.onchange = () => C.setEngineEnabled(cb.dataset.id, cb.checked).then(renderEngines));
|
||||
list.querySelectorAll(".eng").forEach((row) => {
|
||||
row.addEventListener("contextmenu", (ev) => { ev.preventDefault(); openEngineMenu(row, ev.clientX, ev.clientY); });
|
||||
});
|
||||
const cat = document.getElementById("llmCatalogList");
|
||||
const extra = document.getElementById("llmExtraList");
|
||||
const filterInput = document.getElementById("llmEngineFilter");
|
||||
const uninstalled = d.engines.filter((e) => e.builtin && !e.installed && (e.kind || "search") === "llm");
|
||||
const catalogOff = uninstalled.filter((e) => (e.tier || "catalog") === "catalog");
|
||||
const extraOff = uninstalled.filter((e) => e.tier === "extra");
|
||||
if (cat) {
|
||||
cat.innerHTML = catalogOff.length
|
||||
? catalogOff.map(catRow).join("")
|
||||
: `<div class="cempty2">All AI engines are already in your list.</div>`;
|
||||
cat.querySelectorAll(".add").forEach((b) => b.onclick = () => C.setEngineEnabled(b.dataset.add, true).then(renderEngines));
|
||||
}
|
||||
if (extra) {
|
||||
const paint = (q) => {
|
||||
const filt = String(q || "").trim().toLowerCase();
|
||||
const shown = filt ? extraOff.filter((e) => e.name.toLowerCase().includes(filt)) : extraOff;
|
||||
extra.innerHTML = shown.length
|
||||
? shown.map(catRow).join("")
|
||||
: `<div class="cempty2">${filt ? "No AI engines match that filter." : "No extra AI engines yet."}</div>`;
|
||||
extra.querySelectorAll(".add").forEach((b) => b.onclick = () => C.setEngineEnabled(b.dataset.add, true).then(renderEngines));
|
||||
};
|
||||
paint(filterInput ? filterInput.value : "");
|
||||
if (filterInput && !filterInput.dataset.wired) {
|
||||
filterInput.dataset.wired = "1";
|
||||
filterInput.addEventListener("input", () => paint(filterInput.value));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Default AI engine dropdown — mirrors the Search engine picker's menu.
|
||||
let llmMenu = null;
|
||||
function closeDefaultLlmMenu() { if (llmMenu) { llmMenu.remove(); llmMenu = null; defLlmSel?.classList.remove("open"); defLlmSel?.setAttribute("aria-expanded", "false"); } }
|
||||
function openDefaultLlmMenu() {
|
||||
if (!defLlmSel) return;
|
||||
closeEngineMenu(); closeDefaultEngineMenu(); closeDefaultLlmMenu();
|
||||
const enabledLlms = selEngines.filter((e) => (e.kind || "search") === "llm");
|
||||
const m = document.createElement("div");
|
||||
m.className = "ctxmenu eselmenu"; m.setAttribute("role", "listbox");
|
||||
m.innerHTML = enabledLlms.map((e) => `<div class="mi${e.id === defLlmId ? " cur" : ""}" role="option" data-id="${esc(e.id)}"><span class="eic">${engIcon(e)}</span><span class="enm">${esc(e.name)}</span>${e.id === defLlmId ? '<span class="chk">✓</span>' : ""}</div>`).join("")
|
||||
|| `<div class="cempty2" style="padding:10px 12px">Turn an AI engine on below first.</div>`;
|
||||
document.body.appendChild(m);
|
||||
const r = defLlmSel.getBoundingClientRect(), mr = m.getBoundingClientRect();
|
||||
const vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
|
||||
m.style.minWidth = r.width + "px";
|
||||
m.style.left = Math.max(6, Math.min(r.left, vw - mr.width - 6)) + "px";
|
||||
m.style.top = (r.bottom + 4 + mr.height > vh - 6 ? Math.max(6, r.top - 4 - mr.height) : r.bottom + 4) + "px";
|
||||
const items = [...m.querySelectorAll(".mi")];
|
||||
const choose = (id) => { closeDefaultLlmMenu(); if (id && id !== defLlmId) { defLlmId = id; C.set("defaultLlmId", id); paintDefaultLlm(enabledLlms); } defLlmSel.focus(); };
|
||||
items.forEach((i) => { i.onclick = () => choose(i.dataset.id); });
|
||||
const off = (ev) => { if (!m.contains(ev.target) && ev.target !== defLlmSel && !defLlmSel.contains(ev.target)) closeDefaultLlmMenu(); };
|
||||
const key = (ev) => { if (ev.key === "Escape") { ev.preventDefault(); closeDefaultLlmMenu(); defLlmSel.focus(); } };
|
||||
const cleanup = () => { document.removeEventListener("mousedown", off); document.removeEventListener("keydown", key, true); };
|
||||
document.addEventListener("mousedown", off);
|
||||
document.addEventListener("keydown", key, true);
|
||||
const origRemove = m.remove.bind(m); m.remove = () => { cleanup(); origRemove(); };
|
||||
llmMenu = m; defLlmSel.classList.add("open"); defLlmSel.setAttribute("aria-expanded", "true");
|
||||
}
|
||||
if (defLlmSel) {
|
||||
defLlmSel.onclick = () => (llmMenu ? closeDefaultLlmMenu() : openDefaultLlmMenu());
|
||||
defLlmSel.onkeydown = (ev) => { if (llmMenu) return; if (ev.key === "Enter" || ev.key === " " || ev.key === "ArrowDown" || ev.key === "ArrowUp") { ev.preventDefault(); openDefaultLlmMenu(); } };
|
||||
}
|
||||
// LLM custom-URL add form — same shape as the Search page's engAdd.
|
||||
const llmAddBtn = document.getElementById("llmAddBtn");
|
||||
const llmCatBox = document.getElementById("llmEngineCatalog");
|
||||
if (llmAddBtn && llmCatBox) {
|
||||
llmAddBtn.onclick = () => {
|
||||
const open = llmCatBox.hidden;
|
||||
llmCatBox.hidden = !open;
|
||||
llmAddBtn.textContent = open ? "− Hide catalog" : "+ Add AI engine";
|
||||
};
|
||||
}
|
||||
const llmEngAdd = document.getElementById("llmEngAdd");
|
||||
if (llmEngAdd) {
|
||||
llmEngAdd.onclick = () => {
|
||||
const name = document.getElementById("llmEngName").value.trim();
|
||||
const url = document.getElementById("llmEngUrl").value.trim();
|
||||
const sym = document.getElementById("llmEngSym").value.trim();
|
||||
if (!name || !url.includes("%s")) { alert("Enter a name and a URL containing %s (where the prompt goes)."); return; }
|
||||
C.addEngine({ name, url, sym, kind: "llm" }).then((d) => {
|
||||
document.getElementById("llmEngName").value = ""; document.getElementById("llmEngUrl").value = ""; document.getElementById("llmEngSym").value = "";
|
||||
renderEngines(d);
|
||||
});
|
||||
};
|
||||
}
|
||||
if (C.onSettingsUpdate) C.onSettingsUpdate((next) => { if (next && typeof next.defaultLlmId !== "undefined" && next.defaultLlmId !== defLlmId) { defLlmId = next.defaultLlmId; C.engines().then((d) => paintDefaultLlm(d.engines.filter((e) => e.enabled && (e.kind || "search") === "llm"))); } });
|
||||
// appearance (theme) — three visual cards: system | light | dark. Any
|
||||
// unrecognised saved value falls back to "system" (follow the OS).
|
||||
const th = document.getElementById("theme");
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue