skill_web/pages/
search_test.rs1use 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 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 let api = use_memo((), |_| Rc::new(Api::new()));
37 let notifications = use_notifications();
38
39 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 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 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 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 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 <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 <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 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 <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}