- {/* ☝️ That md:relative class is crucial for making the search bar pop up in the right place. Don't change it, or add any other positioning classes, unless you're ready to account for positioning across all breakpoints. */}
-
-
-
- ⌘K
-
-
-
-
-
- {/* Text updated by JavaScript. */}
+ <>
+ {/* Search Input Trigger */}
+
+
+
+
+ ⌘K
+
- {/* Enhanced Popover for Search Results */}
-
-
- {/* Results will be populated via JavaScript */}
-
+
+ {/* Search Header */}
+
+
+
+ {/* Mode Toggle */}
+
+
+
+
+ {/* Loading Indicator */}
+
+
+
+
- {/* Footer with search tips */}
-
-
+ {/* Search Results */}
+
+
+ {/* Results will be populated via JavaScript */}
+
+
+
+ {/* Footer with keyboard shortcuts */}
+
+
-
-
+
+ {/* Screen reader announcements */}
+
+ {/* Text updated by JavaScript */}
+
+
+ >
);
}
diff --git a/search.client.ts b/search.client.ts
index d9d297b41..4e502ab27 100644
--- a/search.client.ts
+++ b/search.client.ts
@@ -1,5 +1,5 @@
// Orama Search Client
-// This handles the client-side search functionality for the Deno docs
+// This handles the client-side search functionality for the Deno docs with AI Context Engineering
import type { Hit, OramaCloud, SearchResult } from "jsr:@orama/core@1.2.4";
@@ -14,6 +14,13 @@ interface OramaDocument {
command?: string;
}
+interface AISession {
+ answer: (params: { query: string }) => Promise;
+ answerStream: (params: { query: string }) => AsyncIterable;
+ state: unknown[];
+ abort: () => void | Promise;
+}
+
// Configuration - Replace these with your actual Orama Cloud credentials
const ORAMA_CONFIG = {
projectId: "c9394670-656a-4f78-a551-c2603ee119e7",
@@ -23,12 +30,17 @@ const ORAMA_CONFIG = {
class OramaSearch {
private client: OramaCloud | null = null;
private searchInput: HTMLInputElement | null = null;
+ private searchInputModal: HTMLInputElement | null = null;
+ private searchModal: HTMLElement | null = null;
private searchResults: HTMLElement | null = null;
private searchLoading: HTMLElement | null = null;
private ariaLiveRegion: HTMLElement | null = null;
private searchTimeout: number | null = null;
- private isResultsOpen = false;
+ private isModalOpen = false;
private selectedIndex = -1; // Track selected result for keyboard navigation
+ private aiSession: AISession | null = null;
+ private currentAiSearch: AbortController | null = null;
+ private isAiMode = false; // Toggle between regular and AI search
constructor() {
this.init();
@@ -50,28 +62,66 @@ class OramaSearch {
this.searchInput = document.getElementById(
"orama-search-input",
) as HTMLInputElement;
+ this.searchInputModal = document.getElementById(
+ "orama-search-input-modal",
+ ) as HTMLInputElement;
+ this.searchModal = document.getElementById("orama-search-modal");
this.searchResults = document.getElementById(
"orama-search-results-content",
- ); // Target the scrollable container
+ );
this.searchLoading = document.getElementById("orama-search-loading");
this.ariaLiveRegion = document.getElementById("orama-results-announcer");
- if (!this.searchInput) {
- console.warn("Orama search input not found");
+ if (!this.searchInput || !this.searchInputModal) {
+ console.warn("Orama search inputs not found");
return;
}
- // Bind event listeners
- this.searchInput.addEventListener("input", this.handleInput.bind(this));
- this.searchInput.addEventListener("focus", this.handleFocus.bind(this));
- this.searchInput.addEventListener("keydown", this.handleKeyDown.bind(this));
- this.searchResults?.addEventListener("keyup", (event) => {
- if (event.key === "Escape") this.handleEscape();
+ // Initialize search mode UI
+ this.updateSearchMode();
+
+ // Make the trigger input readonly and add click handler to open modal
+ this.searchInput.setAttribute("readonly", "true");
+ this.searchInput.addEventListener("click", () => {
+ this.openSearchModal();
});
+
+ // Set up modal input handlers
+ this.searchInputModal.addEventListener(
+ "input",
+ this.handleInput.bind(this),
+ );
+ this.searchInputModal.addEventListener(
+ "keydown",
+ this.handleKeyDown.bind(this),
+ );
+
+ // Add click handler to mode toggle button
+ const modeToggle = document.getElementById("search-mode-toggle");
+ modeToggle?.addEventListener("click", () => {
+ this.toggleSearchMode();
+ });
+
+ // Finish setting up global handlers
+ this.finishElementSetup();
+ }
+
+ openSearchModal() {
+ this.showResults();
+ }
+
+ finishElementSetup() {
+ // Set up global keyboard handlers
document.addEventListener("keydown", (event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "k") {
- event.preventDefault();
- this.searchInput?.focus();
+ if (event.shiftKey) {
+ console.log("TRIGGEREDDD");
+ event.preventDefault();
+ this.toggleSearchMode();
+ } else {
+ event.preventDefault();
+ this.openSearchModal();
+ }
}
});
@@ -119,6 +169,9 @@ class OramaSearch {
apiKey: ORAMA_CONFIG.apiKey,
});
+ // Initialize AI session for context engineering
+ await this.initAISession();
+
console.log("Orama search client initialized successfully");
} catch (error) {
console.error("Failed to initialize Orama client:", error);
@@ -126,44 +179,77 @@ class OramaSearch {
}
}
+ async initAISession() {
+ if (!this.client) return;
+
+ try {
+ // Create AI session with context engineering capabilities
+ this.aiSession = await this.client.ai.createAISession({
+ events: {
+ onStateChange: (state) => {
+ console.log("AI Session state changed:", state);
+ },
+ },
+ LLMConfig: {
+ provider: "openai",
+ model: "gpt-4o-mini", // Use a fast, efficient model for search assistance
+ },
+ }) as AISession;
+ console.log("AI session initialized for context engineering");
+ } catch (error) {
+ console.warn("AI session initialization failed:", error);
+ // Continue without AI features if they fail to initialize
+ }
+ }
+
handleInput(event: Event) {
const value = (event.target as HTMLInputElement).value;
+ // Clear any existing timeout
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
}
+ // Abort any ongoing AI search immediately when user types
+ if (this.currentAiSearch) {
+ this.currentAiSearch.abort();
+ this.currentAiSearch = null;
+ }
+
if (!value.trim()) {
- this.hideResults();
+ // Clear results but keep modal open
+ if (this.searchResults) {
+ this.searchResults.innerHTML = "";
+ }
return;
}
// Reset selection when user types
this.selectedIndex = -1;
- // Debounce search by 300ms
+ // Use longer debounce for AI mode since it's more expensive
+ // Regular search: 300ms, AI search: 500ms to prevent flashing results
+ const debounceTime = this.isAiMode ? 500 : 300;
+
this.searchTimeout = setTimeout(() => {
this.performSearch(value);
- }, 300);
+ }, debounceTime);
}
handleFocus() {
- if (this.searchInput?.value.trim() && this.searchResults?.children.length) {
- this.showResults();
- }
+ // No longer needed since we use modal
}
handleEscape() {
this.hideResults();
- if (this.searchInput) {
- this.searchInput.value = ""; // Clears input when escape is triggered from within the list rather than on the input
- this.searchInput.focus();
+ if (this.searchInputModal) {
+ this.searchInputModal.value = ""; // Clear modal input when escape is triggered
}
this.selectedIndex = -1;
}
handleKeyDown(event: KeyboardEvent) {
- if (!this.isResultsOpen) {
+ if (!this.isModalOpen) {
return;
}
@@ -212,9 +298,19 @@ class OramaSearch {
// Remove previous selection
resultsLinks.forEach((link, index) => {
- link.classList.remove("bg-blue-50", "dark:bg-blue-900/20");
+ link.classList.remove(
+ "bg-gray-50",
+ "dark:bg-gray-800",
+ "text-gray-900",
+ "dark:text-white",
+ );
if (index === this.selectedIndex) {
- link.classList.add("bg-blue-50", "dark:bg-blue-900/20");
+ link.classList.add(
+ "bg-gray-50",
+ "dark:bg-gray-800",
+ "text-gray-900",
+ "dark:text-white",
+ );
// Scroll the selected item into view
link.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
@@ -224,17 +320,16 @@ class OramaSearch {
handleClickOutside(event: MouseEvent) {
const target = event.target as Element;
- // Don't hide results if clicking on a search result link
+ // Don't hide modal if clicking on a search result link
if (target.closest(".search-result-link")) {
return;
}
- // Check if click is outside the search container
- const searchContainer = document.getElementById("orama-search-results");
- if (
- !this.searchInput?.parentElement?.contains(target) &&
- !searchContainer?.contains(target)
- ) {
+ // Check if click is outside the search modal content
+ const searchModal = document.getElementById("orama-search-modal");
+ const modalContent = searchModal?.querySelector("div"); // The inner content div
+
+ if (this.isModalOpen && modalContent && !modalContent.contains(target)) {
this.hideResults();
}
}
@@ -248,25 +343,11 @@ class OramaSearch {
this.showLoading(true);
try {
- const results = await this.client.search({
- term,
- mode: "fulltext",
- limit: 8,
- threshold: 1,
- properties: ["title", "content", "description"],
- datasources: ["0fe1e86b-60c9-4715-8bba-0c4686a58e7e"],
- boost: {
- title: 12,
- content: 4,
- description: 2,
- },
- });
-
- this.renderResults(
- results as unknown as SearchResult,
- term,
- );
- this.showResults();
+ if (this.isAiMode && this.aiSession) {
+ await this.performAISearch(term);
+ } else {
+ await this.performRegularSearch(term);
+ }
} catch (error) {
console.error("Search error:", error);
this.showErrorMessage("Search failed. Please try again.");
@@ -275,16 +356,99 @@ class OramaSearch {
}
}
+ async performRegularSearch(term: string) {
+ const results = await this.client!.search({
+ term,
+ mode: "fulltext",
+ limit: 8,
+ threshold: 1,
+ properties: ["title", "content", "description"],
+ datasources: ["0fe1e86b-60c9-4715-8bba-0c4686a58e7e"],
+ boost: {
+ title: 12,
+ content: 4,
+ description: 2,
+ },
+ });
+
+ this.renderResults(
+ results as unknown as SearchResult,
+ term,
+ );
+ this.showResults();
+ }
+
+ async performAISearch(term: string) {
+ if (!this.aiSession) {
+ await this.performRegularSearch(term);
+ return;
+ }
+
+ // Abort any existing AI search before starting new one
+ if (this.currentAiSearch) {
+ this.currentAiSearch.abort();
+ }
+ this.currentAiSearch = new AbortController();
+
+ // Show AI loading state
+ this.renderAILoading(term);
+ this.showResults();
+
+ try {
+ let response = "";
+ let sources: OramaDocument[] = [];
+ let lastResponse = "";
+
+ // Stream the AI response with abort check
+ for await (const chunk of this.aiSession.answerStream({ query: term })) {
+ // Check if search was aborted
+ if (this.currentAiSearch.signal.aborted) {
+ return; // Exit early if aborted
+ }
+
+ // Chunks from Orama are cumulative (contain full response so far)
+ response = String(chunk);
+
+ // Only re-render if response has actually changed and not aborted
+ if (response !== lastResponse && !this.currentAiSearch.signal.aborted) {
+ this.renderAIResponse(term, response, sources, true);
+ lastResponse = response;
+ }
+ }
+
+ // Only proceed with final rendering if not aborted
+ if (!this.currentAiSearch.signal.aborted) {
+ // Get the final state to extract sources
+ const sessionState = this.aiSession.state;
+ if (sessionState && sessionState.length > 0) {
+ const latestInteraction = sessionState[sessionState.length - 1] as {
+ sources?: OramaDocument[];
+ };
+ sources = latestInteraction.sources || [];
+ }
+
+ // Render final result
+ this.renderAIResponse(term, response, sources, false);
+ }
+ } catch (error) {
+ if ((error as Error).name !== "AbortError") {
+ console.error("AI search error:", error);
+ this.renderAIError(
+ term,
+ "AI search failed. Falling back to regular search.",
+ );
+ await this.performRegularSearch(term);
+ }
+ }
+ }
+
renderResults(results: SearchResult, searchTerm: string) {
if (!this.searchResults) return;
if (results.hits.length === 0) {
this.searchResults.innerHTML = `
-