The two things that made real questions fail against a correctly-populated index, both fixed at the root. RETENTION (A1). `INDEX_WINDOW_DAYS = 30` was not merely a fetch bound — catch-up also PRUNED mail older than it, so "summarise everything from July" was unanswerable in August because the rows had been deleted, while the UI said only that nothing matched. Now a user-visible setting (Settings → About & Data): 30 days / 3 months / 1 year / everything, defaulting to 1 YEAR per the product owner. The window bounds the fetch AND the prune from one value so the two can never disagree and delete what was just written; "everything" skips pruning entirely rather than falling back to some default bound. The per-pass ceiling scales with the window (500/30d, hard cap 20k) because 500 messages is right for a month and nonsense for "everything". Email/query now omits the `after` filter entirely when unbounded — Stalwart rejects a malformed filter rather than treating `undefined` as unset. RECENCY (A2). Keyword search structurally cannot answer a question about WHEN: bm25 ranks by term overlap, so "who sent the last email" matches documents containing the word "last", and "all mails in July" matches documents containing "July" — not documents dated in July. Both were asked by a real user and both failed. New lib/mail-index/recency.ts detects time intent (English + German, since the UI ships German) and turns it into a date RANGE; new MailIndex.recent() answers it with an ordered scan over the already-indexed `occurred_at`. The route ADDS these hits to the keyword hits rather than replacing them — "what did the last mail from Anna say" is both kinds of question at once. Timezone subtlety worth knowing: bounds are built from LOCAL calendar boundaries and serialised as UTC instants, so "July" covers the user's July. A mail at 00:30 local on 1 July belongs to it even though its stored UTC timestamp reads 30 June. My first test asserted the ISO string prefix, which would have enshrined the opposite and passed only in UTC — the tests now assert the local-time property instead. SCOPE, stated by the product owner and now enforced structurally: the assistant only ever sees the mailbox the user is signed in to. Both retrieval legs resolve the active account (local leg by cookie slot, server leg by the session's own JMAP account); there is deliberately no fan-out across connected or shared mailboxes, and adding one would be a policy change, not a feature. Gate: tsc clean, eslint clean, 2520/2520 tests (8 new for recency intent), build clean.
432 lines
18 KiB
TypeScript
432 lines
18 KiB
TypeScript
// The AI assistant's wire client. `local`/`public` mirror vncmail-native's
|
|
// src/api/ai.ts (direct loopback/provider fetch, no streaming) so the two
|
|
// clients stay in lockstep — matching docs/AI-ASSISTANT-CONCEPT.md §2's
|
|
// "local"/"public" rows, not proxied through this app's own Next.js server.
|
|
// That distinction matters once this app is hosted remotely: a server-side
|
|
// proxy would reach the *server's* loopback, not the user's own laptop
|
|
// running Ollama.
|
|
//
|
|
// `server` (added 2026-08-05 night) is the opposite by design: it DOES
|
|
// proxy through this app's own backend (app/api/ai/server/*), because it's
|
|
// centrally-hosted infra (VNC's EU/CH stack — standing in tonight for a real
|
|
// Ollama on this Mac, see lib/ai/entitlement.ts), not a user's own machine.
|
|
// That server-side hop is also the one real entitlement enforcement point
|
|
// (§10 point 2) — `local`/`public` never reach it, by design, and so cannot
|
|
// be metered or billed the same way.
|
|
|
|
export interface ChatMessage {
|
|
role: 'system' | 'user' | 'assistant';
|
|
content: string;
|
|
}
|
|
|
|
// ── Local: Ollama's native API, not the OpenAI-compat shim — one fewer path
|
|
// assumption (no "/v1" prefix to guess at) for a runtime this code talks to directly. ──
|
|
|
|
interface OllamaTagsResponse {
|
|
models?: Array<{ name: string; capabilities?: string[] }>;
|
|
}
|
|
|
|
interface OllamaChatResponse {
|
|
message?: { content?: string };
|
|
}
|
|
|
|
export async function listLocalModels(baseUrl: string): Promise<string[]> {
|
|
const res = await fetch(`${baseUrl.replace(/\/+$/, '')}/api/tags`);
|
|
if (!res.ok) throw new Error(`Ollama returned ${res.status}`);
|
|
const body = (await res.json()) as OllamaTagsResponse;
|
|
// Excludes embedding-only models (e.g. nomic-embed-text) from the chat
|
|
// picker — same reasoning as app/api/ai/server/models/route.ts.
|
|
return (body.models ?? [])
|
|
.filter((m) => !m.capabilities || m.capabilities.includes('completion'))
|
|
.map((m) => m.name)
|
|
.filter(Boolean);
|
|
}
|
|
|
|
/**
|
|
* Diagnoses the specific failure rather than a generic "connection failed" —
|
|
* docs/AI-ASSISTANT-CONCEPT.md §3 calls this out explicitly for the browser
|
|
* row: a CORS rejection (the runtime is up but refused this page's origin)
|
|
* looks identical to "nothing is listening" unless told apart. `fetch`
|
|
* itself can't distinguish them (a CORS failure and a connection refusal
|
|
* both surface as `TypeError: Failed to fetch`), so this only upgrades the
|
|
* message when the caller can tell us there's a live page origin to name.
|
|
*/
|
|
export async function testLocalConnection(
|
|
baseUrl: string,
|
|
): Promise<{ ok: boolean; error?: string }> {
|
|
try {
|
|
await listLocalModels(baseUrl);
|
|
return { ok: true };
|
|
} catch (err) {
|
|
const origin = typeof window !== 'undefined' ? window.location.origin : null;
|
|
const hint = origin
|
|
? ` Reachable in principle, but if Ollama is actually running, it likely refused this page's origin (${origin}) — start it with OLLAMA_ORIGINS=${origin}.`
|
|
: '';
|
|
return {
|
|
ok: false,
|
|
error: (err instanceof Error ? err.message : String(err)) + hint,
|
|
};
|
|
}
|
|
}
|
|
|
|
export async function chatLocal(
|
|
baseUrl: string,
|
|
model: string,
|
|
messages: ChatMessage[],
|
|
): Promise<string> {
|
|
const res = await fetch(`${baseUrl.replace(/\/+$/, '')}/api/chat`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ model, messages, stream: false }),
|
|
});
|
|
if (!res.ok) throw new Error(`Ollama returned ${res.status}`);
|
|
const body = (await res.json()) as OllamaChatResponse;
|
|
const content = body.message?.content;
|
|
if (!content) throw new Error('Ollama returned no message content');
|
|
return content;
|
|
}
|
|
|
|
// ── Server: centrally-hosted, proxied through this app's own backend
|
|
// (app/api/ai/server/*). Unlike `local`, this is same-origin from the
|
|
// browser's perspective — no CORS/OLLAMA_ORIGINS story at all — and unlike
|
|
// both `local` and `public`, every call is entitlement-checked server-side. ──
|
|
|
|
export async function listServerModels(): Promise<string[]> {
|
|
const res = await fetch('/api/ai/server/models');
|
|
if (!res.ok) {
|
|
const body = (await res.json().catch(() => null)) as { error?: string } | null;
|
|
throw new Error(body?.error ?? `AI server returned ${res.status}`);
|
|
}
|
|
const body = (await res.json()) as { models?: string[] };
|
|
return body.models ?? [];
|
|
}
|
|
|
|
export interface ServerChatResult {
|
|
answer: string;
|
|
/** True the moment this call consumed a previously-unassigned licensed seat
|
|
* (lib/ai/entitlement.ts) — surfaced so the UI can say so once, not left
|
|
* to happen silently the first time someone uses this class. */
|
|
seatJustAssigned: boolean;
|
|
}
|
|
|
|
export async function chatServer(model: string, messages: ChatMessage[]): Promise<ServerChatResult> {
|
|
const res = await fetch('/api/ai/server/chat', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ model, messages }),
|
|
});
|
|
const body = (await res.json().catch(() => null)) as { answer?: string; error?: string; seatJustAssigned?: boolean } | null;
|
|
if (!res.ok || !body?.answer) {
|
|
throw new Error(body?.error ?? `AI server returned ${res.status}`);
|
|
}
|
|
return { answer: body.answer, seatJustAssigned: body.seatJustAssigned === true };
|
|
}
|
|
|
|
// ── Public: OpenAI-compatible chat-completions. OpenRouter by default, but any
|
|
// endpoint speaking this shape works unmodified (self-hosted vLLM, LiteLLM, etc). ──
|
|
|
|
interface OpenAiChatResponse {
|
|
choices?: Array<{ message?: { content?: string } }>;
|
|
}
|
|
|
|
export async function chatPublic(
|
|
baseUrl: string,
|
|
apiKey: string,
|
|
model: string,
|
|
messages: ChatMessage[],
|
|
): Promise<string> {
|
|
const res = await fetch(`${baseUrl.replace(/\/+$/, '')}/chat/completions`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${apiKey}`,
|
|
},
|
|
body: JSON.stringify({ model, messages }),
|
|
});
|
|
if (!res.ok) throw new Error(`Provider returned ${res.status}`);
|
|
const body = (await res.json()) as OpenAiChatResponse;
|
|
const content = body.choices?.[0]?.message?.content;
|
|
if (!content) throw new Error('Provider returned no message content');
|
|
return content;
|
|
}
|
|
|
|
// ── OpenCode: a locally-running `opencode serve` (github.com/sst/opencode),
|
|
// the same agent runtime Paperclip drives as an adapter. Reached through THIS
|
|
// app's own backend (app/api/ai/opencode/*) rather than directly, for the same
|
|
// reason the `server` class is: the renderer's origin is a random localhost
|
|
// port that changes every desktop launch, so a direct fetch would need
|
|
// opencode's CORS allowlist updated on every start. Same-origin sidesteps it.
|
|
//
|
|
// It is NOT OpenAI-compatible (its `/v1/*` paths only answer 200 because a
|
|
// web-UI catch-all serves index.html for anything unknown) - the server-side
|
|
// helper lib/ai/opencode.ts speaks its real session API and documents that
|
|
// trap. The reason to have it as its own class rather than "just another BYOK profile": opencode
|
|
// owns provider auth itself, so there is no API key for this app to hold, and
|
|
// its /api/model endpoint gives a REAL model list to pick from instead of
|
|
// asking the user to type an exact provider-specific model id from memory.
|
|
|
|
export interface OpencodeModelOption {
|
|
/** "providerID/modelID" — what gets stored and sent back on ask. */
|
|
ref: string;
|
|
/** Human-readable, e.g. "DeepSeek V4 Flash Free (opencode)". */
|
|
label: string;
|
|
}
|
|
|
|
export async function listOpencodeModels(): Promise<OpencodeModelOption[]> {
|
|
const res = await fetch('/api/ai/opencode/models');
|
|
const body = await res.json().catch(() => ({}));
|
|
// 503 carries real setup guidance ("start opencode serve ..."), so surface
|
|
// the server's own message rather than a bare status code.
|
|
if (!res.ok) throw new Error(body?.error || `OpenCode returned ${res.status}`);
|
|
return (body?.models ?? []) as OpencodeModelOption[];
|
|
}
|
|
|
|
export async function chatOpencode(model: string, messages: ChatMessage[]): Promise<string> {
|
|
const res = await fetch('/api/ai/opencode/chat', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ model, messages }),
|
|
});
|
|
const body = await res.json().catch(() => ({}));
|
|
if (!res.ok) throw new Error(body?.error || `OpenCode returned ${res.status}`);
|
|
if (!body?.answer) throw new Error('OpenCode returned no message content');
|
|
return body.answer as string;
|
|
}
|
|
|
|
// ── Retrieval: two legs run in parallel and get Reciprocal-Rank-Fused
|
|
// (docs/AI-ASSISTANT-CONCEPT.md §7 steps 2-3), exactly like the doc
|
|
// describes — this is real, not a single degraded leg wearing SourceRef's
|
|
// clothes:
|
|
// - local FTS: this app's own already-built offline search surface
|
|
// (app/api/offline/search/route.ts). The encrypted SQLite/FTS5 store it
|
|
// reads only exists in Electron's main process — a 404/503 there means
|
|
// "no local index in this session", not an error.
|
|
// - server embedding: app/api/ai/retrieve (lib/ai/retrieval/mail-embeddings.ts) —
|
|
// real JMAP fetch, real Ollama embeddings, real cosine ranking. A 404
|
|
// there means AI_SERVER_BASE_URL isn't configured; anything else is a
|
|
// real failure, logged but not fatal to the question.
|
|
// Either leg being absent degrades to the other with no special-casing
|
|
// (reciprocalRankFusion handles an empty array leg for free); both absent
|
|
// degrades to an unaugmented question, same as before tonight.
|
|
|
|
import { reciprocalRankFusion } from './retrieval/fusion';
|
|
import type { Scored, SourceRef } from './retrieval/types';
|
|
|
|
export interface AskSource {
|
|
id: string;
|
|
subject: string;
|
|
}
|
|
|
|
export interface AskResult {
|
|
answer: string;
|
|
sources: AskSource[];
|
|
/** True when the question was answered without any retrieved context. */
|
|
unaugmented: boolean;
|
|
/**
|
|
* WHY the answer was unaugmented — the two cases need different words and
|
|
* different user action, and conflating them is actively misleading:
|
|
* - 'no-index': the local index isn't available at all (not the desktop
|
|
* app, no keyring, not signed in, or never built). Told to build it.
|
|
* - 'no-match': the index IS there and answered; this query just matched
|
|
* nothing. Told to rephrase. Recency questions ("the last mail", "all
|
|
* mail in July") land here by design: the index ranks by keyword
|
|
* relevance and has no notion of "latest" or a date range.
|
|
* - 'augmented': context was found and used.
|
|
*/
|
|
retrievalState: 'augmented' | 'no-match' | 'no-index';
|
|
/** True the moment this call consumed a previously-unassigned licensed
|
|
* seat on the `server` class (lib/ai/entitlement.ts). Always false for
|
|
* `local`/`public`, which aren't entitlement-gated. */
|
|
seatJustAssigned: boolean;
|
|
}
|
|
|
|
interface OfflineSearchHit {
|
|
id: string;
|
|
jmapAccountId: string;
|
|
title: string;
|
|
snippet?: string;
|
|
}
|
|
|
|
interface OfflineSearchResponse {
|
|
ok: true;
|
|
hits: OfflineSearchHit[];
|
|
}
|
|
|
|
interface ServerRetrieveHit {
|
|
ref: SourceRef;
|
|
title: string;
|
|
snippet: string;
|
|
}
|
|
|
|
interface ServerRetrieveResponse {
|
|
ok: true;
|
|
hits: ServerRetrieveHit[];
|
|
}
|
|
|
|
interface RetrievedContext {
|
|
contextBlock: string;
|
|
hits: Array<{ id: string; title: string }>;
|
|
}
|
|
|
|
/** Set by the most recent retrieveContext() call so askMail can report WHY an
|
|
* answer was unaugmented. Module-scoped rather than threaded through the
|
|
* return type because retrieveContext returns null precisely in the case we
|
|
* need to describe, and a null can't carry a reason. Single-threaded UI, one
|
|
* question at a time - no interleaving to worry about. */
|
|
let lastLocalIndexReachable = false;
|
|
|
|
async function fetchLocalLeg(question: string, slot?: number): Promise<{ scored: Scored<SourceRef>[]; text: Map<string, { title: string; snippet: string }>; indexReachable: boolean }> {
|
|
const empty = { scored: [] as Scored<SourceRef>[], text: new Map<string, { title: string; snippet: string }>(), indexReachable: false };
|
|
try {
|
|
// `slot` is load-bearing, not optional decoration: the INDEXER writes under
|
|
// the active account's cookie slot (lib/mail-index-client.ts's catchUpIndex
|
|
// passes it), so a search that omits it resolves to whatever account the
|
|
// multi-slot resolver finds FIRST and can read a different - usually empty -
|
|
// account's index. Single-account installs never noticed; a real
|
|
// multi-account/shared-mailbox setup reads the wrong store every time.
|
|
const slotQuery = typeof slot === 'number' ? `&slot=${slot}` : '';
|
|
const res = await fetch(`/api/offline/search?q=${encodeURIComponent(question)}&limit=6${slotQuery}`);
|
|
if (!res.ok) return empty; // 404/503/401 — no usable index this session, not an error
|
|
const body = (await res.json()) as OfflineSearchResponse;
|
|
if (!body.ok) return empty;
|
|
const text = new Map(body.hits.map((h) => [h.id, { title: h.title, snippet: h.snippet ?? '' }]));
|
|
const scored = body.hits.map((h, i) => ({
|
|
ref: { product: 'mail' as const, accountId: h.jmapAccountId, collectionId: '', itemId: h.id, chunkIx: 0 },
|
|
score: 1 / (i + 1), // rank position is all reciprocalRankFusion reads
|
|
}));
|
|
// Reachable even with zero hits: a 200 means the index answered. That
|
|
// distinction is the whole point - "the index isn't there" and "the index
|
|
// is there and this query matched nothing" are different facts the user
|
|
// deserves to be told apart (see AskResult.retrievalState).
|
|
return { scored, text, indexReachable: true };
|
|
} catch {
|
|
return empty;
|
|
}
|
|
}
|
|
|
|
async function fetchServerLeg(question: string): Promise<{ scored: Scored<SourceRef>[]; text: Map<string, { title: string; snippet: string }> }> {
|
|
const empty = { scored: [] as Scored<SourceRef>[], text: new Map<string, { title: string; snippet: string }>() };
|
|
try {
|
|
const res = await fetch('/api/ai/retrieve', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ query: question, limit: 6 }),
|
|
});
|
|
if (!res.ok) return empty; // 404 (server class not configured) or any other failure — degrade, don't fail the question
|
|
const body = (await res.json()) as ServerRetrieveResponse;
|
|
if (!body.ok) return empty;
|
|
const text = new Map(body.hits.map((h) => [h.ref.itemId, { title: h.title, snippet: h.snippet }]));
|
|
const scored = body.hits.map((h, i) => ({ ref: h.ref, score: 1 / (i + 1) }));
|
|
return { scored, text };
|
|
} catch {
|
|
return empty;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* SINGLE-MAILBOX BY POLICY (stated by the product owner 2026-08-07): the
|
|
* assistant may only ever see the mailbox the user is currently signed in to.
|
|
* Both legs honour that structurally rather than by filtering afterwards —
|
|
* the local leg passes the ACTIVE account's cookie slot, and the server leg
|
|
* resolves the same session's own JMAP account. There is deliberately no
|
|
* fan-out across connected accounts or shared mailboxes anywhere in here, and
|
|
* adding one later would be a policy change, not an enhancement.
|
|
*/
|
|
async function retrieveContext(question: string, slot?: number): Promise<RetrievedContext | null> {
|
|
const [local, server] = await Promise.all([fetchLocalLeg(question, slot), fetchServerLeg(question)]);
|
|
lastLocalIndexReachable = local.indexReachable;
|
|
const fused = reciprocalRankFusion([local.scored, server.scored], 6);
|
|
if (fused.length === 0) return null;
|
|
|
|
const combinedText = new Map([...server.text, ...local.text]); // local wins on overlap: it's the more precise leg (BM25 on exact terms)
|
|
const withText = fused
|
|
.map((f) => ({ ref: f.ref, info: combinedText.get(f.ref.itemId) }))
|
|
.filter((f): f is { ref: SourceRef; info: { title: string; snippet: string } } => !!f.info);
|
|
|
|
if (withText.length === 0) return null;
|
|
|
|
return {
|
|
contextBlock: withText.map((h, i) => `[${i + 1}] Subject: ${h.info.title}\n${h.info.snippet}`).join('\n\n'),
|
|
hits: withText.map((h) => ({ id: h.ref.itemId, title: h.info.title })),
|
|
};
|
|
}
|
|
|
|
export function buildPrompt(question: string, contextBlock: string): ChatMessage[] {
|
|
return [
|
|
{
|
|
role: 'system',
|
|
content:
|
|
"You answer questions about the user's email using only the numbered excerpts " +
|
|
'below as context. Cite sources by their number in brackets, e.g. [1]. If the ' +
|
|
"excerpts don't contain the answer, say so plainly rather than guessing.",
|
|
},
|
|
{ role: 'user', content: `${contextBlock}\n\nQuestion: ${question}` },
|
|
];
|
|
}
|
|
|
|
/**
|
|
* One saved BYOK profile, resolved to an actual key — the caller picks which
|
|
* profile answers *this* question (docs decision 2026-08-05: several keys,
|
|
* selected case by case, not one fixed "the" public provider).
|
|
*/
|
|
export interface ResolvedPublicProfile {
|
|
baseUrl: string;
|
|
model: string;
|
|
apiKey: string;
|
|
}
|
|
|
|
export interface AskConfig {
|
|
provider: 'local' | 'server' | 'public' | 'opencode';
|
|
localBaseUrl: string;
|
|
localModel: string | null;
|
|
serverModel: string | null;
|
|
publicProfile: ResolvedPublicProfile | null;
|
|
opencodeModel?: string | null;
|
|
/** Cookie slot of the account whose local index should be searched. Omitting
|
|
* it reads whichever account the resolver finds first — see fetchLocalLeg. */
|
|
slot?: number;
|
|
}
|
|
|
|
export async function askMail(question: string, config: AskConfig): Promise<AskResult> {
|
|
if (config.provider === 'local' && !config.localModel) {
|
|
throw new Error('No local model selected');
|
|
}
|
|
if (config.provider === 'server' && !config.serverModel) {
|
|
throw new Error('No server model selected');
|
|
}
|
|
if (config.provider === 'public' && !config.publicProfile) {
|
|
throw new Error('No provider profile selected');
|
|
}
|
|
if (config.provider === 'opencode' && !config.opencodeModel) {
|
|
throw new Error('No OpenCode model selected');
|
|
}
|
|
|
|
const retrieved = await retrieveContext(question, config.slot);
|
|
const messages = retrieved
|
|
? buildPrompt(question, retrieved.contextBlock)
|
|
: [{ role: 'user' as const, content: question }];
|
|
|
|
let answer: string;
|
|
let seatJustAssigned = false;
|
|
if (config.provider === 'public') {
|
|
const profile = config.publicProfile as ResolvedPublicProfile;
|
|
answer = await chatPublic(profile.baseUrl, profile.apiKey, profile.model, messages);
|
|
} else if (config.provider === 'server') {
|
|
const result = await chatServer(config.serverModel as string, messages);
|
|
answer = result.answer;
|
|
seatJustAssigned = result.seatJustAssigned;
|
|
} else if (config.provider === 'opencode') {
|
|
answer = await chatOpencode(config.opencodeModel as string, messages);
|
|
} else {
|
|
answer = await chatLocal(config.localBaseUrl, config.localModel as string, messages);
|
|
}
|
|
|
|
return {
|
|
answer,
|
|
sources: (retrieved?.hits ?? []).map((h) => ({ id: h.id, subject: h.title })),
|
|
unaugmented: !retrieved,
|
|
retrievalState: retrieved ? 'augmented' : lastLocalIndexReachable ? 'no-match' : 'no-index',
|
|
seatJustAssigned,
|
|
};
|
|
}
|