From 6e33532799684ea125d6c575f9f0bd61c58e62b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Tue, 16 Jun 2026 15:18:40 +0200 Subject: [PATCH] feat(search): add on-device Ask AI to the search box Add an 'Ask AI' action to the search results that synthesizes an answer from the current site-wide search hits using the browser's built-in, on-device model (Chrome Prompt API) when available. It is grounded only on the retrieved docs, lists its sources, and is entirely client-side and free (no server endpoint or API key). When the on-device model is unavailable it falls back to a Claude hand-off with the question and the most relevant pages prefilled. Everything is feature-detected and wrapped in try/catch so existing search is never affected. --- js/search.ts | 155 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) diff --git a/js/search.ts b/js/search.ts index 454d45246..fb874a497 100644 --- a/js/search.ts +++ b/js/search.ts @@ -14,6 +14,45 @@ interface OramaDocument { command?: string; } +// Minimal typing for the experimental browser Prompt API (Chrome's built-in, +// on-device AI). It is feature-detected at runtime, so this is only a shape. +// https://developer.chrome.com/docs/ai/prompt-api +type LanguageModelAvailability = + | "unavailable" + | "downloadable" + | "downloading" + | "available"; + +interface LanguageModelSession { + prompt(input: string): Promise; + destroy(): void; +} + +interface LanguageModelStatic { + availability(): Promise; + create(options?: { + initialPrompts?: { + role: "system" | "user" | "assistant"; + content: string; + }[]; + }): Promise; +} + +function getLanguageModel(): LanguageModelStatic | undefined { + return (globalThis as { LanguageModel?: LanguageModelStatic }).LanguageModel; +} + +// The on-device model has a small context window, so retrieved context is kept +// compact: a handful of the top search hits, each truncated. +const ASK_MAX_HITS = 5; +const ASK_MAX_CHARS_PER_HIT = 500; +const ASK_SYSTEM_PROMPT = + "You are the Deno documentation assistant. Answer the user's question " + + "using only the provided context from the Deno docs. Be concise and " + + "practical, and prefer Deno's recommended APIs. If the answer is not in " + + "the context, say you couldn't find it in the docs and point them to the " + + "linked pages. Never invent APIs, flags, or permissions."; + // Configuration - Replace these with your actual Orama Cloud credentials const ORAMA_CONFIG = { projectId: "c9394670-656a-4f78-a551-c2603ee119e7", @@ -29,6 +68,9 @@ class OramaSearch { private searchTimeout: number | null = null; private isResultsOpen = false; private selectedIndex = -1; // Track selected result for keyboard navigation + // Latest query + hits, reused by the Ask AI feature as retrieval context. + private lastTerm = ""; + private lastHits: Hit[] = []; constructor() { this.init(); @@ -262,6 +304,9 @@ class OramaSearch { }, }); + this.lastTerm = term; + this.lastHits = results.hits as unknown as Hit[]; + this.renderResults( results as unknown as SearchResult, term, @@ -321,6 +366,19 @@ class OramaSearch { }); const resultsHtml = ` +
+ + +

Search Results

@@ -370,6 +428,9 @@ class OramaSearch { this.searchResults.innerHTML = resultsHtml; + const askButton = this.searchResults.querySelector("#ask-ai-button"); + askButton?.addEventListener("click", () => this.runAsk()); + // Reset selection for new results this.selectedIndex = -1; @@ -379,6 +440,100 @@ class OramaSearch { } } + // Build a compact context block from the current search hits, kept small + // for the on-device model's limited context window. + private buildAskContext(): string { + return this.lastHits + .slice(0, ASK_MAX_HITS) + .map((hit) => { + const url = hit.document.url || hit.document.path || ""; + const title = this.cleanTitle(hit.document.title); + const body = (hit.document.content || "").slice( + 0, + ASK_MAX_CHARS_PER_HIT, + ); + return `## ${title} (${url})\n${body}`; + }) + .join("\n\n"); + } + + private renderAskAnswer(answerEl: HTMLElement, answerText: string) { + const sources = this.lastHits + .slice(0, ASK_MAX_HITS) + .map((hit) => { + const url = hit.document.url || hit.document.path || "#"; + const title = this.cleanTitle(hit.document.title); + return `
  • ${this.escapeHtml(title)}
  • `; + }) + .join(""); + answerEl.innerHTML = ` +
    ${ + this.escapeHtml(answerText) + }
    +

    Sources

    +
      ${sources}
    +

    AI-generated from the Deno docs. Verify against the linked pages.

    + `; + } + + // When no on-device model is available, hand off to Claude with the question + // and the most relevant doc links prefilled. + private renderAskFallback(answerEl: HTMLElement) { + const links = this.lastHits + .slice(0, ASK_MAX_HITS) + .map((h) => h.document.url || h.document.path || "") + .filter(Boolean) + .map((u) => `https://docs.deno.com${u}`); + const prompt = + `Answer this question about Deno using these documentation pages:\n${this.lastTerm}\n\n${ + links.join("\n") + }`; + const claudeUrl = `https://claude.ai/new?q=${encodeURIComponent(prompt)}`; + answerEl.innerHTML = ` +

    On-device AI isn't available in this browser. Ask Claude with the most relevant pages prefilled:

    + Open in Claude → + `; + } + + // Synthesize an answer from the current search hits using the browser's + // built-in on-device model, falling back to a Claude hand-off otherwise. + async runAsk() { + const answerEl = this.searchResults?.querySelector( + "#ask-ai-answer", + ) as HTMLElement | null; + if (!answerEl || this.lastHits.length === 0) return; + answerEl.classList.remove("hidden"); + + const languageModel = getLanguageModel(); + if (!languageModel) { + this.renderAskFallback(answerEl); + return; + } + + answerEl.innerHTML = + `

    Thinking…

    `; + try { + const availability = await languageModel.availability(); + if (availability !== "available") { + this.renderAskFallback(answerEl); + return; + } + const session = await languageModel.create({ + initialPrompts: [{ role: "system", content: ASK_SYSTEM_PROMPT }], + }); + const answer = await session.prompt( + `Question: ${this.lastTerm}\n\nContext:\n${this.buildAskContext()}`, + ); + session.destroy(); + this.renderAskAnswer(answerEl, answer); + } catch (error) { + console.error("Ask AI error:", error); + this.renderAskFallback(answerEl); + } + } + showNotConfiguredMessage() { if (!this.searchResults) return;