dtSpark 1.1.0a2__py3-none-any.whl → 1.1.0a6__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 (56) hide show
  1. dtSpark/_version.txt +1 -1
  2. dtSpark/aws/authentication.py +1 -1
  3. dtSpark/aws/bedrock.py +238 -239
  4. dtSpark/aws/costs.py +9 -5
  5. dtSpark/aws/pricing.py +25 -21
  6. dtSpark/cli_interface.py +69 -62
  7. dtSpark/conversation_manager.py +54 -47
  8. dtSpark/core/application.py +151 -111
  9. dtSpark/core/context_compaction.py +241 -226
  10. dtSpark/daemon/__init__.py +36 -22
  11. dtSpark/daemon/action_monitor.py +46 -17
  12. dtSpark/daemon/daemon_app.py +126 -104
  13. dtSpark/daemon/daemon_manager.py +59 -23
  14. dtSpark/daemon/pid_file.py +3 -2
  15. dtSpark/database/autonomous_actions.py +3 -0
  16. dtSpark/database/credential_prompt.py +52 -54
  17. dtSpark/files/manager.py +6 -12
  18. dtSpark/limits/__init__.py +1 -1
  19. dtSpark/limits/tokens.py +2 -2
  20. dtSpark/llm/anthropic_direct.py +246 -141
  21. dtSpark/llm/ollama.py +3 -1
  22. dtSpark/mcp_integration/manager.py +4 -4
  23. dtSpark/mcp_integration/tool_selector.py +83 -77
  24. dtSpark/resources/config.yaml.template +10 -0
  25. dtSpark/safety/patterns.py +45 -46
  26. dtSpark/safety/prompt_inspector.py +8 -1
  27. dtSpark/scheduler/creation_tools.py +273 -181
  28. dtSpark/scheduler/executor.py +503 -221
  29. dtSpark/tools/builtin.py +70 -53
  30. dtSpark/web/endpoints/autonomous_actions.py +12 -9
  31. dtSpark/web/endpoints/chat.py +18 -6
  32. dtSpark/web/endpoints/conversations.py +57 -17
  33. dtSpark/web/endpoints/main_menu.py +132 -105
  34. dtSpark/web/endpoints/streaming.py +2 -2
  35. dtSpark/web/server.py +65 -5
  36. dtSpark/web/ssl_utils.py +3 -3
  37. dtSpark/web/static/css/dark-theme.css +8 -29
  38. dtSpark/web/static/js/actions.js +2 -1
  39. dtSpark/web/static/js/chat.js +6 -8
  40. dtSpark/web/static/js/main.js +8 -8
  41. dtSpark/web/static/js/sse-client.js +130 -122
  42. dtSpark/web/templates/actions.html +5 -5
  43. dtSpark/web/templates/base.html +13 -0
  44. dtSpark/web/templates/chat.html +52 -50
  45. dtSpark/web/templates/conversations.html +50 -22
  46. dtSpark/web/templates/goodbye.html +2 -2
  47. dtSpark/web/templates/main_menu.html +17 -17
  48. dtSpark/web/templates/new_conversation.html +51 -20
  49. dtSpark/web/web_interface.py +2 -2
  50. {dtspark-1.1.0a2.dist-info → dtspark-1.1.0a6.dist-info}/METADATA +9 -2
  51. dtspark-1.1.0a6.dist-info/RECORD +96 -0
  52. dtspark-1.1.0a2.dist-info/RECORD +0 -96
  53. {dtspark-1.1.0a2.dist-info → dtspark-1.1.0a6.dist-info}/WHEEL +0 -0
  54. {dtspark-1.1.0a2.dist-info → dtspark-1.1.0a6.dist-info}/entry_points.txt +0 -0
  55. {dtspark-1.1.0a2.dist-info → dtspark-1.1.0a6.dist-info}/licenses/LICENSE +0 -0
  56. {dtspark-1.1.0a2.dist-info → dtspark-1.1.0a6.dist-info}/top_level.txt +0 -0
@@ -10,9 +10,11 @@ Provides REST API for conversation operations:
10
10
 
11
11
  """
12
12
 
13
+ import asyncio
13
14
  import logging
14
15
  import tempfile
15
16
  import os
17
+ from pathlib import Path
16
18
  from typing import Optional, List
17
19
  from datetime import datetime
18
20
 
@@ -37,7 +39,7 @@ def parse_datetime(dt_value):
37
39
  # SQLite returns timestamps as strings
38
40
  try:
39
41
  return datetime.fromisoformat(dt_value.replace('Z', '+00:00'))
40
- except:
42
+ except (ValueError, TypeError):
41
43
  return datetime.strptime(dt_value, '%Y-%m-%d %H:%M:%S.%f')
42
44
  return None
43
45
 
@@ -179,6 +181,14 @@ async def create_conversation(
179
181
  database = app_instance.database
180
182
  conversation_manager = app_instance.conversation_manager
181
183
 
184
+ # Enforce mandatory model if configured
185
+ mandatory_model = getattr(app_instance, 'configured_model_id', None)
186
+ mandatory_provider = getattr(app_instance, 'configured_provider', None)
187
+ if mandatory_model:
188
+ model_id = mandatory_model
189
+ logger.info(f"Mandatory model enforced: {model_id}"
190
+ f"{f' via {mandatory_provider}' if mandatory_provider else ''}")
191
+
182
192
  # Create conversation in database
183
193
  conversation_id = database.create_conversation(
184
194
  name=name,
@@ -190,7 +200,7 @@ async def create_conversation(
190
200
  conversation_manager.load_conversation(conversation_id)
191
201
 
192
202
  # Set the model from the conversation and update service references
193
- app_instance.llm_manager.set_model(model_id)
203
+ app_instance.llm_manager.set_model(model_id, mandatory_provider)
194
204
  app_instance.bedrock_service = app_instance.llm_manager.get_active_service()
195
205
  conversation_manager.update_service(app_instance.bedrock_service)
196
206
 
@@ -212,7 +222,7 @@ async def create_conversation(
212
222
  suffix = os.path.splitext(upload_file.filename)[1]
213
223
  if not suffix:
214
224
  upload_errors.append(f"File '{upload_file.filename}' has no file extension")
215
- logger.warning(f"File '{upload_file.filename}' uploaded without extension")
225
+ logger.warning("File uploaded without extension")
216
226
  continue
217
227
 
218
228
  # Check if extension is supported (using FileManager's validation)
@@ -222,19 +232,19 @@ async def create_conversation(
222
232
  FileManager.SUPPORTED_DOCUMENT_FILES |
223
233
  FileManager.SUPPORTED_IMAGE_FILES):
224
234
  upload_errors.append(f"File type '{suffix}' is not supported for '{upload_file.filename}'")
225
- logger.warning(f"Unsupported file type '{suffix}' for file '{upload_file.filename}'")
235
+ logger.warning("Unsupported file type uploaded")
226
236
  continue
227
237
 
228
238
  # Create temporary file with proper extension
229
239
  temp_fd, temp_path = tempfile.mkstemp(suffix=suffix)
240
+ os.close(temp_fd)
230
241
 
231
- # Write uploaded content to temp file
232
- with os.fdopen(temp_fd, 'wb') as f:
233
- content = await upload_file.read()
234
- f.write(content)
242
+ # Write uploaded content to temp file asynchronously
243
+ content = await upload_file.read()
244
+ await asyncio.to_thread(Path(temp_path).write_bytes, content)
235
245
 
236
246
  temp_files.append(temp_path)
237
- logger.info(f"Saved uploaded file {upload_file.filename} to {temp_path}")
247
+ logger.info("Saved uploaded file to %s", temp_path)
238
248
 
239
249
  # Attach files using conversation manager
240
250
  if temp_files:
@@ -257,7 +267,7 @@ async def create_conversation(
257
267
  try:
258
268
  if os.path.exists(temp_path):
259
269
  os.unlink(temp_path)
260
- except:
270
+ except OSError:
261
271
  pass
262
272
 
263
273
  # Get the created conversation details
@@ -325,29 +335,59 @@ async def delete_conversation(
325
335
  async def list_models(
326
336
  request: Request,
327
337
  session_id: str = Depends(get_current_session),
328
- ) -> List[dict]:
338
+ ) -> dict:
329
339
  """
330
- Get available models.
340
+ Get available models and mandatory model configuration.
331
341
 
332
342
  Returns:
333
- List of available models with their IDs and names
343
+ Dictionary with models list and mandatory model info
334
344
  """
335
345
  try:
336
346
  app_instance = request.app.state.app_instance
337
347
 
348
+ mandatory_model = getattr(app_instance, 'configured_model_id', None)
349
+ mandatory_provider = getattr(app_instance, 'configured_provider', None)
350
+
338
351
  # Get available models from LLM manager
339
- models = app_instance.llm_manager.list_all_models()
352
+ all_models = app_instance.llm_manager.list_all_models()
340
353
 
341
- return [
354
+ models = [
342
355
  {
343
356
  "id": model.get('id', model.get('name', 'unknown')),
344
357
  "name": model.get('name', 'Unknown'),
345
358
  "provider": model.get('provider', 'Unknown'),
346
- "model_maker": model.get('model_maker'), # Optional: model creator (for Bedrock models)
359
+ "model_maker": model.get('model_maker'),
347
360
  }
348
- for model in models
361
+ for model in all_models
349
362
  ]
350
363
 
364
+ # If mandatory model is set, filter to matching models only
365
+ if mandatory_model:
366
+ filtered = [
367
+ m for m in models
368
+ if m['id'] == mandatory_model
369
+ or m['name'] == mandatory_model
370
+ ]
371
+
372
+ # Further filter by provider if mandatory_provider is set
373
+ if mandatory_provider and filtered:
374
+ provider_filtered = [
375
+ m for m in filtered
376
+ if m['provider'] == mandatory_provider
377
+ ]
378
+ if provider_filtered:
379
+ filtered = provider_filtered
380
+
381
+ if filtered:
382
+ models = filtered
383
+
384
+ return {
385
+ "models": models,
386
+ "mandatory_model": mandatory_model,
387
+ "mandatory_provider": mandatory_provider,
388
+ "model_locked": mandatory_model is not None,
389
+ }
390
+
351
391
  except Exception as e:
352
392
  logger.error(f"Error listing models: {e}")
353
393
  raise HTTPException(status_code=500, detail=str(e))
@@ -80,50 +80,17 @@ async def get_account_info(
80
80
  """
81
81
  try:
82
82
  app_instance = request.app.state.app_instance
83
-
84
- # Get LLM manager to determine active provider
85
83
  llm_manager = getattr(app_instance, 'llm_manager', None)
86
84
  user_guid = getattr(app_instance, 'user_guid', 'unknown')
87
85
 
88
86
  if llm_manager and llm_manager.active_provider:
89
- active_provider = llm_manager.active_provider.lower()
90
-
91
- # AWS Bedrock
92
- if 'bedrock' in active_provider or 'aws' in active_provider:
93
- auth = getattr(app_instance, 'authenticator', None)
94
- if auth:
95
- account_info = auth.get_account_info()
96
- if account_info:
97
- return AccountInfo(
98
- provider='aws',
99
- user_arn=account_info.get('user_arn'),
100
- account_id=account_info.get('account_id'),
101
- region=account_info.get('region'),
102
- user_guid=user_guid,
103
- auth_method=account_info.get('auth_method'),
104
- )
105
-
106
- # Anthropic
107
- elif 'anthropic' in active_provider:
108
- return AccountInfo(
109
- provider='anthropic',
110
- user_guid=user_guid,
111
- auth_method='api_key',
112
- )
113
-
114
- # Ollama
115
- elif 'ollama' in active_provider:
116
- return AccountInfo(
117
- provider='ollama',
118
- user_guid=user_guid,
119
- auth_method='local',
120
- )
87
+ result = _build_account_info_for_provider(
88
+ app_instance, llm_manager.active_provider.lower(), user_guid
89
+ )
90
+ if result:
91
+ return result
121
92
 
122
- # No provider configured - return basic info
123
- return AccountInfo(
124
- provider='none',
125
- user_guid=user_guid,
126
- )
93
+ return AccountInfo(provider='none', user_guid=user_guid)
127
94
 
128
95
  except HTTPException:
129
96
  raise
@@ -132,6 +99,40 @@ async def get_account_info(
132
99
  raise HTTPException(status_code=500, detail=str(e))
133
100
 
134
101
 
102
+ def _build_account_info_for_provider(
103
+ app_instance, active_provider: str, user_guid: str
104
+ ) -> Optional[AccountInfo]:
105
+ """Build AccountInfo for the given active provider, or return None if unavailable."""
106
+ if 'bedrock' in active_provider or 'aws' in active_provider:
107
+ return _build_aws_account_info(app_instance, user_guid)
108
+
109
+ if 'anthropic' in active_provider:
110
+ return AccountInfo(provider='anthropic', user_guid=user_guid, auth_method='api_key')
111
+
112
+ if 'ollama' in active_provider:
113
+ return AccountInfo(provider='ollama', user_guid=user_guid, auth_method='local')
114
+
115
+ return None
116
+
117
+
118
+ def _build_aws_account_info(app_instance, user_guid: str) -> Optional[AccountInfo]:
119
+ """Build AccountInfo from the AWS authenticator, or return None."""
120
+ auth = getattr(app_instance, 'authenticator', None)
121
+ if not auth:
122
+ return None
123
+ account_info = auth.get_account_info()
124
+ if not account_info:
125
+ return None
126
+ return AccountInfo(
127
+ provider='aws',
128
+ user_arn=account_info.get('user_arn'),
129
+ account_id=account_info.get('account_id'),
130
+ region=account_info.get('region'),
131
+ user_guid=user_guid,
132
+ auth_method=account_info.get('auth_method'),
133
+ )
134
+
135
+
135
136
  @router.get("/providers")
136
137
  async def get_providers(
137
138
  request: Request,
@@ -147,71 +148,11 @@ async def get_providers(
147
148
  app_instance = request.app.state.app_instance
148
149
  providers = []
149
150
 
150
- # Get LLM manager which has all registered providers
151
151
  llm_manager = getattr(app_instance, 'llm_manager', None)
152
-
153
152
  if llm_manager and hasattr(llm_manager, 'providers'):
154
153
  for provider_name, service in llm_manager.providers.items():
155
- models = []
156
- status = 'connected'
157
- provider_type = 'unknown'
158
- auth_method = None
159
- region = None
160
- base_url = None
161
-
162
- # Determine provider type
163
- provider_name_lower = provider_name.lower()
164
- if 'bedrock' in provider_name_lower or 'aws' in provider_name_lower:
165
- provider_type = 'aws'
166
- auth = getattr(app_instance, 'authenticator', None)
167
- if auth:
168
- account_info = auth.get_account_info()
169
- if account_info:
170
- auth_method = account_info.get('auth_method')
171
- region = account_info.get('region')
172
- elif 'anthropic' in provider_name_lower:
173
- provider_type = 'anthropic'
174
- auth_method = 'api_key'
175
- elif 'ollama' in provider_name_lower:
176
- provider_type = 'ollama'
177
- auth_method = 'local'
178
- base_url = getattr(service, 'base_url', 'http://localhost:11434')
179
-
180
- # Get available models
181
- try:
182
- if hasattr(service, 'list_available_models'):
183
- available_models = service.list_available_models()
184
- elif hasattr(service, 'list_models'):
185
- available_models = service.list_models()
186
- else:
187
- available_models = []
188
-
189
- for model in available_models:
190
- if isinstance(model, dict):
191
- model_id = model.get('id') or model.get('modelId') or model.get('name') or str(model)
192
- display_name = model.get('display_name') or model.get('modelName') or model_id
193
- else:
194
- model_id = str(model)
195
- display_name = model_id
196
-
197
- models.append(ProviderModelInfo(
198
- model_id=model_id,
199
- display_name=display_name,
200
- ))
201
- except Exception as e:
202
- logger.warning(f"Failed to list models from {provider_name}: {e}")
203
- status = 'error'
204
-
205
- providers.append(ProviderInfo(
206
- name=provider_name,
207
- type=provider_type,
208
- enabled=True,
209
- status=status,
210
- models=models,
211
- auth_method=auth_method,
212
- region=region,
213
- base_url=base_url,
214
- ))
154
+ provider_info = _build_provider_info(app_instance, provider_name, service)
155
+ providers.append(provider_info)
215
156
 
216
157
  return providers
217
158
 
@@ -220,6 +161,88 @@ async def get_providers(
220
161
  raise HTTPException(status_code=500, detail=str(e))
221
162
 
222
163
 
164
+ def _build_provider_info(app_instance, provider_name: str, service) -> ProviderInfo:
165
+ """Build a ProviderInfo for a single registered provider."""
166
+ provider_type, auth_method, region, base_url = _detect_provider_type(
167
+ app_instance, provider_name, service
168
+ )
169
+
170
+ models, status = _list_provider_models(provider_name, service)
171
+
172
+ return ProviderInfo(
173
+ name=provider_name,
174
+ type=provider_type,
175
+ enabled=True,
176
+ status=status,
177
+ models=models,
178
+ auth_method=auth_method,
179
+ region=region,
180
+ base_url=base_url,
181
+ )
182
+
183
+
184
+ def _detect_provider_type(app_instance, provider_name: str, service) -> tuple:
185
+ """Detect the provider type, auth method, region, and base URL from the provider name."""
186
+ provider_name_lower = provider_name.lower()
187
+ auth_method = None
188
+ region = None
189
+ base_url = None
190
+
191
+ if 'bedrock' in provider_name_lower or 'aws' in provider_name_lower:
192
+ provider_type = 'aws'
193
+ auth = getattr(app_instance, 'authenticator', None)
194
+ if auth:
195
+ account_info = auth.get_account_info()
196
+ if account_info:
197
+ auth_method = account_info.get('auth_method')
198
+ region = account_info.get('region')
199
+ elif 'anthropic' in provider_name_lower:
200
+ provider_type = 'anthropic'
201
+ auth_method = 'api_key'
202
+ elif 'ollama' in provider_name_lower:
203
+ provider_type = 'ollama'
204
+ auth_method = 'local'
205
+ base_url = getattr(service, 'base_url', 'http://localhost:11434')
206
+ else:
207
+ provider_type = 'unknown'
208
+
209
+ return provider_type, auth_method, region, base_url
210
+
211
+
212
+ def _list_provider_models(provider_name: str, service) -> tuple:
213
+ """List available models from a provider service. Returns (models, status)."""
214
+ models = []
215
+ status = 'connected'
216
+
217
+ try:
218
+ if hasattr(service, 'list_available_models'):
219
+ available_models = service.list_available_models()
220
+ elif hasattr(service, 'list_models'):
221
+ available_models = service.list_models()
222
+ else:
223
+ available_models = []
224
+
225
+ for model in available_models:
226
+ models.append(_parse_model_info(model))
227
+ except Exception as e:
228
+ logger.warning(f"Failed to list models from {provider_name}: {e}")
229
+ status = 'error'
230
+
231
+ return models, status
232
+
233
+
234
+ def _parse_model_info(model) -> ProviderModelInfo:
235
+ """Parse a model entry (dict or string) into a ProviderModelInfo."""
236
+ if isinstance(model, dict):
237
+ model_id = model.get('id') or model.get('modelId') or model.get('name') or str(model)
238
+ display_name = model.get('display_name') or model.get('modelName') or model_id
239
+ else:
240
+ model_id = str(model)
241
+ display_name = model_id
242
+
243
+ return ProviderModelInfo(model_id=model_id, display_name=display_name)
244
+
245
+
223
246
  @router.get("/costs/last-month")
224
247
  async def get_last_month_costs(
225
248
  request: Request,
@@ -607,14 +630,18 @@ async def get_daemon_status(request: Request):
607
630
  except Exception:
608
631
  pass
609
632
 
633
+ if not daemon_running and scheduled_count > 0:
634
+ warning_message = (
635
+ f"Daemon is not running - {scheduled_count} scheduled action(s) will not execute"
636
+ )
637
+ else:
638
+ warning_message = None
639
+
610
640
  return {
611
641
  'daemon_running': daemon_running,
612
642
  'daemon_pid': daemon_pid,
613
643
  'scheduled_actions_count': scheduled_count,
614
- 'warning': None if daemon_running else (
615
- f"Daemon is not running - {scheduled_count} scheduled action(s) will not execute"
616
- if scheduled_count > 0 else None
617
- )
644
+ 'warning': warning_message,
618
645
  }
619
646
 
620
647
  except Exception as e:
@@ -151,7 +151,7 @@ class StreamingManager:
151
151
  "content": result.get('content', ''),
152
152
  }),
153
153
  }
154
- except:
154
+ except ValueError:
155
155
  pass
156
156
 
157
157
  elif role == 'assistant' and content.strip().startswith('['):
@@ -179,7 +179,7 @@ class StreamingManager:
179
179
  "input": block.get('input', {}),
180
180
  }),
181
181
  }
182
- except:
182
+ except ValueError:
183
183
  pass
184
184
 
185
185
  last_message_count = len(current_messages)
dtSpark/web/server.py CHANGED
@@ -9,6 +9,7 @@ import os
9
9
  import sys
10
10
  import socket
11
11
  import logging
12
+ import time
12
13
  import webbrowser
13
14
  import signal
14
15
  import asyncio
@@ -252,6 +253,48 @@ def create_app(
252
253
  loop = asyncio.get_running_loop()
253
254
  loop.set_exception_handler(_suppress_connection_reset_errors)
254
255
 
256
+ # Set to hold references to background tasks (prevents garbage collection)
257
+ _background_tasks = set()
258
+
259
+ # Browser heartbeat state
260
+ app.state.last_heartbeat = 0.0
261
+
262
+ @app.post("/api/heartbeat")
263
+ async def heartbeat():
264
+ """Receive browser heartbeat ping."""
265
+ app.state.last_heartbeat = time.time()
266
+ return JSONResponse({"status": "ok"})
267
+
268
+ @app.on_event("startup")
269
+ async def start_heartbeat_monitor():
270
+ """Start background task to monitor browser heartbeat."""
271
+ if not heartbeat_enabled:
272
+ return
273
+
274
+ async def _monitor_heartbeat():
275
+ # Initial grace period - wait for browser to connect and send first heartbeat
276
+ grace_period = heartbeat_timeout * 2
277
+ logger.info(
278
+ f"Browser heartbeat monitor started (interval={heartbeat_interval}s, "
279
+ f"timeout={heartbeat_timeout}s, grace={grace_period}s)"
280
+ )
281
+ await asyncio.sleep(grace_period)
282
+
283
+ while True:
284
+ await asyncio.sleep(heartbeat_interval)
285
+ last = app.state.last_heartbeat
286
+ if last > 0 and (time.time() - last) > heartbeat_timeout:
287
+ logger.info(
288
+ f"No browser heartbeat for {heartbeat_timeout}s - "
289
+ f"shutting down (browser likely closed)"
290
+ )
291
+ os.kill(os.getpid(), signal.SIGTERM)
292
+ return
293
+
294
+ task = asyncio.create_task(_monitor_heartbeat())
295
+ _background_tasks.add(task)
296
+ task.add_done_callback(_background_tasks.discard)
297
+
255
298
  # Get template and static directories
256
299
  web_dir = Path(__file__).parent
257
300
  templates_dir = web_dir / "templates"
@@ -260,11 +303,24 @@ def create_app(
260
303
  # Setup templates
261
304
  templates = Jinja2Templates(directory=str(templates_dir))
262
305
 
306
+ # Determine feature flags from app instance
307
+ actions_enabled = getattr(app_instance, 'actions_enabled', False)
308
+
309
+ # Read heartbeat configuration
310
+ from dtPyAppFramework.settings import Settings as _Settings
311
+ _hb_settings = _Settings()
312
+ heartbeat_enabled = _hb_settings.get('interface.web.browser_heartbeat.enabled', True)
313
+ heartbeat_interval = _hb_settings.get('interface.web.browser_heartbeat.interval_seconds', 15)
314
+ heartbeat_timeout = _hb_settings.get('interface.web.browser_heartbeat.timeout_seconds', 60)
315
+
263
316
  # Add global template variables for app name and version
264
317
  templates.env.globals['app_name'] = full_name()
265
318
  templates.env.globals['app_version'] = version()
266
319
  templates.env.globals['app_description'] = description()
267
320
  templates.env.globals['agent_name'] = agent_name()
321
+ templates.env.globals['actions_enabled'] = actions_enabled
322
+ templates.env.globals['heartbeat_enabled'] = heartbeat_enabled
323
+ templates.env.globals['heartbeat_interval_ms'] = heartbeat_interval * 1000
268
324
 
269
325
  # Mount static files
270
326
  app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
@@ -428,13 +484,14 @@ def create_app(
428
484
  logger.info("Shutdown request received via API")
429
485
  # Send shutdown signal to the process
430
486
  # Use a background task to allow the response to be sent first
431
- import asyncio
432
487
  async def shutdown_server():
433
488
  await asyncio.sleep(0.5) # Give time for response to be sent
434
489
  logger.info("Shutting down web server...")
435
490
  os.kill(os.getpid(), signal.SIGTERM)
436
491
 
437
- asyncio.create_task(shutdown_server())
492
+ task = asyncio.create_task(shutdown_server())
493
+ _background_tasks.add(task)
494
+ task.add_done_callback(_background_tasks.discard)
438
495
  return JSONResponse({"status": "shutdown initiated"})
439
496
 
440
497
  @app.get("/menu", response_class=HTMLResponse)
@@ -456,13 +513,14 @@ def create_app(
456
513
  chat_router,
457
514
  streaming_router,
458
515
  )
459
- from .endpoints.autonomous_actions import router as autonomous_actions_router
460
-
461
516
  app.include_router(main_menu_router, prefix="/api", tags=["Main Menu"])
462
517
  app.include_router(conversations_router, prefix="/api", tags=["Conversations"])
463
518
  app.include_router(chat_router, prefix="/api", tags=["Chat"])
464
519
  app.include_router(streaming_router, prefix="/api", tags=["Streaming"])
465
- app.include_router(autonomous_actions_router, prefix="/api", tags=["Autonomous Actions"])
520
+
521
+ if actions_enabled:
522
+ from .endpoints.autonomous_actions import router as autonomous_actions_router
523
+ app.include_router(autonomous_actions_router, prefix="/api", tags=["Autonomous Actions"])
466
524
 
467
525
  # Add template routes for conversations and chat
468
526
  @app.get("/conversations", response_class=HTMLResponse)
@@ -515,6 +573,8 @@ def create_app(
515
573
  @app.get("/actions", response_class=HTMLResponse)
516
574
  async def actions_page(request: Request, session_id: str = Depends(get_session)):
517
575
  """Display autonomous actions management page."""
576
+ if not actions_enabled:
577
+ return RedirectResponse(url="/menu", status_code=303)
518
578
  return templates.TemplateResponse(
519
579
  "actions.html",
520
580
  {
dtSpark/web/ssl_utils.py CHANGED
@@ -10,7 +10,7 @@ import os.path
10
10
  import socket
11
11
  import ipaddress
12
12
  from pathlib import Path
13
- from datetime import datetime, timedelta
13
+ from datetime import datetime, timedelta, timezone
14
14
  from typing import Tuple, Optional
15
15
 
16
16
  from dtPyAppFramework.paths import ApplicationPaths
@@ -72,8 +72,8 @@ def generate_self_signed_certificate(
72
72
  .issuer_name(issuer)
73
73
  .public_key(private_key.public_key())
74
74
  .serial_number(x509.random_serial_number())
75
- .not_valid_before(datetime.utcnow())
76
- .not_valid_after(datetime.utcnow() + timedelta(days=validity_days))
75
+ .not_valid_before(datetime.now(timezone.utc))
76
+ .not_valid_after(datetime.now(timezone.utc) + timedelta(days=validity_days))
77
77
  .add_extension(
78
78
  x509.SubjectAlternativeName([
79
79
  x509.DNSName(hostname),
@@ -94,7 +94,8 @@ body {
94
94
  }
95
95
 
96
96
  .chat-message .message-content pre {
97
- background-color: rgba(0, 0, 0, 0.5);
97
+ background-color: #0d1117;
98
+ border: 1px solid #30363d;
98
99
  padding: 1rem;
99
100
  border-radius: 0.5rem;
100
101
  overflow-x: auto;
@@ -195,16 +196,19 @@ body {
195
196
 
196
197
  /* Code blocks in markdown */
197
198
  .markdown-content pre {
198
- background-color: rgba(0, 0, 0, 0.5);
199
+ background-color: #0d1117;
200
+ border: 1px solid #30363d;
199
201
  padding: 1rem;
200
202
  border-radius: 0.5rem;
201
203
  overflow-x: auto;
204
+ margin: 1rem 0;
202
205
  }
203
206
 
204
207
  .markdown-content code {
205
- background-color: rgba(0, 0, 0, 0.3);
208
+ background-color: rgba(110, 118, 129, 0.3);
206
209
  padding: 0.2rem 0.4rem;
207
210
  border-radius: 0.25rem;
211
+ font-size: 0.875em;
208
212
  }
209
213
 
210
214
  /* Alerts */
@@ -324,7 +328,7 @@ body {
324
328
  border: 1px solid #f44336;
325
329
  border-radius: 0.5rem;
326
330
  padding: 1rem;
327
- color: #ff8a80;
331
+ color: #ffd7d3;
328
332
  }
329
333
 
330
334
  .mermaid-error .text-danger {
@@ -339,14 +343,6 @@ body {
339
343
  padding: 0;
340
344
  }
341
345
 
342
- .chat-message .message-content pre {
343
- background-color: #0d1117;
344
- border: 1px solid #30363d;
345
- border-radius: 0.5rem;
346
- padding: 1rem;
347
- overflow-x: auto;
348
- }
349
-
350
346
  .chat-message .message-content code:not(.hljs) {
351
347
  background-color: rgba(110, 118, 129, 0.3);
352
348
  padding: 0.2rem 0.4rem;
@@ -354,29 +350,12 @@ body {
354
350
  font-size: 0.875em;
355
351
  }
356
352
 
357
- /* Markdown content styling */
358
- .markdown-content pre {
359
- background-color: #0d1117;
360
- border: 1px solid #30363d;
361
- border-radius: 0.5rem;
362
- padding: 1rem;
363
- overflow-x: auto;
364
- margin: 1rem 0;
365
- }
366
-
367
353
  .markdown-content pre code {
368
354
  background-color: transparent;
369
355
  padding: 0;
370
356
  border-radius: 0;
371
357
  }
372
358
 
373
- .markdown-content code {
374
- background-color: rgba(110, 118, 129, 0.3);
375
- padding: 0.2rem 0.4rem;
376
- border-radius: 0.25rem;
377
- font-size: 0.875em;
378
- }
379
-
380
359
  .markdown-content blockquote {
381
360
  border-left: 4px solid var(--spark-cyan);
382
361
  padding-left: 1rem;