skill_web/pages/
search_test.rs

1//! Search Testing Page - Test semantic search with live results
2//!
3//! Features:
4//! - Live semantic search with query input
5//! - Results display with scores and metadata
6//! - Configuration toggle (use current settings vs custom)
7//! - Search statistics (latency, results count)
8
9use std::rc::Rc;
10use wasm_bindgen_futures::spawn_local;
11use yew::prelude::*;
12
13use crate::api::{Api, SearchRequest, SearchResponse};
14use crate::components::card::Card;
15use crate::components::result_card::ResultCard;
16use crate::components::use_notifications;
17
18#[derive(Clone, PartialEq)]
19struct SearchStats {
20    total_results: usize,
21    latency_ms: u64,
22    query: String,
23}
24
25#[function_component(SearchTestPage)]
26pub fn search_test_page() -> Html {
27    // State
28    let query = use_state(|| String::new());
29    let results = use_state(|| None::<SearchResponse>);
30    let is_searching = use_state(|| false);
31    let search_stats = use_state(|| None::<SearchStats>);
32    let top_k = use_state(|| 10_usize);
33    let is_indexing = use_state(|| false);
34
35    // API & notifications
36    let api = use_memo((), |_| Rc::new(Api::new()));
37    let notifications = use_notifications();
38
39    // Search handler
40    let on_search = {
41        let api = api.clone();
42        let query = query.clone();
43        let results = results.clone();
44        let is_searching = is_searching.clone();
45        let search_stats = search_stats.clone();
46        let top_k = top_k.clone();
47        let notifications = notifications.clone();
48
49        Callback::from(move |_: web_sys::MouseEvent| {
50            let query_text = (*query).clone();
51            if query_text.trim().is_empty() {
52                return;
53            }
54
55            is_searching.set(true);
56            results.set(None);
57            search_stats.set(None);
58
59            let api = api.clone();
60            let results = results.clone();
61            let is_searching = is_searching.clone();
62            let search_stats = search_stats.clone();
63            let notifications = notifications.clone();
64            let top_k_val = *top_k;
65
66            spawn_local(async move {
67                let start = js_sys::Date::now();
68
69                let request = SearchRequest {
70                    query: query_text.clone(),
71                    top_k: top_k_val,
72                    skill_filter: None,
73                    include_examples: false,
74                };
75
76                match api.search.search(&request).await {
77                    Ok(response) => {
78                        let end = js_sys::Date::now();
79                        let elapsed = (end - start) as u64;
80                        let total = response.results.len();
81
82                        search_stats.set(Some(SearchStats {
83                            total_results: total,
84                            latency_ms: elapsed,
85                            query: query_text.clone(),
86                        }));
87
88                        results.set(Some(response));
89
90                        notifications.success(
91                            "Search completed",
92                            format!("Found {} results in {}ms", total, elapsed),
93                        );
94                    }
95                    Err(e) => {
96                        notifications.error("Search failed", format!("Error: {}", e));
97                    }
98                }
99                is_searching.set(false);
100            });
101        })
102    };
103
104    // Input change handler
105    let on_input_change = {
106        let query = query.clone();
107        Callback::from(move |e: InputEvent| {
108            let input: web_sys::HtmlInputElement = e.target_unchecked_into();
109            query.set(input.value());
110        })
111    };
112
113    // Enter key handler
114    let on_key_press = {
115        let api = api.clone();
116        let query = query.clone();
117        let results = results.clone();
118        let is_searching = is_searching.clone();
119        let search_stats = search_stats.clone();
120        let top_k = top_k.clone();
121        let notifications = notifications.clone();
122
123        Callback::from(move |e: KeyboardEvent| {
124            if e.key() == "Enter" {
125                let query_text = (*query).clone();
126                if query_text.trim().is_empty() {
127                    return;
128                }
129
130                is_searching.set(true);
131                results.set(None);
132                search_stats.set(None);
133
134                let api = api.clone();
135                let results = results.clone();
136                let is_searching = is_searching.clone();
137                let search_stats = search_stats.clone();
138                let notifications = notifications.clone();
139                let top_k_val = *top_k;
140
141                spawn_local(async move {
142                    let start = js_sys::Date::now();
143
144                    let request = SearchRequest {
145                        query: query_text.clone(),
146                        top_k: top_k_val,
147                        skill_filter: None,
148                        include_examples: false,
149                    };
150
151                    match api.search.search(&request).await {
152                        Ok(response) => {
153                            let end = js_sys::Date::now();
154                            let elapsed = (end - start) as u64;
155                            let total = response.results.len();
156
157                            search_stats.set(Some(SearchStats {
158                                total_results: total,
159                                latency_ms: elapsed,
160                                query: query_text.clone(),
161                            }));
162
163                            results.set(Some(response));
164
165                            notifications.success(
166                                "Search completed",
167                                format!("Found {} results in {}ms", total, elapsed),
168                            );
169                        }
170                        Err(e) => {
171                            notifications.error("Search failed", format!("Error: {}", e));
172                        }
173                    }
174                    is_searching.set(false);
175                });
176            }
177        })
178    };
179
180    // Top K change handler
181    let on_top_k_change = {
182        let top_k = top_k.clone();
183        Callback::from(move |e: Event| {
184            let select: web_sys::HtmlSelectElement = e.target_unchecked_into();
185            if let Ok(value) = select.value().parse::<usize>() {
186                top_k.set(value);
187            }
188        })
189    };
190
191    // Index handler
192    let on_index = {
193        let api = api.clone();
194        let is_indexing = is_indexing.clone();
195        let notifications = notifications.clone();
196
197        Callback::from(move |_: web_sys::MouseEvent| {
198            is_indexing.set(true);
199
200            let api = api.clone();
201            let is_indexing = is_indexing.clone();
202            let notifications = notifications.clone();
203
204            spawn_local(async move {
205                let start = js_sys::Date::now();
206
207                match api.search.index().await {
208                    Ok(response) => {
209                        let end = js_sys::Date::now();
210                        let elapsed = (end - start) as u64;
211
212                        notifications.success(
213                            "Indexing completed",
214                            format!(
215                                "Indexed {} documents in {}ms (server: {}ms)",
216                                response.documents_indexed, elapsed, response.duration_ms
217                            ),
218                        );
219                    }
220                    Err(e) => {
221                        notifications.error("Indexing failed", format!("Error: {}", e));
222                    }
223                }
224                is_indexing.set(false);
225            });
226        })
227    };
228
229    html! {
230        <div class="min-h-screen bg-gray-50 dark:bg-gray-900 p-6">
231            <div class="max-w-6xl mx-auto space-y-6">
232                // Header
233                <div class="flex items-start justify-between">
234                    <div>
235                        <h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-1">
236                            { "Semantic Search Testing" }
237                        </h1>
238                        <p class="text-sm text-gray-600 dark:text-gray-400">
239                            { "Test semantic search with live results from your skill catalog" }
240                        </p>
241                    </div>
242                    <button
243                        class="btn btn-secondary flex items-center gap-2"
244                        onclick={on_index}
245                        disabled={*is_indexing}
246                    >
247                        if *is_indexing {
248                            <span class="animate-spin">{ "⟳" }</span>
249                            { "Indexing..." }
250                        } else {
251                            <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
252                                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
253                            </svg>
254                            { "Re-index Skills" }
255                        }
256                    </button>
257                </div>
258
259                // Search Input Card
260                <Card title="Search Query">
261                    <div class="space-y-4">
262                        <div class="flex gap-2">
263                            <input
264                                type="text"
265                                class="input flex-1"
266                                placeholder="Enter search query (e.g., 'list kubernetes pods')"
267                                value={(*query).clone()}
268                                oninput={on_input_change}
269                                onkeypress={on_key_press}
270                            />
271                            <select
272                                class="input w-24"
273                                value={top_k.to_string()}
274                                onchange={on_top_k_change}
275                            >
276                                <option value="5">{ "Top 5" }</option>
277                                <option value="10" selected={*top_k == 10}>{ "Top 10" }</option>
278                                <option value="20">{ "Top 20" }</option>
279                                <option value="50">{ "Top 50" }</option>
280                            </select>
281                            <button
282                                class="btn btn-primary px-6"
283                                onclick={on_search}
284                                disabled={*is_searching || query.trim().is_empty()}
285                            >
286                                if *is_searching {
287                                    <span class="flex items-center gap-2">
288                                        <span class="animate-spin">{ "⟳" }</span>
289                                        { "Searching..." }
290                                    </span>
291                                } else {
292                                    { "Search" }
293                                }
294                            </button>
295                        </div>
296
297                        // Search statistics
298                        if let Some(stats) = &*search_stats {
299                            <div class="p-3 bg-blue-50 dark:bg-blue-900/20 rounded border border-blue-200 dark:border-blue-800">
300                                <div class="grid grid-cols-3 gap-4 text-sm">
301                                    <div>
302                                        <span class="text-gray-600 dark:text-gray-400">{ "Query:" }</span>
303                                        <span class="ml-2 font-medium text-gray-900 dark:text-white">
304                                            { &stats.query }
305                                        </span>
306                                    </div>
307                                    <div>
308                                        <span class="text-gray-600 dark:text-gray-400">{ "Results:" }</span>
309                                        <span class="ml-2 font-medium text-gray-900 dark:text-white">
310                                            { stats.total_results }
311                                        </span>
312                                    </div>
313                                    <div>
314                                        <span class="text-gray-600 dark:text-gray-400">{ "Latency:" }</span>
315                                        <span class="ml-2 font-medium text-gray-900 dark:text-white">
316                                            { format!("{}ms", stats.latency_ms) }
317                                        </span>
318                                    </div>
319                                </div>
320                            </div>
321                        }
322                    </div>
323                </Card>
324
325                // Results Card
326                <Card title="Search Results">
327                    if let Some(response) = &*results {
328                        if response.results.is_empty() {
329                            <div class="text-center py-8 text-gray-500 dark:text-gray-400">
330                                <svg class="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
331                                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
332                                </svg>
333                                <p>{ "No results found for your query." }</p>
334                            </div>
335                        } else {
336                            <div class="space-y-3">
337                                { for response.results.iter().enumerate().map(|(idx, result)| {
338                                    html! {
339                                        <ResultCard
340                                            index={idx + 1}
341                                            id={format!("{}:{}", result.skill, result.tool)}
342                                            skill={result.skill.clone()}
343                                            tool={result.tool.clone()}
344                                            content={result.content.clone()}
345                                            score={result.score}
346                                            rerank_score={None}
347                                            query={(*query).clone()}
348                                        />
349                                    }
350                                }) }
351                            </div>
352                        }
353                    } else if *is_searching {
354                        <div class="flex items-center justify-center py-8">
355                            <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500"></div>
356                        </div>
357                    } else {
358                        <div class="text-center py-8 text-gray-500 dark:text-gray-400">
359                            <svg class="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
360                                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
361                            </svg>
362                            <p>{ "No results yet. Enter a query and click Search." }</p>
363                        </div>
364                    }
365                </Card>
366            </div>
367        </div>
368    }
369}