skill_web/pages/
settings.rs

1//! Settings page with full API integration
2//!
3//! Provides configuration for:
4//! - Appearance (theme)
5//! - Execution defaults (timeout, metadata, history)
6//! - Search pipeline (embedding provider, vector store, hybrid search, reranking)
7//! - Data management (import/export, clear history)
8
9use 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/// Settings state
22#[derive(Clone, PartialEq)]
23struct SettingsState {
24    // Appearance
25    theme: String,
26    // Execution
27    default_timeout_secs: u64,
28    max_concurrent_executions: usize,
29    include_metadata: bool,
30    enable_history: bool,
31    max_history_entries: usize,
32    // Search
33    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    // Advanced embedding model override
42    use_advanced_model: bool,
43    // Agent
44    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        // Check if model is a non-standard value (advanced)
83        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 defaults (not user-configurable anymore)
104            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/// Test result for vector DB testing
133#[derive(Clone, PartialEq)]
134struct TestResult {
135    success: bool,
136    message: String,
137    duration_ms: u128,
138    details: Option<String>,
139}
140
141/// Settings page component
142#[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    // API client
149    let api = use_memo((), |_| Rc::new(Api::new()));
150
151    // State
152    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    // Vector DB testing state
159    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    // Load settings on mount
164    {
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    // Theme change handler
187    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            // Apply theme immediately
197            ui_dispatch.apply(UiAction::SetDarkMode(value == "dark"));
198        })
199    };
200
201    // Timeout change handler
202    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    // Max concurrent change handler
217    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    // History entries change handler
232    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    // Enable history toggle
247    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    // Include metadata toggle
259    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    // Embedding provider change
271    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            // Auto-set default model for new provider if not in advanced mode
281            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    // Vector backend change
291    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    // Embedding model dropdown change (for standard models)
304    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    // Embedding model text input change (for advanced/custom models)
317    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    // Toggle advanced model mode
330    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 switching to standard mode, set default model for current provider
338            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    // Ollama URL change
348    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    // Qdrant URL change
361    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    // Hybrid search toggle
374    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    // Reranking toggle
386    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    // Save changes handler
398    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                // Update app config
416                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                // Update search config
427                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    // Reset to defaults handler
454    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 defaults
477                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    // Test connection handler (quick validation)
490    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    // Test pipeline handler (full test with indexing)
560    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    // Import config handler
631    let on_import_click = {
632        let import_modal = import_modal.clone();
633        Callback::from(move |_: MouseEvent| {
634            import_modal.open();
635        })
636    };
637
638    // Reload settings after import
639    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    // Clear history handler
655    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            // Show confirmation
663            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    // Current settings
684    let current = (*settings).clone();
685
686    html! {
687        <div class="space-y-6 animate-fade-in">
688            // Import Config Modal
689            <ImportConfigModal on_imported={on_config_imported} />
690
691            // Page header
692            <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            // Error alert
712            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            // Loading state
724            if *loading {
725                <div class="space-y-6">
726                    { for (0..4).map(|_| html! { <SettingsCardSkeleton /> }) }
727                </div>
728            } else {
729                // Appearance settings
730                <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                // Execution settings
764                <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                // Search pipeline settings
821                <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                        // Embedding Model selection
876                        <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                                // Advanced: Free-form text input
892                                <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                                // Standard: Dropdown with predefined models
911                                <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                        // Conditional Ollama URL input (only shown when provider = "ollama")
972                        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                        // Conditional Qdrant URL input (only shown when backend = "qdrant")
991                        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                        // Vector DB Testing Section
1017                        <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                            // Test result display
1058                            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                // Data settings
1118                <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                // About section
1164                <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">{ &current.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                // Save buttons
1195                <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/// Toggle switch component props
1225#[derive(Properties, PartialEq)]
1226struct ToggleSwitchProps {
1227    label: &'static str,
1228    description: &'static str,
1229    checked: bool,
1230    on_toggle: Callback<MouseEvent>,
1231}
1232
1233/// Toggle switch component
1234#[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/// Skeleton loader for settings cards
1273#[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}