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.
- dtSpark/_version.txt +1 -1
- dtSpark/aws/authentication.py +1 -1
- dtSpark/aws/bedrock.py +238 -239
- dtSpark/aws/costs.py +9 -5
- dtSpark/aws/pricing.py +25 -21
- dtSpark/cli_interface.py +69 -62
- dtSpark/conversation_manager.py +54 -47
- dtSpark/core/application.py +151 -111
- dtSpark/core/context_compaction.py +241 -226
- dtSpark/daemon/__init__.py +36 -22
- dtSpark/daemon/action_monitor.py +46 -17
- dtSpark/daemon/daemon_app.py +126 -104
- dtSpark/daemon/daemon_manager.py +59 -23
- dtSpark/daemon/pid_file.py +3 -2
- dtSpark/database/autonomous_actions.py +3 -0
- dtSpark/database/credential_prompt.py +52 -54
- dtSpark/files/manager.py +6 -12
- dtSpark/limits/__init__.py +1 -1
- dtSpark/limits/tokens.py +2 -2
- dtSpark/llm/anthropic_direct.py +246 -141
- dtSpark/llm/ollama.py +3 -1
- dtSpark/mcp_integration/manager.py +4 -4
- dtSpark/mcp_integration/tool_selector.py +83 -77
- dtSpark/resources/config.yaml.template +10 -0
- dtSpark/safety/patterns.py +45 -46
- dtSpark/safety/prompt_inspector.py +8 -1
- dtSpark/scheduler/creation_tools.py +273 -181
- dtSpark/scheduler/executor.py +503 -221
- dtSpark/tools/builtin.py +70 -53
- dtSpark/web/endpoints/autonomous_actions.py +12 -9
- dtSpark/web/endpoints/chat.py +18 -6
- dtSpark/web/endpoints/conversations.py +57 -17
- dtSpark/web/endpoints/main_menu.py +132 -105
- dtSpark/web/endpoints/streaming.py +2 -2
- dtSpark/web/server.py +65 -5
- dtSpark/web/ssl_utils.py +3 -3
- dtSpark/web/static/css/dark-theme.css +8 -29
- dtSpark/web/static/js/actions.js +2 -1
- dtSpark/web/static/js/chat.js +6 -8
- dtSpark/web/static/js/main.js +8 -8
- dtSpark/web/static/js/sse-client.js +130 -122
- dtSpark/web/templates/actions.html +5 -5
- dtSpark/web/templates/base.html +13 -0
- dtSpark/web/templates/chat.html +52 -50
- dtSpark/web/templates/conversations.html +50 -22
- dtSpark/web/templates/goodbye.html +2 -2
- dtSpark/web/templates/main_menu.html +17 -17
- dtSpark/web/templates/new_conversation.html +51 -20
- dtSpark/web/web_interface.py +2 -2
- {dtspark-1.1.0a2.dist-info → dtspark-1.1.0a6.dist-info}/METADATA +9 -2
- dtspark-1.1.0a6.dist-info/RECORD +96 -0
- dtspark-1.1.0a2.dist-info/RECORD +0 -96
- {dtspark-1.1.0a2.dist-info → dtspark-1.1.0a6.dist-info}/WHEEL +0 -0
- {dtspark-1.1.0a2.dist-info → dtspark-1.1.0a6.dist-info}/entry_points.txt +0 -0
- {dtspark-1.1.0a2.dist-info → dtspark-1.1.0a6.dist-info}/licenses/LICENSE +0 -0
- {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(
|
|
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(
|
|
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
|
-
|
|
233
|
-
|
|
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(
|
|
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
|
-
) ->
|
|
338
|
+
) -> dict:
|
|
329
339
|
"""
|
|
330
|
-
Get available models.
|
|
340
|
+
Get available models and mandatory model configuration.
|
|
331
341
|
|
|
332
342
|
Returns:
|
|
333
|
-
|
|
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
|
-
|
|
352
|
+
all_models = app_instance.llm_manager.list_all_models()
|
|
340
353
|
|
|
341
|
-
|
|
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'),
|
|
359
|
+
"model_maker": model.get('model_maker'),
|
|
347
360
|
}
|
|
348
|
-
for model in
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
if
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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':
|
|
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
|
-
|
|
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.
|
|
76
|
-
.not_valid_after(datetime.
|
|
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:
|
|
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:
|
|
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(
|
|
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: #
|
|
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;
|