vector-inspector 0.2.6__py3-none-any.whl → 0.3.1__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 (38) hide show
  1. vector_inspector/config/__init__.py +4 -0
  2. vector_inspector/config/known_embedding_models.json +432 -0
  3. vector_inspector/core/cache_manager.py +159 -0
  4. vector_inspector/core/connection_manager.py +277 -0
  5. vector_inspector/core/connections/__init__.py +2 -1
  6. vector_inspector/core/connections/base_connection.py +42 -1
  7. vector_inspector/core/connections/chroma_connection.py +137 -16
  8. vector_inspector/core/connections/pinecone_connection.py +768 -0
  9. vector_inspector/core/connections/qdrant_connection.py +62 -8
  10. vector_inspector/core/embedding_providers/__init__.py +14 -0
  11. vector_inspector/core/embedding_providers/base_provider.py +128 -0
  12. vector_inspector/core/embedding_providers/clip_provider.py +260 -0
  13. vector_inspector/core/embedding_providers/provider_factory.py +176 -0
  14. vector_inspector/core/embedding_providers/sentence_transformer_provider.py +203 -0
  15. vector_inspector/core/embedding_utils.py +167 -0
  16. vector_inspector/core/model_registry.py +205 -0
  17. vector_inspector/services/backup_restore_service.py +19 -29
  18. vector_inspector/services/credential_service.py +130 -0
  19. vector_inspector/services/filter_service.py +1 -1
  20. vector_inspector/services/profile_service.py +409 -0
  21. vector_inspector/services/settings_service.py +136 -1
  22. vector_inspector/ui/components/connection_manager_panel.py +327 -0
  23. vector_inspector/ui/components/profile_manager_panel.py +565 -0
  24. vector_inspector/ui/dialogs/__init__.py +6 -0
  25. vector_inspector/ui/dialogs/cross_db_migration.py +383 -0
  26. vector_inspector/ui/dialogs/embedding_config_dialog.py +315 -0
  27. vector_inspector/ui/dialogs/provider_type_dialog.py +189 -0
  28. vector_inspector/ui/main_window.py +456 -190
  29. vector_inspector/ui/views/connection_view.py +55 -10
  30. vector_inspector/ui/views/info_panel.py +272 -55
  31. vector_inspector/ui/views/metadata_view.py +71 -3
  32. vector_inspector/ui/views/search_view.py +44 -4
  33. vector_inspector/ui/views/visualization_view.py +19 -5
  34. {vector_inspector-0.2.6.dist-info → vector_inspector-0.3.1.dist-info}/METADATA +3 -1
  35. vector_inspector-0.3.1.dist-info/RECORD +55 -0
  36. vector_inspector-0.2.6.dist-info/RECORD +0 -35
  37. {vector_inspector-0.2.6.dist-info → vector_inspector-0.3.1.dist-info}/WHEEL +0 -0
  38. {vector_inspector-0.2.6.dist-info → vector_inspector-0.3.1.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,383 @@
1
+ """Cross-database operations for migrating data between vector databases."""
2
+
3
+ from typing import Optional, List, Dict, Any
4
+ from pathlib import Path
5
+ import tempfile
6
+ from PySide6.QtWidgets import (
7
+ QDialog, QVBoxLayout, QHBoxLayout, QLabel, QComboBox,
8
+ QPushButton, QProgressBar, QTextEdit, QGroupBox, QFormLayout,
9
+ QSpinBox, QCheckBox, QMessageBox
10
+ )
11
+ from PySide6.QtCore import QThread, Signal
12
+
13
+ from vector_inspector.core.connection_manager import ConnectionManager, ConnectionInstance
14
+ from vector_inspector.services.backup_restore_service import BackupRestoreService
15
+
16
+
17
+ class MigrationThread(QThread):
18
+ """Background thread for migrating data between databases using backup/restore."""
19
+
20
+ progress = Signal(int, str) # progress percentage, status message
21
+ finished = Signal(bool, str) # success, message
22
+
23
+ def __init__(
24
+ self,
25
+ source_conn: ConnectionInstance,
26
+ target_conn: ConnectionInstance,
27
+ source_collection: str,
28
+ target_collection: str,
29
+ include_embeddings: bool
30
+ ):
31
+ super().__init__()
32
+ self.source_conn = source_conn
33
+ self.target_conn = target_conn
34
+ self.source_collection = source_collection
35
+ self.target_collection = target_collection
36
+ self.include_embeddings = include_embeddings
37
+ self._cancelled = False
38
+ self.backup_service = BackupRestoreService()
39
+
40
+ def cancel(self):
41
+ """Cancel the migration."""
42
+ self._cancelled = True
43
+
44
+ def run(self):
45
+ """Run the migration using backup and restore."""
46
+ temp_backup_path = None
47
+ try:
48
+ if self._cancelled:
49
+ self.finished.emit(False, "Migration cancelled by user.")
50
+ return
51
+
52
+ # Ensure connections are active
53
+ if not self.source_conn.connection.is_connected:
54
+ self.finished.emit(False, "Source connection is not active.")
55
+ return
56
+
57
+ if not self.target_conn.connection.is_connected:
58
+ self.finished.emit(False, "Target connection is not active.")
59
+ return
60
+
61
+ # Create temporary directory for backup
62
+ temp_dir = tempfile.mkdtemp(prefix="vector_migration_")
63
+
64
+ # Step 1: Create backup of source collection
65
+ self.progress.emit(10, f"Creating backup of {self.source_collection}...")
66
+
67
+ temp_backup_path = self.backup_service.backup_collection(
68
+ self.source_conn.connection,
69
+ self.source_collection,
70
+ temp_dir,
71
+ include_embeddings=self.include_embeddings
72
+ )
73
+
74
+ if not temp_backup_path:
75
+ self.finished.emit(False, "Failed to create backup.")
76
+ return
77
+
78
+ if self._cancelled:
79
+ self.finished.emit(False, "Migration cancelled by user.")
80
+ return
81
+
82
+ # Step 2: Restore to target collection
83
+ self.progress.emit(50, f"Restoring to {self.target_collection}...")
84
+
85
+ # Verify target connection before restore
86
+ if not self.target_conn.connection.is_connected:
87
+ # Try to reconnect
88
+ if not self.target_conn.connection.connect():
89
+ self.finished.emit(False, "Target connection lost. Please try again.")
90
+ return
91
+
92
+ # Check if target collection exists
93
+ target_exists = self.target_collection in self.target_conn.collections
94
+
95
+ success = self.backup_service.restore_collection(
96
+ self.target_conn.connection,
97
+ temp_backup_path,
98
+ collection_name=self.target_collection,
99
+ overwrite=target_exists
100
+ )
101
+
102
+ if self._cancelled:
103
+ self.finished.emit(False, "Migration cancelled by user.")
104
+ return
105
+
106
+ if success:
107
+ self.progress.emit(100, f"Migration complete!")
108
+ self.finished.emit(True, f"Successfully migrated {self.source_collection} to {self.target_collection}")
109
+ else:
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.")
120
+
121
+ except Exception as e:
122
+ import traceback
123
+ error_details = traceback.format_exc()
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
+
135
+ self.finished.emit(False, f"Migration error: {str(e)}")
136
+
137
+ finally:
138
+ # Clean up temporary backup file
139
+ if temp_backup_path:
140
+ try:
141
+ Path(temp_backup_path).unlink()
142
+ # Also remove temp directory if empty
143
+ temp_dir = Path(temp_backup_path).parent
144
+ if temp_dir.exists() and not list(temp_dir.iterdir()):
145
+ temp_dir.rmdir()
146
+ except Exception:
147
+ pass # Ignore cleanup errors
148
+
149
+
150
+ class CrossDatabaseMigrationDialog(QDialog):
151
+ """Dialog for migrating data between vector databases."""
152
+
153
+ def __init__(self, connection_manager: ConnectionManager, parent=None):
154
+ super().__init__(parent)
155
+ self.connection_manager = connection_manager
156
+ self.migration_thread: Optional[MigrationThread] = None
157
+
158
+ self.setWindowTitle("Cross-Database Migration")
159
+ self.setMinimumWidth(600)
160
+ self.setMinimumHeight(400)
161
+
162
+ self._setup_ui()
163
+ self._populate_connections()
164
+
165
+ def _setup_ui(self):
166
+ """Setup the UI."""
167
+ layout = QVBoxLayout(self)
168
+
169
+ # Source section
170
+ source_group = QGroupBox("Source")
171
+ source_layout = QFormLayout()
172
+
173
+ self.source_connection_combo = QComboBox()
174
+ self.source_connection_combo.currentIndexChanged.connect(self._on_source_connection_changed)
175
+ source_layout.addRow("Connection:", self.source_connection_combo)
176
+
177
+ self.source_collection_combo = QComboBox()
178
+ source_layout.addRow("Collection:", self.source_collection_combo)
179
+
180
+ source_group.setLayout(source_layout)
181
+ layout.addWidget(source_group)
182
+
183
+ # Target section
184
+ target_group = QGroupBox("Target")
185
+ target_layout = QFormLayout()
186
+
187
+ self.target_connection_combo = QComboBox()
188
+ self.target_connection_combo.currentIndexChanged.connect(self._on_target_connection_changed)
189
+ target_layout.addRow("Connection:", self.target_connection_combo)
190
+
191
+ self.target_collection_combo = QComboBox()
192
+ self.target_collection_combo.setEditable(True)
193
+ target_layout.addRow("Collection:", self.target_collection_combo)
194
+
195
+ self.create_new_check = QCheckBox("Create new collection if it doesn't exist")
196
+ self.create_new_check.setChecked(True)
197
+ target_layout.addRow("", self.create_new_check)
198
+
199
+ target_group.setLayout(target_layout)
200
+ layout.addWidget(target_group)
201
+
202
+ # Options
203
+ options_group = QGroupBox("Options")
204
+ options_layout = QFormLayout()
205
+
206
+ self.include_embeddings_check = QCheckBox("Include Embeddings")
207
+ self.include_embeddings_check.setChecked(True)
208
+ options_layout.addRow("", self.include_embeddings_check)
209
+
210
+ options_group.setLayout(options_layout)
211
+ layout.addWidget(options_group)
212
+
213
+ # Progress section
214
+ self.progress_bar = QProgressBar()
215
+ self.progress_bar.setRange(0, 100)
216
+ self.progress_bar.setValue(0)
217
+ layout.addWidget(self.progress_bar)
218
+
219
+ self.status_text = QTextEdit()
220
+ self.status_text.setReadOnly(True)
221
+ self.status_text.setMaximumHeight(100)
222
+ layout.addWidget(self.status_text)
223
+
224
+ # Buttons
225
+ button_layout = QHBoxLayout()
226
+
227
+ self.start_button = QPushButton("Start Migration")
228
+ self.start_button.clicked.connect(self._start_migration)
229
+ button_layout.addWidget(self.start_button)
230
+
231
+ self.cancel_button = QPushButton("Cancel")
232
+ self.cancel_button.clicked.connect(self._cancel_migration)
233
+ self.cancel_button.setEnabled(False)
234
+ button_layout.addWidget(self.cancel_button)
235
+
236
+ self.close_button = QPushButton("Close")
237
+ self.close_button.clicked.connect(self.close)
238
+ button_layout.addWidget(self.close_button)
239
+
240
+ layout.addLayout(button_layout)
241
+
242
+ def _populate_connections(self):
243
+ """Populate connection dropdowns."""
244
+ connections = self.connection_manager.get_all_connections()
245
+
246
+ self.source_connection_combo.clear()
247
+ self.target_connection_combo.clear()
248
+
249
+ for conn in connections:
250
+ self.source_connection_combo.addItem(conn.get_display_name(), conn.id)
251
+ self.target_connection_combo.addItem(conn.get_display_name(), conn.id)
252
+
253
+ # Populate collections for first connection
254
+ if connections:
255
+ self._on_source_connection_changed(0)
256
+ self._on_target_connection_changed(0)
257
+
258
+ def _on_source_connection_changed(self, index: int):
259
+ """Handle source connection change."""
260
+ connection_id = self.source_connection_combo.currentData()
261
+ if connection_id:
262
+ instance = self.connection_manager.get_connection(connection_id)
263
+ if instance:
264
+ self.source_collection_combo.clear()
265
+ self.source_collection_combo.addItems(instance.collections)
266
+
267
+ def _on_target_connection_changed(self, index: int):
268
+ """Handle target connection change."""
269
+ connection_id = self.target_connection_combo.currentData()
270
+ if connection_id:
271
+ instance = self.connection_manager.get_connection(connection_id)
272
+ if instance:
273
+ self.target_collection_combo.clear()
274
+ self.target_collection_combo.addItems(instance.collections)
275
+
276
+ def _start_migration(self):
277
+ """Start the migration."""
278
+ # Validate selection
279
+ source_conn_id = self.source_connection_combo.currentData()
280
+ target_conn_id = self.target_connection_combo.currentData()
281
+
282
+ if not source_conn_id or not target_conn_id:
283
+ QMessageBox.warning(self, "Invalid Selection", "Please select both source and target connections.")
284
+ return
285
+
286
+ if source_conn_id == target_conn_id:
287
+ source_coll = self.source_collection_combo.currentText()
288
+ target_coll = self.target_collection_combo.currentText()
289
+ if source_coll == target_coll:
290
+ QMessageBox.warning(
291
+ self,
292
+ "Invalid Selection",
293
+ "Source and target cannot be the same collection in the same connection."
294
+ )
295
+ return
296
+
297
+ source_conn = self.connection_manager.get_connection(source_conn_id)
298
+ target_conn = self.connection_manager.get_connection(target_conn_id)
299
+
300
+ if not source_conn or not target_conn:
301
+ QMessageBox.warning(self, "Error", "Failed to get connection instances.")
302
+ return
303
+
304
+ source_collection = self.source_collection_combo.currentText()
305
+ target_collection = self.target_collection_combo.currentText().strip()
306
+
307
+ if not source_collection or not target_collection:
308
+ QMessageBox.warning(self, "Invalid Selection", "Please select both source and target collections.")
309
+ return
310
+
311
+ # Check if target collection exists
312
+ target_exists = target_collection in target_conn.collections
313
+
314
+ # If target doesn't exist and we're not set to create, warn user
315
+ if not target_exists and not self.create_new_check.isChecked():
316
+ QMessageBox.warning(
317
+ self,
318
+ "Collection Does Not Exist",
319
+ f"Target collection '{target_collection}' does not exist.\n"
320
+ "Please check 'Create new collection' to allow automatic creation during migration."
321
+ )
322
+ return
323
+
324
+ # Confirm
325
+ action = "create and migrate" if not target_exists else "migrate"
326
+ reply = QMessageBox.question(
327
+ self,
328
+ "Confirm Migration",
329
+ f"Migrate data from:\n {source_conn.name}/{source_collection}\n"
330
+ f"to:\n {target_conn.name}/{target_collection}\n\n"
331
+ f"This will {action} all data. Continue?",
332
+ QMessageBox.Yes | QMessageBox.No
333
+ )
334
+
335
+ if reply != QMessageBox.Yes:
336
+ return
337
+
338
+ # Start migration thread
339
+ self.migration_thread = MigrationThread(
340
+ source_conn=source_conn,
341
+ target_conn=target_conn,
342
+ source_collection=source_collection,
343
+ target_collection=target_collection,
344
+ include_embeddings=self.include_embeddings_check.isChecked()
345
+ )
346
+
347
+ self.migration_thread.progress.connect(self._on_migration_progress)
348
+ self.migration_thread.finished.connect(self._on_migration_finished)
349
+
350
+ self.start_button.setEnabled(False)
351
+ self.cancel_button.setEnabled(True)
352
+ self.close_button.setEnabled(False)
353
+
354
+ self.status_text.clear()
355
+ self.progress_bar.setValue(0)
356
+
357
+ self.migration_thread.start()
358
+
359
+ def _cancel_migration(self):
360
+ """Cancel the migration."""
361
+ if self.migration_thread:
362
+ self.migration_thread.cancel()
363
+ self.status_text.append("Cancelling migration...")
364
+
365
+ def _on_migration_progress(self, progress: int, message: str):
366
+ """Handle migration progress update."""
367
+ self.progress_bar.setValue(progress)
368
+ self.status_text.append(message)
369
+
370
+ def _on_migration_finished(self, success: bool, message: str):
371
+ """Handle migration completion."""
372
+ self.status_text.append(message)
373
+
374
+ if success:
375
+ QMessageBox.information(self, "Success", message)
376
+ else:
377
+ QMessageBox.warning(self, "Failed", message)
378
+
379
+ self.start_button.setEnabled(True)
380
+ self.cancel_button.setEnabled(False)
381
+ self.close_button.setEnabled(True)
382
+ self.migration_thread = None
383
+
@@ -0,0 +1,315 @@
1
+ """Dialog for configuring embedding models for collections (Step 2: Model Selection)."""
2
+
3
+ from typing import Optional, Tuple
4
+ from PySide6.QtWidgets import (
5
+ QDialog, QVBoxLayout, QHBoxLayout, QLabel,
6
+ QComboBox, QPushButton, QGroupBox, QTextEdit,
7
+ QMessageBox, QLineEdit, QFormLayout
8
+ )
9
+ from PySide6.QtCore import Qt
10
+
11
+ from vector_inspector.core.embedding_utils import get_available_models_for_dimension
12
+ from vector_inspector.core.model_registry import get_model_registry
13
+
14
+
15
+ class EmbeddingConfigDialog(QDialog):
16
+ """Dialog for selecting embedding model for a collection."""
17
+
18
+ def __init__(self, collection_name: str, vector_dimension: int,
19
+ provider_type: Optional[str] = None,
20
+ current_model: Optional[str] = None,
21
+ current_type: Optional[str] = None,
22
+ parent=None):
23
+ super().__init__(parent)
24
+ self.collection_name = collection_name
25
+ self.vector_dimension = vector_dimension
26
+ self.provider_type = provider_type # Filter by this type
27
+ self.current_model = current_model
28
+ self.current_type = current_type
29
+ self.selected_model = None
30
+ self.selected_type = None
31
+
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)
50
+ self.setMinimumWidth(500)
51
+ self._setup_ui()
52
+
53
+ def _setup_ui(self):
54
+ """Setup dialog UI."""
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
61
+
62
+ # Info section
63
+ info_group = QGroupBox("Collection Information")
64
+ info_layout = QVBoxLayout()
65
+
66
+ info_layout.addWidget(QLabel(f"<b>Collection:</b> {self.collection_name}"))
67
+ info_layout.addWidget(QLabel(f"<b>Vector Dimension:</b> {self.vector_dimension}"))
68
+
69
+ if self.current_model:
70
+ info_layout.addWidget(QLabel(f"<b>Current Model:</b> {self.current_model} ({self.current_type})"))
71
+ else:
72
+ warning = QLabel("⚠️ No embedding model configured - using automatic detection")
73
+ warning.setStyleSheet("color: orange;")
74
+ info_layout.addWidget(warning)
75
+
76
+ info_group.setLayout(info_layout)
77
+ layout.addWidget(info_group)
78
+
79
+ # Model selection section
80
+ model_group = QGroupBox("Embedding Model Selection")
81
+ model_layout = QVBoxLayout()
82
+
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)
106
+
107
+ if available_models:
108
+ model_layout.addWidget(QLabel(f"Available models for {self.vector_dimension}-dimensional vectors:"))
109
+
110
+ self.model_combo = QComboBox()
111
+ for model_name, model_type, description in available_models:
112
+ display_text = f"{model_name} ({model_type}) - {description}"
113
+ self.model_combo.addItem(display_text, (model_name, model_type))
114
+
115
+ # Set current selection if it matches
116
+ if self.current_model and self.current_type:
117
+ for i in range(self.model_combo.count()):
118
+ model_name, model_type = self.model_combo.itemData(i)
119
+ if model_name == self.current_model and model_type == self.current_type:
120
+ self.model_combo.setCurrentIndex(i)
121
+ break
122
+
123
+ model_layout.addWidget(self.model_combo)
124
+
125
+ # Description area
126
+ desc_label = QLabel("<b>About the selected model:</b>")
127
+ model_layout.addWidget(desc_label)
128
+
129
+ self.description_text = QTextEdit()
130
+ self.description_text.setReadOnly(True)
131
+ self.description_text.setMaximumHeight(100)
132
+ self.description_text.setStyleSheet("background-color: #f5f5f5; border: 1px solid #ccc; color: #000000;")
133
+ model_layout.addWidget(self.description_text)
134
+
135
+ # Update description when selection changes
136
+ self.model_combo.currentIndexChanged.connect(self._update_description)
137
+ self._update_description()
138
+
139
+ else:
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.")
143
+ warning.setWordWrap(True)
144
+ model_layout.addWidget(warning)
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))
149
+ model_layout.addWidget(QLabel(dims_text))
150
+
151
+ self.model_combo = None
152
+
153
+ model_group.setLayout(model_layout)
154
+ layout.addWidget(model_group)
155
+
156
+ # Buttons
157
+ button_layout = QHBoxLayout()
158
+ button_layout.addStretch()
159
+
160
+ self.save_btn = QPushButton("Save Configuration")
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)
164
+
165
+ self.clear_btn = QPushButton("Clear Configuration")
166
+ self.clear_btn.clicked.connect(self._clear_config)
167
+ self.clear_btn.setEnabled(self.current_model is not None)
168
+
169
+ cancel_btn = QPushButton("Cancel")
170
+ cancel_btn.clicked.connect(self.reject)
171
+
172
+ button_layout.addWidget(self.save_btn)
173
+ button_layout.addWidget(self.clear_btn)
174
+ button_layout.addWidget(cancel_btn)
175
+
176
+ layout.addLayout(button_layout)
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
+
257
+ def _update_description(self):
258
+ """Update the description text based on selected model."""
259
+ if not self.model_combo:
260
+ return
261
+
262
+ model_name, model_type = self.model_combo.currentData()
263
+
264
+ descriptions = {
265
+ "sentence-transformer": (
266
+ "Sentence-Transformers are text-only embedding models optimized for semantic similarity. "
267
+ "They work well for text search, clustering, and classification tasks."
268
+ ),
269
+ "clip": (
270
+ "CLIP (Contrastive Language-Image Pre-training) is a multi-modal model that can embed both "
271
+ "text and images into the same vector space. This allows text queries to find semantically "
272
+ "similar images, and vice versa. Perfect for image search with text descriptions."
273
+ )
274
+ }
275
+
276
+ desc = descriptions.get(model_type, "Embedding model for vector similarity search.")
277
+ self.description_text.setPlainText(
278
+ f"Model: {model_name}\n"
279
+ f"Type: {model_type}\n"
280
+ f"Dimension: {self.vector_dimension}\n\n"
281
+ f"{desc}"
282
+ )
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
+
297
+ def _clear_config(self):
298
+ """Clear the embedding model configuration."""
299
+ reply = QMessageBox.question(
300
+ self,
301
+ "Clear Configuration",
302
+ "This will remove the custom embedding model configuration and use automatic detection. Continue?",
303
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
304
+ )
305
+
306
+ if reply == QMessageBox.StandardButton.Yes:
307
+ self.selected_model = None
308
+ self.selected_type = None
309
+ self.done(2) # Custom code for "clear"
310
+
311
+ def get_selection(self) -> Optional[Tuple[str, str]]:
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