chatPublic() calls the provider's /chat/completions directly from the renderer. Verified live: a saved profile pointing at platform.deepseek.com (DeepSeek's console) instead of api.deepseek.com (their actual API) fails the CORS preflight outright - 403, no Access-Control-* headers - which surfaces to fetch() as an undifferentiated "Failed to fetch" with no status to inspect. Confirmed the real API and OpenRouter both support being called directly from a browser fine, so the architecture is sound; only the error message was useless. Now names the URL and the likely cause instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
490 lines
21 KiB
TypeScript
490 lines
21 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 url = `${baseUrl.replace(/\/+$/, '')}/chat/completions`;
|
|
let res: Response;
|
|
try {
|
|
res = await fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${apiKey}`,
|
|
},
|
|
body: JSON.stringify({ model, messages }),
|
|
});
|
|
} catch {
|
|
// A request that never got a response (DNS failure, TLS failure, or -
|
|
// by far the most common cause in practice - a CORS preflight the
|
|
// target rejected) surfaces to fetch() as a bare, undifferentiated
|
|
// "TypeError: Failed to fetch" with no status code to inspect. Verified
|
|
// live: a Base URL pointing at a provider's website instead of its API
|
|
// (platform.deepseek.com vs api.deepseek.com) fails exactly this way,
|
|
// the preflight OPTIONS getting a 403 with no Access-Control-* headers
|
|
// at all. Naming the Base URL is the one actionable thing this error
|
|
// can tell the user, since the browser gives back nothing else.
|
|
throw new Error(
|
|
`Could not reach ${url} — check the Base URL is the provider's API endpoint, not its ` +
|
|
'website or console (e.g. api.deepseek.com, not platform.deepseek.com), and that it ' +
|
|
'allows being called directly from a browser.',
|
|
);
|
|
}
|
|
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 interface OpencodeProviderOption {
|
|
id: string;
|
|
name: string;
|
|
connected: boolean;
|
|
supportsApiKey: boolean;
|
|
}
|
|
|
|
/** Every provider OpenCode knows about, not just ones already authenticated —
|
|
* this is what lets "add any LLM OpenCode supports" mean something from
|
|
* inside this app instead of only whatever its CLI already set up. */
|
|
export async function listOpencodeProviders(): Promise<OpencodeProviderOption[]> {
|
|
const res = await fetch('/api/ai/opencode/providers');
|
|
const body = await res.json().catch(() => ({}));
|
|
if (!res.ok) throw new Error(body?.error || `OpenCode returned ${res.status}`);
|
|
return (body?.providers ?? []) as OpencodeProviderOption[];
|
|
}
|
|
|
|
/** The key is relayed to OpenCode's own credential store, never held by this
|
|
* app — same reasoning as the module note above, extended to provider setup. */
|
|
export async function addOpencodeProvider(providerID: string, key: string): Promise<void> {
|
|
const res = await fetch('/api/ai/opencode/providers', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ providerID, key }),
|
|
});
|
|
const body = await res.json().catch(() => ({}));
|
|
// A 200 with `ok: false` means the route VERIFIED the write and the
|
|
// provider still isn't connected (some need more than one credential
|
|
// field — see the route's own comment) - that is as much a failure as a
|
|
// non-2xx status and must not be swallowed as success.
|
|
if (!res.ok || body?.ok === false) throw new Error(body?.error || `OpenCode returned ${res.status}`);
|
|
}
|
|
|
|
export async function removeOpencodeProvider(providerID: string): Promise<void> {
|
|
const res = await fetch(`/api/ai/opencode/providers?providerID=${encodeURIComponent(providerID)}`, { method: 'DELETE' });
|
|
const body = await res.json().catch(() => ({}));
|
|
if (!res.ok) throw new Error(body?.error || `OpenCode returned ${res.status}`);
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|