local-deep-research 0.1.26__py3-none-any.whl → 0.2.0__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 (140) hide show
  1. local_deep_research/__init__.py +23 -22
  2. local_deep_research/__main__.py +16 -0
  3. local_deep_research/advanced_search_system/__init__.py +7 -0
  4. local_deep_research/advanced_search_system/filters/__init__.py +8 -0
  5. local_deep_research/advanced_search_system/filters/base_filter.py +38 -0
  6. local_deep_research/advanced_search_system/filters/cross_engine_filter.py +200 -0
  7. local_deep_research/advanced_search_system/findings/base_findings.py +81 -0
  8. local_deep_research/advanced_search_system/findings/repository.py +452 -0
  9. local_deep_research/advanced_search_system/knowledge/__init__.py +1 -0
  10. local_deep_research/advanced_search_system/knowledge/base_knowledge.py +151 -0
  11. local_deep_research/advanced_search_system/knowledge/standard_knowledge.py +159 -0
  12. local_deep_research/advanced_search_system/questions/__init__.py +1 -0
  13. local_deep_research/advanced_search_system/questions/base_question.py +64 -0
  14. local_deep_research/advanced_search_system/questions/decomposition_question.py +445 -0
  15. local_deep_research/advanced_search_system/questions/standard_question.py +119 -0
  16. local_deep_research/advanced_search_system/repositories/__init__.py +7 -0
  17. local_deep_research/advanced_search_system/strategies/__init__.py +1 -0
  18. local_deep_research/advanced_search_system/strategies/base_strategy.py +118 -0
  19. local_deep_research/advanced_search_system/strategies/iterdrag_strategy.py +450 -0
  20. local_deep_research/advanced_search_system/strategies/parallel_search_strategy.py +312 -0
  21. local_deep_research/advanced_search_system/strategies/rapid_search_strategy.py +270 -0
  22. local_deep_research/advanced_search_system/strategies/standard_strategy.py +300 -0
  23. local_deep_research/advanced_search_system/tools/__init__.py +1 -0
  24. local_deep_research/advanced_search_system/tools/base_tool.py +100 -0
  25. local_deep_research/advanced_search_system/tools/knowledge_tools/__init__.py +1 -0
  26. local_deep_research/advanced_search_system/tools/question_tools/__init__.py +1 -0
  27. local_deep_research/advanced_search_system/tools/search_tools/__init__.py +1 -0
  28. local_deep_research/api/__init__.py +5 -5
  29. local_deep_research/api/research_functions.py +96 -84
  30. local_deep_research/app.py +8 -0
  31. local_deep_research/citation_handler.py +25 -16
  32. local_deep_research/{config.py → config/config_files.py} +102 -110
  33. local_deep_research/config/llm_config.py +472 -0
  34. local_deep_research/config/search_config.py +77 -0
  35. local_deep_research/defaults/__init__.py +10 -5
  36. local_deep_research/defaults/main.toml +2 -2
  37. local_deep_research/defaults/search_engines.toml +60 -34
  38. local_deep_research/main.py +121 -19
  39. local_deep_research/migrate_db.py +147 -0
  40. local_deep_research/report_generator.py +72 -44
  41. local_deep_research/search_system.py +147 -283
  42. local_deep_research/setup_data_dir.py +35 -0
  43. local_deep_research/test_migration.py +178 -0
  44. local_deep_research/utilities/__init__.py +0 -0
  45. local_deep_research/utilities/db_utils.py +49 -0
  46. local_deep_research/{utilties → utilities}/enums.py +2 -2
  47. local_deep_research/{utilties → utilities}/llm_utils.py +63 -29
  48. local_deep_research/utilities/search_utilities.py +242 -0
  49. local_deep_research/{utilties → utilities}/setup_utils.py +4 -2
  50. local_deep_research/web/__init__.py +0 -1
  51. local_deep_research/web/app.py +86 -1709
  52. local_deep_research/web/app_factory.py +289 -0
  53. local_deep_research/web/database/README.md +70 -0
  54. local_deep_research/web/database/migrate_to_ldr_db.py +289 -0
  55. local_deep_research/web/database/migrations.py +447 -0
  56. local_deep_research/web/database/models.py +117 -0
  57. local_deep_research/web/database/schema_upgrade.py +107 -0
  58. local_deep_research/web/models/database.py +294 -0
  59. local_deep_research/web/models/settings.py +94 -0
  60. local_deep_research/web/routes/api_routes.py +559 -0
  61. local_deep_research/web/routes/history_routes.py +354 -0
  62. local_deep_research/web/routes/research_routes.py +715 -0
  63. local_deep_research/web/routes/settings_routes.py +1592 -0
  64. local_deep_research/web/services/research_service.py +947 -0
  65. local_deep_research/web/services/resource_service.py +149 -0
  66. local_deep_research/web/services/settings_manager.py +669 -0
  67. local_deep_research/web/services/settings_service.py +187 -0
  68. local_deep_research/web/services/socket_service.py +210 -0
  69. local_deep_research/web/static/css/custom_dropdown.css +277 -0
  70. local_deep_research/web/static/css/settings.css +1223 -0
  71. local_deep_research/web/static/css/styles.css +525 -48
  72. local_deep_research/web/static/js/components/custom_dropdown.js +428 -0
  73. local_deep_research/web/static/js/components/detail.js +348 -0
  74. local_deep_research/web/static/js/components/fallback/formatting.js +122 -0
  75. local_deep_research/web/static/js/components/fallback/ui.js +215 -0
  76. local_deep_research/web/static/js/components/history.js +487 -0
  77. local_deep_research/web/static/js/components/logpanel.js +949 -0
  78. local_deep_research/web/static/js/components/progress.js +1107 -0
  79. local_deep_research/web/static/js/components/research.js +1865 -0
  80. local_deep_research/web/static/js/components/results.js +766 -0
  81. local_deep_research/web/static/js/components/settings.js +3981 -0
  82. local_deep_research/web/static/js/components/settings_sync.js +106 -0
  83. local_deep_research/web/static/js/main.js +226 -0
  84. local_deep_research/web/static/js/services/api.js +253 -0
  85. local_deep_research/web/static/js/services/audio.js +31 -0
  86. local_deep_research/web/static/js/services/formatting.js +119 -0
  87. local_deep_research/web/static/js/services/pdf.js +622 -0
  88. local_deep_research/web/static/js/services/socket.js +882 -0
  89. local_deep_research/web/static/js/services/ui.js +546 -0
  90. local_deep_research/web/templates/base.html +72 -0
  91. local_deep_research/web/templates/components/custom_dropdown.html +47 -0
  92. local_deep_research/web/templates/components/log_panel.html +32 -0
  93. local_deep_research/web/templates/components/mobile_nav.html +22 -0
  94. local_deep_research/web/templates/components/settings_form.html +299 -0
  95. local_deep_research/web/templates/components/sidebar.html +21 -0
  96. local_deep_research/web/templates/pages/details.html +73 -0
  97. local_deep_research/web/templates/pages/history.html +51 -0
  98. local_deep_research/web/templates/pages/progress.html +57 -0
  99. local_deep_research/web/templates/pages/research.html +139 -0
  100. local_deep_research/web/templates/pages/results.html +59 -0
  101. local_deep_research/web/templates/settings_dashboard.html +78 -192
  102. local_deep_research/web/utils/__init__.py +0 -0
  103. local_deep_research/web/utils/formatters.py +76 -0
  104. local_deep_research/web_search_engines/engines/full_search.py +18 -16
  105. local_deep_research/web_search_engines/engines/meta_search_engine.py +182 -131
  106. local_deep_research/web_search_engines/engines/search_engine_arxiv.py +224 -139
  107. local_deep_research/web_search_engines/engines/search_engine_brave.py +88 -71
  108. local_deep_research/web_search_engines/engines/search_engine_ddg.py +48 -39
  109. local_deep_research/web_search_engines/engines/search_engine_github.py +415 -204
  110. local_deep_research/web_search_engines/engines/search_engine_google_pse.py +123 -90
  111. local_deep_research/web_search_engines/engines/search_engine_guardian.py +210 -157
  112. local_deep_research/web_search_engines/engines/search_engine_local.py +532 -369
  113. local_deep_research/web_search_engines/engines/search_engine_local_all.py +42 -36
  114. local_deep_research/web_search_engines/engines/search_engine_pubmed.py +358 -266
  115. local_deep_research/web_search_engines/engines/search_engine_searxng.py +211 -159
  116. local_deep_research/web_search_engines/engines/search_engine_semantic_scholar.py +213 -170
  117. local_deep_research/web_search_engines/engines/search_engine_serpapi.py +84 -68
  118. local_deep_research/web_search_engines/engines/search_engine_wayback.py +186 -154
  119. local_deep_research/web_search_engines/engines/search_engine_wikipedia.py +115 -77
  120. local_deep_research/web_search_engines/search_engine_base.py +174 -99
  121. local_deep_research/web_search_engines/search_engine_factory.py +192 -102
  122. local_deep_research/web_search_engines/search_engines_config.py +22 -15
  123. {local_deep_research-0.1.26.dist-info → local_deep_research-0.2.0.dist-info}/METADATA +177 -97
  124. local_deep_research-0.2.0.dist-info/RECORD +135 -0
  125. {local_deep_research-0.1.26.dist-info → local_deep_research-0.2.0.dist-info}/WHEEL +1 -2
  126. {local_deep_research-0.1.26.dist-info → local_deep_research-0.2.0.dist-info}/entry_points.txt +3 -0
  127. local_deep_research/defaults/llm_config.py +0 -338
  128. local_deep_research/utilties/search_utilities.py +0 -114
  129. local_deep_research/web/static/js/app.js +0 -3763
  130. local_deep_research/web/templates/api_keys_config.html +0 -82
  131. local_deep_research/web/templates/collections_config.html +0 -90
  132. local_deep_research/web/templates/index.html +0 -348
  133. local_deep_research/web/templates/llm_config.html +0 -120
  134. local_deep_research/web/templates/main_config.html +0 -89
  135. local_deep_research/web/templates/search_engines_config.html +0 -154
  136. local_deep_research/web/templates/settings.html +0 -519
  137. local_deep_research-0.1.26.dist-info/RECORD +0 -61
  138. local_deep_research-0.1.26.dist-info/top_level.txt +0 -1
  139. /local_deep_research/{utilties → config}/__init__.py +0 -0
  140. {local_deep_research-0.1.26.dist-info → local_deep_research-0.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1592 @@
1
+ import json
2
+ import logging
3
+ import os
4
+ import platform
5
+ import subprocess
6
+ from pathlib import Path
7
+ from typing import Any, Optional, Tuple
8
+
9
+ import requests
10
+ import toml
11
+ from flask import (
12
+ Blueprint,
13
+ current_app,
14
+ flash,
15
+ jsonify,
16
+ redirect,
17
+ render_template,
18
+ request,
19
+ url_for,
20
+ )
21
+ from flask_wtf.csrf import generate_csrf
22
+ from sqlalchemy.orm import Session
23
+
24
+ from ...config.config_files import get_config_dir
25
+ from ...web_search_engines.search_engine_factory import get_available_engines
26
+ from ..database.models import Setting, SettingType
27
+ from ..services.settings_service import (
28
+ create_or_update_setting,
29
+ get_setting,
30
+ get_settings_manager,
31
+ set_setting,
32
+ )
33
+
34
+ # Initialize logger
35
+ logger = logging.getLogger(__name__)
36
+
37
+ # Create a Blueprint for settings
38
+ settings_bp = Blueprint("settings", __name__, url_prefix="/research/settings")
39
+
40
+ # Legacy config for backwards compatibility
41
+ SEARCH_ENGINES_FILE = None
42
+ CONFIG_DIR = None
43
+ MAIN_CONFIG_FILE = None
44
+ LOCAL_COLLECTIONS_FILE = None
45
+
46
+
47
+ def set_config_paths(
48
+ config_dir, search_engines_file, main_config_file, local_collections_file
49
+ ):
50
+ """Set the config paths for the settings routes (legacy support)"""
51
+ global CONFIG_DIR, SEARCH_ENGINES_FILE, MAIN_CONFIG_FILE, LOCAL_COLLECTIONS_FILE
52
+ CONFIG_DIR = config_dir
53
+ SEARCH_ENGINES_FILE = search_engines_file
54
+ MAIN_CONFIG_FILE = main_config_file
55
+ LOCAL_COLLECTIONS_FILE = local_collections_file
56
+
57
+
58
+ def get_db_session() -> Session:
59
+ """Get the database session from the app context"""
60
+ if hasattr(current_app, "db_session"):
61
+ return current_app.db_session
62
+ else:
63
+ return current_app.extensions["sqlalchemy"].session()
64
+
65
+
66
+ def validate_setting(setting: Setting, value: Any) -> Tuple[bool, Optional[str]]:
67
+ """
68
+ Validate a setting value based on its type and constraints.
69
+
70
+ Args:
71
+ setting: The Setting object to validate against
72
+ value: The value to validate
73
+
74
+ Returns:
75
+ Tuple of (is_valid, error_message)
76
+ """
77
+ # Convert value based on UI element type
78
+ if setting.ui_element == "checkbox":
79
+ # Convert string representations of boolean to actual boolean
80
+ if isinstance(value, str):
81
+ value = value.lower() in ("true", "on", "yes", "1")
82
+ elif setting.ui_element == "number" or setting.ui_element == "slider":
83
+ try:
84
+ value = float(value)
85
+ except (ValueError, TypeError):
86
+ return False, "Value must be a number"
87
+
88
+ # Check min/max constraints if defined
89
+ if setting.min_value is not None and value < setting.min_value:
90
+ return False, f"Value must be at least {setting.min_value}"
91
+ if setting.max_value is not None and value > setting.max_value:
92
+ return False, f"Value must be at most {setting.max_value}"
93
+ elif setting.ui_element == "select":
94
+ # Check if value is in the allowed options
95
+ if setting.options:
96
+ # Skip options validation for dynamically populated dropdowns
97
+ if setting.key not in ["llm.provider", "llm.model"]:
98
+ allowed_values = [opt.get("value") for opt in setting.options]
99
+ if value not in allowed_values:
100
+ return (
101
+ False,
102
+ f"Value must be one of: {', '.join(str(v) for v in allowed_values)}",
103
+ )
104
+ # All checks passed
105
+ return True, None
106
+
107
+
108
+ def get_all_settings_json():
109
+ """Get all settings as a JSON-serializable dictionary
110
+
111
+ Returns:
112
+ List of setting dictionaries
113
+ """
114
+ db_session = get_db_session()
115
+ settings_list = []
116
+
117
+ # Get all settings
118
+ settings = (
119
+ db_session.query(Setting)
120
+ .order_by(Setting.type, Setting.category, Setting.name)
121
+ .all()
122
+ )
123
+
124
+ # Convert to dictionaries
125
+ for setting in settings:
126
+ # Ensure objects are properly serialized
127
+ value = setting.value
128
+
129
+ # Convert objects to properly formatted JSON strings for display
130
+ if isinstance(value, (dict, list)) and value:
131
+ try:
132
+ # For frontend display, we'll keep objects as they are
133
+ # The javascript will handle formatting them
134
+ pass
135
+ except Exception as e:
136
+ logger.error(f"Error serializing setting {setting.key}: {e}")
137
+
138
+ setting_dict = {
139
+ "key": setting.key,
140
+ "value": value,
141
+ "type": setting.type.value if setting.type else None,
142
+ "name": setting.name,
143
+ "description": setting.description,
144
+ "category": setting.category,
145
+ "ui_element": setting.ui_element,
146
+ "options": setting.options,
147
+ "min_value": setting.min_value,
148
+ "max_value": setting.max_value,
149
+ "step": setting.step,
150
+ "visible": setting.visible,
151
+ "editable": setting.editable,
152
+ }
153
+ settings_list.append(setting_dict)
154
+
155
+ return settings_list
156
+
157
+
158
+ @settings_bp.route("/", methods=["GET"])
159
+ def settings_page():
160
+ """Main settings dashboard with links to specialized config pages"""
161
+ return render_template("settings_dashboard.html")
162
+
163
+
164
+ @settings_bp.route("/save_all_settings", methods=["POST"])
165
+ def save_all_settings():
166
+ """Handle saving all settings at once from the unified settings page"""
167
+ db_session = get_db_session()
168
+ # Get the settings manager but we don't need to assign it to a variable right now
169
+ # get_settings_manager(db_session)
170
+
171
+ try:
172
+ # Process JSON data
173
+ form_data = request.get_json()
174
+ if not form_data:
175
+ return (
176
+ jsonify({"status": "error", "message": "No settings data provided"}),
177
+ 400,
178
+ )
179
+
180
+ # Track validation errors
181
+ validation_errors = []
182
+ settings_by_type = {}
183
+
184
+ # Track changes for logging
185
+ updated_settings = []
186
+ created_settings = []
187
+
188
+ # Store original values for better messaging
189
+ original_values = {}
190
+
191
+ # Update each setting
192
+ for key, value in form_data.items():
193
+ # Skip corrupted keys or empty strings as keys
194
+ if not key or not isinstance(key, str) or key.strip() == "":
195
+ continue
196
+
197
+ # Get the original value
198
+ current_setting = (
199
+ db_session.query(Setting).filter(Setting.key == key).first()
200
+ )
201
+ if current_setting:
202
+ original_values[key] = current_setting.value
203
+
204
+ # Determine setting type and category
205
+ setting_type = None
206
+ if key.startswith("llm."):
207
+ setting_type = SettingType.LLM
208
+ category = "llm_general"
209
+ if (
210
+ "temperature" in key
211
+ or "max_tokens" in key
212
+ or "batch" in key
213
+ or "layers" in key
214
+ ):
215
+ category = "llm_parameters"
216
+ elif key.startswith("search."):
217
+ setting_type = SettingType.SEARCH
218
+ category = "search_general"
219
+ if (
220
+ "iterations" in key
221
+ or "results" in key
222
+ or "region" in key
223
+ or "questions" in key
224
+ or "section" in key
225
+ ):
226
+ category = "search_parameters"
227
+ elif key.startswith("report."):
228
+ setting_type = SettingType.REPORT
229
+ category = "report_parameters"
230
+ elif key.startswith("app."):
231
+ setting_type = SettingType.APP
232
+ category = "app_interface"
233
+ else:
234
+ # Skip keys without a known prefix
235
+ logger.warning(f"Skipping setting with unknown type: {key}")
236
+ continue
237
+
238
+ # Special handling for corrupted or empty values
239
+ if value == "[object Object]" or (
240
+ isinstance(value, str) and value.strip() in ["{}", "[]", "{", "["]
241
+ ):
242
+ if key.startswith("report."):
243
+ value = {}
244
+ else:
245
+ # Use default or null for other types
246
+ if key == "llm.model":
247
+ value = "gpt-3.5-turbo"
248
+ elif key == "llm.provider":
249
+ value = "openai"
250
+ elif key == "search.tool":
251
+ value = "auto"
252
+ elif key in ["app.theme", "app.default_theme"]:
253
+ value = "dark"
254
+ else:
255
+ value = None
256
+
257
+ logger.warning(f"Corrected corrupted value for {key}: {value}")
258
+
259
+ # Handle JSON string values (already parsed by JavaScript)
260
+ if isinstance(value, (dict, list)):
261
+ # Keep as is, already parsed
262
+ pass
263
+ # Handle string values that might be JSON
264
+ elif isinstance(value, str) and (
265
+ value.startswith("{") or value.startswith("[")
266
+ ):
267
+ try:
268
+ # Try to parse the string as JSON
269
+ value = json.loads(value)
270
+ except json.JSONDecodeError:
271
+ # If it fails to parse, keep as string
272
+ pass
273
+
274
+ if current_setting:
275
+ # Validate the setting
276
+ is_valid, error_message = validate_setting(current_setting, value)
277
+
278
+ if is_valid:
279
+ # Update category if different from our determination
280
+ if category and current_setting.category != category:
281
+ current_setting.category = category
282
+
283
+ # Save the setting
284
+ success = set_setting(key, value, db_session=db_session)
285
+ if success:
286
+ updated_settings.append(key)
287
+
288
+ # Track settings by type for exporting
289
+ if current_setting.type not in settings_by_type:
290
+ settings_by_type[current_setting.type] = []
291
+ settings_by_type[current_setting.type].append(current_setting)
292
+ else:
293
+ # Add to validation errors
294
+ validation_errors.append(
295
+ {
296
+ "key": key,
297
+ "name": current_setting.name,
298
+ "error": error_message,
299
+ }
300
+ )
301
+ else:
302
+ # Create a new setting
303
+ new_setting = {
304
+ "key": key,
305
+ "value": value,
306
+ "type": setting_type.value.lower(),
307
+ "name": key.split(".")[-1].replace("_", " ").title(),
308
+ "description": f"Setting for {key}",
309
+ "category": category,
310
+ "ui_element": "text", # Default UI element
311
+ }
312
+
313
+ # Determine better UI element based on value type
314
+ if isinstance(value, bool):
315
+ new_setting["ui_element"] = "checkbox"
316
+ elif isinstance(value, (int, float)) and not isinstance(value, bool):
317
+ new_setting["ui_element"] = "number"
318
+ elif isinstance(value, (dict, list)):
319
+ new_setting["ui_element"] = "textarea"
320
+
321
+ # Create the setting
322
+ db_setting = create_or_update_setting(new_setting)
323
+
324
+ if db_setting:
325
+ created_settings.append(key)
326
+ # Track settings by type for exporting
327
+ if db_setting.type not in settings_by_type:
328
+ settings_by_type[db_setting.type] = []
329
+ settings_by_type[db_setting.type].append(db_setting)
330
+ else:
331
+ validation_errors.append(
332
+ {
333
+ "key": key,
334
+ "name": new_setting["name"],
335
+ "error": "Failed to create setting",
336
+ }
337
+ )
338
+
339
+ # Report validation errors if any
340
+ if validation_errors:
341
+ return (
342
+ jsonify(
343
+ {
344
+ "status": "error",
345
+ "message": "Validation errors",
346
+ "errors": validation_errors,
347
+ }
348
+ ),
349
+ 400,
350
+ )
351
+
352
+ # Export settings to file for each type
353
+ for setting_type in settings_by_type:
354
+ get_settings_manager(db_session).export_to_file(setting_type)
355
+
356
+ # Get all settings to return to the client for proper state update
357
+ all_settings = []
358
+ for setting in db_session.query(Setting).all():
359
+ # Convert enum to string if present
360
+ setting_type = setting.type
361
+ if hasattr(setting_type, "value"):
362
+ setting_type = setting_type.value
363
+
364
+ all_settings.append(
365
+ {
366
+ "key": setting.key,
367
+ "value": setting.value,
368
+ "name": setting.name,
369
+ "description": setting.description,
370
+ "type": setting_type,
371
+ "category": setting.category,
372
+ "ui_element": setting.ui_element,
373
+ "editable": setting.editable,
374
+ "options": setting.options,
375
+ }
376
+ )
377
+
378
+ # Customize the success message based on what changed
379
+ success_message = ""
380
+ if len(updated_settings) == 1:
381
+ # For a single update, provide more specific info about what changed
382
+ key = updated_settings[0]
383
+ updated_setting = (
384
+ db_session.query(Setting).filter(Setting.key == key).first()
385
+ )
386
+ name = (
387
+ updated_setting.name
388
+ if updated_setting
389
+ else key.split(".")[-1].replace("_", " ").title()
390
+ )
391
+
392
+ # Format the message
393
+ if key in original_values:
394
+ # Get original value but comment out if not used
395
+ # old_value = original_values[key]
396
+ new_value = updated_setting.value if updated_setting else None
397
+
398
+ # If it's a boolean, use "enabled/disabled" language
399
+ if isinstance(new_value, bool):
400
+ state = "enabled" if new_value else "disabled"
401
+ success_message = f"{name} {state}"
402
+ else:
403
+ # For non-boolean values
404
+ if isinstance(new_value, (dict, list)):
405
+ success_message = f"{name} updated"
406
+ else:
407
+ success_message = f"{name} updated"
408
+ else:
409
+ success_message = f"{name} updated"
410
+ else:
411
+ # Multiple settings or generic message
412
+ success_message = f"Settings saved successfully ({len(updated_settings)} updated, {len(created_settings)} created)"
413
+
414
+ return jsonify(
415
+ {
416
+ "status": "success",
417
+ "message": success_message,
418
+ "updated": updated_settings,
419
+ "created": created_settings,
420
+ "settings": all_settings,
421
+ }
422
+ )
423
+
424
+ except Exception as e:
425
+ logger.error(f"Error saving settings: {e}")
426
+ return (
427
+ jsonify({"status": "error", "message": f"Error saving settings: {str(e)}"}),
428
+ 500,
429
+ )
430
+
431
+
432
+ @settings_bp.route("/reset_to_defaults", methods=["POST"])
433
+ def reset_to_defaults():
434
+ """Reset all settings to their default values"""
435
+ db_session = get_db_session()
436
+
437
+ try:
438
+ # First, delete all existing settings to ensure clean state
439
+ try:
440
+ # Get count before deletion
441
+ settings_count = db_session.query(Setting).count()
442
+ logger.info(f"Deleting {settings_count} existing settings before reset")
443
+
444
+ # Delete all settings
445
+ db_session.query(Setting).delete()
446
+ db_session.commit()
447
+ logger.info("Successfully deleted all existing settings")
448
+ except Exception as e:
449
+ logger.error(f"Error deleting existing settings: {e}")
450
+ db_session.rollback()
451
+ return (
452
+ jsonify(
453
+ {
454
+ "status": "error",
455
+ "message": f"Error cleaning existing settings: {str(e)}",
456
+ }
457
+ ),
458
+ 500,
459
+ )
460
+
461
+ # Import default settings from files
462
+ try:
463
+ # Import default config files from the defaults directory
464
+ from importlib.resources import files
465
+
466
+ try:
467
+ defaults_dir = files("local_deep_research.defaults")
468
+ except ImportError:
469
+ # Fallback for older Python versions
470
+ from pkg_resources import resource_filename
471
+
472
+ defaults_dir = Path(
473
+ resource_filename("local_deep_research", "defaults")
474
+ )
475
+
476
+ logger.info(f"Loading defaults from: {defaults_dir}")
477
+
478
+ # Get temporary path to default files
479
+ import tempfile
480
+
481
+ with tempfile.TemporaryDirectory() as temp_dir:
482
+ # Copy default files to temp directory
483
+ temp_main = Path(temp_dir) / "settings.toml"
484
+ temp_search = Path(temp_dir) / "search_engines.toml"
485
+ temp_collections = Path(temp_dir) / "local_collections.toml"
486
+
487
+ # Copy default files (platform independent)
488
+ import importlib.resources as pkg_resources
489
+
490
+ from ... import defaults
491
+
492
+ with open(temp_main, "wb") as f:
493
+ f.write(pkg_resources.read_binary(defaults, "main.toml"))
494
+
495
+ with open(temp_search, "wb") as f:
496
+ f.write(pkg_resources.read_binary(defaults, "search_engines.toml"))
497
+
498
+ with open(temp_collections, "wb") as f:
499
+ f.write(
500
+ pkg_resources.read_binary(defaults, "local_collections.toml")
501
+ )
502
+
503
+ # Create settings manager with temp files
504
+ # Get configuration directory (not used currently but might be needed in future)
505
+ # config_dir = get_config_dir() / "config"
506
+
507
+ # Create settings manager for the temporary config
508
+ settings_mgr = get_settings_manager(db_session)
509
+
510
+ # Import settings from default files
511
+ settings_mgr.import_default_settings(
512
+ temp_main, temp_search, temp_collections
513
+ )
514
+
515
+ logger.info("Successfully imported settings from default files")
516
+ except Exception as e:
517
+ logger.error(f"Error importing default settings: {e}")
518
+
519
+ # Fallback to predefined settings if file import fails
520
+ logger.info("Falling back to predefined settings")
521
+ # Import here to avoid circular imports
522
+ from ..database.migrations import (
523
+ setup_predefined_settings as setup_settings,
524
+ )
525
+
526
+ setup_settings(db_session)
527
+
528
+ # Also export the settings to file for consistency
529
+ settings_mgr = get_settings_manager(db_session)
530
+ for setting_type in SettingType:
531
+ settings_mgr.export_to_file(setting_type)
532
+
533
+ # Return success
534
+ return jsonify(
535
+ {
536
+ "status": "success",
537
+ "message": "All settings have been reset to default values",
538
+ }
539
+ )
540
+
541
+ except Exception as e:
542
+ logger.error(f"Error resetting settings to defaults: {e}")
543
+ return (
544
+ jsonify(
545
+ {
546
+ "status": "error",
547
+ "message": f"Error resetting settings to defaults: {str(e)}",
548
+ }
549
+ ),
550
+ 500,
551
+ )
552
+
553
+
554
+ @settings_bp.route("/all_settings", methods=["GET"])
555
+ def get_all_settings_route():
556
+ """Get all settings for the unified dashboard"""
557
+ settings_list = get_all_settings_json()
558
+ return jsonify({"status": "success", "settings": settings_list})
559
+
560
+
561
+ # API Routes
562
+ @settings_bp.route("/api", methods=["GET"])
563
+ def api_get_all_settings():
564
+ """Get all settings"""
565
+ try:
566
+ # Get query parameters
567
+ setting_type = request.args.get("type")
568
+ category = request.args.get("category")
569
+
570
+ # Create settings manager
571
+ db_session = get_db_session()
572
+ settings_manager = get_settings_manager(db_session)
573
+
574
+ # Get settings
575
+ if setting_type:
576
+ try:
577
+ setting_type_enum = SettingType[setting_type.upper()]
578
+ settings = settings_manager.get_all_settings(setting_type_enum)
579
+ except KeyError:
580
+ return jsonify({"error": f"Invalid setting type: {setting_type}"}), 400
581
+ else:
582
+ settings = settings_manager.get_all_settings()
583
+
584
+ # Filter by category if requested
585
+ if category:
586
+ filtered_settings = {}
587
+ # Need to get all setting details to check category
588
+ db_settings = db_session.query(Setting).all()
589
+ category_keys = [s.key for s in db_settings if s.category == category]
590
+
591
+ # Filter settings by keys
592
+ for key, value in settings.items():
593
+ if key in category_keys:
594
+ filtered_settings[key] = value
595
+
596
+ settings = filtered_settings
597
+
598
+ return jsonify({"settings": settings})
599
+ except Exception as e:
600
+ logger.error(f"Error getting settings: {e}")
601
+ return jsonify({"error": str(e)}), 500
602
+
603
+
604
+ @settings_bp.route("/api/<path:key>", methods=["GET"])
605
+ def api_get_setting(key):
606
+ """Get a specific setting by key"""
607
+ try:
608
+ db_session = get_db_session()
609
+ # No need to assign if not used
610
+ # get_settings_manager(db_session)
611
+
612
+ # Get setting
613
+ value = get_setting(key, db_session=db_session)
614
+ if value is None:
615
+ return jsonify({"error": f"Setting not found: {key}"}), 404
616
+
617
+ # Get additional metadata from database.
618
+ db_setting = db_session.query(Setting).filter(Setting.key == key).first()
619
+
620
+ if db_setting:
621
+ # Return full setting details
622
+ setting_data = {
623
+ "key": db_setting.key,
624
+ "value": db_setting.value,
625
+ "type": db_setting.type.value,
626
+ "name": db_setting.name,
627
+ "description": db_setting.description,
628
+ "category": db_setting.category,
629
+ "ui_element": db_setting.ui_element,
630
+ "options": db_setting.options,
631
+ "min_value": db_setting.min_value,
632
+ "max_value": db_setting.max_value,
633
+ "step": db_setting.step,
634
+ "visible": db_setting.visible,
635
+ "editable": db_setting.editable,
636
+ }
637
+ else:
638
+ # Return minimal info
639
+ setting_data = {"key": key, "value": value}
640
+
641
+ return jsonify({"settings": setting_data})
642
+ except Exception as e:
643
+ logger.error(f"Error getting setting {key}: {e}")
644
+ return jsonify({"error": str(e)}), 500
645
+
646
+
647
+ @settings_bp.route("/api/<path:key>", methods=["PUT"])
648
+ def api_update_setting(key):
649
+ """Update a setting"""
650
+ try:
651
+ # Get request data
652
+ data = request.get_json()
653
+ if not data:
654
+ return jsonify({"error": "No data provided"}), 400
655
+
656
+ value = data.get("value")
657
+ if value is None:
658
+ return jsonify({"error": "No value provided"}), 400
659
+
660
+ # Get DB session and settings manager
661
+ db_session = get_db_session()
662
+ # Only use settings_manager if needed - we don't need to assign if not used
663
+ # get_settings_manager(db_session)
664
+
665
+ # Check if setting exists
666
+ db_setting = db_session.query(Setting).filter(Setting.key == key).first()
667
+
668
+ if db_setting:
669
+ # Check if setting is editable
670
+ if not db_setting.editable:
671
+ return jsonify({"error": f"Setting {key} is not editable"}), 403
672
+
673
+ # Update setting
674
+ success = set_setting(key, value)
675
+ if success:
676
+ return jsonify({"message": f"Setting {key} updated successfully"})
677
+ else:
678
+ return jsonify({"error": f"Failed to update setting {key}"}), 500
679
+ else:
680
+ # Create new setting with default metadata
681
+ setting_dict = {
682
+ "key": key,
683
+ "value": value,
684
+ "name": key.split(".")[-1].replace("_", " ").title(),
685
+ "description": f"Setting for {key}",
686
+ }
687
+
688
+ # Add additional metadata if provided
689
+ for field in [
690
+ "type",
691
+ "name",
692
+ "description",
693
+ "category",
694
+ "ui_element",
695
+ "options",
696
+ "min_value",
697
+ "max_value",
698
+ "step",
699
+ "visible",
700
+ "editable",
701
+ ]:
702
+ if field in data:
703
+ setting_dict[field] = data[field]
704
+
705
+ # Create setting
706
+ db_setting = create_or_update_setting(setting_dict)
707
+
708
+ if db_setting:
709
+ return (
710
+ jsonify(
711
+ {
712
+ "message": f"Setting {key} created successfully",
713
+ "setting": {
714
+ "key": db_setting.key,
715
+ "value": db_setting.value,
716
+ "type": db_setting.type.value,
717
+ "name": db_setting.name,
718
+ },
719
+ }
720
+ ),
721
+ 201,
722
+ )
723
+ else:
724
+ return jsonify({"error": f"Failed to create setting {key}"}), 500
725
+ except Exception as e:
726
+ logger.error(f"Error updating setting {key}: {e}")
727
+ return jsonify({"error": str(e)}), 500
728
+
729
+
730
+ @settings_bp.route("/api/<path:key>", methods=["DELETE"])
731
+ def api_delete_setting(key):
732
+ """Delete a setting"""
733
+ try:
734
+ db_session = get_db_session()
735
+ settings_manager = get_settings_manager(db_session)
736
+
737
+ # Check if setting exists
738
+ db_setting = db_session.query(Setting).filter(Setting.key == key).first()
739
+ if not db_setting:
740
+ return jsonify({"error": f"Setting not found: {key}"}), 404
741
+
742
+ # Delete setting
743
+ success = settings_manager.delete_setting(key)
744
+ if success:
745
+ return jsonify({"message": f"Setting {key} deleted successfully"})
746
+ else:
747
+ return jsonify({"error": f"Failed to delete setting {key}"}), 500
748
+ except Exception as e:
749
+ logger.error(f"Error deleting setting {key}: {e}")
750
+ return jsonify({"error": str(e)}), 500
751
+
752
+
753
+ @settings_bp.route("/api/export", methods=["POST"])
754
+ def api_export_settings():
755
+ """Export settings to file"""
756
+ try:
757
+ data = request.get_json() or {}
758
+ setting_type_str = data.get("type")
759
+
760
+ db_session = get_db_session()
761
+ settings_manager = get_settings_manager(db_session)
762
+
763
+ # Export settings
764
+ if setting_type_str:
765
+ try:
766
+ setting_type = SettingType[setting_type_str.upper()]
767
+ success = settings_manager.export_to_file(setting_type)
768
+ except KeyError:
769
+ return (
770
+ jsonify({"error": f"Invalid setting type: {setting_type_str}"}),
771
+ 400,
772
+ )
773
+ else:
774
+ success = settings_manager.export_to_file()
775
+
776
+ if success:
777
+ return jsonify({"message": "Settings exported successfully"})
778
+ else:
779
+ return jsonify({"error": "Failed to export settings"}), 500
780
+ except Exception as e:
781
+ logger.error(f"Error exporting settings: {e}")
782
+ return jsonify({"error": str(e)}), 500
783
+
784
+
785
+ @settings_bp.route("/api/import", methods=["POST"])
786
+ def api_import_settings():
787
+ """Import settings from file"""
788
+ try:
789
+ data = request.get_json() or {}
790
+ setting_type_str = data.get("type")
791
+
792
+ db_session = get_db_session()
793
+ settings_manager = get_settings_manager(db_session)
794
+
795
+ # Import settings
796
+ if setting_type_str:
797
+ try:
798
+ setting_type = SettingType[setting_type_str.upper()]
799
+ success = settings_manager.import_from_file(setting_type)
800
+ except KeyError:
801
+ return (
802
+ jsonify({"error": f"Invalid setting type: {setting_type_str}"}),
803
+ 400,
804
+ )
805
+ else:
806
+ success = settings_manager.import_from_file()
807
+
808
+ if success:
809
+ return jsonify({"message": "Settings imported successfully"})
810
+ else:
811
+ return jsonify({"error": "Failed to import settings"}), 500
812
+ except Exception as e:
813
+ logger.error(f"Error importing settings: {e}")
814
+ return jsonify({"error": str(e)}), 500
815
+
816
+
817
+ @settings_bp.route("/api/categories", methods=["GET"])
818
+ def api_get_categories():
819
+ """Get all setting categories"""
820
+ try:
821
+ db_session = get_db_session()
822
+
823
+ # Get all distinct categories
824
+ categories = db_session.query(Setting.category).distinct().all()
825
+ category_list = [c[0] for c in categories if c[0] is not None]
826
+
827
+ return jsonify({"categories": category_list})
828
+ except Exception as e:
829
+ logger.error(f"Error getting categories: {e}")
830
+ return jsonify({"error": str(e)}), 500
831
+
832
+
833
+ @settings_bp.route("/api/types", methods=["GET"])
834
+ def api_get_types():
835
+ """Get all setting types"""
836
+ try:
837
+ # Get all setting types
838
+ types = [t.value for t in SettingType]
839
+ return jsonify({"types": types})
840
+ except Exception as e:
841
+ logger.error(f"Error getting types: {e}")
842
+ return jsonify({"error": str(e)}), 500
843
+
844
+
845
+ @settings_bp.route("/api/ui_elements", methods=["GET"])
846
+ def api_get_ui_elements():
847
+ """Get all UI element types"""
848
+ try:
849
+ # Define supported UI element types
850
+ ui_elements = [
851
+ "text",
852
+ "select",
853
+ "checkbox",
854
+ "slider",
855
+ "number",
856
+ "textarea",
857
+ "color",
858
+ "date",
859
+ "file",
860
+ "password",
861
+ ]
862
+
863
+ return jsonify({"ui_elements": ui_elements})
864
+ except Exception as e:
865
+ logger.error(f"Error getting UI elements: {e}")
866
+ return jsonify({"error": str(e)}), 500
867
+
868
+
869
+ @settings_bp.route("/api/available-models", methods=["GET"])
870
+ def api_get_available_models():
871
+ """Get available LLM models from various providers"""
872
+ try:
873
+ # Define provider options with generic provider names
874
+ provider_options = [
875
+ {"value": "OLLAMA", "label": "Ollama (Local)"},
876
+ {"value": "OPENAI", "label": "OpenAI (Cloud)"},
877
+ {"value": "ANTHROPIC", "label": "Anthropic (Cloud)"},
878
+ {"value": "OPENAI_ENDPOINT", "label": "Custom OpenAI Endpoint"},
879
+ {"value": "VLLM", "label": "vLLM (Local)"},
880
+ {"value": "LMSTUDIO", "label": "LM Studio (Local)"},
881
+ {"value": "LLAMACPP", "label": "Llama.cpp (Local)"},
882
+ ]
883
+
884
+ # Available models by provider
885
+ providers = {}
886
+
887
+ # Try to get Ollama models
888
+ ollama_models = []
889
+ try:
890
+ import json
891
+ import re
892
+
893
+ import requests
894
+ from flask import current_app
895
+
896
+ # Try to query the Ollama API directly
897
+ try:
898
+ current_app.logger.info("Attempting to connect to Ollama API")
899
+
900
+ base_url = os.getenv(
901
+ "OLLAMA_BASE_URL",
902
+ "http://localhost:11434",
903
+ )
904
+ ollama_response = requests.get(f"{base_url}/api/tags", timeout=5)
905
+
906
+ current_app.logger.debug(
907
+ f"Ollama API response: Status {ollama_response.status_code}"
908
+ )
909
+
910
+ # Try to parse the response even if status code is not 200 to help with debugging
911
+ response_text = ollama_response.text
912
+ current_app.logger.debug(
913
+ f"Ollama API raw response: {response_text[:500]}..."
914
+ )
915
+
916
+ if ollama_response.status_code == 200:
917
+ try:
918
+ ollama_data = ollama_response.json()
919
+ current_app.logger.debug(
920
+ f"Ollama API JSON data: {json.dumps(ollama_data)[:500]}..."
921
+ )
922
+
923
+ if "models" in ollama_data:
924
+ # Format for newer Ollama API
925
+ current_app.logger.info(
926
+ f"Found {len(ollama_data.get('models', []))} models in newer Ollama API format"
927
+ )
928
+ for model in ollama_data.get("models", []):
929
+ # Extract name correctly from the model object
930
+ name = model.get("name", "")
931
+ if name:
932
+ # Improved display name formatting
933
+ display_name = re.sub(r"[:/]", " ", name).strip()
934
+ display_name = " ".join(
935
+ word.capitalize()
936
+ for word in display_name.split()
937
+ )
938
+ # Create the model entry with value and label
939
+ ollama_models.append(
940
+ {
941
+ "value": name, # Original model name as value (for API calls)
942
+ "label": f"{display_name} (Ollama)", # Pretty name as label
943
+ "provider": "OLLAMA", # Add provider field for consistency
944
+ }
945
+ )
946
+ current_app.logger.debug(
947
+ f"Added Ollama model: {name} -> {display_name}"
948
+ )
949
+ else:
950
+ # Format for older Ollama API
951
+ current_app.logger.info(
952
+ f"Found {len(ollama_data)} models in older Ollama API format"
953
+ )
954
+ for model in ollama_data:
955
+ name = model.get("name", "")
956
+ if name:
957
+ # Improved display name formatting
958
+ display_name = re.sub(r"[:/]", " ", name).strip()
959
+ display_name = " ".join(
960
+ word.capitalize()
961
+ for word in display_name.split()
962
+ )
963
+ ollama_models.append(
964
+ {
965
+ "value": name,
966
+ "label": f"{display_name} (Ollama)",
967
+ "provider": "OLLAMA", # Add provider field for consistency
968
+ }
969
+ )
970
+ current_app.logger.debug(
971
+ f"Added Ollama model: {name} -> {display_name}"
972
+ )
973
+
974
+ # Sort models alphabetically
975
+ ollama_models.sort(key=lambda x: x["label"])
976
+
977
+ except json.JSONDecodeError as json_err:
978
+ current_app.logger.error(
979
+ f"Failed to parse Ollama API response as JSON: {json_err}"
980
+ )
981
+ raise Exception(f"Ollama API returned invalid JSON: {json_err}")
982
+ else:
983
+ current_app.logger.warning(
984
+ f"Ollama API returned non-200 status code: {ollama_response.status_code}"
985
+ )
986
+ raise Exception(
987
+ f"Ollama API returned status code {ollama_response.status_code}"
988
+ )
989
+
990
+ except requests.exceptions.RequestException as e:
991
+ current_app.logger.warning(f"Could not connect to Ollama API: {str(e)}")
992
+ # Fallback to default models if Ollama is not running
993
+ current_app.logger.info(
994
+ "Using fallback Ollama models due to connection error"
995
+ )
996
+ ollama_models = [
997
+ {
998
+ "value": "llama3",
999
+ "label": "Llama 3 (Ollama)",
1000
+ "provider": "OLLAMA",
1001
+ },
1002
+ {
1003
+ "value": "mistral",
1004
+ "label": "Mistral (Ollama)",
1005
+ "provider": "OLLAMA",
1006
+ },
1007
+ {
1008
+ "value": "gemma:latest",
1009
+ "label": "Gemma (Ollama)",
1010
+ "provider": "OLLAMA",
1011
+ },
1012
+ ]
1013
+
1014
+ # Always set the ollama_models in providers, whether we got real or fallback models
1015
+ providers["ollama_models"] = ollama_models
1016
+ current_app.logger.info(f"Final Ollama models count: {len(ollama_models)}")
1017
+
1018
+ # Log some model names for debugging
1019
+ if ollama_models:
1020
+ model_names = [m["value"] for m in ollama_models[:5]]
1021
+ current_app.logger.info(
1022
+ f"Sample Ollama models: {', '.join(model_names)}"
1023
+ )
1024
+
1025
+ except Exception as e:
1026
+ current_app.logger.error(f"Error getting Ollama models: {str(e)}")
1027
+ # Use fallback models
1028
+ current_app.logger.info("Using fallback Ollama models due to error")
1029
+ providers["ollama_models"] = [
1030
+ {"value": "llama3", "label": "Llama 3 (Ollama)", "provider": "OLLAMA"},
1031
+ {"value": "mistral", "label": "Mistral (Ollama)", "provider": "OLLAMA"},
1032
+ {
1033
+ "value": "gemma:latest",
1034
+ "label": "Gemma (Ollama)",
1035
+ "provider": "OLLAMA",
1036
+ },
1037
+ ]
1038
+
1039
+ # Add OpenAI models
1040
+ providers["openai_models"] = [
1041
+ {"value": "gpt-4o", "label": "GPT-4o (OpenAI)"},
1042
+ {"value": "gpt-4", "label": "GPT-4 (OpenAI)"},
1043
+ {"value": "gpt-3.5-turbo", "label": "GPT-3.5 Turbo (OpenAI)"},
1044
+ ]
1045
+
1046
+ # Add Anthropic models
1047
+ providers["anthropic_models"] = [
1048
+ {
1049
+ "value": "claude-3-5-sonnet-latest",
1050
+ "label": "Claude 3.5 Sonnet (Anthropic)",
1051
+ },
1052
+ {"value": "claude-3-opus-20240229", "label": "Claude 3 Opus (Anthropic)"},
1053
+ {
1054
+ "value": "claude-3-sonnet-20240229",
1055
+ "label": "Claude 3 Sonnet (Anthropic)",
1056
+ },
1057
+ {"value": "claude-3-haiku-20240307", "label": "Claude 3 Haiku (Anthropic)"},
1058
+ ]
1059
+
1060
+ # Return all options
1061
+ return jsonify({"provider_options": provider_options, "providers": providers})
1062
+
1063
+ except Exception as e:
1064
+ import traceback
1065
+
1066
+ error_trace = traceback.format_exc()
1067
+ current_app.logger.error(
1068
+ f"Error getting available models: {str(e)}\n{error_trace}"
1069
+ )
1070
+ return jsonify({"status": "error", "message": str(e)}), 500
1071
+
1072
+
1073
+ @settings_bp.route("/api/available-search-engines", methods=["GET"])
1074
+ def api_get_available_search_engines():
1075
+ """Get available search engines"""
1076
+ try:
1077
+ # First try to get engines from search_engines.toml file
1078
+ engines_dict = get_engines_from_file()
1079
+
1080
+ # If we got engines from file, use those
1081
+ if engines_dict:
1082
+ # Make sure searxng is included if it should be
1083
+ if "searxng" not in engines_dict:
1084
+ engines_dict["searxng"] = {
1085
+ "display_name": "SearXNG (Self-hosted)",
1086
+ "description": "Self-hosted metasearch engine",
1087
+ "strengths": ["privacy", "customization", "no API key needed"],
1088
+ }
1089
+
1090
+ # Format as options for dropdown
1091
+ engine_options = [
1092
+ {
1093
+ "value": key,
1094
+ "label": engines_dict.get(key, {}).get("display_name", key),
1095
+ }
1096
+ for key in engines_dict.keys()
1097
+ ]
1098
+
1099
+ return jsonify({"engines": engines_dict, "engine_options": engine_options})
1100
+
1101
+ # Fallback to factory function if file method failed
1102
+ try:
1103
+ # Get available engines
1104
+ search_engines = get_available_engines(include_api_key_services=True)
1105
+
1106
+ # Handle if search_engines is a list (not a dict)
1107
+ if isinstance(search_engines, list):
1108
+ # Convert to dict with engine name as key and display name as value
1109
+ engines_dict = {
1110
+ engine: engine.replace("_", " ").title()
1111
+ for engine in search_engines
1112
+ }
1113
+ else:
1114
+ engines_dict = search_engines
1115
+
1116
+ # Make sure searxng is included
1117
+ if "searxng" not in engines_dict:
1118
+ engines_dict["searxng"] = "SearXNG (Self-hosted)"
1119
+
1120
+ # Format as options for dropdown
1121
+ engine_options = [
1122
+ {
1123
+ "value": key,
1124
+ "label": (
1125
+ value
1126
+ if isinstance(value, str)
1127
+ else key.replace("_", " ").title()
1128
+ ),
1129
+ }
1130
+ for key, value in engines_dict.items()
1131
+ ]
1132
+
1133
+ return jsonify({"engines": engines_dict, "engine_options": engine_options})
1134
+ except Exception as e:
1135
+ # If both methods fail, return default engines with searxng
1136
+ logger.error(f"Error getting available search engines from factory: {e}")
1137
+
1138
+ # Use hardcoded defaults from search_engines.toml
1139
+ defaults = {
1140
+ "wikipedia": "Wikipedia",
1141
+ "arxiv": "ArXiv Papers",
1142
+ "pubmed": "PubMed Medical",
1143
+ "github": "GitHub Code",
1144
+ "searxng": "SearXNG (Self-hosted)",
1145
+ "serpapi": "SerpAPI (Google)",
1146
+ "google_pse": "Google PSE",
1147
+ "auto": "Auto-select",
1148
+ }
1149
+
1150
+ engine_options = [
1151
+ {"value": key, "label": value} for key, value in defaults.items()
1152
+ ]
1153
+
1154
+ return jsonify({"engines": defaults, "engine_options": engine_options})
1155
+ except Exception as e:
1156
+ logger.error(f"Error getting available search engines: {e}")
1157
+ return jsonify({"error": str(e)}), 500
1158
+
1159
+
1160
+ def get_engines_from_file():
1161
+ """Get available search engines directly from the toml file"""
1162
+ try:
1163
+ # Try to load from the actual config directory
1164
+ config_dir = get_config_dir()
1165
+ search_engines_file = config_dir / "config" / "search_engines.toml"
1166
+
1167
+ # If file doesn't exist in user config, try the defaults
1168
+ if not search_engines_file.exists():
1169
+ # Look in the defaults folder instead
1170
+ import inspect
1171
+
1172
+ from ...defaults import search_engines
1173
+
1174
+ # Get the path to the search_engines.toml file
1175
+ module_path = inspect.getfile(search_engines)
1176
+ default_file = Path(module_path)
1177
+
1178
+ if default_file.exists() and default_file.suffix == ".toml":
1179
+ search_engines_file = default_file
1180
+
1181
+ # If we found a file, load it
1182
+ if search_engines_file.exists():
1183
+ data = toml.load(search_engines_file)
1184
+
1185
+ # Filter out the metadata entries (like DEFAULT_SEARCH_ENGINE)
1186
+ engines = {k: v for k, v in data.items() if isinstance(v, dict)}
1187
+
1188
+ # Add display names for each engine
1189
+ for key, engine in engines.items():
1190
+ if "display_name" not in engine:
1191
+ # Create a display name from the key
1192
+ engine["display_name"] = key.replace("_", " ").title()
1193
+
1194
+ return engines
1195
+
1196
+ return None
1197
+ except Exception as e:
1198
+ logger.error(f"Error loading search engines from file: {e}")
1199
+ return None
1200
+
1201
+
1202
+ # Legacy routes for backward compatibility - these will redirect to the new routes
1203
+ @settings_bp.route("/main", methods=["GET"])
1204
+ def main_config_page():
1205
+ """Redirect to app settings page"""
1206
+ return redirect(url_for("settings.settings_page"))
1207
+
1208
+
1209
+ @settings_bp.route("/collections", methods=["GET"])
1210
+ def collections_config_page():
1211
+ """Redirect to app settings page"""
1212
+ return redirect(url_for("settings.settings_page"))
1213
+
1214
+
1215
+ @settings_bp.route("/api_keys", methods=["GET"])
1216
+ def api_keys_config_page():
1217
+ """Redirect to LLM settings page"""
1218
+ return redirect(url_for("settings.settings_page"))
1219
+
1220
+
1221
+ @settings_bp.route("/search_engines", methods=["GET"])
1222
+ def search_engines_config_page():
1223
+ """Redirect to search settings page"""
1224
+ return redirect(url_for("settings.settings_page"))
1225
+
1226
+
1227
+ @settings_bp.route("/open_file_location", methods=["POST"])
1228
+ def open_file_location():
1229
+ """Open the location of a configuration file"""
1230
+ file_path = request.form.get("file_path")
1231
+
1232
+ if not file_path:
1233
+ flash("No file path provided", "error")
1234
+ return redirect(url_for("settings.settings_page"))
1235
+
1236
+ # Get the directory containing the file
1237
+ dir_path = os.path.dirname(os.path.abspath(file_path))
1238
+
1239
+ # Open the directory in the file explorer
1240
+ try:
1241
+ if platform.system() == "Windows":
1242
+ subprocess.Popen(f'explorer "{dir_path}"')
1243
+ elif platform.system() == "Darwin": # macOS
1244
+ subprocess.Popen(["open", dir_path])
1245
+ else: # Linux
1246
+ subprocess.Popen(["xdg-open", dir_path])
1247
+
1248
+ flash(f"Opening folder: {dir_path}", "success")
1249
+ except Exception as e:
1250
+ flash(f"Error opening folder: {str(e)}", "error")
1251
+
1252
+ # Redirect back to the settings page
1253
+ return redirect(url_for("settings.settings_page"))
1254
+
1255
+
1256
+ @settings_bp.context_processor
1257
+ def inject_csrf_token():
1258
+ """Inject CSRF token into the template context for all settings routes."""
1259
+ return dict(csrf_token=generate_csrf)
1260
+
1261
+
1262
+ @settings_bp.route("/fix_corrupted_settings", methods=["POST"])
1263
+ def fix_corrupted_settings():
1264
+ """Fix corrupted settings in the database"""
1265
+ db_session = get_db_session()
1266
+
1267
+ try:
1268
+ # Track fixed and removed settings
1269
+ fixed_settings = []
1270
+ removed_duplicate_settings = []
1271
+ fixed_scoping_issues = []
1272
+
1273
+ # First, find and remove duplicate settings with the same key
1274
+ # This happens because of errors in settings import/export
1275
+ from sqlalchemy import func as sql_func
1276
+
1277
+ # Find keys with duplicates
1278
+ duplicate_keys = (
1279
+ db_session.query(Setting.key)
1280
+ .group_by(Setting.key)
1281
+ .having(sql_func.count(Setting.key) > 1)
1282
+ .all()
1283
+ )
1284
+ duplicate_keys = [key[0] for key in duplicate_keys]
1285
+
1286
+ # For each duplicate key, keep the latest updated one and remove others
1287
+ for key in duplicate_keys:
1288
+ dupe_settings = (
1289
+ db_session.query(Setting)
1290
+ .filter(Setting.key == key)
1291
+ .order_by(Setting.updated_at.desc())
1292
+ .all()
1293
+ )
1294
+
1295
+ # Keep the first one (most recently updated) and delete the rest
1296
+ for i, setting in enumerate(dupe_settings):
1297
+ if i > 0: # Skip the first one (keep it)
1298
+ db_session.delete(setting)
1299
+ removed_duplicate_settings.append(key)
1300
+
1301
+ # Fix scoping issues - remove app.* settings that should be in other categories
1302
+ # Report settings
1303
+ for key in [
1304
+ "app.enable_fact_checking",
1305
+ "app.knowledge_accumulation",
1306
+ "app.knowledge_accumulation_context_limit",
1307
+ "app.output_dir",
1308
+ ]:
1309
+ setting = db_session.query(Setting).filter(Setting.key == key).first()
1310
+ if setting:
1311
+ # Move to proper category if not already there
1312
+ proper_key = key.replace("app.", "report.")
1313
+ existing_proper = (
1314
+ db_session.query(Setting).filter(Setting.key == proper_key).first()
1315
+ )
1316
+
1317
+ if not existing_proper:
1318
+ # Create proper setting
1319
+ new_setting = Setting(
1320
+ key=proper_key,
1321
+ value=setting.value,
1322
+ type=SettingType.REPORT,
1323
+ name=setting.name,
1324
+ description=setting.description,
1325
+ category=(
1326
+ setting.category.replace("app", "report")
1327
+ if setting.category
1328
+ else "report_parameters"
1329
+ ),
1330
+ ui_element=setting.ui_element,
1331
+ options=setting.options,
1332
+ min_value=setting.min_value,
1333
+ max_value=setting.max_value,
1334
+ step=setting.step,
1335
+ visible=setting.visible,
1336
+ editable=setting.editable,
1337
+ )
1338
+ db_session.add(new_setting)
1339
+
1340
+ # Delete the app one
1341
+ db_session.delete(setting)
1342
+ fixed_scoping_issues.append(key)
1343
+
1344
+ # Search settings
1345
+ for key in [
1346
+ "app.research_iterations",
1347
+ "app.questions_per_iteration",
1348
+ "app.search_engine",
1349
+ "app.iterations",
1350
+ "app.max_results",
1351
+ "app.region",
1352
+ "app.safe_search",
1353
+ "app.search_language",
1354
+ "app.snippets_only",
1355
+ ]:
1356
+ setting = db_session.query(Setting).filter(Setting.key == key).first()
1357
+ if setting:
1358
+ # Move to proper category if not already there
1359
+ proper_key = key.replace("app.", "search.")
1360
+ existing_proper = (
1361
+ db_session.query(Setting).filter(Setting.key == proper_key).first()
1362
+ )
1363
+
1364
+ if not existing_proper:
1365
+ # Create proper setting
1366
+ new_setting = Setting(
1367
+ key=proper_key,
1368
+ value=setting.value,
1369
+ type=SettingType.SEARCH,
1370
+ name=setting.name,
1371
+ description=setting.description,
1372
+ category=(
1373
+ setting.category.replace("app", "search")
1374
+ if setting.category
1375
+ else "search_parameters"
1376
+ ),
1377
+ ui_element=setting.ui_element,
1378
+ options=setting.options,
1379
+ min_value=setting.min_value,
1380
+ max_value=setting.max_value,
1381
+ step=setting.step,
1382
+ visible=setting.visible,
1383
+ editable=setting.editable,
1384
+ )
1385
+ db_session.add(new_setting)
1386
+
1387
+ # Delete the app one
1388
+ db_session.delete(setting)
1389
+ fixed_scoping_issues.append(key)
1390
+
1391
+ # LLM settings
1392
+ for key in [
1393
+ "app.model",
1394
+ "app.provider",
1395
+ "app.temperature",
1396
+ "app.max_tokens",
1397
+ "app.openai_endpoint_url",
1398
+ "app.lmstudio_url",
1399
+ "app.llamacpp_model_path",
1400
+ ]:
1401
+ setting = db_session.query(Setting).filter(Setting.key == key).first()
1402
+ if setting:
1403
+ # Move to proper category if not already there
1404
+ proper_key = key.replace("app.", "llm.")
1405
+ existing_proper = (
1406
+ db_session.query(Setting).filter(Setting.key == proper_key).first()
1407
+ )
1408
+
1409
+ if not existing_proper:
1410
+ # Create proper setting
1411
+ new_setting = Setting(
1412
+ key=proper_key,
1413
+ value=setting.value,
1414
+ type=SettingType.LLM,
1415
+ name=setting.name,
1416
+ description=setting.description,
1417
+ category=(
1418
+ setting.category.replace("app", "llm")
1419
+ if setting.category
1420
+ else "llm_parameters"
1421
+ ),
1422
+ ui_element=setting.ui_element,
1423
+ options=setting.options,
1424
+ min_value=setting.min_value,
1425
+ max_value=setting.max_value,
1426
+ step=setting.step,
1427
+ visible=setting.visible,
1428
+ editable=setting.editable,
1429
+ )
1430
+ db_session.add(new_setting)
1431
+
1432
+ # Delete the app one
1433
+ db_session.delete(setting)
1434
+ fixed_scoping_issues.append(key)
1435
+
1436
+ # Check for settings with corrupted values
1437
+ all_settings = db_session.query(Setting).all()
1438
+ for setting in all_settings:
1439
+ # Check different types of corruption
1440
+ is_corrupted = False
1441
+
1442
+ if setting.value is None:
1443
+ is_corrupted = True
1444
+ elif isinstance(setting.value, str) and setting.value in [
1445
+ "{",
1446
+ "[",
1447
+ "{}",
1448
+ "[]",
1449
+ "[object Object]",
1450
+ "null",
1451
+ "undefined",
1452
+ ]:
1453
+ is_corrupted = True
1454
+ elif isinstance(setting.value, dict) and len(setting.value) == 0:
1455
+ is_corrupted = True
1456
+
1457
+ # Skip if not corrupted
1458
+ if not is_corrupted:
1459
+ continue
1460
+
1461
+ # Get default value from migrations
1462
+ # Import commented out as it's not directly used
1463
+ # from ..database.migrations import setup_predefined_settings
1464
+
1465
+ default_value = None
1466
+
1467
+ # Try to find a matching default setting based on key
1468
+ if setting.key.startswith("llm."):
1469
+ if setting.key == "llm.model":
1470
+ default_value = "gpt-3.5-turbo"
1471
+ elif setting.key == "llm.provider":
1472
+ default_value = "openai"
1473
+ elif setting.key == "llm.temperature":
1474
+ default_value = 0.7
1475
+ elif setting.key == "llm.max_tokens":
1476
+ default_value = 1024
1477
+ elif setting.key.startswith("search."):
1478
+ if setting.key == "search.tool":
1479
+ default_value = "auto"
1480
+ elif setting.key == "search.max_results":
1481
+ default_value = 10
1482
+ elif setting.key == "search.region":
1483
+ default_value = "us"
1484
+ elif setting.key == "search.research_iterations":
1485
+ default_value = 2
1486
+ elif setting.key == "search.questions_per_iteration":
1487
+ default_value = 3
1488
+ elif setting.key == "search.searches_per_section":
1489
+ default_value = 2
1490
+ elif setting.key == "search.skip_relevance_filter":
1491
+ default_value = False
1492
+ elif setting.key == "search.safe_search":
1493
+ default_value = True
1494
+ elif setting.key == "search.search_language":
1495
+ default_value = "English"
1496
+ elif setting.key.startswith("report."):
1497
+ if setting.key == "report.searches_per_section":
1498
+ default_value = 2
1499
+ elif setting.key == "report.enable_fact_checking":
1500
+ default_value = True
1501
+ elif setting.key == "report.detailed_citations":
1502
+ default_value = True
1503
+ elif setting.key.startswith("app."):
1504
+ if setting.key == "app.theme" or setting.key == "app.default_theme":
1505
+ default_value = "dark"
1506
+ elif setting.key == "app.enable_notifications":
1507
+ default_value = True
1508
+ elif (
1509
+ setting.key == "app.enable_web"
1510
+ or setting.key == "app.web_interface"
1511
+ ):
1512
+ default_value = True
1513
+ elif setting.key == "app.host":
1514
+ default_value = "0.0.0.0"
1515
+ elif setting.key == "app.port":
1516
+ default_value = 5000
1517
+ elif setting.key == "app.debug":
1518
+ default_value = True
1519
+
1520
+ # Update the setting with the default value if found
1521
+ if default_value is not None:
1522
+ setting.value = default_value
1523
+ fixed_settings.append(setting.key)
1524
+ else:
1525
+ # If no default found but it's a corrupted JSON, set to empty object
1526
+ if setting.key.startswith("report."):
1527
+ setting.value = {}
1528
+ fixed_settings.append(setting.key)
1529
+
1530
+ # Commit changes
1531
+ if fixed_settings or removed_duplicate_settings or fixed_scoping_issues:
1532
+ db_session.commit()
1533
+ logger.info(
1534
+ f"Fixed {len(fixed_settings)} corrupted settings: {', '.join(fixed_settings)}"
1535
+ )
1536
+ if removed_duplicate_settings:
1537
+ logger.info(
1538
+ f"Removed {len(removed_duplicate_settings)} duplicate settings"
1539
+ )
1540
+ if fixed_scoping_issues:
1541
+ logger.info(f"Fixed {len(fixed_scoping_issues)} scoping issues")
1542
+
1543
+ # Return success
1544
+ return jsonify(
1545
+ {
1546
+ "status": "success",
1547
+ "message": f"Fixed {len(fixed_settings)} corrupted settings, removed {len(removed_duplicate_settings)} duplicates, and fixed {len(fixed_scoping_issues)} scoping issues",
1548
+ "fixed_settings": fixed_settings,
1549
+ "removed_duplicates": removed_duplicate_settings,
1550
+ "fixed_scoping": fixed_scoping_issues,
1551
+ }
1552
+ )
1553
+
1554
+ except Exception as e:
1555
+ logger.error(f"Error fixing corrupted settings: {e}")
1556
+ db_session.rollback()
1557
+ return (
1558
+ jsonify(
1559
+ {
1560
+ "status": "error",
1561
+ "message": f"Error fixing corrupted settings: {str(e)}",
1562
+ }
1563
+ ),
1564
+ 500,
1565
+ )
1566
+
1567
+
1568
+ @settings_bp.route("/api/ollama-status", methods=["GET"])
1569
+ def check_ollama_status():
1570
+ """Check if Ollama is running and available"""
1571
+ try:
1572
+ # Set a shorter timeout for the request
1573
+ base_url = os.getenv(
1574
+ "OLLAMA_BASE_URL",
1575
+ "http://localhost:11434",
1576
+ )
1577
+ response = requests.get(f"{base_url}/api/version", timeout=2.0)
1578
+
1579
+ if response.status_code == 200:
1580
+ return jsonify(
1581
+ {"running": True, "version": response.json().get("version", "unknown")}
1582
+ )
1583
+ else:
1584
+ return jsonify(
1585
+ {
1586
+ "running": False,
1587
+ "error": f"Ollama returned status code {response.status_code}",
1588
+ }
1589
+ )
1590
+ except requests.exceptions.RequestException as e:
1591
+ logger.info(f"Ollama check failed: {str(e)}")
1592
+ return jsonify({"running": False, "error": str(e)})