/** Optional generation is separate from local search and never starts itself. */ export interface AiConfiguration { enabled: boolean; base_url: string; model: string; api_key: string | null; } const bytes = (text: string) => new TextEncoder().encode(text).length; export function aiConfigurationProblem(config: AiConfiguration): string | null { if (!config.enabled) return "Optional AI is disabled."; let endpoint: URL; try { endpoint = new URL(config.base_url); } catch { return "Enter your OpenAI-compatible endpoint URL."; } if ( !["http:", "https:"].includes(endpoint.protocol) || endpoint.username || endpoint.password || endpoint.search || endpoint.hash || bytes(config.base_url) > 2048 ) return "Use an HTTP(S) endpoint without URL credentials, query or fragment."; if ( !config.model.trim() || bytes(config.model) > 200 || /[\u0000-\u001f\u007f]/u.test(config.model) ) return "Enter a model name (up to 200 bytes)."; if ( config.api_key && (bytes(config.api_key) > 2048 || /[\u0000-\u001f\u007f]/u.test(config.api_key)) ) return "The session API key is invalid or too long."; return null; } export function aiQuestionProblem(question: string): string | null { return !question.trim() ? "Enter a question about this passage." : bytes(question) > 2048 ? "The question exceeds 2048 bytes." : null; } export function aiEndpointLabel(baseURL: string) { try { return new URL(baseURL).origin; } catch { return "your configured endpoint"; } } export function aiSourceEligible( source?: { kind: string; number: number }, media = false, ) { return ( !media && Boolean( source && ["page", "paragraph", "line"].includes(source.kind) && Number.isInteger(source.number) && source.number > 0, ) ); } export class AiRevision { private revision = 0; private selection = ""; invalidate() { this.revision++; } select(selection: string) { if (selection !== this.selection) { this.selection = selection; this.invalidate(); } } current() { return this.revision; } accepts(revision: number) { return revision === this.revision; } }