vector-inspector 0.2.7__py3-none-any.whl → 0.3.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. vector_inspector/config/__init__.py +4 -0
  2. vector_inspector/config/known_embedding_models.json +432 -0
  3. vector_inspector/core/connections/__init__.py +2 -1
  4. vector_inspector/core/connections/base_connection.py +42 -1
  5. vector_inspector/core/connections/chroma_connection.py +47 -11
  6. vector_inspector/core/connections/pinecone_connection.py +768 -0
  7. vector_inspector/core/embedding_providers/__init__.py +14 -0
  8. vector_inspector/core/embedding_providers/base_provider.py +128 -0
  9. vector_inspector/core/embedding_providers/clip_provider.py +260 -0
  10. vector_inspector/core/embedding_providers/provider_factory.py +176 -0
  11. vector_inspector/core/embedding_providers/sentence_transformer_provider.py +203 -0
  12. vector_inspector/core/embedding_utils.py +69 -42
  13. vector_inspector/core/model_registry.py +205 -0
  14. vector_inspector/services/backup_restore_service.py +16 -0
  15. vector_inspector/services/settings_service.py +117 -1
  16. vector_inspector/ui/components/connection_manager_panel.py +7 -0
  17. vector_inspector/ui/components/profile_manager_panel.py +61 -14
  18. vector_inspector/ui/dialogs/__init__.py +2 -1
  19. vector_inspector/ui/dialogs/cross_db_migration.py +20 -1
  20. vector_inspector/ui/dialogs/embedding_config_dialog.py +166 -27
  21. vector_inspector/ui/dialogs/provider_type_dialog.py +189 -0
  22. vector_inspector/ui/main_window.py +33 -2
  23. vector_inspector/ui/views/connection_view.py +55 -10
  24. vector_inspector/ui/views/info_panel.py +83 -36
  25. vector_inspector/ui/views/search_view.py +1 -1
  26. vector_inspector/ui/views/visualization_view.py +20 -6
  27. {vector_inspector-0.2.7.dist-info → vector_inspector-0.3.2.dist-info}/METADATA +7 -2
  28. vector_inspector-0.3.2.dist-info/RECORD +55 -0
  29. vector_inspector-0.2.7.dist-info/RECORD +0 -45
  30. {vector_inspector-0.2.7.dist-info → vector_inspector-0.3.2.dist-info}/WHEEL +0 -0
  31. {vector_inspector-0.2.7.dist-info → vector_inspector-0.3.2.dist-info}/entry_points.txt +0 -0
@@ -2,7 +2,7 @@
2
2
 
3
3
  import json
4
4
  from pathlib import Path
5
- from typing import Dict, Any, Optional
5
+ from typing import Dict, Any, Optional, List
6
6
  from vector_inspector.core.cache_manager import invalidate_cache_on_settings_change
7
7
 
8
8
 
@@ -77,3 +77,119 @@ class SettingsService:
77
77
  """Clear all settings."""
78
78
  self.settings = {}
79
79
  self._save_settings()
80
+
81
+ def save_embedding_model(self, connection_id: str, collection_name: str, model_name: str, model_type: str = "user-configured"):
82
+ """Save embedding model mapping for a collection.
83
+
84
+ Args:
85
+ connection_id: Connection identifier
86
+ collection_name: Collection name
87
+ model_name: Embedding model name (e.g., 'sentence-transformers/all-MiniLM-L6-v2')
88
+ model_type: Type of configuration ('user-configured', 'auto-detected', 'stored')
89
+ """
90
+ if "collection_embedding_models" not in self.settings:
91
+ self.settings["collection_embedding_models"] = {}
92
+
93
+ collection_key = f"{connection_id}:{collection_name}"
94
+ self.settings["collection_embedding_models"][collection_key] = {
95
+ "model": model_name,
96
+ "type": model_type,
97
+ "timestamp": self._get_timestamp()
98
+ }
99
+ self._save_settings()
100
+
101
+ def get_embedding_model(self, connection_id: str, collection_name: str) -> Optional[Dict[str, Any]]:
102
+ """Get embedding model mapping for a collection.
103
+
104
+ Args:
105
+ connection_id: Connection identifier
106
+ collection_name: Collection name
107
+
108
+ Returns:
109
+ Dictionary with 'model', 'type', and 'timestamp' or None
110
+ """
111
+ collection_models = self.settings.get("collection_embedding_models", {})
112
+ collection_key = f"{connection_id}:{collection_name}"
113
+ return collection_models.get(collection_key)
114
+
115
+ def remove_embedding_model(self, connection_id: str, collection_name: str):
116
+ """Remove embedding model mapping for a collection.
117
+
118
+ Args:
119
+ connection_id: Connection identifier
120
+ collection_name: Collection name
121
+ """
122
+ if "collection_embedding_models" not in self.settings:
123
+ return
124
+
125
+ collection_key = f"{connection_id}:{collection_name}"
126
+ self.settings["collection_embedding_models"].pop(collection_key, None)
127
+ self._save_settings()
128
+
129
+ def _get_timestamp(self) -> str:
130
+ """Get current timestamp as ISO string."""
131
+ from datetime import datetime
132
+ return datetime.now().isoformat()
133
+
134
+ def add_custom_embedding_model(self, model_name: str, dimension: int, model_type: str = "sentence-transformer", description: str = "Custom model"):
135
+ """Add a custom embedding model to the known models list.
136
+
137
+ Args:
138
+ model_name: Name of the embedding model
139
+ dimension: Vector dimension
140
+ model_type: Type of model (e.g., 'sentence-transformer', 'clip', 'openai')
141
+ description: Brief description of the model
142
+ """
143
+ if "custom_embedding_models" not in self.settings:
144
+ self.settings["custom_embedding_models"] = []
145
+
146
+ # Check if already exists
147
+ for model in self.settings["custom_embedding_models"]:
148
+ if model["name"] == model_name and model["dimension"] == dimension:
149
+ # Update existing entry
150
+ model["type"] = model_type
151
+ model["description"] = description
152
+ model["last_used"] = self._get_timestamp()
153
+ self._save_settings()
154
+ return
155
+
156
+ # Add new entry
157
+ self.settings["custom_embedding_models"].append({
158
+ "name": model_name,
159
+ "dimension": dimension,
160
+ "type": model_type,
161
+ "description": description,
162
+ "added": self._get_timestamp(),
163
+ "last_used": self._get_timestamp()
164
+ })
165
+ self._save_settings()
166
+
167
+ def get_custom_embedding_models(self, dimension: Optional[int] = None) -> List[Dict[str, Any]]:
168
+ """Get list of custom embedding models.
169
+
170
+ Args:
171
+ dimension: Optional filter by dimension
172
+
173
+ Returns:
174
+ List of custom model dictionaries
175
+ """
176
+ models = self.settings.get("custom_embedding_models", [])
177
+ if dimension is not None:
178
+ return [m for m in models if m["dimension"] == dimension]
179
+ return models
180
+
181
+ def remove_custom_embedding_model(self, model_name: str, dimension: int):
182
+ """Remove a custom embedding model.
183
+
184
+ Args:
185
+ model_name: Name of the model to remove
186
+ dimension: Vector dimension
187
+ """
188
+ if "custom_embedding_models" not in self.settings:
189
+ return
190
+
191
+ self.settings["custom_embedding_models"] = [
192
+ m for m in self.settings["custom_embedding_models"]
193
+ if not (m["name"] == model_name and m["dimension"] == dimension)
194
+ ]
195
+ self._save_settings()
@@ -289,11 +289,18 @@ class ConnectionManagerPanel(QWidget):
289
289
  if not instance or not instance.connection.is_connected:
290
290
  return
291
291
 
292
+ # Show loading while refreshing
293
+ from ..components.loading_dialog import LoadingDialog
294
+ loading = LoadingDialog("Refreshing collections...", self)
295
+ loading.show_loading("Refreshing collections...")
296
+ QApplication.processEvents()
292
297
  try:
293
298
  collections = instance.connection.list_collections()
294
299
  self.connection_manager.update_collections(connection_id, collections)
295
300
  except Exception as e:
296
301
  QMessageBox.warning(self, "Error", f"Failed to refresh collections: {e}")
302
+ finally:
303
+ loading.hide_loading()
297
304
 
298
305
  def _disconnect_connection(self, connection_id: str):
299
306
  """Disconnect a connection."""
@@ -266,6 +266,7 @@ class ProfileEditorDialog(QDialog):
266
266
  self.provider_combo = QComboBox()
267
267
  self.provider_combo.addItem("ChromaDB", "chromadb")
268
268
  self.provider_combo.addItem("Qdrant", "qdrant")
269
+ self.provider_combo.addItem("Pinecone", "pinecone")
269
270
  self.provider_combo.currentIndexChanged.connect(self._on_provider_changed)
270
271
  form_layout.addRow("Provider:", self.provider_combo)
271
272
 
@@ -356,23 +357,48 @@ class ProfileEditorDialog(QDialog):
356
357
  if self.port_input.text() == "6333":
357
358
  self.port_input.setText("8000")
358
359
 
359
- # Show/hide API key field
360
- is_http = self.http_radio.isChecked()
361
- self.api_key_input.setEnabled(is_http and provider == "qdrant")
360
+ # For Pinecone, disable persistent/ephemeral modes and only show API key
361
+ if provider == "pinecone":
362
+ self.persistent_radio.setEnabled(False)
363
+ self.http_radio.setEnabled(True)
364
+ self.http_radio.setChecked(True)
365
+ self.ephemeral_radio.setEnabled(False)
366
+ self.path_input.setEnabled(False)
367
+ self.path_browse_btn.setEnabled(False)
368
+ self.host_input.setEnabled(False)
369
+ self.port_input.setEnabled(False)
370
+ self.api_key_input.setEnabled(True)
371
+ else:
372
+ self.persistent_radio.setEnabled(True)
373
+ self.http_radio.setEnabled(True)
374
+ self.ephemeral_radio.setEnabled(True)
375
+ # Show/hide API key field
376
+ is_http = self.http_radio.isChecked()
377
+ self.api_key_input.setEnabled(is_http and provider == "qdrant")
378
+ # Update other fields based on connection type
379
+ self._on_type_changed()
362
380
 
363
381
  def _on_type_changed(self):
364
382
  """Handle connection type change."""
365
383
  is_persistent = self.persistent_radio.isChecked()
366
384
  is_http = self.http_radio.isChecked()
367
385
 
368
- # Show/hide relevant fields
369
- self.path_input.setEnabled(is_persistent)
370
- self.path_browse_btn.setEnabled(is_persistent)
371
- self.host_input.setEnabled(is_http)
372
- self.port_input.setEnabled(is_http)
373
-
374
386
  provider = self.provider_combo.currentData()
375
- self.api_key_input.setEnabled(is_http and provider == "qdrant")
387
+
388
+ # Pinecone always uses API key, no path/host/port
389
+ if provider == "pinecone":
390
+ self.path_input.setEnabled(False)
391
+ self.path_browse_btn.setEnabled(False)
392
+ self.host_input.setEnabled(False)
393
+ self.port_input.setEnabled(False)
394
+ self.api_key_input.setEnabled(True)
395
+ else:
396
+ # Show/hide relevant fields
397
+ self.path_input.setEnabled(is_persistent)
398
+ self.path_browse_btn.setEnabled(is_persistent)
399
+ self.host_input.setEnabled(is_http)
400
+ self.port_input.setEnabled(is_http)
401
+ self.api_key_input.setEnabled(is_http and provider == "qdrant")
376
402
 
377
403
  def _browse_for_path(self):
378
404
  """Browse for persistent storage path."""
@@ -405,7 +431,10 @@ class ProfileEditorDialog(QDialog):
405
431
  conn_type = config.get("type", "persistent")
406
432
 
407
433
  # Set connection type
408
- if conn_type == "persistent":
434
+ if conn_type == "cloud":
435
+ # Pinecone cloud connection
436
+ self.http_radio.setChecked(True)
437
+ elif conn_type == "persistent":
409
438
  self.persistent_radio.setChecked(True)
410
439
  self.path_input.setText(config.get("path", ""))
411
440
  elif conn_type == "http":
@@ -429,9 +458,16 @@ class ProfileEditorDialog(QDialog):
429
458
  # Create connection
430
459
  from vector_inspector.core.connections.chroma_connection import ChromaDBConnection
431
460
  from vector_inspector.core.connections.qdrant_connection import QdrantConnection
461
+ from vector_inspector.core.connections.pinecone_connection import PineconeConnection
432
462
 
433
463
  try:
434
- if provider == "chromadb":
464
+ if provider == "pinecone":
465
+ api_key = self.api_key_input.text()
466
+ if not api_key:
467
+ QMessageBox.warning(self, "Missing API Key", "Pinecone requires an API key.")
468
+ return
469
+ conn = PineconeConnection(api_key=api_key)
470
+ elif provider == "chromadb":
435
471
  conn = ChromaDBConnection(**self._get_connection_kwargs(config))
436
472
  else:
437
473
  conn = QdrantConnection(**self._get_connection_kwargs(config))
@@ -455,8 +491,12 @@ class ProfileEditorDialog(QDialog):
455
491
  def _get_config(self) -> dict:
456
492
  """Get configuration from form."""
457
493
  config = {}
494
+ provider = self.provider_combo.currentData()
458
495
 
459
- if self.persistent_radio.isChecked():
496
+ # Pinecone uses cloud connection type
497
+ if provider == "pinecone":
498
+ config["type"] = "cloud"
499
+ elif self.persistent_radio.isChecked():
460
500
  config["type"] = "persistent"
461
501
  config["path"] = self.path_input.text()
462
502
  elif self.http_radio.isChecked():
@@ -494,7 +534,14 @@ class ProfileEditorDialog(QDialog):
494
534
 
495
535
  # Get credentials
496
536
  credentials = {}
497
- if self.api_key_input.text() and self.http_radio.isChecked():
537
+ if provider == "pinecone":
538
+ # Pinecone always requires API key
539
+ if self.api_key_input.text():
540
+ credentials["api_key"] = self.api_key_input.text()
541
+ else:
542
+ QMessageBox.warning(self, "Missing API Key", "Pinecone requires an API key.")
543
+ return
544
+ elif self.api_key_input.text() and self.http_radio.isChecked():
498
545
  credentials["api_key"] = self.api_key_input.text()
499
546
 
500
547
  if self.is_edit_mode:
@@ -1,5 +1,6 @@
1
1
  """UI Dialogs for vector-inspector."""
2
2
 
3
3
  from .embedding_config_dialog import EmbeddingConfigDialog
4
+ from .provider_type_dialog import ProviderTypeDialog
4
5
 
5
- __all__ = ['EmbeddingConfigDialog']
6
+ __all__ = ['EmbeddingConfigDialog', 'ProviderTypeDialog']
@@ -107,12 +107,31 @@ class MigrationThread(QThread):
107
107
  self.progress.emit(100, f"Migration complete!")
108
108
  self.finished.emit(True, f"Successfully migrated {self.source_collection} to {self.target_collection}")
109
109
  else:
110
- self.finished.emit(False, "Failed to restore to target collection.")
110
+ # Clean up target collection on failure
111
+ try:
112
+ if self.target_collection in self.target_conn.connection.list_collections():
113
+ self.progress.emit(90, "Cleaning up failed migration...")
114
+ print(f"Cleaning up failed migration: deleting target collection '{self.target_collection}'")
115
+ self.target_conn.connection.delete_collection(self.target_collection)
116
+ except Exception as cleanup_error:
117
+ print(f"Warning: Failed to clean up target collection: {cleanup_error}")
118
+
119
+ self.finished.emit(False, "Failed to restore to target collection. Target collection cleaned up.")
111
120
 
112
121
  except Exception as e:
113
122
  import traceback
114
123
  error_details = traceback.format_exc()
115
124
  print(f"Migration error details:\n{error_details}")
125
+
126
+ # Clean up target collection on exception
127
+ try:
128
+ if self.target_conn and self.target_conn.connection.is_connected:
129
+ if self.target_collection in self.target_conn.connection.list_collections():
130
+ print(f"Cleaning up failed migration: deleting target collection '{self.target_collection}'")
131
+ self.target_conn.connection.delete_collection(self.target_collection)
132
+ except Exception as cleanup_error:
133
+ print(f"Warning: Failed to clean up target collection: {cleanup_error}")
134
+
116
135
  self.finished.emit(False, f"Migration error: {str(e)}")
117
136
 
118
137
  finally:
@@ -1,38 +1,63 @@
1
- """Dialog for configuring embedding models for collections."""
1
+ """Dialog for configuring embedding models for collections (Step 2: Model Selection)."""
2
2
 
3
3
  from typing import Optional, Tuple
4
4
  from PySide6.QtWidgets import (
5
5
  QDialog, QVBoxLayout, QHBoxLayout, QLabel,
6
6
  QComboBox, QPushButton, QGroupBox, QTextEdit,
7
- QMessageBox
7
+ QMessageBox, QLineEdit, QFormLayout
8
8
  )
9
9
  from PySide6.QtCore import Qt
10
10
 
11
- from vector_inspector.core.embedding_utils import get_available_models_for_dimension, DIMENSION_TO_MODEL
11
+ from vector_inspector.core.embedding_utils import get_available_models_for_dimension
12
+ from vector_inspector.core.model_registry import get_model_registry
12
13
 
13
14
 
14
15
  class EmbeddingConfigDialog(QDialog):
15
16
  """Dialog for selecting embedding model for a collection."""
16
17
 
17
18
  def __init__(self, collection_name: str, vector_dimension: int,
19
+ provider_type: Optional[str] = None,
18
20
  current_model: Optional[str] = None,
19
21
  current_type: Optional[str] = None,
20
22
  parent=None):
21
23
  super().__init__(parent)
22
24
  self.collection_name = collection_name
23
25
  self.vector_dimension = vector_dimension
26
+ self.provider_type = provider_type # Filter by this type
24
27
  self.current_model = current_model
25
28
  self.current_type = current_type
26
29
  self.selected_model = None
27
30
  self.selected_type = None
28
31
 
29
- self.setWindowTitle(f"Configure Embedding Model - {collection_name}")
32
+ # Determine title based on provider type
33
+ if provider_type == "custom":
34
+ title = "Enter Custom Model"
35
+ elif provider_type:
36
+ type_names = {
37
+ "sentence-transformer": "Sentence Transformers",
38
+ "clip": "CLIP Models",
39
+ "openai": "OpenAI API",
40
+ "cohere": "Cohere API",
41
+ "vertex-ai": "Google Vertex AI",
42
+ "voyage": "Voyage AI"
43
+ }
44
+ type_name = type_names.get(provider_type, provider_type.title())
45
+ title = f"Select Model: {type_name}"
46
+ else:
47
+ title = f"Configure Embedding Model - {collection_name}"
48
+
49
+ self.setWindowTitle(title)
30
50
  self.setMinimumWidth(500)
31
51
  self._setup_ui()
32
52
 
33
53
  def _setup_ui(self):
34
54
  """Setup dialog UI."""
35
55
  layout = QVBoxLayout(self)
56
+
57
+ # Handle custom model entry case
58
+ if self.provider_type == "custom":
59
+ self._setup_custom_ui(layout)
60
+ return
36
61
 
37
62
  # Info section
38
63
  info_group = QGroupBox("Collection Information")
@@ -55,17 +80,38 @@ class EmbeddingConfigDialog(QDialog):
55
80
  model_group = QGroupBox("Embedding Model Selection")
56
81
  model_layout = QVBoxLayout()
57
82
 
58
- # Get available models for this dimension
59
- available_models = get_available_models_for_dimension(self.vector_dimension)
83
+ # Get available models for this dimension, filtered by provider type
84
+ if self.provider_type:
85
+ registry = get_model_registry()
86
+ registry_models = registry.get_models_by_dimension(self.vector_dimension)
87
+ filtered_models = [m for m in registry_models if m.type == self.provider_type]
88
+ available_models = [(m.name, m.type, m.description) for m in filtered_models]
89
+
90
+ # Add custom models from settings
91
+ try:
92
+ from ...services.settings_service import SettingsService
93
+ settings = SettingsService()
94
+ custom_models = settings.get_custom_embedding_models(self.vector_dimension)
95
+ for model in custom_models:
96
+ if model["type"] == self.provider_type:
97
+ available_models.append((
98
+ model["name"],
99
+ model["type"],
100
+ f"{model['description']} (custom)"
101
+ ))
102
+ except Exception:
103
+ pass
104
+ else:
105
+ available_models = get_available_models_for_dimension(self.vector_dimension)
60
106
 
61
107
  if available_models:
62
108
  model_layout.addWidget(QLabel(f"Available models for {self.vector_dimension}-dimensional vectors:"))
63
-
109
+
64
110
  self.model_combo = QComboBox()
65
111
  for model_name, model_type, description in available_models:
66
112
  display_text = f"{model_name} ({model_type}) - {description}"
67
113
  self.model_combo.addItem(display_text, (model_name, model_type))
68
-
114
+
69
115
  # Set current selection if it matches
70
116
  if self.current_model and self.current_type:
71
117
  for i in range(self.model_combo.count()):
@@ -73,33 +119,35 @@ class EmbeddingConfigDialog(QDialog):
73
119
  if model_name == self.current_model and model_type == self.current_type:
74
120
  self.model_combo.setCurrentIndex(i)
75
121
  break
76
-
122
+
77
123
  model_layout.addWidget(self.model_combo)
78
-
124
+
79
125
  # Description area
80
126
  desc_label = QLabel("<b>About the selected model:</b>")
81
127
  model_layout.addWidget(desc_label)
82
-
128
+
83
129
  self.description_text = QTextEdit()
84
130
  self.description_text.setReadOnly(True)
85
131
  self.description_text.setMaximumHeight(100)
86
132
  self.description_text.setStyleSheet("background-color: #f5f5f5; border: 1px solid #ccc; color: #000000;")
87
133
  model_layout.addWidget(self.description_text)
88
-
134
+
89
135
  # Update description when selection changes
90
136
  self.model_combo.currentIndexChanged.connect(self._update_description)
91
137
  self._update_description()
92
-
138
+
93
139
  else:
94
- # No known models for this dimension
95
- warning = QLabel(f"⚠️ No pre-configured models available for {self.vector_dimension} dimensions.")
140
+ # No models for this type + dimension
141
+ type_name = self.provider_type or "any type"
142
+ warning = QLabel(f"⚠️ No models of type '{type_name}' available for {self.vector_dimension} dimensions.")
96
143
  warning.setWordWrap(True)
97
144
  model_layout.addWidget(warning)
98
-
99
- # Show all available dimensions
100
- dims_text = "Available dimensions: " + ", ".join(str(d) for d in sorted(DIMENSION_TO_MODEL.keys()))
145
+
146
+ registry = get_model_registry()
147
+ all_dims = registry.get_all_dimensions()
148
+ dims_text = "Available dimensions: " + ", ".join(str(d) for d in sorted(all_dims))
101
149
  model_layout.addWidget(QLabel(dims_text))
102
-
150
+
103
151
  self.model_combo = None
104
152
 
105
153
  model_group.setLayout(model_layout)
@@ -110,8 +158,9 @@ class EmbeddingConfigDialog(QDialog):
110
158
  button_layout.addStretch()
111
159
 
112
160
  self.save_btn = QPushButton("Save Configuration")
113
- self.save_btn.clicked.connect(self.accept)
114
- self.save_btn.setEnabled(available_models and len(available_models) > 0)
161
+ self.save_btn.clicked.connect(self._on_save)
162
+ # Always enabled - user can choose from combo OR enter custom
163
+ self.save_btn.setEnabled(True)
115
164
 
116
165
  self.clear_btn = QPushButton("Clear Configuration")
117
166
  self.clear_btn.clicked.connect(self._clear_config)
@@ -126,6 +175,85 @@ class EmbeddingConfigDialog(QDialog):
126
175
 
127
176
  layout.addLayout(button_layout)
128
177
 
178
+ def _setup_custom_ui(self, layout):
179
+ """Setup UI for custom model entry."""
180
+ # Info section
181
+ info_group = QGroupBox("Collection Information")
182
+ info_layout = QVBoxLayout()
183
+ info_layout.addWidget(QLabel(f"<b>Collection:</b> {self.collection_name}"))
184
+ info_layout.addWidget(QLabel(f"<b>Vector Dimension:</b> {self.vector_dimension}"))
185
+ info_group.setLayout(info_layout)
186
+ layout.addWidget(info_group)
187
+
188
+ # Custom model entry section
189
+ custom_group = QGroupBox("Enter Custom Model Details")
190
+ custom_layout = QFormLayout()
191
+
192
+ self.custom_name_input = QLineEdit()
193
+ self.custom_name_input.setPlaceholderText("e.g., sentence-transformers/all-mpnet-base-v2")
194
+ custom_layout.addRow("Model Name:", self.custom_name_input)
195
+
196
+ self.custom_type_combo = QComboBox()
197
+ self.custom_type_combo.addItems(["sentence-transformer", "clip", "openai", "cohere", "vertex-ai", "voyage", "custom"])
198
+ custom_layout.addRow("Model Type:", self.custom_type_combo)
199
+
200
+ self.custom_desc_input = QLineEdit()
201
+ self.custom_desc_input.setPlaceholderText("Brief description (optional)")
202
+ custom_layout.addRow("Description:", self.custom_desc_input)
203
+
204
+ custom_note = QLabel("💡 Custom models will be saved and available for future use with this dimension.")
205
+ custom_note.setWordWrap(True)
206
+ custom_note.setStyleSheet("color: #666; font-size: 10px; padding: 4px;")
207
+ custom_layout.addRow(custom_note)
208
+
209
+ custom_group.setLayout(custom_layout)
210
+ layout.addWidget(custom_group)
211
+
212
+ # Buttons for custom entry
213
+ button_layout = QHBoxLayout()
214
+ button_layout.addStretch()
215
+
216
+ cancel_btn = QPushButton("Cancel")
217
+ cancel_btn.clicked.connect(self.reject)
218
+
219
+ save_btn = QPushButton("Save")
220
+ save_btn.clicked.connect(self._save_custom_model)
221
+ save_btn.setDefault(True)
222
+
223
+ button_layout.addWidget(cancel_btn)
224
+ button_layout.addWidget(save_btn)
225
+
226
+ layout.addLayout(button_layout)
227
+
228
+ # No combo or description for custom mode
229
+ self.model_combo = None
230
+
231
+ def _save_custom_model(self):
232
+ """Save custom model entry."""
233
+ custom_name = self.custom_name_input.text().strip()
234
+ custom_desc = self.custom_desc_input.text().strip()
235
+ custom_type = self.custom_type_combo.currentText()
236
+
237
+ if not custom_name:
238
+ QMessageBox.warning(self, "Invalid Input", "Please enter a model name.")
239
+ return
240
+
241
+ # Save custom model to registry
242
+ from vector_inspector.services.settings_service import SettingsService
243
+ settings = SettingsService()
244
+
245
+ settings.add_custom_embedding_model(
246
+ model_name=custom_name,
247
+ dimension=self.vector_dimension,
248
+ model_type=custom_type,
249
+ description=custom_desc if custom_desc else f"Custom {custom_type} model"
250
+ )
251
+
252
+ # Set selection to custom model
253
+ self.selected_model = custom_name
254
+ self.selected_type = custom_type
255
+ self.accept()
256
+
129
257
  def _update_description(self):
130
258
  """Update the description text based on selected model."""
131
259
  if not self.model_combo:
@@ -153,6 +281,19 @@ class EmbeddingConfigDialog(QDialog):
153
281
  f"{desc}"
154
282
  )
155
283
 
284
+ def _on_save(self):
285
+ """Handle save button click."""
286
+ if self.model_combo and self.model_combo.currentData():
287
+ # Use combo selection
288
+ model_name, model_type = self.model_combo.currentData()
289
+ self.selected_model = model_name
290
+ self.selected_type = model_type
291
+ else:
292
+ QMessageBox.warning(self, "No Selection", "Please select a model from the list.")
293
+ return
294
+
295
+ self.accept()
296
+
156
297
  def _clear_config(self):
157
298
  """Clear the embedding model configuration."""
158
299
  reply = QMessageBox.question(
@@ -168,9 +309,7 @@ class EmbeddingConfigDialog(QDialog):
168
309
  self.done(2) # Custom code for "clear"
169
310
 
170
311
  def get_selection(self) -> Optional[Tuple[str, str]]:
171
- """Get the selected model and type."""
172
- if not self.model_combo:
173
- return None
174
-
175
- model_name, model_type = self.model_combo.currentData()
176
- return (model_name, model_type)
312
+ """Get the selected model and type (from either combo or custom entry)."""
313
+ if self.selected_model and self.selected_type:
314
+ return (self.selected_model, self.selected_type)
315
+ return None