1use std::rc::Rc;
10use wasm_bindgen_futures::spawn_local;
11use yew::prelude::*;
12use yewdux::prelude::*;
13
14use crate::api::{
15 Api, AppConfig, SearchConfigResponse, UpdateSearchConfigRequest,
16};
17use crate::components::card::Card;
18use crate::components::{use_import_config_modal, use_notifications, ImportConfigModal, Tooltip};
19use crate::store::ui::{UiAction, UiStore};
20
21#[derive(Clone, PartialEq)]
23struct SettingsState {
24 theme: String,
26 default_timeout_secs: u64,
28 max_concurrent_executions: usize,
29 include_metadata: bool,
30 enable_history: bool,
31 max_history_entries: usize,
32 embedding_provider: String,
34 embedding_model: String,
35 vector_backend: String,
36 ollama_url: Option<String>,
37 qdrant_url: Option<String>,
38 hybrid_search_enabled: bool,
39 reranking_enabled: bool,
40 indexed_documents: usize,
41 use_advanced_model: bool,
43 agent_runtime: String,
45 agent_provider: String,
46 agent_model: String,
47 agent_temperature: f32,
48 agent_max_tokens: usize,
49 agent_timeout_secs: u64,
50}
51
52impl Default for SettingsState {
53 fn default() -> Self {
54 Self {
55 theme: "system".to_string(),
56 default_timeout_secs: 30,
57 max_concurrent_executions: 10,
58 include_metadata: false,
59 enable_history: true,
60 max_history_entries: 1000,
61 embedding_provider: "fastembed".to_string(),
62 embedding_model: "all-minilm".to_string(),
63 vector_backend: "file".to_string(),
64 ollama_url: Some("http://localhost:11434".to_string()),
65 qdrant_url: Some("http://localhost:6333".to_string()),
66 hybrid_search_enabled: true,
67 reranking_enabled: false,
68 indexed_documents: 0,
69 use_advanced_model: false,
70 agent_runtime: "claude-code".to_string(),
71 agent_provider: "anthropic".to_string(),
72 agent_model: "claude-sonnet-4".to_string(),
73 agent_temperature: 0.7,
74 agent_max_tokens: 4096,
75 agent_timeout_secs: 300,
76 }
77 }
78}
79
80impl SettingsState {
81 fn from_config(config: &AppConfig) -> Self {
82 let model = &config.search.embedding_model;
84 let provider = &config.search.embedding_provider;
85 let is_standard_model = Self::is_standard_model(provider, model);
86
87 Self {
88 theme: "system".to_string(),
89 default_timeout_secs: config.default_timeout_secs,
90 max_concurrent_executions: config.max_concurrent_executions,
91 include_metadata: false,
92 enable_history: config.enable_history,
93 max_history_entries: config.max_history_entries,
94 embedding_provider: provider.clone(),
95 embedding_model: model.clone(),
96 vector_backend: config.search.vector_backend.clone(),
97 ollama_url: Some("http://localhost:11434".to_string()),
98 qdrant_url: Some("http://localhost:6333".to_string()),
99 hybrid_search_enabled: config.search.hybrid_search_enabled,
100 reranking_enabled: config.search.reranking_enabled,
101 indexed_documents: config.search.indexed_documents,
102 use_advanced_model: !is_standard_model,
103 agent_runtime: "claude-code".to_string(),
105 agent_provider: "anthropic".to_string(),
106 agent_model: "claude-sonnet-4".to_string(),
107 agent_temperature: 0.7,
108 agent_max_tokens: 4096,
109 agent_timeout_secs: 300,
110 }
111 }
112
113 fn is_standard_model(provider: &str, model: &str) -> bool {
114 match provider {
115 "fastembed" => matches!(model, "all-minilm" | "bge-small" | "bge-base" | "bge-large"),
116 "openai" => matches!(model, "text-embedding-ada-002" | "text-embedding-3-small" | "text-embedding-3-large"),
117 "ollama" => matches!(model, "nomic-embed-text" | "mxbai-embed-large" | "all-minilm"),
118 _ => false,
119 }
120 }
121
122 fn get_default_model(provider: &str) -> &'static str {
123 match provider {
124 "fastembed" => "all-minilm",
125 "openai" => "text-embedding-3-small",
126 "ollama" => "nomic-embed-text",
127 _ => "all-minilm",
128 }
129 }
130}
131
132#[derive(Clone, PartialEq)]
134struct TestResult {
135 success: bool,
136 message: String,
137 duration_ms: u128,
138 details: Option<String>,
139}
140
141#[function_component(SettingsPage)]
143pub fn settings_page() -> Html {
144 let (_, ui_dispatch) = use_store::<UiStore>();
145 let notifications = use_notifications();
146 let import_modal = use_import_config_modal();
147
148 let api = use_memo((), |_| Rc::new(Api::new()));
150
151 let settings = use_state(SettingsState::default);
153 let loading = use_state(|| true);
154 let saving = use_state(|| false);
155 let error = use_state(|| Option::<String>::None);
156 let has_changes = use_state(|| false);
157
158 let test_connection_loading = use_state(|| false);
160 let test_pipeline_loading = use_state(|| false);
161 let test_result = use_state(|| Option::<TestResult>::None);
162
163 {
165 let api = api.clone();
166 let settings = settings.clone();
167 let loading = loading.clone();
168 let error = error.clone();
169
170 use_effect_with((), move |_| {
171 spawn_local(async move {
172 match api.config.get().await {
173 Ok(config) => {
174 settings.set(SettingsState::from_config(&config));
175 loading.set(false);
176 }
177 Err(e) => {
178 error.set(Some(e.to_string()));
179 loading.set(false);
180 }
181 }
182 });
183 });
184 }
185
186 let on_theme_change = {
188 let settings = settings.clone();
189 let has_changes = has_changes.clone();
190 let ui_dispatch = ui_dispatch.clone();
191 Callback::from(move |value: String| {
192 let mut new_settings = (*settings).clone();
193 new_settings.theme = value.clone();
194 settings.set(new_settings);
195 has_changes.set(true);
196 ui_dispatch.apply(UiAction::SetDarkMode(value == "dark"));
198 })
199 };
200
201 let on_timeout_change = {
203 let settings = settings.clone();
204 let has_changes = has_changes.clone();
205 Callback::from(move |e: InputEvent| {
206 let input: web_sys::HtmlInputElement = e.target_unchecked_into();
207 if let Ok(value) = input.value().parse::<u64>() {
208 let mut new_settings = (*settings).clone();
209 new_settings.default_timeout_secs = value;
210 settings.set(new_settings);
211 has_changes.set(true);
212 }
213 })
214 };
215
216 let on_max_concurrent_change = {
218 let settings = settings.clone();
219 let has_changes = has_changes.clone();
220 Callback::from(move |e: InputEvent| {
221 let input: web_sys::HtmlInputElement = e.target_unchecked_into();
222 if let Ok(value) = input.value().parse::<usize>() {
223 let mut new_settings = (*settings).clone();
224 new_settings.max_concurrent_executions = value;
225 settings.set(new_settings);
226 has_changes.set(true);
227 }
228 })
229 };
230
231 let on_history_entries_change = {
233 let settings = settings.clone();
234 let has_changes = has_changes.clone();
235 Callback::from(move |e: InputEvent| {
236 let input: web_sys::HtmlInputElement = e.target_unchecked_into();
237 if let Ok(value) = input.value().parse::<usize>() {
238 let mut new_settings = (*settings).clone();
239 new_settings.max_history_entries = value;
240 settings.set(new_settings);
241 has_changes.set(true);
242 }
243 })
244 };
245
246 let on_enable_history_toggle = {
248 let settings = settings.clone();
249 let has_changes = has_changes.clone();
250 Callback::from(move |_: MouseEvent| {
251 let mut new_settings = (*settings).clone();
252 new_settings.enable_history = !new_settings.enable_history;
253 settings.set(new_settings);
254 has_changes.set(true);
255 })
256 };
257
258 let on_include_metadata_toggle = {
260 let settings = settings.clone();
261 let has_changes = has_changes.clone();
262 Callback::from(move |_: MouseEvent| {
263 let mut new_settings = (*settings).clone();
264 new_settings.include_metadata = !new_settings.include_metadata;
265 settings.set(new_settings);
266 has_changes.set(true);
267 })
268 };
269
270 let on_embedding_provider_change = {
272 let settings = settings.clone();
273 let has_changes = has_changes.clone();
274 Callback::from(move |e: Event| {
275 let select: web_sys::HtmlSelectElement = e.target_unchecked_into();
276 let mut new_settings = (*settings).clone();
277 let new_provider = select.value();
278 new_settings.embedding_provider = new_provider.clone();
279
280 if !new_settings.use_advanced_model {
282 new_settings.embedding_model = SettingsState::get_default_model(&new_provider).to_string();
283 }
284
285 settings.set(new_settings);
286 has_changes.set(true);
287 })
288 };
289
290 let on_vector_backend_change = {
292 let settings = settings.clone();
293 let has_changes = has_changes.clone();
294 Callback::from(move |e: Event| {
295 let select: web_sys::HtmlSelectElement = e.target_unchecked_into();
296 let mut new_settings = (*settings).clone();
297 new_settings.vector_backend = select.value();
298 settings.set(new_settings);
299 has_changes.set(true);
300 })
301 };
302
303 let on_embedding_model_select = {
305 let settings = settings.clone();
306 let has_changes = has_changes.clone();
307 Callback::from(move |e: Event| {
308 let select: web_sys::HtmlSelectElement = e.target_unchecked_into();
309 let mut new_settings = (*settings).clone();
310 new_settings.embedding_model = select.value();
311 settings.set(new_settings);
312 has_changes.set(true);
313 })
314 };
315
316 let on_embedding_model_change = {
318 let settings = settings.clone();
319 let has_changes = has_changes.clone();
320 Callback::from(move |e: InputEvent| {
321 let input: web_sys::HtmlInputElement = e.target_unchecked_into();
322 let mut new_settings = (*settings).clone();
323 new_settings.embedding_model = input.value();
324 settings.set(new_settings);
325 has_changes.set(true);
326 })
327 };
328
329 let on_advanced_model_toggle = {
331 let settings = settings.clone();
332 let has_changes = has_changes.clone();
333 Callback::from(move |_: MouseEvent| {
334 let mut new_settings = (*settings).clone();
335 new_settings.use_advanced_model = !new_settings.use_advanced_model;
336
337 if !new_settings.use_advanced_model {
339 new_settings.embedding_model = SettingsState::get_default_model(&new_settings.embedding_provider).to_string();
340 }
341
342 settings.set(new_settings);
343 has_changes.set(true);
344 })
345 };
346
347 let on_ollama_url_change = {
349 let settings = settings.clone();
350 let has_changes = has_changes.clone();
351 Callback::from(move |e: InputEvent| {
352 let input: web_sys::HtmlInputElement = e.target_unchecked_into();
353 let mut new_settings = (*settings).clone();
354 new_settings.ollama_url = Some(input.value());
355 settings.set(new_settings);
356 has_changes.set(true);
357 })
358 };
359
360 let on_qdrant_url_change = {
362 let settings = settings.clone();
363 let has_changes = has_changes.clone();
364 Callback::from(move |e: InputEvent| {
365 let input: web_sys::HtmlInputElement = e.target_unchecked_into();
366 let mut new_settings = (*settings).clone();
367 new_settings.qdrant_url = Some(input.value());
368 settings.set(new_settings);
369 has_changes.set(true);
370 })
371 };
372
373 let on_hybrid_toggle = {
375 let settings = settings.clone();
376 let has_changes = has_changes.clone();
377 Callback::from(move |_: MouseEvent| {
378 let mut new_settings = (*settings).clone();
379 new_settings.hybrid_search_enabled = !new_settings.hybrid_search_enabled;
380 settings.set(new_settings);
381 has_changes.set(true);
382 })
383 };
384
385 let on_reranking_toggle = {
387 let settings = settings.clone();
388 let has_changes = has_changes.clone();
389 Callback::from(move |_: MouseEvent| {
390 let mut new_settings = (*settings).clone();
391 new_settings.reranking_enabled = !new_settings.reranking_enabled;
392 settings.set(new_settings);
393 has_changes.set(true);
394 })
395 };
396
397 let on_save = {
399 let api = api.clone();
400 let settings = settings.clone();
401 let saving = saving.clone();
402 let has_changes = has_changes.clone();
403 let notifications = notifications.clone();
404
405 Callback::from(move |_: MouseEvent| {
406 let current_settings = (*settings).clone();
407 saving.set(true);
408
409 let api = api.clone();
410 let saving = saving.clone();
411 let has_changes = has_changes.clone();
412 let notifications = notifications.clone();
413
414 spawn_local(async move {
415 let app_result = api
417 .config
418 .update(&crate::api::UpdateAppConfigRequest {
419 default_timeout_secs: Some(current_settings.default_timeout_secs),
420 max_concurrent_executions: Some(current_settings.max_concurrent_executions),
421 enable_history: Some(current_settings.enable_history),
422 max_history_entries: Some(current_settings.max_history_entries),
423 })
424 .await;
425
426 let search_result = api
428 .config
429 .update_search_config(&UpdateSearchConfigRequest {
430 embedding_provider: Some(current_settings.embedding_provider),
431 embedding_model: None,
432 vector_backend: Some(current_settings.vector_backend),
433 enable_hybrid: Some(current_settings.hybrid_search_enabled),
434 enable_reranking: Some(current_settings.reranking_enabled),
435 })
436 .await;
437
438 saving.set(false);
439
440 match (app_result, search_result) {
441 (Ok(_), Ok(_)) => {
442 has_changes.set(false);
443 notifications.success("Settings Saved", "Your settings have been updated");
444 }
445 (Err(e), _) | (_, Err(e)) => {
446 notifications.error("Save Failed", &e.to_string());
447 }
448 }
449 });
450 })
451 };
452
453 let on_reset = {
455 let settings = settings.clone();
456 let has_changes = has_changes.clone();
457 let notifications = notifications.clone();
458
459 Callback::from(move |_: MouseEvent| {
460 settings.set(SettingsState {
461 theme: "system".to_string(),
462 default_timeout_secs: 30,
463 max_concurrent_executions: 4,
464 include_metadata: false,
465 enable_history: true,
466 max_history_entries: 1000,
467 embedding_provider: "fastembed".to_string(),
468 embedding_model: "all-minilm".to_string(),
469 vector_backend: "file".to_string(),
470 ollama_url: Some("http://localhost:11434".to_string()),
471 qdrant_url: Some("http://localhost:6333".to_string()),
472 hybrid_search_enabled: false,
473 reranking_enabled: false,
474 indexed_documents: 0,
475 use_advanced_model: false,
476 agent_runtime: "claude-code".to_string(),
478 agent_provider: "anthropic".to_string(),
479 agent_model: "claude-sonnet-4".to_string(),
480 agent_temperature: 0.7,
481 agent_max_tokens: 4096,
482 agent_timeout_secs: 300,
483 });
484 has_changes.set(true);
485 notifications.info("Settings Reset", "Settings have been reset to defaults");
486 })
487 };
488
489 let on_test_connection = {
491 let api = api.clone();
492 let settings = settings.clone();
493 let test_connection_loading = test_connection_loading.clone();
494 let test_result = test_result.clone();
495 let notifications = notifications.clone();
496
497 Callback::from(move |_: MouseEvent| {
498 test_connection_loading.set(true);
499 test_result.set(None);
500
501 let api = api.clone();
502 let settings = (*settings).clone();
503 let test_connection_loading = test_connection_loading.clone();
504 let test_result = test_result.clone();
505 let notifications = notifications.clone();
506
507 spawn_local(async move {
508 let request = crate::api::TestConnectionRequest {
509 embedding_provider: settings.embedding_provider,
510 embedding_model: settings.embedding_model,
511 vector_backend: settings.vector_backend,
512 qdrant_url: settings.qdrant_url.clone(),
513 ollama_url: settings.ollama_url.clone(),
514 };
515
516 match api.search.test_connection(&request).await {
517 Ok(response) => {
518 let details = format!(
519 "Embedding: {} | Backend: {}",
520 if response.embedding_provider_status.healthy { "✓" } else { "✗" },
521 if response.vector_backend_status.healthy { "✓" } else { "✗" }
522 );
523
524 let success = response.success;
525 let duration_ms = response.duration_ms;
526 let message = response.message.clone();
527
528 test_result.set(Some(TestResult {
529 success,
530 message: message.clone(),
531 duration_ms,
532 details: Some(details),
533 }));
534
535 if success {
536 notifications.success(
537 "Connection Test Passed",
538 &format!("All components healthy ({}ms)", duration_ms)
539 );
540 } else {
541 notifications.error("Connection Test Failed", &message);
542 }
543 }
544 Err(e) => {
545 notifications.error("Test Failed", &format!("Error: {}", e));
546 test_result.set(Some(TestResult {
547 success: false,
548 message: format!("Error: {}", e),
549 duration_ms: 0,
550 details: None,
551 }));
552 }
553 }
554 test_connection_loading.set(false);
555 });
556 })
557 };
558
559 let on_test_pipeline = {
561 let api = api.clone();
562 let settings = settings.clone();
563 let test_pipeline_loading = test_pipeline_loading.clone();
564 let test_result = test_result.clone();
565 let notifications = notifications.clone();
566
567 Callback::from(move |_: MouseEvent| {
568 test_pipeline_loading.set(true);
569 test_result.set(None);
570
571 let api = api.clone();
572 let settings = (*settings).clone();
573 let test_pipeline_loading = test_pipeline_loading.clone();
574 let test_result = test_result.clone();
575 let notifications = notifications.clone();
576
577 spawn_local(async move {
578 let request = crate::api::TestPipelineRequest {
579 embedding_provider: settings.embedding_provider,
580 embedding_model: settings.embedding_model,
581 vector_backend: settings.vector_backend,
582 enable_hybrid: settings.hybrid_search_enabled,
583 enable_reranking: settings.reranking_enabled,
584 qdrant_url: settings.qdrant_url.clone(),
585 };
586
587 match api.search.test_pipeline(&request).await {
588 Ok(response) => {
589 let details = format!(
590 "Indexed {} docs | Found {} results",
591 response.index_stats.documents_indexed,
592 response.search_results.len()
593 );
594
595 let success = response.success;
596 let duration_ms = response.duration_ms;
597 let message = response.message.clone();
598
599 test_result.set(Some(TestResult {
600 success,
601 message: message.clone(),
602 duration_ms,
603 details: Some(details),
604 }));
605
606 if success {
607 notifications.success(
608 "Pipeline Test Passed",
609 &format!("Pipeline working correctly ({}ms)", duration_ms)
610 );
611 } else {
612 notifications.error("Pipeline Test Failed", &message);
613 }
614 }
615 Err(e) => {
616 notifications.error("Test Failed", &format!("Error: {}", e));
617 test_result.set(Some(TestResult {
618 success: false,
619 message: format!("Error: {}", e),
620 duration_ms: 0,
621 details: None,
622 }));
623 }
624 }
625 test_pipeline_loading.set(false);
626 });
627 })
628 };
629
630 let on_import_click = {
632 let import_modal = import_modal.clone();
633 Callback::from(move |_: MouseEvent| {
634 import_modal.open();
635 })
636 };
637
638 let on_config_imported = {
640 let api = api.clone();
641 let settings = settings.clone();
642 let notifications = notifications.clone();
643 Callback::from(move |count: usize| {
644 let api = api.clone();
645 let settings = settings.clone();
646 spawn_local(async move {
647 if let Ok(config) = api.config.get().await {
648 settings.set(SettingsState::from_config(&config));
649 }
650 });
651 })
652 };
653
654 let on_clear_history = {
656 let api = api.clone();
657 let notifications = notifications.clone();
658 Callback::from(move |_: MouseEvent| {
659 let api = api.clone();
660 let notifications = notifications.clone();
661
662 if !web_sys::window()
664 .and_then(|w| w.confirm_with_message("Are you sure you want to clear all execution history? This cannot be undone.").ok())
665 .unwrap_or(false)
666 {
667 return;
668 }
669
670 spawn_local(async move {
671 match api.executions.clear_history().await {
672 Ok(_) => {
673 notifications.success("History Cleared", "Execution history cleared successfully");
674 }
675 Err(e) => {
676 notifications.error("Clear Failed", &format!("Failed to clear history: {}", e));
677 }
678 }
679 });
680 })
681 };
682
683 let current = (*settings).clone();
685
686 html! {
687 <div class="space-y-6 animate-fade-in">
688 <ImportConfigModal on_imported={on_config_imported} />
690
691 <div class="flex items-center justify-between">
693 <div>
694 <h1 class="text-2xl font-bold text-gray-900 dark:text-white">
695 { "Settings" }
696 </h1>
697 <p class="text-gray-500 dark:text-gray-400 mt-1">
698 { "Configure your Skill Engine preferences" }
699 </p>
700 </div>
701 if *has_changes {
702 <div class="flex items-center gap-2 px-3 py-1 bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 rounded-full text-sm">
703 <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
704 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
705 </svg>
706 { "Unsaved changes" }
707 </div>
708 }
709 </div>
710
711 if let Some(err) = (*error).clone() {
713 <div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
714 <div class="flex items-center gap-3">
715 <svg class="w-5 h-5 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
716 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
717 </svg>
718 <p class="text-sm text-red-700 dark:text-red-300">{ err }</p>
719 </div>
720 </div>
721 }
722
723 if *loading {
725 <div class="space-y-6">
726 { for (0..4).map(|_| html! { <SettingsCardSkeleton /> }) }
727 </div>
728 } else {
729 <Card title="Appearance">
731 <div class="space-y-4">
732 <div>
733 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
734 { "Theme" }
735 </label>
736 <div class="flex gap-4">
737 { for ["light", "dark", "system"].iter().map(|t| {
738 let is_selected = current.theme == *t;
739 let on_change = on_theme_change.clone();
740 let value = t.to_string();
741
742 html! {
743 <button
744 onclick={Callback::from(move |_: MouseEvent| on_change.emit(value.clone()))}
745 class={classes!(
746 "flex", "items-center", "gap-2", "px-4", "py-2", "rounded-lg", "border", "cursor-pointer", "transition-colors",
747 if is_selected {
748 "border-primary-500 bg-primary-50 dark:bg-primary-900/30"
749 } else {
750 "border-gray-200 dark:border-gray-700 hover:border-gray-300"
751 }
752 )}
753 >
754 <span class="capitalize">{ *t }</span>
755 </button>
756 }
757 }) }
758 </div>
759 </div>
760 </div>
761 </Card>
762
763 <Card title="Execution">
765 <div class="space-y-4">
766 <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
767 <div>
768 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
769 { "Default timeout (seconds)" }
770 </label>
771 <input
772 type="number"
773 class="input w-full"
774 value={current.default_timeout_secs.to_string()}
775 min="1"
776 max="300"
777 oninput={on_timeout_change}
778 />
779 <p class="text-xs text-gray-500 mt-1">
780 { "Maximum time a skill can run (1-300 seconds)" }
781 </p>
782 </div>
783
784 <div>
785 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
786 { "Max concurrent executions" }
787 </label>
788 <input
789 type="number"
790 class="input w-full"
791 value={current.max_concurrent_executions.to_string()}
792 min="1"
793 max="16"
794 oninput={on_max_concurrent_change}
795 />
796 <p class="text-xs text-gray-500 mt-1">
797 { "Number of skills that can run in parallel (1-16)" }
798 </p>
799 </div>
800 </div>
801
802 <div class="space-y-3 pt-2">
803 <ToggleSwitch
804 label="Include execution metadata by default"
805 description="Attach timing and environment info to results"
806 checked={current.include_metadata}
807 on_toggle={on_include_metadata_toggle}
808 />
809
810 <ToggleSwitch
811 label="Enable execution history"
812 description="Track and store execution history for analysis"
813 checked={current.enable_history}
814 on_toggle={on_enable_history_toggle}
815 />
816 </div>
817 </div>
818 </Card>
819
820 <Card title="Search Pipeline">
822 <div class="space-y-4">
823 <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
824 <div>
825 <label class="flex items-center text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
826 { "Embedding Provider" }
827 <Tooltip text="Converts text into numerical vectors for semantic search. FastEmbed runs locally, OpenAI uses API, Ollama is self-hosted." />
828 </label>
829 <select
830 class="input w-full"
831 value={current.embedding_provider.clone()}
832 onchange={on_embedding_provider_change}
833 >
834 <option value="fastembed" selected={current.embedding_provider == "fastembed"}>
835 { "FastEmbed (Local)" }
836 </option>
837 <option value="openai" selected={current.embedding_provider == "openai"}>
838 { "OpenAI" }
839 </option>
840 <option value="ollama" selected={current.embedding_provider == "ollama"}>
841 { "Ollama" }
842 </option>
843 </select>
844 <p class="text-xs text-gray-500 mt-1">
845 { "FastEmbed runs locally with no API key required" }
846 </p>
847 </div>
848
849 <div>
850 <label class="flex items-center text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
851 { "Vector Store" }
852 <Tooltip text="Database for storing and searching document embeddings. File-based is persistent and fast. InMemory is faster but data is lost on restart. Qdrant requires Docker." />
853 </label>
854 <select
855 class="input w-full"
856 value={current.vector_backend.clone()}
857 onchange={on_vector_backend_change}
858 >
859 <option value="file" selected={current.vector_backend == "file"}>
860 { "File-based (Persistent)" }
861 </option>
862 <option value="memory" selected={current.vector_backend == "memory"}>
863 { "In-Memory" }
864 </option>
865 <option value="qdrant" selected={current.vector_backend == "qdrant"}>
866 { "Qdrant (Docker)" }
867 </option>
868 </select>
869 <p class="text-xs text-gray-500 mt-1">
870 { "File-based stores vectors locally with persistence. In-memory is fastest but temporary." }
871 </p>
872 </div>
873 </div>
874
875 <div>
877 <div class="flex items-center justify-between mb-2">
878 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
879 { "Embedding Model" }
880 </label>
881 <button
882 type="button"
883 class="text-xs text-primary-600 dark:text-primary-400 hover:underline"
884 onclick={on_advanced_model_toggle}
885 >
886 { if current.use_advanced_model { "Use Standard Models" } else { "Advanced (Custom Model)" } }
887 </button>
888 </div>
889
890 if current.use_advanced_model {
891 <input
893 type="text"
894 class="input w-full font-mono text-sm"
895 value={current.embedding_model.clone()}
896 oninput={on_embedding_model_change}
897 placeholder={
898 match current.embedding_provider.as_str() {
899 "fastembed" => "all-minilm",
900 "openai" => "text-embedding-3-small",
901 "ollama" => "nomic-embed-text",
902 _ => "model-name"
903 }
904 }
905 />
906 <p class="text-xs text-gray-500 mt-1">
907 { "Enter a custom embedding model name" }
908 </p>
909 } else {
910 <select
912 class="input w-full"
913 value={current.embedding_model.clone()}
914 onchange={on_embedding_model_select}
915 >
916 { match current.embedding_provider.as_str() {
917 "fastembed" => html! {
918 <>
919 <option value="all-minilm" selected={current.embedding_model == "all-minilm"}>
920 { "all-MiniLM (384 dims) - Recommended" }
921 </option>
922 <option value="bge-small" selected={current.embedding_model == "bge-small"}>
923 { "BGE-Small (384 dims)" }
924 </option>
925 <option value="bge-base" selected={current.embedding_model == "bge-base"}>
926 { "BGE-Base (768 dims)" }
927 </option>
928 <option value="bge-large" selected={current.embedding_model == "bge-large"}>
929 { "BGE-Large (1024 dims)" }
930 </option>
931 </>
932 },
933 "openai" => html! {
934 <>
935 <option value="text-embedding-3-small" selected={current.embedding_model == "text-embedding-3-small"}>
936 { "text-embedding-3-small (1536 dims) - Recommended" }
937 </option>
938 <option value="text-embedding-3-large" selected={current.embedding_model == "text-embedding-3-large"}>
939 { "text-embedding-3-large (3072 dims)" }
940 </option>
941 <option value="text-embedding-ada-002" selected={current.embedding_model == "text-embedding-ada-002"}>
942 { "text-embedding-ada-002 (1536 dims) - Legacy" }
943 </option>
944 </>
945 },
946 "ollama" => html! {
947 <>
948 <option value="nomic-embed-text" selected={current.embedding_model == "nomic-embed-text"}>
949 { "nomic-embed-text - Recommended" }
950 </option>
951 <option value="mxbai-embed-large" selected={current.embedding_model == "mxbai-embed-large"}>
952 { "mxbai-embed-large" }
953 </option>
954 <option value="all-minilm" selected={current.embedding_model == "all-minilm"}>
955 { "all-minilm" }
956 </option>
957 </>
958 },
959 _ => html! {
960 <option value="all-minilm">{ "all-minilm" }</option>
961 }
962 }}
963 </select>
964 <p class="text-xs text-gray-500 mt-1">
965 { "Select from recommended models for " }
966 <span class="capitalize">{ current.embedding_provider.clone() }</span>
967 </p>
968 }
969 </div>
970
971 if current.embedding_provider == "ollama" {
973 <div>
974 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
975 { "Ollama Server URL" }
976 </label>
977 <input
978 type="text"
979 class="input w-full font-mono text-sm"
980 value={current.ollama_url.clone().unwrap_or_else(|| "http://localhost:11434".to_string())}
981 oninput={on_ollama_url_change}
982 placeholder="http://localhost:11434"
983 />
984 <p class="text-xs text-gray-500 mt-1">
985 { "URL of your Ollama server instance" }
986 </p>
987 </div>
988 }
989
990 if current.vector_backend == "qdrant" {
992 <div>
993 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
994 { "Qdrant Server URL" }
995 </label>
996 <input
997 type="text"
998 class="input w-full font-mono text-sm"
999 value={current.qdrant_url.clone().unwrap_or_else(|| "http://localhost:6333".to_string())}
1000 oninput={on_qdrant_url_change}
1001 placeholder="http://localhost:6333"
1002 />
1003 <p class="text-xs text-gray-500 mt-1">
1004 { "URL of your Qdrant server instance (requires Docker)" }
1005 </p>
1006 </div>
1007 }
1008
1009 <div class="p-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
1010 <div class="flex items-center justify-between text-sm">
1011 <span class="text-gray-600 dark:text-gray-400">{ "Indexed Documents" }</span>
1012 <span class="font-mono text-gray-900 dark:text-white">{ current.indexed_documents }</span>
1013 </div>
1014 </div>
1015
1016 <div class="border-t border-gray-200 dark:border-gray-700 pt-4 mt-4">
1018 <h4 class="text-sm font-medium text-gray-900 dark:text-white mb-3">
1019 { "Connection Testing" }
1020 </h4>
1021 <p class="text-xs text-gray-600 dark:text-gray-400 mb-3">
1022 { "Test your embedding provider and vector backend configuration before saving." }
1023 </p>
1024
1025 <div class="flex gap-2">
1026 <button
1027 class="btn btn-secondary text-sm"
1028 onclick={on_test_connection}
1029 disabled={*test_connection_loading || *loading}
1030 >
1031 if *test_connection_loading {
1032 <span class="flex items-center gap-2">
1033 <span class="animate-spin">{ "⟳" }</span>
1034 { "Testing..." }
1035 </span>
1036 } else {
1037 { "Quick Test" }
1038 }
1039 </button>
1040
1041 <button
1042 class="btn btn-secondary text-sm"
1043 onclick={on_test_pipeline}
1044 disabled={*test_pipeline_loading || *loading}
1045 >
1046 if *test_pipeline_loading {
1047 <span class="flex items-center gap-2">
1048 <span class="animate-spin">{ "⟳" }</span>
1049 { "Testing..." }
1050 </span>
1051 } else {
1052 { "Full Pipeline Test" }
1053 }
1054 </button>
1055 </div>
1056
1057 if let Some(result) = &*test_result {
1059 <div class={classes!(
1060 "mt-3", "p-3", "rounded", "text-sm",
1061 if result.success {
1062 "bg-success-50 dark:bg-success-900/20 border border-success-200 dark:border-success-800"
1063 } else {
1064 "bg-error-50 dark:bg-error-900/20 border border-error-200 dark:border-error-800"
1065 }
1066 )}>
1067 <div class="flex items-start gap-2">
1068 if result.success {
1069 <svg class="w-4 h-4 text-success-500 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1070 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
1071 </svg>
1072 } else {
1073 <svg class="w-4 h-4 text-error-500 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1074 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
1075 </svg>
1076 }
1077 <div class="flex-1">
1078 <p class={classes!(
1079 "font-medium",
1080 if result.success { "text-success-700 dark:text-success-300" }
1081 else { "text-error-700 dark:text-error-300" }
1082 )}>
1083 { &result.message }
1084 </p>
1085 if let Some(details) = &result.details {
1086 <p class="text-xs mt-1 text-gray-600 dark:text-gray-400">
1087 { details }
1088 </p>
1089 }
1090 <p class="text-xs mt-1 text-gray-500 dark:text-gray-500">
1091 { format!("Completed in {}ms", result.duration_ms) }
1092 </p>
1093 </div>
1094 </div>
1095 </div>
1096 }
1097 </div>
1098
1099 <div class="space-y-3 pt-2">
1100 <ToggleSwitch
1101 label="Enable Hybrid Search"
1102 description="Combines semantic (AI-based) and keyword (BM25) search for better results across different query types"
1103 checked={current.hybrid_search_enabled}
1104 on_toggle={on_hybrid_toggle}
1105 />
1106
1107 <ToggleSwitch
1108 label="Enable Reranking"
1109 description="Re-orders results using cross-encoder model for better accuracy. Slower but more precise."
1110 checked={current.reranking_enabled}
1111 on_toggle={on_reranking_toggle}
1112 />
1113 </div>
1114 </div>
1115 </Card>
1116
1117 <Card title="Data Management">
1119 <div class="space-y-4">
1120 <div>
1121 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
1122 { "History retention" }
1123 </label>
1124 <div class="flex items-center gap-2">
1125 <input
1126 type="number"
1127 class="input w-32"
1128 value={current.max_history_entries.to_string()}
1129 min="100"
1130 max="10000"
1131 oninput={on_history_entries_change}
1132 />
1133 <span class="text-gray-500 dark:text-gray-400">{ "executions" }</span>
1134 </div>
1135 <p class="text-xs text-gray-500 mt-1">
1136 { "Older entries will be automatically removed (100-10,000)" }
1137 </p>
1138 </div>
1139
1140 <div class="flex flex-wrap gap-3 pt-2">
1141 <button class="btn btn-secondary" onclick={on_clear_history}>
1142 <svg class="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1143 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
1144 </svg>
1145 { "Clear History" }
1146 </button>
1147 <button class="btn btn-secondary">
1148 <svg class="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1149 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
1150 </svg>
1151 { "Export Config" }
1152 </button>
1153 <button class="btn btn-secondary" onclick={on_import_click}>
1154 <svg class="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1155 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
1156 </svg>
1157 { "Import Config" }
1158 </button>
1159 </div>
1160 </div>
1161 </Card>
1162
1163 <Card title="About">
1165 <div class="space-y-3">
1166 <div class="flex justify-between py-1">
1167 <span class="text-gray-500 dark:text-gray-400">{ "Version" }</span>
1168 <span class="font-mono text-gray-900 dark:text-white">{ "0.2.2" }</span>
1169 </div>
1170 <div class="flex justify-between py-1">
1171 <span class="text-gray-500 dark:text-gray-400">{ "Build Date" }</span>
1172 <span class="font-mono text-gray-900 dark:text-white">{ "2025-12-22" }</span>
1173 </div>
1174 <div class="flex justify-between py-1">
1175 <span class="text-gray-500 dark:text-gray-400">{ "Embedding Model" }</span>
1176 <span class="font-mono text-gray-900 dark:text-white">{ ¤t.embedding_model }</span>
1177 </div>
1178 </div>
1179 <div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
1180 <a
1181 href="https://github.com/your-repo/skill-engine"
1182 target="_blank"
1183 rel="noopener noreferrer"
1184 class="btn btn-secondary"
1185 >
1186 <svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 24 24">
1187 <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
1188 </svg>
1189 { "View on GitHub" }
1190 </a>
1191 </div>
1192 </Card>
1193
1194 <div class="flex justify-end gap-4 sticky bottom-6 bg-gray-50 dark:bg-gray-900 py-4 -mx-6 px-6 border-t border-gray-200 dark:border-gray-800">
1196 <button
1197 class="btn btn-secondary"
1198 onclick={on_reset}
1199 disabled={*saving}
1200 >
1201 { "Reset to Defaults" }
1202 </button>
1203 <button
1204 class="btn btn-primary"
1205 onclick={on_save}
1206 disabled={!*has_changes || *saving}
1207 >
1208 if *saving {
1209 <svg class="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
1210 <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
1211 <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
1212 </svg>
1213 { "Saving..." }
1214 } else {
1215 { "Save Changes" }
1216 }
1217 </button>
1218 </div>
1219 }
1220 </div>
1221 }
1222}
1223
1224#[derive(Properties, PartialEq)]
1226struct ToggleSwitchProps {
1227 label: &'static str,
1228 description: &'static str,
1229 checked: bool,
1230 on_toggle: Callback<MouseEvent>,
1231}
1232
1233#[function_component(ToggleSwitch)]
1235fn toggle_switch(props: &ToggleSwitchProps) -> Html {
1236 html! {
1237 <div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
1238 <div>
1239 <p class="text-sm font-medium text-gray-900 dark:text-white">
1240 { props.label }
1241 </p>
1242 <p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
1243 { props.description }
1244 </p>
1245 </div>
1246 <button
1247 type="button"
1248 role="switch"
1249 aria-checked={props.checked.to_string()}
1250 onclick={props.on_toggle.clone()}
1251 class={classes!(
1252 "relative", "inline-flex", "h-6", "w-11", "flex-shrink-0",
1253 "cursor-pointer", "rounded-full", "border-2", "border-transparent",
1254 "transition-colors", "duration-200", "ease-in-out",
1255 "focus:outline-none", "focus:ring-2", "focus:ring-primary-500", "focus:ring-offset-2",
1256 if props.checked { "bg-primary-600" } else { "bg-gray-200 dark:bg-gray-600" }
1257 )}
1258 >
1259 <span
1260 class={classes!(
1261 "pointer-events-none", "inline-block", "h-5", "w-5",
1262 "transform", "rounded-full", "bg-white", "shadow",
1263 "ring-0", "transition", "duration-200", "ease-in-out",
1264 if props.checked { "translate-x-5" } else { "translate-x-0" }
1265 )}
1266 />
1267 </button>
1268 </div>
1269 }
1270}
1271
1272#[function_component(SettingsCardSkeleton)]
1274fn settings_card_skeleton() -> Html {
1275 html! {
1276 <div class="card p-6 animate-pulse">
1277 <div class="h-5 w-32 bg-gray-200 dark:bg-gray-700 rounded mb-4"></div>
1278 <div class="space-y-4">
1279 <div class="h-10 w-full bg-gray-200 dark:bg-gray-700 rounded"></div>
1280 <div class="h-10 w-3/4 bg-gray-200 dark:bg-gray-700 rounded"></div>
1281 </div>
1282 </div>
1283 }
1284}