dtSpark 1.0.4__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/__init__.py +0 -0
- dtSpark/_description.txt +1 -0
- dtSpark/_full_name.txt +1 -0
- dtSpark/_licence.txt +21 -0
- dtSpark/_metadata.yaml +6 -0
- dtSpark/_name.txt +1 -0
- dtSpark/_version.txt +1 -0
- dtSpark/aws/__init__.py +7 -0
- dtSpark/aws/authentication.py +296 -0
- dtSpark/aws/bedrock.py +578 -0
- dtSpark/aws/costs.py +318 -0
- dtSpark/aws/pricing.py +580 -0
- dtSpark/cli_interface.py +2645 -0
- dtSpark/conversation_manager.py +3050 -0
- dtSpark/core/__init__.py +12 -0
- dtSpark/core/application.py +3355 -0
- dtSpark/core/context_compaction.py +735 -0
- dtSpark/daemon/__init__.py +104 -0
- dtSpark/daemon/__main__.py +10 -0
- dtSpark/daemon/action_monitor.py +213 -0
- dtSpark/daemon/daemon_app.py +730 -0
- dtSpark/daemon/daemon_manager.py +289 -0
- dtSpark/daemon/execution_coordinator.py +194 -0
- dtSpark/daemon/pid_file.py +169 -0
- dtSpark/database/__init__.py +482 -0
- dtSpark/database/autonomous_actions.py +1191 -0
- dtSpark/database/backends.py +329 -0
- dtSpark/database/connection.py +122 -0
- dtSpark/database/conversations.py +520 -0
- dtSpark/database/credential_prompt.py +218 -0
- dtSpark/database/files.py +205 -0
- dtSpark/database/mcp_ops.py +355 -0
- dtSpark/database/messages.py +161 -0
- dtSpark/database/schema.py +673 -0
- dtSpark/database/tool_permissions.py +186 -0
- dtSpark/database/usage.py +167 -0
- dtSpark/files/__init__.py +4 -0
- dtSpark/files/manager.py +322 -0
- dtSpark/launch.py +39 -0
- dtSpark/limits/__init__.py +10 -0
- dtSpark/limits/costs.py +296 -0
- dtSpark/limits/tokens.py +342 -0
- dtSpark/llm/__init__.py +17 -0
- dtSpark/llm/anthropic_direct.py +446 -0
- dtSpark/llm/base.py +146 -0
- dtSpark/llm/context_limits.py +438 -0
- dtSpark/llm/manager.py +177 -0
- dtSpark/llm/ollama.py +578 -0
- dtSpark/mcp_integration/__init__.py +5 -0
- dtSpark/mcp_integration/manager.py +653 -0
- dtSpark/mcp_integration/tool_selector.py +225 -0
- dtSpark/resources/config.yaml.template +631 -0
- dtSpark/safety/__init__.py +22 -0
- dtSpark/safety/llm_service.py +111 -0
- dtSpark/safety/patterns.py +229 -0
- dtSpark/safety/prompt_inspector.py +442 -0
- dtSpark/safety/violation_logger.py +346 -0
- dtSpark/scheduler/__init__.py +20 -0
- dtSpark/scheduler/creation_tools.py +599 -0
- dtSpark/scheduler/execution_queue.py +159 -0
- dtSpark/scheduler/executor.py +1152 -0
- dtSpark/scheduler/manager.py +395 -0
- dtSpark/tools/__init__.py +4 -0
- dtSpark/tools/builtin.py +833 -0
- dtSpark/web/__init__.py +20 -0
- dtSpark/web/auth.py +152 -0
- dtSpark/web/dependencies.py +37 -0
- dtSpark/web/endpoints/__init__.py +17 -0
- dtSpark/web/endpoints/autonomous_actions.py +1125 -0
- dtSpark/web/endpoints/chat.py +621 -0
- dtSpark/web/endpoints/conversations.py +353 -0
- dtSpark/web/endpoints/main_menu.py +547 -0
- dtSpark/web/endpoints/streaming.py +421 -0
- dtSpark/web/server.py +578 -0
- dtSpark/web/session.py +167 -0
- dtSpark/web/ssl_utils.py +195 -0
- dtSpark/web/static/css/dark-theme.css +427 -0
- dtSpark/web/static/js/actions.js +1101 -0
- dtSpark/web/static/js/chat.js +614 -0
- dtSpark/web/static/js/main.js +496 -0
- dtSpark/web/static/js/sse-client.js +242 -0
- dtSpark/web/templates/actions.html +408 -0
- dtSpark/web/templates/base.html +93 -0
- dtSpark/web/templates/chat.html +814 -0
- dtSpark/web/templates/conversations.html +350 -0
- dtSpark/web/templates/goodbye.html +81 -0
- dtSpark/web/templates/login.html +90 -0
- dtSpark/web/templates/main_menu.html +983 -0
- dtSpark/web/templates/new_conversation.html +191 -0
- dtSpark/web/web_interface.py +137 -0
- dtspark-1.0.4.dist-info/METADATA +187 -0
- dtspark-1.0.4.dist-info/RECORD +96 -0
- dtspark-1.0.4.dist-info/WHEEL +5 -0
- dtspark-1.0.4.dist-info/entry_points.txt +3 -0
- dtspark-1.0.4.dist-info/licenses/LICENSE +21 -0
- dtspark-1.0.4.dist-info/top_level.txt +1 -0
dtSpark/cli_interface.py
ADDED
|
@@ -0,0 +1,2645 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI interface module for user interaction using Rich terminal UI.
|
|
3
|
+
|
|
4
|
+
This module provides functionality for:
|
|
5
|
+
- Displaying menus and prompts with beautiful formatting
|
|
6
|
+
- Handling user input
|
|
7
|
+
- Progress tracking for initialisation
|
|
8
|
+
- Application splash screens
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from typing import List, Dict, Optional
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from rich.panel import Panel
|
|
16
|
+
from rich.table import Table
|
|
17
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TimeElapsedColumn
|
|
18
|
+
from rich.text import Text
|
|
19
|
+
from rich.prompt import Prompt, Confirm
|
|
20
|
+
from rich import box
|
|
21
|
+
from rich.align import Align
|
|
22
|
+
from rich.columns import Columns
|
|
23
|
+
from rich.markdown import Markdown
|
|
24
|
+
from rich.live import Live
|
|
25
|
+
from rich.spinner import Spinner
|
|
26
|
+
import time
|
|
27
|
+
import re
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def extract_friendly_model_name(model_id: str) -> str:
|
|
31
|
+
"""
|
|
32
|
+
Extract a human-friendly model name from a full model ID or ARN.
|
|
33
|
+
|
|
34
|
+
Examples:
|
|
35
|
+
- 'arn:aws:bedrock:...:inference-profile/au.anthropic.claude-sonnet-4-5-20250929-v1:0'
|
|
36
|
+
→ 'Claude Sonnet 4.5'
|
|
37
|
+
- 'anthropic.claude-3-5-sonnet-20241022-v2:0' → 'Claude 3.5 Sonnet'
|
|
38
|
+
- 'meta.llama3-1-70b-instruct-v1:0' → 'Llama 3.1 70B Instruct'
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
model_id: Full model ID, ARN, or inference profile
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
Human-readable model name
|
|
45
|
+
"""
|
|
46
|
+
if not model_id:
|
|
47
|
+
return "Unknown"
|
|
48
|
+
|
|
49
|
+
# Extract the core model identifier from ARN or full path
|
|
50
|
+
model_lower = model_id.lower()
|
|
51
|
+
|
|
52
|
+
# Handle inference profile ARNs
|
|
53
|
+
if 'inference-profile/' in model_lower:
|
|
54
|
+
# Extract after inference-profile/
|
|
55
|
+
match = re.search(r'inference-profile/([^:]+)', model_id, re.IGNORECASE)
|
|
56
|
+
if match:
|
|
57
|
+
model_lower = match.group(1).lower()
|
|
58
|
+
|
|
59
|
+
# Claude model patterns
|
|
60
|
+
claude_patterns = [
|
|
61
|
+
(r'claude-opus-4\.5|claude-opus-4-5', 'Claude Opus 4.5'),
|
|
62
|
+
(r'claude-sonnet-4\.5|claude-sonnet-4-5', 'Claude Sonnet 4.5'),
|
|
63
|
+
(r'claude-opus-4(?!\.)', 'Claude Opus 4'),
|
|
64
|
+
(r'claude-sonnet-4(?!\.)', 'Claude Sonnet 4'),
|
|
65
|
+
(r'claude-3-5-sonnet', 'Claude 3.5 Sonnet'),
|
|
66
|
+
(r'claude-3-5-haiku', 'Claude 3.5 Haiku'),
|
|
67
|
+
(r'claude-3-opus', 'Claude 3 Opus'),
|
|
68
|
+
(r'claude-3-sonnet', 'Claude 3 Sonnet'),
|
|
69
|
+
(r'claude-3-haiku', 'Claude 3 Haiku'),
|
|
70
|
+
(r'claude-2\.1', 'Claude 2.1'),
|
|
71
|
+
(r'claude-2', 'Claude 2'),
|
|
72
|
+
(r'claude-instant', 'Claude Instant'),
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
for pattern, name in claude_patterns:
|
|
76
|
+
if re.search(pattern, model_lower):
|
|
77
|
+
return name
|
|
78
|
+
|
|
79
|
+
# Llama patterns
|
|
80
|
+
llama_patterns = [
|
|
81
|
+
(r'llama3-1-(\d+)b', lambda m: f"Llama 3.1 {m.group(1)}B"),
|
|
82
|
+
(r'llama3\.2', 'Llama 3.2'),
|
|
83
|
+
(r'llama3', 'Llama 3'),
|
|
84
|
+
(r'llama2-(\d+)b', lambda m: f"Llama 2 {m.group(1)}B"),
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
for pattern, name in llama_patterns:
|
|
88
|
+
match = re.search(pattern, model_lower)
|
|
89
|
+
if match:
|
|
90
|
+
if callable(name):
|
|
91
|
+
return name(match)
|
|
92
|
+
return name
|
|
93
|
+
|
|
94
|
+
# Mistral patterns
|
|
95
|
+
if 'mistral-large' in model_lower:
|
|
96
|
+
return 'Mistral Large'
|
|
97
|
+
if 'mistral-small' in model_lower:
|
|
98
|
+
return 'Mistral Small'
|
|
99
|
+
if 'mistral' in model_lower:
|
|
100
|
+
return 'Mistral'
|
|
101
|
+
|
|
102
|
+
# Amazon Titan patterns
|
|
103
|
+
if 'titan-text-express' in model_lower:
|
|
104
|
+
return 'Amazon Titan Text Express'
|
|
105
|
+
if 'titan-text-lite' in model_lower:
|
|
106
|
+
return 'Amazon Titan Text Lite'
|
|
107
|
+
if 'titan' in model_lower:
|
|
108
|
+
return 'Amazon Titan'
|
|
109
|
+
|
|
110
|
+
# Cohere patterns
|
|
111
|
+
if 'cohere.command-r-plus' in model_lower:
|
|
112
|
+
return 'Cohere Command R+'
|
|
113
|
+
if 'cohere.command-r' in model_lower:
|
|
114
|
+
return 'Cohere Command R'
|
|
115
|
+
if 'cohere' in model_lower:
|
|
116
|
+
return 'Cohere'
|
|
117
|
+
|
|
118
|
+
# If no pattern matched, try to clean up the model ID
|
|
119
|
+
# Remove common prefixes and suffixes
|
|
120
|
+
cleaned = model_id
|
|
121
|
+
for prefix in ['arn:aws:bedrock:', 'anthropic.', 'meta.', 'amazon.', 'cohere.', 'mistral.', 'au.', 'us.', 'eu.']:
|
|
122
|
+
if cleaned.lower().startswith(prefix):
|
|
123
|
+
cleaned = cleaned[len(prefix):]
|
|
124
|
+
|
|
125
|
+
# Remove version suffixes like -v1:0, -20241022-v2:0
|
|
126
|
+
cleaned = re.sub(r'-\d{8}-v\d+:\d+$', '', cleaned)
|
|
127
|
+
cleaned = re.sub(r'-v\d+:\d+$', '', cleaned)
|
|
128
|
+
cleaned = re.sub(r':\d+$', '', cleaned)
|
|
129
|
+
|
|
130
|
+
# Title case and limit length
|
|
131
|
+
if len(cleaned) > 50:
|
|
132
|
+
cleaned = cleaned[:47] + '...'
|
|
133
|
+
|
|
134
|
+
return cleaned
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class StatusIndicator:
|
|
138
|
+
"""Context manager for displaying an animated status indicator with elapsed time."""
|
|
139
|
+
|
|
140
|
+
def __init__(self, console: Console, message: str, cli_interface=None):
|
|
141
|
+
"""
|
|
142
|
+
Initialise status indicator.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
console: Rich console instance
|
|
146
|
+
message: Status message to display
|
|
147
|
+
cli_interface: Optional CLIInterface to register with for pause/resume control
|
|
148
|
+
"""
|
|
149
|
+
self.console = console
|
|
150
|
+
self.message = message
|
|
151
|
+
self.start_time = None
|
|
152
|
+
self.live = None
|
|
153
|
+
self.cli_interface = cli_interface
|
|
154
|
+
self._is_paused = False
|
|
155
|
+
|
|
156
|
+
def __enter__(self):
|
|
157
|
+
"""Start the status indicator."""
|
|
158
|
+
self.start_time = time.time()
|
|
159
|
+
spinner = Spinner("dots", text=f"[cyan]{self.message}[/cyan]", style="cyan")
|
|
160
|
+
self.live = Live(spinner, console=self.console, refresh_per_second=10)
|
|
161
|
+
self.live.start()
|
|
162
|
+
# Register with CLI interface for pause/resume control
|
|
163
|
+
if self.cli_interface:
|
|
164
|
+
self.cli_interface._active_status_indicator = self
|
|
165
|
+
return self
|
|
166
|
+
|
|
167
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
168
|
+
"""Stop the status indicator and show elapsed time."""
|
|
169
|
+
if self.live:
|
|
170
|
+
self.live.stop()
|
|
171
|
+
|
|
172
|
+
# Unregister from CLI interface
|
|
173
|
+
if self.cli_interface:
|
|
174
|
+
self.cli_interface._active_status_indicator = None
|
|
175
|
+
|
|
176
|
+
if self.start_time and not self._is_paused:
|
|
177
|
+
elapsed = time.time() - self.start_time
|
|
178
|
+
self.console.print(f"[dim]✓ Completed in {elapsed:.1f}s[/dim]")
|
|
179
|
+
|
|
180
|
+
return False # Don't suppress exceptions
|
|
181
|
+
|
|
182
|
+
def pause(self):
|
|
183
|
+
"""
|
|
184
|
+
Temporarily pause the status indicator to allow user interaction.
|
|
185
|
+
Call resume() to continue the indicator.
|
|
186
|
+
"""
|
|
187
|
+
if self.live and not self._is_paused:
|
|
188
|
+
self.live.stop()
|
|
189
|
+
self._is_paused = True
|
|
190
|
+
|
|
191
|
+
def resume(self):
|
|
192
|
+
"""
|
|
193
|
+
Resume the status indicator after a pause.
|
|
194
|
+
"""
|
|
195
|
+
if self._is_paused and self.start_time:
|
|
196
|
+
elapsed = time.time() - self.start_time
|
|
197
|
+
spinner = Spinner("dots", text=f"[cyan]{self.message} ({elapsed:.0f}s)[/cyan]", style="cyan")
|
|
198
|
+
self.live = Live(spinner, console=self.console, refresh_per_second=10)
|
|
199
|
+
self.live.start()
|
|
200
|
+
self._is_paused = False
|
|
201
|
+
|
|
202
|
+
def update(self, message: str):
|
|
203
|
+
"""
|
|
204
|
+
Update the status message.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
message: New status message
|
|
208
|
+
"""
|
|
209
|
+
if self.live and self.start_time and not self._is_paused:
|
|
210
|
+
elapsed = time.time() - self.start_time
|
|
211
|
+
spinner = Spinner("dots", text=f"[cyan]{message} ({elapsed:.0f}s)[/cyan]", style="cyan")
|
|
212
|
+
self.live.update(spinner)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class CLIInterface:
|
|
216
|
+
"""Provides command-line interface functionality using Rich terminal UI."""
|
|
217
|
+
|
|
218
|
+
def __init__(self):
|
|
219
|
+
"""Initialise the CLI interface."""
|
|
220
|
+
self.console = Console()
|
|
221
|
+
self.running = True
|
|
222
|
+
self.model_changing_enabled = True # Can be disabled if model is locked via config
|
|
223
|
+
self.cost_tracking_enabled = False # Can be enabled via config
|
|
224
|
+
self._active_status_indicator = None # Track active status indicator for pause/resume
|
|
225
|
+
|
|
226
|
+
def print_splash_screen(self, full_name: str, description: str, version: str):
|
|
227
|
+
"""
|
|
228
|
+
Print application splash screen with SPARK branding.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
full_name: Application full name
|
|
232
|
+
description: Application description
|
|
233
|
+
version: Application version
|
|
234
|
+
"""
|
|
235
|
+
import os
|
|
236
|
+
from dtPyAppFramework.process import ProcessManager
|
|
237
|
+
|
|
238
|
+
# Get log path
|
|
239
|
+
log_path = ProcessManager().log_path
|
|
240
|
+
|
|
241
|
+
# Build splash content line by line
|
|
242
|
+
splash_content = Text()
|
|
243
|
+
splash_content.append("\n")
|
|
244
|
+
|
|
245
|
+
# Line 1: * . * and DIGITAL-THOUGHT
|
|
246
|
+
splash_content.append(" * . * ", style="bright_yellow")
|
|
247
|
+
splash_content.append(" DIGITAL-THOUGHT\n", style="bold bright_magenta")
|
|
248
|
+
|
|
249
|
+
# Line 2: . \|/ . and Secure Personal AI Research Kit
|
|
250
|
+
splash_content.append(" . \\|/ . ", style="yellow")
|
|
251
|
+
splash_content.append(" Secure Personal AI Research Kit\n", style="cyan")
|
|
252
|
+
|
|
253
|
+
# Line 3: S P A R K and Version
|
|
254
|
+
splash_content.append(" *-- ", style="bright_yellow")
|
|
255
|
+
splash_content.append("S P A R K", style="bold bright_cyan")
|
|
256
|
+
splash_content.append(" --* ", style="bright_yellow")
|
|
257
|
+
splash_content.append(f" Version {version}\n", style="green")
|
|
258
|
+
|
|
259
|
+
# Line 4: . /|\ .
|
|
260
|
+
splash_content.append(" . /|\\ . \n", style="yellow")
|
|
261
|
+
|
|
262
|
+
# Line 5: * . * and Process ID
|
|
263
|
+
splash_content.append(" * . * ", style="bright_yellow")
|
|
264
|
+
splash_content.append(f" Process ID: {os.getpid()}\n", style="cyan")
|
|
265
|
+
|
|
266
|
+
# Line 6: blank space and Log Path
|
|
267
|
+
splash_content.append(" ", style="")
|
|
268
|
+
splash_content.append(f"Log Path: {log_path}\n", style="dim")
|
|
269
|
+
|
|
270
|
+
splash_content.append("")
|
|
271
|
+
|
|
272
|
+
# Create panel
|
|
273
|
+
splash_panel = Panel(
|
|
274
|
+
splash_content,
|
|
275
|
+
border_style="bright_cyan",
|
|
276
|
+
box=box.HEAVY,
|
|
277
|
+
padding=(0, 2)
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
self.console.print()
|
|
281
|
+
self.console.print(splash_panel)
|
|
282
|
+
self.console.print()
|
|
283
|
+
|
|
284
|
+
def print_banner(self):
|
|
285
|
+
"""Print the application banner."""
|
|
286
|
+
# This is now replaced by print_splash_screen
|
|
287
|
+
pass
|
|
288
|
+
|
|
289
|
+
def create_progress(self, description: str = "Initialising...") -> Progress:
|
|
290
|
+
"""
|
|
291
|
+
Create a progress bar for tracking operations.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
description: Description of the operation
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
Progress instance
|
|
298
|
+
"""
|
|
299
|
+
return Progress(
|
|
300
|
+
SpinnerColumn(),
|
|
301
|
+
TextColumn("[bold blue]{task.description}"),
|
|
302
|
+
BarColumn(),
|
|
303
|
+
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
|
304
|
+
TimeElapsedColumn(),
|
|
305
|
+
console=self.console
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
def status_indicator(self, message: str):
|
|
309
|
+
"""
|
|
310
|
+
Create a status indicator with spinner and elapsed time.
|
|
311
|
+
Use as a context manager.
|
|
312
|
+
|
|
313
|
+
The status indicator registers with the CLI interface so it can be
|
|
314
|
+
paused/resumed when user input is needed (e.g., tool permission prompts).
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
message: Status message to display
|
|
318
|
+
|
|
319
|
+
Returns:
|
|
320
|
+
StatusIndicator context manager
|
|
321
|
+
|
|
322
|
+
Example:
|
|
323
|
+
with cli.status_indicator("Processing request..."):
|
|
324
|
+
# Do work here
|
|
325
|
+
pass
|
|
326
|
+
"""
|
|
327
|
+
return StatusIndicator(self.console, message, cli_interface=self)
|
|
328
|
+
|
|
329
|
+
def pause_status_indicator(self):
|
|
330
|
+
"""
|
|
331
|
+
Pause the active status indicator to allow user interaction.
|
|
332
|
+
Call resume_status_indicator() after the interaction is complete.
|
|
333
|
+
"""
|
|
334
|
+
if self._active_status_indicator:
|
|
335
|
+
self._active_status_indicator.pause()
|
|
336
|
+
|
|
337
|
+
def resume_status_indicator(self):
|
|
338
|
+
"""
|
|
339
|
+
Resume the active status indicator after user interaction.
|
|
340
|
+
"""
|
|
341
|
+
if self._active_status_indicator:
|
|
342
|
+
self._active_status_indicator.resume()
|
|
343
|
+
|
|
344
|
+
def print_separator(self, char: str = "─", length: int = 70):
|
|
345
|
+
"""
|
|
346
|
+
Print a separator line.
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
char: Character to use for the separator
|
|
350
|
+
length: Length of the separator line
|
|
351
|
+
"""
|
|
352
|
+
self.console.print(char * length, style="dim")
|
|
353
|
+
|
|
354
|
+
def display_main_menu(self) -> str:
|
|
355
|
+
"""
|
|
356
|
+
Display the main menu and get user's choice.
|
|
357
|
+
|
|
358
|
+
Returns:
|
|
359
|
+
User's menu choice: 'costs', 'new', 'list', or 'quit'
|
|
360
|
+
"""
|
|
361
|
+
# Create menu content
|
|
362
|
+
menu_content = Text()
|
|
363
|
+
option_num = 1
|
|
364
|
+
choice_map = {}
|
|
365
|
+
|
|
366
|
+
# Conditionally show cost tracking option
|
|
367
|
+
if self.cost_tracking_enabled:
|
|
368
|
+
menu_content.append(" ", style="")
|
|
369
|
+
menu_content.append(str(option_num), style="cyan")
|
|
370
|
+
menu_content.append(". Re-gather AWS Bedrock Costs\n", style="")
|
|
371
|
+
choice_map[str(option_num)] = 'costs'
|
|
372
|
+
option_num += 1
|
|
373
|
+
|
|
374
|
+
# Start New Conversation
|
|
375
|
+
menu_content.append(" ", style="")
|
|
376
|
+
menu_content.append(str(option_num), style="cyan")
|
|
377
|
+
menu_content.append(". Start New Conversation\n", style="")
|
|
378
|
+
choice_map[str(option_num)] = 'new'
|
|
379
|
+
option_num += 1
|
|
380
|
+
|
|
381
|
+
# List and Select Conversation
|
|
382
|
+
menu_content.append(" ", style="")
|
|
383
|
+
menu_content.append(str(option_num), style="cyan")
|
|
384
|
+
menu_content.append(". List and Select Conversation\n", style="")
|
|
385
|
+
choice_map[str(option_num)] = 'list'
|
|
386
|
+
option_num += 1
|
|
387
|
+
|
|
388
|
+
# Manage Autonomous Actions
|
|
389
|
+
menu_content.append(" ", style="")
|
|
390
|
+
menu_content.append(str(option_num), style="cyan")
|
|
391
|
+
menu_content.append(". Manage Autonomous Actions\n", style="")
|
|
392
|
+
choice_map[str(option_num)] = 'autonomous'
|
|
393
|
+
option_num += 1
|
|
394
|
+
|
|
395
|
+
# Quit
|
|
396
|
+
menu_content.append(" ", style="")
|
|
397
|
+
menu_content.append(str(option_num), style="cyan")
|
|
398
|
+
menu_content.append(". Quit", style="")
|
|
399
|
+
choice_map[str(option_num)] = 'quit'
|
|
400
|
+
|
|
401
|
+
# Create panel with HEAVY borders
|
|
402
|
+
menu_panel = Panel(
|
|
403
|
+
menu_content,
|
|
404
|
+
title="[bold bright_magenta]MAIN MENU[/bold bright_magenta]",
|
|
405
|
+
border_style="bold cyan",
|
|
406
|
+
box=box.HEAVY,
|
|
407
|
+
padding=(0, 1)
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
self.console.print()
|
|
411
|
+
self.console.print(menu_panel)
|
|
412
|
+
self.console.print()
|
|
413
|
+
|
|
414
|
+
# Get user input
|
|
415
|
+
choice = self.get_input("Select an option")
|
|
416
|
+
|
|
417
|
+
return choice_map.get(choice, 'invalid')
|
|
418
|
+
|
|
419
|
+
def print_budget_warning(self, message: str, level: str = "75"):
|
|
420
|
+
"""
|
|
421
|
+
Print a budget warning with appropriate colour based on level.
|
|
422
|
+
|
|
423
|
+
Args:
|
|
424
|
+
message: Warning message to display
|
|
425
|
+
level: Warning level ('75', '85', '95')
|
|
426
|
+
"""
|
|
427
|
+
if level == "75":
|
|
428
|
+
# Yellow/amber warning
|
|
429
|
+
self.console.print(f"⚠️ [yellow]{message}[/yellow]")
|
|
430
|
+
elif level == "85":
|
|
431
|
+
# Orange warning
|
|
432
|
+
self.console.print(f"⚠️ [bold yellow]{message}[/bold yellow]")
|
|
433
|
+
elif level == "95":
|
|
434
|
+
# Red warning
|
|
435
|
+
self.console.print(f"🚨 [bold red]{message}[/bold red]")
|
|
436
|
+
else:
|
|
437
|
+
self.console.print(f"⚠️ {message}")
|
|
438
|
+
|
|
439
|
+
def prompt_budget_override(self) -> tuple[bool, float]:
|
|
440
|
+
"""
|
|
441
|
+
Prompt user for budget override when limit is reached.
|
|
442
|
+
|
|
443
|
+
Returns:
|
|
444
|
+
Tuple of (override_accepted, additional_percentage)
|
|
445
|
+
"""
|
|
446
|
+
self.console.print("\n[bold red]❌ Budget Limit Reached[/bold red]")
|
|
447
|
+
self.console.print("[yellow]Would you like to override the budget limit?[/yellow]")
|
|
448
|
+
|
|
449
|
+
override = self.confirm("Allow budget override?")
|
|
450
|
+
if not override:
|
|
451
|
+
return False, 0.0
|
|
452
|
+
|
|
453
|
+
# Get additional percentage
|
|
454
|
+
while True:
|
|
455
|
+
try:
|
|
456
|
+
percentage_input = input("Enter additional percentage to allow (e.g., 10 for 10% more): ").strip()
|
|
457
|
+
percentage = float(percentage_input)
|
|
458
|
+
|
|
459
|
+
if percentage <= 0:
|
|
460
|
+
self.console.print("[red]Please enter a positive number[/red]")
|
|
461
|
+
continue
|
|
462
|
+
|
|
463
|
+
if percentage > 500: # Sanity check
|
|
464
|
+
self.console.print("[red]Maximum override is 500%[/red]")
|
|
465
|
+
continue
|
|
466
|
+
|
|
467
|
+
return True, percentage
|
|
468
|
+
|
|
469
|
+
except ValueError:
|
|
470
|
+
self.console.print("[red]Please enter a valid number[/red]")
|
|
471
|
+
except KeyboardInterrupt:
|
|
472
|
+
self.console.print("\n[yellow]Override cancelled[/yellow]")
|
|
473
|
+
return False, 0.0
|
|
474
|
+
|
|
475
|
+
def print_error(self, message: str):
|
|
476
|
+
"""
|
|
477
|
+
Print an error message.
|
|
478
|
+
|
|
479
|
+
Args:
|
|
480
|
+
message: Error message to display
|
|
481
|
+
"""
|
|
482
|
+
self.console.print(f"\n[bold red]✗[/bold red] [red]{message}[/red]\n")
|
|
483
|
+
|
|
484
|
+
def print_success(self, message: str):
|
|
485
|
+
"""
|
|
486
|
+
Print a success message.
|
|
487
|
+
|
|
488
|
+
Args:
|
|
489
|
+
message: Success message to display
|
|
490
|
+
"""
|
|
491
|
+
self.console.print(f"\n[bold green]✓[/bold green] [green]{message}[/green]\n")
|
|
492
|
+
|
|
493
|
+
def print_info(self, message: str):
|
|
494
|
+
"""
|
|
495
|
+
Print an informational message.
|
|
496
|
+
|
|
497
|
+
Args:
|
|
498
|
+
message: Info message to display
|
|
499
|
+
"""
|
|
500
|
+
self.console.print(f"\n[bold cyan]ℹ[/bold cyan] [cyan]{message}[/cyan]\n")
|
|
501
|
+
|
|
502
|
+
def print_warning(self, message: str):
|
|
503
|
+
"""
|
|
504
|
+
Print a warning message.
|
|
505
|
+
|
|
506
|
+
Args:
|
|
507
|
+
message: Warning message to display
|
|
508
|
+
"""
|
|
509
|
+
self.console.print(f"\n[bold yellow]⚠[/bold yellow] [yellow]{message}[/yellow]\n")
|
|
510
|
+
|
|
511
|
+
def get_input(self, prompt: str) -> str:
|
|
512
|
+
"""
|
|
513
|
+
Get user input with a prompt.
|
|
514
|
+
|
|
515
|
+
Args:
|
|
516
|
+
prompt: Prompt to display
|
|
517
|
+
|
|
518
|
+
Returns:
|
|
519
|
+
User input string
|
|
520
|
+
"""
|
|
521
|
+
return Prompt.ask(f"[bold cyan]{prompt}[/bold cyan]").strip()
|
|
522
|
+
|
|
523
|
+
def get_multiline_input(self, prompt: str) -> str:
|
|
524
|
+
"""
|
|
525
|
+
Get multiline user input (ends with double Enter).
|
|
526
|
+
|
|
527
|
+
Args:
|
|
528
|
+
prompt: Prompt to display
|
|
529
|
+
|
|
530
|
+
Returns:
|
|
531
|
+
Multiline user input string
|
|
532
|
+
"""
|
|
533
|
+
self.console.print(f"\n[bold cyan]{prompt}[/bold cyan]")
|
|
534
|
+
self.console.print("[dim](Press Enter twice to finish)[/dim]\n")
|
|
535
|
+
|
|
536
|
+
lines = []
|
|
537
|
+
empty_line_count = 0
|
|
538
|
+
|
|
539
|
+
while True:
|
|
540
|
+
line = input()
|
|
541
|
+
if line == "":
|
|
542
|
+
empty_line_count += 1
|
|
543
|
+
if empty_line_count >= 2:
|
|
544
|
+
break
|
|
545
|
+
lines.append(line)
|
|
546
|
+
else:
|
|
547
|
+
empty_line_count = 0
|
|
548
|
+
lines.append(line)
|
|
549
|
+
|
|
550
|
+
return '\n'.join(lines).strip()
|
|
551
|
+
|
|
552
|
+
def display_menu(self, title: str, options: List[str]) -> int:
|
|
553
|
+
"""
|
|
554
|
+
Display a menu and get user selection.
|
|
555
|
+
|
|
556
|
+
Args:
|
|
557
|
+
title: Menu title
|
|
558
|
+
options: List of menu options
|
|
559
|
+
|
|
560
|
+
Returns:
|
|
561
|
+
Selected option index (0-based) or -1 for invalid selection
|
|
562
|
+
"""
|
|
563
|
+
# Create table
|
|
564
|
+
table = Table(show_header=False, box=box.ROUNDED, border_style="cyan")
|
|
565
|
+
table.add_column("No.", style="bold yellow", width=4)
|
|
566
|
+
table.add_column("Option", style="white")
|
|
567
|
+
|
|
568
|
+
for i, option in enumerate(options, 1):
|
|
569
|
+
table.add_row(str(i), option)
|
|
570
|
+
|
|
571
|
+
table.add_row(str(len(options) + 1), "[red]Exit[/red]")
|
|
572
|
+
|
|
573
|
+
# Display in panel
|
|
574
|
+
panel = Panel(
|
|
575
|
+
table,
|
|
576
|
+
title=f"[bold cyan]{title}[/bold cyan]",
|
|
577
|
+
border_style="cyan"
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
self.console.print()
|
|
581
|
+
self.console.print(panel)
|
|
582
|
+
|
|
583
|
+
try:
|
|
584
|
+
choice = int(Prompt.ask("[bold]Select an option[/bold]"))
|
|
585
|
+
if 1 <= choice <= len(options):
|
|
586
|
+
return choice - 1
|
|
587
|
+
elif choice == len(options) + 1:
|
|
588
|
+
return -1
|
|
589
|
+
else:
|
|
590
|
+
self.print_error("Invalid selection")
|
|
591
|
+
return -1
|
|
592
|
+
except ValueError:
|
|
593
|
+
self.print_error("Please enter a valid number")
|
|
594
|
+
return -1
|
|
595
|
+
|
|
596
|
+
def prompt_tool_permission(self, tool_name: str, tool_description: str = None) -> Optional[str]:
|
|
597
|
+
"""
|
|
598
|
+
Prompt user for permission to use a tool.
|
|
599
|
+
|
|
600
|
+
Args:
|
|
601
|
+
tool_name: Name of the tool
|
|
602
|
+
tool_description: Optional description of the tool
|
|
603
|
+
|
|
604
|
+
Returns:
|
|
605
|
+
'allowed' if user grants permission for all future uses
|
|
606
|
+
'denied' if user denies this and all future uses
|
|
607
|
+
'once' if user grants permission for this time only
|
|
608
|
+
None if user cancelled
|
|
609
|
+
"""
|
|
610
|
+
# Pause any active status indicator to prevent visual interference
|
|
611
|
+
self.pause_status_indicator()
|
|
612
|
+
|
|
613
|
+
self.console.print()
|
|
614
|
+
self.print_separator("─")
|
|
615
|
+
self.console.print(f"\n[bold yellow]🔐 Tool Permission Request[/bold yellow]")
|
|
616
|
+
self.console.print(f"\nThe assistant wants to use the tool: [bold cyan]{tool_name}[/bold cyan]")
|
|
617
|
+
|
|
618
|
+
if tool_description:
|
|
619
|
+
self.console.print(f"\n[dim]{tool_description}[/dim]")
|
|
620
|
+
|
|
621
|
+
self.console.print("\n[bold]Please choose an option:[/bold]")
|
|
622
|
+
self.console.print(" [bold green]1.[/bold green] Allow once - Run this time only")
|
|
623
|
+
self.console.print(" [bold green]2.[/bold green] Allow always - Run this time and all future times")
|
|
624
|
+
self.console.print(" [bold red]3.[/bold red] Deny - Don't run this time or in the future")
|
|
625
|
+
self.console.print(" [bold yellow]4.[/bold yellow] Cancel")
|
|
626
|
+
|
|
627
|
+
try:
|
|
628
|
+
choice = Prompt.ask("\n[bold]Your choice[/bold]", choices=["1", "2", "3", "4"], default="1")
|
|
629
|
+
|
|
630
|
+
if choice == "1":
|
|
631
|
+
self.console.print("\n[bold green]✓[/bold green] Tool will run this time only")
|
|
632
|
+
return 'once'
|
|
633
|
+
elif choice == "2":
|
|
634
|
+
self.console.print("\n[bold green]✓[/bold green] Tool permission granted for all future uses")
|
|
635
|
+
return 'allowed'
|
|
636
|
+
elif choice == "3":
|
|
637
|
+
self.console.print("\n[bold red]✗[/bold red] Tool denied")
|
|
638
|
+
return 'denied'
|
|
639
|
+
else: # "4" or invalid
|
|
640
|
+
self.console.print("\n[yellow]Cancelled[/yellow]")
|
|
641
|
+
return None
|
|
642
|
+
|
|
643
|
+
except (KeyboardInterrupt, EOFError):
|
|
644
|
+
self.console.print("\n[yellow]Cancelled[/yellow]")
|
|
645
|
+
return None
|
|
646
|
+
finally:
|
|
647
|
+
self.print_separator("─")
|
|
648
|
+
# Resume status indicator after user interaction
|
|
649
|
+
self.resume_status_indicator()
|
|
650
|
+
|
|
651
|
+
def display_models(self, models: List[Dict]) -> Optional[str]:
|
|
652
|
+
"""
|
|
653
|
+
Display available models and get user selection.
|
|
654
|
+
|
|
655
|
+
Args:
|
|
656
|
+
models: List of model dictionaries
|
|
657
|
+
|
|
658
|
+
Returns:
|
|
659
|
+
Selected model ID or None
|
|
660
|
+
"""
|
|
661
|
+
if not models:
|
|
662
|
+
self.print_error("No models available")
|
|
663
|
+
return None
|
|
664
|
+
|
|
665
|
+
# Create table
|
|
666
|
+
table = Table(
|
|
667
|
+
show_header=True,
|
|
668
|
+
header_style="bold magenta",
|
|
669
|
+
box=box.ROUNDED,
|
|
670
|
+
border_style="cyan"
|
|
671
|
+
)
|
|
672
|
+
table.add_column("No.", style="bold yellow", width=4)
|
|
673
|
+
table.add_column("Model Name", style="cyan")
|
|
674
|
+
table.add_column("Provider", style="green")
|
|
675
|
+
table.add_column("Access Method", style="magenta")
|
|
676
|
+
table.add_column("Streaming", style="blue", justify="center")
|
|
677
|
+
|
|
678
|
+
for i, model in enumerate(models, 1):
|
|
679
|
+
streaming = "✓" if model.get('response_streaming') else "✗"
|
|
680
|
+
access_info = model.get('access_info', 'Unknown')
|
|
681
|
+
table.add_row(
|
|
682
|
+
str(i),
|
|
683
|
+
model['name'],
|
|
684
|
+
model['provider'],
|
|
685
|
+
access_info,
|
|
686
|
+
streaming
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
# Add quit option
|
|
690
|
+
table.add_row(
|
|
691
|
+
"Q",
|
|
692
|
+
"[red]Quit[/red]",
|
|
693
|
+
"",
|
|
694
|
+
"",
|
|
695
|
+
""
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
# Display in panel
|
|
699
|
+
panel = Panel(
|
|
700
|
+
table,
|
|
701
|
+
title="[bold magenta]📋 Available LLM Models[/bold magenta]",
|
|
702
|
+
border_style="magenta"
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
self.console.print()
|
|
706
|
+
self.console.print(panel)
|
|
707
|
+
|
|
708
|
+
try:
|
|
709
|
+
choice_str = Prompt.ask("\n[bold]Select a model (or Q to quit)[/bold]")
|
|
710
|
+
|
|
711
|
+
# Check for quit
|
|
712
|
+
if choice_str.upper() == 'Q':
|
|
713
|
+
return 'QUIT'
|
|
714
|
+
|
|
715
|
+
choice = int(choice_str)
|
|
716
|
+
if 1 <= choice <= len(models):
|
|
717
|
+
return models[choice - 1]['id']
|
|
718
|
+
else:
|
|
719
|
+
self.print_error("Invalid selection")
|
|
720
|
+
return None
|
|
721
|
+
except ValueError:
|
|
722
|
+
self.print_error("Please enter a valid number or Q to quit")
|
|
723
|
+
return None
|
|
724
|
+
|
|
725
|
+
def display_conversations(self, conversations: List[Dict]) -> Optional[int]:
|
|
726
|
+
"""
|
|
727
|
+
Display existing conversations and get user selection.
|
|
728
|
+
|
|
729
|
+
Args:
|
|
730
|
+
conversations: List of conversation dictionaries
|
|
731
|
+
|
|
732
|
+
Returns:
|
|
733
|
+
Selected conversation ID or None
|
|
734
|
+
"""
|
|
735
|
+
if not conversations:
|
|
736
|
+
self.print_info("No existing conversations found")
|
|
737
|
+
return None
|
|
738
|
+
|
|
739
|
+
# Create table
|
|
740
|
+
table = Table(
|
|
741
|
+
show_header=True,
|
|
742
|
+
header_style="bold magenta",
|
|
743
|
+
box=box.ROUNDED,
|
|
744
|
+
border_style="cyan"
|
|
745
|
+
)
|
|
746
|
+
table.add_column("No.", style="bold yellow", width=4)
|
|
747
|
+
table.add_column("Name", style="cyan")
|
|
748
|
+
table.add_column("Model", style="green")
|
|
749
|
+
table.add_column("Created", style="blue")
|
|
750
|
+
table.add_column("Tokens", style="magenta", justify="right")
|
|
751
|
+
|
|
752
|
+
for i, conv in enumerate(conversations, 1):
|
|
753
|
+
created = datetime.fromisoformat(conv['created_at'])
|
|
754
|
+
table.add_row(
|
|
755
|
+
str(i),
|
|
756
|
+
conv['name'],
|
|
757
|
+
conv['model_id'][:40] + "..." if len(conv['model_id']) > 40 else conv['model_id'],
|
|
758
|
+
created.strftime('%Y-%m-%d %H:%M'),
|
|
759
|
+
str(conv['total_tokens'])
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
table.add_row(
|
|
763
|
+
"[bold green]N[/bold green]",
|
|
764
|
+
"[bold green]Start New Conversation[/bold green]",
|
|
765
|
+
"-",
|
|
766
|
+
"-",
|
|
767
|
+
"-"
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
# Display in panel
|
|
771
|
+
panel = Panel(
|
|
772
|
+
table,
|
|
773
|
+
title="[bold magenta]💬 Conversations[/bold magenta]",
|
|
774
|
+
border_style="magenta"
|
|
775
|
+
)
|
|
776
|
+
|
|
777
|
+
self.console.print()
|
|
778
|
+
self.console.print(panel)
|
|
779
|
+
|
|
780
|
+
choice_str = Prompt.ask("\n[bold]Select an option[/bold]")
|
|
781
|
+
|
|
782
|
+
# Check for "N" or "n" for new conversation
|
|
783
|
+
if choice_str.lower() == 'n':
|
|
784
|
+
return None
|
|
785
|
+
|
|
786
|
+
# Try to parse as number
|
|
787
|
+
try:
|
|
788
|
+
choice = int(choice_str)
|
|
789
|
+
if 1 <= choice <= len(conversations):
|
|
790
|
+
return conversations[choice - 1]['id']
|
|
791
|
+
else:
|
|
792
|
+
self.print_error("Invalid selection")
|
|
793
|
+
return None
|
|
794
|
+
except ValueError:
|
|
795
|
+
self.print_error("Please enter a valid number or 'N' for new conversation")
|
|
796
|
+
return None
|
|
797
|
+
|
|
798
|
+
def display_message(self, role: str, content: str, timestamp: Optional[datetime] = None):
|
|
799
|
+
"""
|
|
800
|
+
Display a chat message with markdown rendering for assistant responses.
|
|
801
|
+
|
|
802
|
+
Args:
|
|
803
|
+
role: Message role (user, assistant, system)
|
|
804
|
+
content: Message content
|
|
805
|
+
timestamp: Optional timestamp
|
|
806
|
+
"""
|
|
807
|
+
role_config = {
|
|
808
|
+
'user': {'emoji': '👤', 'style': 'bold cyan', 'border': 'cyan'},
|
|
809
|
+
'assistant': {'emoji': '🤖', 'style': 'bold green', 'border': 'green'},
|
|
810
|
+
'system': {'emoji': 'ℹ️', 'style': 'bold yellow', 'border': 'yellow'}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
config = role_config.get(role.lower(), {'emoji': '💬', 'style': 'white', 'border': 'white'})
|
|
814
|
+
|
|
815
|
+
# Format title
|
|
816
|
+
title = f"[{config['style']}]{config['emoji']} {role.capitalize()}[/{config['style']}]"
|
|
817
|
+
if timestamp:
|
|
818
|
+
time_str = timestamp.strftime('%H:%M:%S')
|
|
819
|
+
title += f" [dim]{time_str}[/dim]"
|
|
820
|
+
|
|
821
|
+
# Render assistant messages as markdown for better formatting
|
|
822
|
+
if role.lower() == 'assistant':
|
|
823
|
+
rendered_content = Markdown(content)
|
|
824
|
+
else:
|
|
825
|
+
rendered_content = content
|
|
826
|
+
|
|
827
|
+
# Create panel
|
|
828
|
+
panel = Panel(
|
|
829
|
+
rendered_content,
|
|
830
|
+
title=title,
|
|
831
|
+
title_align="left",
|
|
832
|
+
border_style=config['border'],
|
|
833
|
+
box=box.ROUNDED,
|
|
834
|
+
padding=(1, 2)
|
|
835
|
+
)
|
|
836
|
+
|
|
837
|
+
self.console.print()
|
|
838
|
+
self.console.print(panel)
|
|
839
|
+
|
|
840
|
+
def display_conversation_history(self, messages: List[Dict]):
|
|
841
|
+
"""
|
|
842
|
+
Display conversation history.
|
|
843
|
+
|
|
844
|
+
Args:
|
|
845
|
+
messages: List of message dictionaries
|
|
846
|
+
"""
|
|
847
|
+
self.console.print()
|
|
848
|
+
self.console.print(Panel(
|
|
849
|
+
"[bold]Conversation History[/bold]",
|
|
850
|
+
style="bold magenta",
|
|
851
|
+
box=box.DOUBLE
|
|
852
|
+
))
|
|
853
|
+
|
|
854
|
+
for msg in messages:
|
|
855
|
+
timestamp = datetime.fromisoformat(msg['timestamp'])
|
|
856
|
+
self.display_message(msg['role'], msg['content'], timestamp)
|
|
857
|
+
|
|
858
|
+
self.console.print()
|
|
859
|
+
self.print_separator("═")
|
|
860
|
+
|
|
861
|
+
def display_conversation_info(self, conversation: Dict, token_count: int, max_tokens: int,
|
|
862
|
+
attached_files: Optional[List[Dict]] = None,
|
|
863
|
+
model_usage: Optional[List[Dict]] = None,
|
|
864
|
+
detailed: bool = False,
|
|
865
|
+
access_method: Optional[str] = None):
|
|
866
|
+
"""
|
|
867
|
+
Display current conversation information.
|
|
868
|
+
|
|
869
|
+
Args:
|
|
870
|
+
conversation: Conversation dictionary
|
|
871
|
+
token_count: Current token count
|
|
872
|
+
max_tokens: Maximum token limit
|
|
873
|
+
attached_files: Optional list of attached files
|
|
874
|
+
model_usage: Optional list of per-model usage breakdowns
|
|
875
|
+
detailed: If True, show full details including full instructions
|
|
876
|
+
access_method: Optional access method description (e.g., 'AWS Bedrock', 'Ollama (http://...)')
|
|
877
|
+
"""
|
|
878
|
+
token_percentage = (token_count / max_tokens) * 100
|
|
879
|
+
|
|
880
|
+
# Create info table
|
|
881
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
882
|
+
table.add_column("Label", style="bold cyan")
|
|
883
|
+
table.add_column("Value", style="white")
|
|
884
|
+
|
|
885
|
+
table.add_row("Conversation", conversation['name'])
|
|
886
|
+
|
|
887
|
+
# Display friendly model name with full ID in detailed view
|
|
888
|
+
model_id = conversation['model_id']
|
|
889
|
+
friendly_name = extract_friendly_model_name(model_id)
|
|
890
|
+
if detailed:
|
|
891
|
+
# Show both friendly name and full model ID
|
|
892
|
+
table.add_row("Current Model", f"{friendly_name}")
|
|
893
|
+
table.add_row("Model ID", f"[dim]{model_id}[/dim]")
|
|
894
|
+
else:
|
|
895
|
+
table.add_row("Current Model", friendly_name)
|
|
896
|
+
|
|
897
|
+
# Add access method if provided
|
|
898
|
+
if access_method:
|
|
899
|
+
table.add_row("Access Method", access_method)
|
|
900
|
+
|
|
901
|
+
# Add instructions indicator
|
|
902
|
+
if conversation.get('instructions'):
|
|
903
|
+
if detailed:
|
|
904
|
+
# In detailed view, show YES and we'll display full instructions below
|
|
905
|
+
table.add_row("Instructions", "[green]YES[/green]")
|
|
906
|
+
else:
|
|
907
|
+
# In regular view, just show YES
|
|
908
|
+
table.add_row("Instructions", "[green]YES[/green]")
|
|
909
|
+
else:
|
|
910
|
+
table.add_row("Instructions", "[dim]NO[/dim]")
|
|
911
|
+
|
|
912
|
+
table.add_row("Tokens", f"{token_count:,} / {max_tokens:,} ({token_percentage:.1f}%)")
|
|
913
|
+
|
|
914
|
+
# Add API token usage
|
|
915
|
+
tokens_sent = conversation.get('tokens_sent', 0)
|
|
916
|
+
tokens_received = conversation.get('tokens_received', 0)
|
|
917
|
+
total_api_tokens = tokens_sent + tokens_received
|
|
918
|
+
table.add_row("Total API Usage", f"↑ {tokens_sent:,} sent | ↓ {tokens_received:,} received | Σ {total_api_tokens:,}")
|
|
919
|
+
|
|
920
|
+
# Add attached files count
|
|
921
|
+
if attached_files:
|
|
922
|
+
total_file_tokens = sum(f.get('token_count', 0) for f in attached_files)
|
|
923
|
+
table.add_row("Files", f"{len(attached_files)} attached ({total_file_tokens:,} tokens)")
|
|
924
|
+
|
|
925
|
+
# Colour coding based on usage
|
|
926
|
+
if token_percentage < 60:
|
|
927
|
+
status = "[green]🟢 Good[/green]"
|
|
928
|
+
bar_style = "green"
|
|
929
|
+
elif token_percentage < 80:
|
|
930
|
+
status = "[yellow]🟡 Moderate[/yellow]"
|
|
931
|
+
bar_style = "yellow"
|
|
932
|
+
else:
|
|
933
|
+
status = "[red]🔴 High[/red]"
|
|
934
|
+
bar_style = "red"
|
|
935
|
+
|
|
936
|
+
# Visual token usage bar
|
|
937
|
+
bar_length = 40
|
|
938
|
+
filled_length = int(bar_length * token_count // max_tokens)
|
|
939
|
+
bar = "█" * filled_length + "░" * (bar_length - filled_length)
|
|
940
|
+
|
|
941
|
+
table.add_row("Usage", f"[{bar_style}]{bar}[/{bar_style}] {status}")
|
|
942
|
+
|
|
943
|
+
panel = Panel(
|
|
944
|
+
table,
|
|
945
|
+
title="[bold cyan]📊 Conversation Status[/bold cyan]",
|
|
946
|
+
border_style="cyan",
|
|
947
|
+
box=box.ROUNDED
|
|
948
|
+
)
|
|
949
|
+
|
|
950
|
+
self.console.print()
|
|
951
|
+
self.console.print(panel)
|
|
952
|
+
|
|
953
|
+
# Display full instructions if in detailed mode and instructions exist
|
|
954
|
+
if detailed and conversation.get('instructions'):
|
|
955
|
+
self.console.print()
|
|
956
|
+
instructions_panel = Panel(
|
|
957
|
+
conversation['instructions'],
|
|
958
|
+
title="[bold cyan]📝 Conversation Instructions[/bold cyan]",
|
|
959
|
+
border_style="cyan",
|
|
960
|
+
box=box.ROUNDED
|
|
961
|
+
)
|
|
962
|
+
self.console.print(instructions_panel)
|
|
963
|
+
|
|
964
|
+
# Display per-model usage breakdown if provided
|
|
965
|
+
if model_usage and len(model_usage) > 0:
|
|
966
|
+
self.console.print()
|
|
967
|
+
self.display_model_usage_breakdown(model_usage)
|
|
968
|
+
|
|
969
|
+
def display_model_usage_breakdown(self, model_usage: List[Dict]):
|
|
970
|
+
"""
|
|
971
|
+
Display per-model token usage breakdown.
|
|
972
|
+
|
|
973
|
+
Args:
|
|
974
|
+
model_usage: List of model usage dictionaries
|
|
975
|
+
"""
|
|
976
|
+
# Create model usage table
|
|
977
|
+
usage_table = Table(title="Model Usage Breakdown", box=box.ROUNDED, show_header=True)
|
|
978
|
+
usage_table.add_column("Model", style="cyan", no_wrap=True)
|
|
979
|
+
usage_table.add_column("Input Tokens", justify="right", style="green")
|
|
980
|
+
usage_table.add_column("Output Tokens", justify="right", style="yellow")
|
|
981
|
+
usage_table.add_column("Total Tokens", justify="right", style="bold")
|
|
982
|
+
|
|
983
|
+
for usage in model_usage:
|
|
984
|
+
usage_table.add_row(
|
|
985
|
+
usage['model_id'],
|
|
986
|
+
f"{usage['input_tokens']:,}",
|
|
987
|
+
f"{usage['output_tokens']:,}",
|
|
988
|
+
f"{usage['total_tokens']:,}"
|
|
989
|
+
)
|
|
990
|
+
|
|
991
|
+
self.console.print(usage_table)
|
|
992
|
+
|
|
993
|
+
def chat_prompt(self) -> Optional[str]:
|
|
994
|
+
"""
|
|
995
|
+
Get user input for chat message.
|
|
996
|
+
Supports multi-line input - press Enter twice to send.
|
|
997
|
+
|
|
998
|
+
Returns:
|
|
999
|
+
User message or None to exit
|
|
1000
|
+
"""
|
|
1001
|
+
self.console.print()
|
|
1002
|
+
self.print_separator("─")
|
|
1003
|
+
|
|
1004
|
+
# Display help text - conditionally include changemodel if enabled
|
|
1005
|
+
commands = "[bold]quit[/bold] | [bold]end[/bold] | [bold]history[/bold] | [bold]info[/bold] | " \
|
|
1006
|
+
"[bold]export[/bold] | [bold]delete[/bold] | [bold]attach[/bold] | [bold]copy[/bold] | " \
|
|
1007
|
+
"[bold]instructions[/bold] | [bold]deletefiles[/bold] | [bold]mcpaudit[/bold] | [bold]mcpservers[/bold]"
|
|
1008
|
+
|
|
1009
|
+
if self.model_changing_enabled:
|
|
1010
|
+
commands += " | [bold]changemodel[/bold]"
|
|
1011
|
+
|
|
1012
|
+
help_panel = Panel(
|
|
1013
|
+
f"[dim]Commands: {commands}\n"
|
|
1014
|
+
"Press [bold]Enter twice[/bold] to send your message[/dim]",
|
|
1015
|
+
border_style="dim",
|
|
1016
|
+
box=box.ROUNDED
|
|
1017
|
+
)
|
|
1018
|
+
self.console.print(help_panel)
|
|
1019
|
+
|
|
1020
|
+
self.console.print("\n[bold cyan]💬 Your message:[/bold cyan]")
|
|
1021
|
+
|
|
1022
|
+
lines = []
|
|
1023
|
+
empty_line_count = 0
|
|
1024
|
+
|
|
1025
|
+
while True:
|
|
1026
|
+
try:
|
|
1027
|
+
line = input()
|
|
1028
|
+
except EOFError:
|
|
1029
|
+
# Handle Ctrl+D or EOF
|
|
1030
|
+
return None
|
|
1031
|
+
|
|
1032
|
+
if line == "":
|
|
1033
|
+
empty_line_count += 1
|
|
1034
|
+
if empty_line_count >= 2:
|
|
1035
|
+
# Double Enter pressed - send message
|
|
1036
|
+
break
|
|
1037
|
+
lines.append(line)
|
|
1038
|
+
else:
|
|
1039
|
+
empty_line_count = 0
|
|
1040
|
+
lines.append(line)
|
|
1041
|
+
|
|
1042
|
+
message = '\n'.join(lines).strip()
|
|
1043
|
+
|
|
1044
|
+
# Check for commands
|
|
1045
|
+
if message.lower() in ['quit', 'exit', 'q']:
|
|
1046
|
+
return None
|
|
1047
|
+
elif message.lower() in ['end', 'endchat']:
|
|
1048
|
+
return 'END_CHAT'
|
|
1049
|
+
elif message.lower() in ['history', 'h']:
|
|
1050
|
+
return 'SHOW_HISTORY'
|
|
1051
|
+
elif message.lower() in ['info', 'i']:
|
|
1052
|
+
return 'SHOW_INFO'
|
|
1053
|
+
elif message.lower() in ['export', 'e']:
|
|
1054
|
+
return 'EXPORT_CONVERSATION'
|
|
1055
|
+
elif message.lower() in ['delete', 'd']:
|
|
1056
|
+
return 'DELETE_CONVERSATION'
|
|
1057
|
+
elif message.lower() in ['attach', 'a']:
|
|
1058
|
+
return 'ATTACH_FILES'
|
|
1059
|
+
elif message.lower() in ['mcpaudit', 'audit']:
|
|
1060
|
+
return 'MCP_AUDIT'
|
|
1061
|
+
elif message.lower() in ['mcpservers', 'servers', 's']:
|
|
1062
|
+
return 'MCP_SERVERS'
|
|
1063
|
+
elif message.lower() in ['changemodel', 'model', 'm']:
|
|
1064
|
+
return 'CHANGE_MODEL'
|
|
1065
|
+
elif message.lower() in ['instructions', 'inst']:
|
|
1066
|
+
return 'CHANGE_INSTRUCTIONS'
|
|
1067
|
+
elif message.lower() in ['deletefiles', 'df']:
|
|
1068
|
+
return 'DELETE_FILES'
|
|
1069
|
+
elif message.lower() in ['copy', 'c']:
|
|
1070
|
+
return 'COPY_LAST'
|
|
1071
|
+
|
|
1072
|
+
return message
|
|
1073
|
+
|
|
1074
|
+
def confirm(self, message: str) -> bool:
|
|
1075
|
+
"""
|
|
1076
|
+
Ask for user confirmation.
|
|
1077
|
+
|
|
1078
|
+
Args:
|
|
1079
|
+
message: Confirmation message
|
|
1080
|
+
|
|
1081
|
+
Returns:
|
|
1082
|
+
True if confirmed, False otherwise
|
|
1083
|
+
"""
|
|
1084
|
+
return Confirm.ask(f"[bold yellow]{message}[/bold yellow]")
|
|
1085
|
+
|
|
1086
|
+
def wait_for_enter(self, message: str = "Press Enter to continue"):
|
|
1087
|
+
"""
|
|
1088
|
+
Wait for user to press Enter.
|
|
1089
|
+
|
|
1090
|
+
Args:
|
|
1091
|
+
message: Message to display
|
|
1092
|
+
"""
|
|
1093
|
+
Prompt.ask(f"\n[dim]{message}[/dim]", default="")
|
|
1094
|
+
|
|
1095
|
+
def print_farewell(self, version: str = None):
|
|
1096
|
+
"""
|
|
1097
|
+
Print farewell message when exiting with SPARK branding.
|
|
1098
|
+
|
|
1099
|
+
Args:
|
|
1100
|
+
version: Application version to display (optional)
|
|
1101
|
+
"""
|
|
1102
|
+
# Get version from launch module if not provided
|
|
1103
|
+
if version is None:
|
|
1104
|
+
try:
|
|
1105
|
+
from dtSpark import launch
|
|
1106
|
+
version = launch.version()
|
|
1107
|
+
except:
|
|
1108
|
+
version = "X.X"
|
|
1109
|
+
|
|
1110
|
+
# Get log path
|
|
1111
|
+
from dtPyAppFramework.process import ProcessManager
|
|
1112
|
+
log_path = ProcessManager().log_path
|
|
1113
|
+
|
|
1114
|
+
# Build farewell content line by line
|
|
1115
|
+
farewell_content = Text()
|
|
1116
|
+
farewell_content.append("\n")
|
|
1117
|
+
|
|
1118
|
+
# Line 1: * . * and DIGITAL-THOUGHT
|
|
1119
|
+
farewell_content.append(" * . * ", style="bright_yellow")
|
|
1120
|
+
farewell_content.append(" DIGITAL-THOUGHT\n", style="bold bright_magenta")
|
|
1121
|
+
|
|
1122
|
+
# Line 2: . \|/ . and Secure Personal AI Research Kit
|
|
1123
|
+
farewell_content.append(" . \\|/ . ", style="yellow")
|
|
1124
|
+
farewell_content.append(" Secure Personal AI Research Kit\n", style="cyan")
|
|
1125
|
+
|
|
1126
|
+
# Line 3: S P A R K and Version
|
|
1127
|
+
farewell_content.append(" *-- ", style="bright_yellow")
|
|
1128
|
+
farewell_content.append("S P A R K", style="bold bright_cyan")
|
|
1129
|
+
farewell_content.append(" --* ", style="bright_yellow")
|
|
1130
|
+
farewell_content.append(f" Version {version}\n", style="green")
|
|
1131
|
+
|
|
1132
|
+
# Line 4: . /|\ .
|
|
1133
|
+
farewell_content.append(" . /|\\ . \n", style="yellow")
|
|
1134
|
+
|
|
1135
|
+
# Line 5: * . * and Thankyou and Goodbye!
|
|
1136
|
+
farewell_content.append(" * . * ", style="bright_yellow")
|
|
1137
|
+
farewell_content.append(" Thankyou and Goodbye!\n", style="bright_green")
|
|
1138
|
+
|
|
1139
|
+
# Line 6: blank space and Log Path
|
|
1140
|
+
farewell_content.append(" ", style="")
|
|
1141
|
+
farewell_content.append(f"Log Path: {log_path}\n", style="dim")
|
|
1142
|
+
|
|
1143
|
+
farewell_content.append("")
|
|
1144
|
+
|
|
1145
|
+
# Create panel
|
|
1146
|
+
farewell_panel = Panel(
|
|
1147
|
+
farewell_content,
|
|
1148
|
+
border_style="bright_cyan",
|
|
1149
|
+
box=box.HEAVY,
|
|
1150
|
+
padding=(0, 2)
|
|
1151
|
+
)
|
|
1152
|
+
|
|
1153
|
+
self.console.print()
|
|
1154
|
+
self.console.print(farewell_panel)
|
|
1155
|
+
self.console.print()
|
|
1156
|
+
|
|
1157
|
+
def display_mcp_status(self, mcp_manager):
|
|
1158
|
+
"""
|
|
1159
|
+
Display MCP server connection status and tools.
|
|
1160
|
+
|
|
1161
|
+
Args:
|
|
1162
|
+
mcp_manager: MCPManager instance
|
|
1163
|
+
"""
|
|
1164
|
+
# Count connected servers
|
|
1165
|
+
connected_count = sum(1 for client in mcp_manager.clients.values() if client.connected)
|
|
1166
|
+
|
|
1167
|
+
if connected_count == 0:
|
|
1168
|
+
self.print_warning("No MCP servers connected")
|
|
1169
|
+
return
|
|
1170
|
+
|
|
1171
|
+
# Create table for server details
|
|
1172
|
+
table = Table(
|
|
1173
|
+
show_header=True,
|
|
1174
|
+
header_style="bold magenta",
|
|
1175
|
+
box=box.ROUNDED,
|
|
1176
|
+
border_style="green"
|
|
1177
|
+
)
|
|
1178
|
+
table.add_column("Server", style="cyan")
|
|
1179
|
+
table.add_column("Status", style="green", justify="center")
|
|
1180
|
+
table.add_column("Transport", style="blue")
|
|
1181
|
+
table.add_column("Tools", style="yellow", justify="right")
|
|
1182
|
+
|
|
1183
|
+
# Get tools by server
|
|
1184
|
+
tools_by_server = {}
|
|
1185
|
+
if hasattr(mcp_manager, '_tools_cache') and mcp_manager._tools_cache:
|
|
1186
|
+
for tool in mcp_manager._tools_cache:
|
|
1187
|
+
server_name = tool.get('server', 'unknown')
|
|
1188
|
+
if server_name not in tools_by_server:
|
|
1189
|
+
tools_by_server[server_name] = []
|
|
1190
|
+
tools_by_server[server_name].append(tool['name'])
|
|
1191
|
+
|
|
1192
|
+
# Add rows for each server
|
|
1193
|
+
for name, client in mcp_manager.clients.items():
|
|
1194
|
+
status = "✓ Connected" if client.connected else "✗ Disconnected"
|
|
1195
|
+
transport = client.config.transport.upper()
|
|
1196
|
+
tool_count = len(tools_by_server.get(name, []))
|
|
1197
|
+
|
|
1198
|
+
table.add_row(
|
|
1199
|
+
name,
|
|
1200
|
+
status if client.connected else f"[red]{status}[/red]",
|
|
1201
|
+
transport,
|
|
1202
|
+
str(tool_count)
|
|
1203
|
+
)
|
|
1204
|
+
|
|
1205
|
+
# Display in panel
|
|
1206
|
+
total_tools = sum(len(tools) for tools in tools_by_server.values())
|
|
1207
|
+
panel = Panel(
|
|
1208
|
+
table,
|
|
1209
|
+
title=f"[bold green]🔧 MCP Servers ({connected_count} connected, {total_tools} tools)[/bold green]",
|
|
1210
|
+
border_style="green"
|
|
1211
|
+
)
|
|
1212
|
+
|
|
1213
|
+
self.console.print()
|
|
1214
|
+
self.console.print(panel)
|
|
1215
|
+
|
|
1216
|
+
# List tools for each server
|
|
1217
|
+
if tools_by_server:
|
|
1218
|
+
self.console.print()
|
|
1219
|
+
for server_name, tool_names in tools_by_server.items():
|
|
1220
|
+
tools_text = ", ".join([f"[cyan]{name}[/cyan]" for name in tool_names])
|
|
1221
|
+
self.console.print(f" [bold]{server_name}[/bold]: {tools_text}")
|
|
1222
|
+
self.console.print()
|
|
1223
|
+
|
|
1224
|
+
def display_bedrock_costs(self, costs_data: Dict):
|
|
1225
|
+
"""
|
|
1226
|
+
Display AWS Bedrock usage costs.
|
|
1227
|
+
|
|
1228
|
+
Args:
|
|
1229
|
+
costs_data: Dictionary containing cost information from CostTracker
|
|
1230
|
+
"""
|
|
1231
|
+
if not costs_data:
|
|
1232
|
+
self.print_warning("No AWS Bedrock cost information available")
|
|
1233
|
+
return
|
|
1234
|
+
|
|
1235
|
+
currency = costs_data.get('currency', 'USD')
|
|
1236
|
+
|
|
1237
|
+
# Create content for the panel
|
|
1238
|
+
content_parts = []
|
|
1239
|
+
|
|
1240
|
+
# Current Month section
|
|
1241
|
+
if 'current_month' in costs_data:
|
|
1242
|
+
current_month = costs_data['current_month']
|
|
1243
|
+
total = current_month.get('total', 0.0)
|
|
1244
|
+
breakdown = current_month.get('breakdown', {})
|
|
1245
|
+
|
|
1246
|
+
content_parts.append(f"[bold cyan]Current Month:[/bold cyan] [yellow]${total:.2f} {currency}[/yellow]")
|
|
1247
|
+
|
|
1248
|
+
if breakdown:
|
|
1249
|
+
# Calculate percentages and sort by cost
|
|
1250
|
+
breakdown_with_pct = []
|
|
1251
|
+
for model, cost in breakdown.items():
|
|
1252
|
+
percentage = (cost / total * 100) if total > 0 else 0
|
|
1253
|
+
breakdown_with_pct.append((model, cost, percentage))
|
|
1254
|
+
|
|
1255
|
+
breakdown_with_pct.sort(key=lambda x: x[1], reverse=True)
|
|
1256
|
+
|
|
1257
|
+
for model, cost, percentage in breakdown_with_pct:
|
|
1258
|
+
content_parts.append(f" • [cyan]{model}[/cyan]: [yellow]${cost:.2f}[/yellow] [dim]({percentage:.1f}%)[/dim]")
|
|
1259
|
+
|
|
1260
|
+
# Last Month section
|
|
1261
|
+
if 'last_month' in costs_data:
|
|
1262
|
+
last_month = costs_data['last_month']
|
|
1263
|
+
total = last_month.get('total', 0.0)
|
|
1264
|
+
breakdown = last_month.get('breakdown', {})
|
|
1265
|
+
|
|
1266
|
+
if content_parts:
|
|
1267
|
+
content_parts.append("") # Add spacing
|
|
1268
|
+
|
|
1269
|
+
content_parts.append(f"[bold cyan]Last Month:[/bold cyan] [yellow]${total:.2f} {currency}[/yellow]")
|
|
1270
|
+
|
|
1271
|
+
if breakdown:
|
|
1272
|
+
# Calculate percentages and sort by cost
|
|
1273
|
+
breakdown_with_pct = []
|
|
1274
|
+
for model, cost in breakdown.items():
|
|
1275
|
+
percentage = (cost / total * 100) if total > 0 else 0
|
|
1276
|
+
breakdown_with_pct.append((model, cost, percentage))
|
|
1277
|
+
|
|
1278
|
+
breakdown_with_pct.sort(key=lambda x: x[1], reverse=True)
|
|
1279
|
+
|
|
1280
|
+
for model, cost, percentage in breakdown_with_pct:
|
|
1281
|
+
content_parts.append(f" • [cyan]{model}[/cyan]: [yellow]${cost:.2f}[/yellow] [dim]({percentage:.1f}%)[/dim]")
|
|
1282
|
+
|
|
1283
|
+
# Last 24 Hours section
|
|
1284
|
+
if 'last_24h' in costs_data:
|
|
1285
|
+
last_24h = costs_data['last_24h']
|
|
1286
|
+
total = last_24h.get('total', 0.0)
|
|
1287
|
+
breakdown = last_24h.get('breakdown', {})
|
|
1288
|
+
|
|
1289
|
+
if content_parts:
|
|
1290
|
+
content_parts.append("") # Add spacing
|
|
1291
|
+
|
|
1292
|
+
content_parts.append(f"[bold cyan]Last 24 Hours:[/bold cyan] [yellow]${total:.4f} {currency}[/yellow]")
|
|
1293
|
+
|
|
1294
|
+
if breakdown:
|
|
1295
|
+
# Calculate percentages and sort by cost
|
|
1296
|
+
breakdown_with_pct = []
|
|
1297
|
+
for model, cost in breakdown.items():
|
|
1298
|
+
percentage = (cost / total * 100) if total > 0 else 0
|
|
1299
|
+
breakdown_with_pct.append((model, cost, percentage))
|
|
1300
|
+
|
|
1301
|
+
breakdown_with_pct.sort(key=lambda x: x[1], reverse=True)
|
|
1302
|
+
|
|
1303
|
+
for model, cost, percentage in breakdown_with_pct:
|
|
1304
|
+
content_parts.append(f" • [cyan]{model}[/cyan]: [yellow]${cost:.4f}[/yellow] [dim]({percentage:.1f}%)[/dim]")
|
|
1305
|
+
|
|
1306
|
+
# Create panel
|
|
1307
|
+
content_text = "\n".join(content_parts)
|
|
1308
|
+
panel = Panel(
|
|
1309
|
+
content_text,
|
|
1310
|
+
title="[bold green]💰 AWS Bedrock Usage Costs[/bold green]",
|
|
1311
|
+
border_style="green"
|
|
1312
|
+
)
|
|
1313
|
+
|
|
1314
|
+
self.console.print()
|
|
1315
|
+
self.console.print(panel)
|
|
1316
|
+
|
|
1317
|
+
def display_anthropic_costs(self, costs_data: Dict):
|
|
1318
|
+
"""
|
|
1319
|
+
Display Anthropic Direct API usage costs and budget status.
|
|
1320
|
+
|
|
1321
|
+
Args:
|
|
1322
|
+
costs_data: Dictionary containing cost information from AnthropicService
|
|
1323
|
+
"""
|
|
1324
|
+
if not costs_data:
|
|
1325
|
+
self.print_warning("No Anthropic cost information available")
|
|
1326
|
+
return
|
|
1327
|
+
|
|
1328
|
+
# Create content for the panel
|
|
1329
|
+
content_parts = []
|
|
1330
|
+
|
|
1331
|
+
# Current month spending
|
|
1332
|
+
current_month_spent = costs_data.get('current_month_spent', 0.0)
|
|
1333
|
+
total_spent = costs_data.get('total_spent', 0.0)
|
|
1334
|
+
budget_limit = costs_data.get('budget_limit', 0.0)
|
|
1335
|
+
budget_remaining = costs_data.get('budget_remaining', 0.0)
|
|
1336
|
+
budget_percentage = costs_data.get('budget_percentage', 0.0)
|
|
1337
|
+
budget_exceeded = costs_data.get('budget_exceeded', False)
|
|
1338
|
+
approaching_limit = costs_data.get('approaching_limit', False)
|
|
1339
|
+
current_month = costs_data.get('current_month', '')
|
|
1340
|
+
|
|
1341
|
+
# Current month section
|
|
1342
|
+
content_parts.append(f"[bold cyan]Current Month ({current_month}):[/bold cyan]")
|
|
1343
|
+
content_parts.append(f" • Spent: [yellow]${current_month_spent:.4f} USD[/yellow]")
|
|
1344
|
+
|
|
1345
|
+
# Budget section
|
|
1346
|
+
if budget_limit > 0:
|
|
1347
|
+
content_parts.append("")
|
|
1348
|
+
content_parts.append(f"[bold cyan]Budget Status:[/bold cyan]")
|
|
1349
|
+
content_parts.append(f" • Budget Limit: [yellow]${budget_limit:.2f} USD[/yellow]")
|
|
1350
|
+
|
|
1351
|
+
if budget_exceeded:
|
|
1352
|
+
content_parts.append(f" • Remaining: [red]${budget_remaining:.2f} USD (EXCEEDED)[/red]")
|
|
1353
|
+
content_parts.append(f" • Usage: [red]{budget_percentage:.1f}%[/red] [red]⚠️ OVER BUDGET[/red]")
|
|
1354
|
+
elif approaching_limit:
|
|
1355
|
+
content_parts.append(f" • Remaining: [yellow]${budget_remaining:.2f} USD[/yellow]")
|
|
1356
|
+
content_parts.append(f" • Usage: [yellow]{budget_percentage:.1f}%[/yellow] [yellow]⚠️ APPROACHING LIMIT[/yellow]")
|
|
1357
|
+
else:
|
|
1358
|
+
content_parts.append(f" • Remaining: [green]${budget_remaining:.2f} USD[/green]")
|
|
1359
|
+
content_parts.append(f" • Usage: [green]{budget_percentage:.1f}%[/green]")
|
|
1360
|
+
|
|
1361
|
+
# Total lifetime spending
|
|
1362
|
+
content_parts.append("")
|
|
1363
|
+
content_parts.append(f"[bold cyan]Total Lifetime Spending:[/bold cyan] [yellow]${total_spent:.4f} USD[/yellow]")
|
|
1364
|
+
|
|
1365
|
+
# Usage count
|
|
1366
|
+
usage_count = costs_data.get('usage_count', 0)
|
|
1367
|
+
if usage_count > 0:
|
|
1368
|
+
content_parts.append(f"[bold cyan]API Calls:[/bold cyan] {usage_count} requests")
|
|
1369
|
+
|
|
1370
|
+
# Create panel with appropriate colour based on budget status
|
|
1371
|
+
if budget_exceeded:
|
|
1372
|
+
border_colour = "red"
|
|
1373
|
+
title = "[bold red]💰 Anthropic API Costs - BUDGET EXCEEDED[/bold red]"
|
|
1374
|
+
elif approaching_limit:
|
|
1375
|
+
border_colour = "yellow"
|
|
1376
|
+
title = "[bold yellow]💰 Anthropic API Costs - APPROACHING LIMIT[/bold yellow]"
|
|
1377
|
+
else:
|
|
1378
|
+
border_colour = "green"
|
|
1379
|
+
title = "[bold green]💰 Anthropic API Costs[/bold green]"
|
|
1380
|
+
|
|
1381
|
+
content_text = "\n".join(content_parts)
|
|
1382
|
+
panel = Panel(
|
|
1383
|
+
content_text,
|
|
1384
|
+
title=title,
|
|
1385
|
+
border_style=border_colour
|
|
1386
|
+
)
|
|
1387
|
+
|
|
1388
|
+
self.console.print()
|
|
1389
|
+
self.console.print(panel)
|
|
1390
|
+
|
|
1391
|
+
def display_aws_account_info(self, account_info: Dict):
|
|
1392
|
+
"""
|
|
1393
|
+
Display AWS account and authentication information.
|
|
1394
|
+
|
|
1395
|
+
Args:
|
|
1396
|
+
account_info: Dictionary containing AWS account information
|
|
1397
|
+
"""
|
|
1398
|
+
if not account_info:
|
|
1399
|
+
self.print_warning("No AWS account information available")
|
|
1400
|
+
return
|
|
1401
|
+
|
|
1402
|
+
# Create content for the panel
|
|
1403
|
+
content_parts = []
|
|
1404
|
+
|
|
1405
|
+
# Identity/ARN
|
|
1406
|
+
if 'user_arn' in account_info:
|
|
1407
|
+
content_parts.append(f"[bold cyan]Authenticated as:[/bold cyan] [green]{account_info['user_arn']}[/green]")
|
|
1408
|
+
|
|
1409
|
+
# Account ID
|
|
1410
|
+
if 'account_id' in account_info:
|
|
1411
|
+
content_parts.append(f"[bold cyan]Account:[/bold cyan] [yellow]{account_info['account_id']}[/yellow]")
|
|
1412
|
+
|
|
1413
|
+
# Region
|
|
1414
|
+
if 'region' in account_info:
|
|
1415
|
+
content_parts.append(f"[bold cyan]Region:[/bold cyan] [yellow]{account_info['region']}[/yellow]")
|
|
1416
|
+
|
|
1417
|
+
# Authentication Method
|
|
1418
|
+
if 'auth_method' in account_info and account_info['auth_method']:
|
|
1419
|
+
auth_method_display = "API Keys" if account_info['auth_method'] == 'api_keys' else "SSO Profile"
|
|
1420
|
+
content_parts.append(f"[bold cyan]Authentication:[/bold cyan] [magenta]{auth_method_display}[/magenta]")
|
|
1421
|
+
|
|
1422
|
+
# Create panel
|
|
1423
|
+
content_text = "\n".join(content_parts)
|
|
1424
|
+
panel = Panel(
|
|
1425
|
+
content_text,
|
|
1426
|
+
title="[bold green]☁️ AWS Account Information[/bold green]",
|
|
1427
|
+
border_style="green"
|
|
1428
|
+
)
|
|
1429
|
+
|
|
1430
|
+
self.console.print()
|
|
1431
|
+
self.console.print(panel)
|
|
1432
|
+
|
|
1433
|
+
def display_application_info(self, user_guid: str):
|
|
1434
|
+
"""
|
|
1435
|
+
Display application and user information.
|
|
1436
|
+
|
|
1437
|
+
Args:
|
|
1438
|
+
user_guid: User's unique identifier
|
|
1439
|
+
"""
|
|
1440
|
+
# Create content for the panel
|
|
1441
|
+
content_parts = []
|
|
1442
|
+
|
|
1443
|
+
# User GUID
|
|
1444
|
+
content_parts.append(f"[bold cyan]User GUID:[/bold cyan] [blue]{user_guid}[/blue]")
|
|
1445
|
+
content_parts.append("")
|
|
1446
|
+
content_parts.append("[dim]This unique identifier is used for database isolation[/dim]")
|
|
1447
|
+
content_parts.append("[dim]and multi-user support when using shared databases.[/dim]")
|
|
1448
|
+
|
|
1449
|
+
# Create panel
|
|
1450
|
+
content_text = "\n".join(content_parts)
|
|
1451
|
+
panel = Panel(
|
|
1452
|
+
content_text,
|
|
1453
|
+
title="[bold green]📋 Application Information[/bold green]",
|
|
1454
|
+
border_style="green"
|
|
1455
|
+
)
|
|
1456
|
+
|
|
1457
|
+
self.console.print()
|
|
1458
|
+
self.console.print(panel)
|
|
1459
|
+
|
|
1460
|
+
def display_tool_call(self, tool_name: str, tool_input: Dict):
|
|
1461
|
+
"""
|
|
1462
|
+
Display a tool call during chat.
|
|
1463
|
+
|
|
1464
|
+
Args:
|
|
1465
|
+
tool_name: Name of the tool being called
|
|
1466
|
+
tool_input: Input parameters for the tool
|
|
1467
|
+
"""
|
|
1468
|
+
# Format input nicely
|
|
1469
|
+
input_str = ", ".join([f"{k}={v}" for k, v in tool_input.items()])
|
|
1470
|
+
|
|
1471
|
+
self.console.print(
|
|
1472
|
+
f"\n[dim]🔧 Calling tool:[/dim] [bold cyan]{tool_name}[/bold cyan]"
|
|
1473
|
+
f"[dim]({input_str})[/dim]"
|
|
1474
|
+
)
|
|
1475
|
+
|
|
1476
|
+
def display_tool_result(self, tool_name: str, result: str, is_error: bool = False):
|
|
1477
|
+
"""
|
|
1478
|
+
Display a tool result during chat.
|
|
1479
|
+
|
|
1480
|
+
Args:
|
|
1481
|
+
tool_name: Name of the tool that was called
|
|
1482
|
+
result: Result from the tool
|
|
1483
|
+
is_error: Whether the result is an error
|
|
1484
|
+
"""
|
|
1485
|
+
if is_error:
|
|
1486
|
+
self.console.print(
|
|
1487
|
+
f"[dim] ✗ Tool failed:[/dim] [red]{result}[/red]"
|
|
1488
|
+
)
|
|
1489
|
+
else:
|
|
1490
|
+
# Truncate long results
|
|
1491
|
+
display_result = result if len(result) <= 100 else result[:100] + "..."
|
|
1492
|
+
self.console.print(
|
|
1493
|
+
f"[dim] ✓ Result:[/dim] [green]{display_result}[/green]"
|
|
1494
|
+
)
|
|
1495
|
+
|
|
1496
|
+
def get_file_attachments(self, supported_extensions: str) -> List[Dict]:
|
|
1497
|
+
"""
|
|
1498
|
+
Prompt user to attach files or directories to the conversation.
|
|
1499
|
+
|
|
1500
|
+
Args:
|
|
1501
|
+
supported_extensions: Comma-separated list of supported file extensions
|
|
1502
|
+
|
|
1503
|
+
Returns:
|
|
1504
|
+
List of dictionaries with 'path' and 'tags' keys
|
|
1505
|
+
"""
|
|
1506
|
+
file_attachments = []
|
|
1507
|
+
|
|
1508
|
+
# Ask if user wants to attach files
|
|
1509
|
+
attach_files = self.confirm("Would you like to attach files to this conversation?")
|
|
1510
|
+
|
|
1511
|
+
if not attach_files:
|
|
1512
|
+
return file_attachments
|
|
1513
|
+
|
|
1514
|
+
# Display supported file types
|
|
1515
|
+
self.console.print()
|
|
1516
|
+
info_panel = Panel(
|
|
1517
|
+
f"[cyan]Supported file types:[/cyan]\n{supported_extensions}\n\n"
|
|
1518
|
+
f"[yellow]You can provide file paths or directory paths.[/yellow]",
|
|
1519
|
+
title="[bold cyan]📎 File Attachments[/bold cyan]",
|
|
1520
|
+
border_style="cyan",
|
|
1521
|
+
box=box.ROUNDED
|
|
1522
|
+
)
|
|
1523
|
+
self.console.print(info_panel)
|
|
1524
|
+
self.console.print()
|
|
1525
|
+
|
|
1526
|
+
# Get file/directory paths from user
|
|
1527
|
+
self.print_info("Enter file or directory paths one at a time (press Enter with empty path to finish)")
|
|
1528
|
+
|
|
1529
|
+
while True:
|
|
1530
|
+
input_path = self.get_input("File/Directory path (or press Enter to finish)").strip()
|
|
1531
|
+
|
|
1532
|
+
if not input_path:
|
|
1533
|
+
# User pressed Enter with empty input
|
|
1534
|
+
break
|
|
1535
|
+
|
|
1536
|
+
# Check if path exists
|
|
1537
|
+
from pathlib import Path
|
|
1538
|
+
from dtSpark.files.manager import FileManager
|
|
1539
|
+
|
|
1540
|
+
path = Path(input_path)
|
|
1541
|
+
|
|
1542
|
+
if not path.exists():
|
|
1543
|
+
self.print_error(f"Path not found: {input_path}")
|
|
1544
|
+
continue
|
|
1545
|
+
|
|
1546
|
+
# Handle directories
|
|
1547
|
+
if path.is_dir():
|
|
1548
|
+
self.print_info(f"Directory detected: {path.name}")
|
|
1549
|
+
|
|
1550
|
+
# Ask if recursive
|
|
1551
|
+
recursive = self.confirm(" Include files from subdirectories?")
|
|
1552
|
+
|
|
1553
|
+
# Scan directory
|
|
1554
|
+
try:
|
|
1555
|
+
found_files = FileManager.scan_directory(str(path.absolute()), recursive=recursive)
|
|
1556
|
+
|
|
1557
|
+
if not found_files:
|
|
1558
|
+
self.print_warning(f" No supported files found in directory")
|
|
1559
|
+
continue
|
|
1560
|
+
|
|
1561
|
+
self.print_success(f" Found {len(found_files)} supported file(s)")
|
|
1562
|
+
|
|
1563
|
+
# Ask for tags for this batch
|
|
1564
|
+
assign_tags = self.confirm(" Would you like to assign tags to these files?")
|
|
1565
|
+
tags = None
|
|
1566
|
+
if assign_tags:
|
|
1567
|
+
tags_input = self.get_input(" Enter tags (comma-separated)").strip()
|
|
1568
|
+
if tags_input:
|
|
1569
|
+
tags = tags_input
|
|
1570
|
+
self.print_success(f" Tagged {len(found_files)} file(s) with: {tags}")
|
|
1571
|
+
|
|
1572
|
+
# Add all found files with the same tags
|
|
1573
|
+
for file_path in found_files:
|
|
1574
|
+
file_attachments.append({
|
|
1575
|
+
'path': file_path,
|
|
1576
|
+
'tags': tags
|
|
1577
|
+
})
|
|
1578
|
+
|
|
1579
|
+
except Exception as e:
|
|
1580
|
+
self.print_error(f" Error scanning directory: {e}")
|
|
1581
|
+
continue
|
|
1582
|
+
|
|
1583
|
+
# Handle individual files
|
|
1584
|
+
elif path.is_file():
|
|
1585
|
+
# Check if file is supported
|
|
1586
|
+
if not FileManager.is_supported(str(path)):
|
|
1587
|
+
self.print_error(f"Unsupported file type: {path.suffix}")
|
|
1588
|
+
continue
|
|
1589
|
+
|
|
1590
|
+
# Ask for tags for this file
|
|
1591
|
+
assign_tags = self.confirm(f" Assign tags to '{path.name}'?")
|
|
1592
|
+
tags = None
|
|
1593
|
+
if assign_tags:
|
|
1594
|
+
tags_input = self.get_input(" Enter tags (comma-separated)").strip()
|
|
1595
|
+
if tags_input:
|
|
1596
|
+
tags = tags_input
|
|
1597
|
+
|
|
1598
|
+
file_attachments.append({
|
|
1599
|
+
'path': str(path.absolute()),
|
|
1600
|
+
'tags': tags
|
|
1601
|
+
})
|
|
1602
|
+
|
|
1603
|
+
tags_str = f" with tags: {tags}" if tags else ""
|
|
1604
|
+
self.print_success(f"Added: {path.name}{tags_str}")
|
|
1605
|
+
|
|
1606
|
+
else:
|
|
1607
|
+
self.print_error(f"Invalid path type: {input_path}")
|
|
1608
|
+
continue
|
|
1609
|
+
|
|
1610
|
+
if file_attachments:
|
|
1611
|
+
self.console.print()
|
|
1612
|
+
|
|
1613
|
+
# Count files by tags
|
|
1614
|
+
tagged_count = sum(1 for f in file_attachments if f['tags'])
|
|
1615
|
+
untagged_count = len(file_attachments) - tagged_count
|
|
1616
|
+
|
|
1617
|
+
self.print_success(f"Total files to attach: {len(file_attachments)}")
|
|
1618
|
+
if tagged_count > 0:
|
|
1619
|
+
self.print_info(f" - {tagged_count} file(s) with tags")
|
|
1620
|
+
if untagged_count > 0:
|
|
1621
|
+
self.print_info(f" - {untagged_count} file(s) without tags")
|
|
1622
|
+
self.console.print()
|
|
1623
|
+
|
|
1624
|
+
return file_attachments
|
|
1625
|
+
|
|
1626
|
+
def display_attached_files(self, files: List[Dict]):
|
|
1627
|
+
"""
|
|
1628
|
+
Display attached files for a conversation.
|
|
1629
|
+
|
|
1630
|
+
Args:
|
|
1631
|
+
files: List of file dictionaries from database
|
|
1632
|
+
"""
|
|
1633
|
+
if not files:
|
|
1634
|
+
return
|
|
1635
|
+
|
|
1636
|
+
# Create table for attached files
|
|
1637
|
+
table = Table(
|
|
1638
|
+
show_header=True,
|
|
1639
|
+
header_style="bold magenta",
|
|
1640
|
+
box=box.ROUNDED,
|
|
1641
|
+
border_style="blue"
|
|
1642
|
+
)
|
|
1643
|
+
table.add_column("ID", style="dim", justify="right")
|
|
1644
|
+
table.add_column("Filename", style="cyan")
|
|
1645
|
+
table.add_column("Type", style="green", justify="center")
|
|
1646
|
+
table.add_column("Size", style="yellow", justify="right")
|
|
1647
|
+
table.add_column("Tokens", style="magenta", justify="right")
|
|
1648
|
+
table.add_column("Tags", style="bright_blue")
|
|
1649
|
+
|
|
1650
|
+
for file_info in files:
|
|
1651
|
+
# Format file size
|
|
1652
|
+
size_bytes = file_info.get('file_size', 0)
|
|
1653
|
+
if size_bytes < 1024:
|
|
1654
|
+
size_str = f"{size_bytes} B"
|
|
1655
|
+
elif size_bytes < 1024 * 1024:
|
|
1656
|
+
size_str = f"{size_bytes / 1024:.1f} KB"
|
|
1657
|
+
else:
|
|
1658
|
+
size_str = f"{size_bytes / (1024 * 1024):.1f} MB"
|
|
1659
|
+
|
|
1660
|
+
# Format tags
|
|
1661
|
+
tags = file_info.get('tags', None)
|
|
1662
|
+
if tags:
|
|
1663
|
+
# Split tags and format them nicely
|
|
1664
|
+
tag_list = [t.strip() for t in tags.split(',') if t.strip()]
|
|
1665
|
+
tags_str = ', '.join([f"[bold]{tag}[/bold]" for tag in tag_list])
|
|
1666
|
+
else:
|
|
1667
|
+
tags_str = "[dim]-[/dim]"
|
|
1668
|
+
|
|
1669
|
+
table.add_row(
|
|
1670
|
+
str(file_info['id']),
|
|
1671
|
+
file_info['filename'],
|
|
1672
|
+
file_info['file_type'],
|
|
1673
|
+
size_str,
|
|
1674
|
+
f"{file_info.get('token_count', 0):,}",
|
|
1675
|
+
tags_str
|
|
1676
|
+
)
|
|
1677
|
+
|
|
1678
|
+
# Display in panel
|
|
1679
|
+
panel = Panel(
|
|
1680
|
+
table,
|
|
1681
|
+
title=f"[bold blue]📎 Attached Files ({len(files)})[/bold blue]",
|
|
1682
|
+
border_style="blue"
|
|
1683
|
+
)
|
|
1684
|
+
|
|
1685
|
+
self.console.print()
|
|
1686
|
+
self.console.print(panel)
|
|
1687
|
+
|
|
1688
|
+
def display_mcp_transactions(self, transactions: List[Dict], title: str = "MCP Tool Transactions"):
|
|
1689
|
+
"""
|
|
1690
|
+
Display MCP transaction history for security monitoring.
|
|
1691
|
+
|
|
1692
|
+
Args:
|
|
1693
|
+
transactions: List of transaction dictionaries
|
|
1694
|
+
title: Title for the display panel
|
|
1695
|
+
"""
|
|
1696
|
+
if not transactions:
|
|
1697
|
+
self.print_info("No MCP transactions found")
|
|
1698
|
+
return
|
|
1699
|
+
|
|
1700
|
+
# Create table for transactions
|
|
1701
|
+
table = Table(
|
|
1702
|
+
show_header=True,
|
|
1703
|
+
header_style="bold magenta",
|
|
1704
|
+
box=box.ROUNDED,
|
|
1705
|
+
border_style="yellow"
|
|
1706
|
+
)
|
|
1707
|
+
table.add_column("ID", style="dim", width=6)
|
|
1708
|
+
table.add_column("Timestamp", style="cyan", width=19)
|
|
1709
|
+
table.add_column("Tool", style="green")
|
|
1710
|
+
table.add_column("Server", style="blue")
|
|
1711
|
+
table.add_column("Status", style="white", justify="center", width=8)
|
|
1712
|
+
table.add_column("Time(ms)", style="magenta", justify="right", width=10)
|
|
1713
|
+
|
|
1714
|
+
for txn in transactions:
|
|
1715
|
+
timestamp = datetime.fromisoformat(txn['transaction_timestamp'])
|
|
1716
|
+
status = "[red]ERROR[/red]" if txn['is_error'] else "[green]OK[/green]"
|
|
1717
|
+
exec_time = str(txn['execution_time_ms']) if txn['execution_time_ms'] else "-"
|
|
1718
|
+
|
|
1719
|
+
table.add_row(
|
|
1720
|
+
str(txn['id']),
|
|
1721
|
+
timestamp.strftime('%Y-%m-%d %H:%M:%S'),
|
|
1722
|
+
txn['tool_name'],
|
|
1723
|
+
txn['tool_server'],
|
|
1724
|
+
status,
|
|
1725
|
+
exec_time
|
|
1726
|
+
)
|
|
1727
|
+
|
|
1728
|
+
# Display in panel
|
|
1729
|
+
panel = Panel(
|
|
1730
|
+
table,
|
|
1731
|
+
title=f"[bold yellow]🔐 {title} ({len(transactions)})[/bold yellow]",
|
|
1732
|
+
border_style="yellow"
|
|
1733
|
+
)
|
|
1734
|
+
|
|
1735
|
+
self.console.print()
|
|
1736
|
+
self.console.print(panel)
|
|
1737
|
+
|
|
1738
|
+
def display_mcp_transaction_details(self, transaction: Dict):
|
|
1739
|
+
"""
|
|
1740
|
+
Display detailed information about a specific MCP transaction.
|
|
1741
|
+
|
|
1742
|
+
Args:
|
|
1743
|
+
transaction: Transaction dictionary
|
|
1744
|
+
"""
|
|
1745
|
+
import json
|
|
1746
|
+
|
|
1747
|
+
# Create details table
|
|
1748
|
+
details_table = Table(show_header=False, box=None, padding=(0, 2))
|
|
1749
|
+
details_table.add_column("Label", style="bold yellow")
|
|
1750
|
+
details_table.add_column("Value", style="white")
|
|
1751
|
+
|
|
1752
|
+
timestamp = datetime.fromisoformat(transaction['transaction_timestamp'])
|
|
1753
|
+
status = "ERROR" if transaction['is_error'] else "SUCCESS"
|
|
1754
|
+
status_style = "red" if transaction['is_error'] else "green"
|
|
1755
|
+
|
|
1756
|
+
details_table.add_row("Transaction ID", str(transaction['id']))
|
|
1757
|
+
details_table.add_row("Timestamp", timestamp.strftime('%Y-%m-%d %H:%M:%S'))
|
|
1758
|
+
details_table.add_row("Conversation ID", str(transaction['conversation_id']))
|
|
1759
|
+
details_table.add_row("Tool Name", transaction['tool_name'])
|
|
1760
|
+
details_table.add_row("Tool Server", transaction['tool_server'])
|
|
1761
|
+
details_table.add_row("Status", f"[{status_style}]{status}[/{status_style}]")
|
|
1762
|
+
if transaction['execution_time_ms']:
|
|
1763
|
+
details_table.add_row("Execution Time", f"{transaction['execution_time_ms']} ms")
|
|
1764
|
+
|
|
1765
|
+
self.console.print()
|
|
1766
|
+
self.console.print(Panel(
|
|
1767
|
+
details_table,
|
|
1768
|
+
title="[bold yellow]🔐 Transaction Details[/bold yellow]",
|
|
1769
|
+
border_style="yellow"
|
|
1770
|
+
))
|
|
1771
|
+
|
|
1772
|
+
# User prompt
|
|
1773
|
+
self.console.print()
|
|
1774
|
+
self.console.print(Panel(
|
|
1775
|
+
transaction['user_prompt'],
|
|
1776
|
+
title="[bold cyan]User Prompt[/bold cyan]",
|
|
1777
|
+
border_style="cyan"
|
|
1778
|
+
))
|
|
1779
|
+
|
|
1780
|
+
# Tool input
|
|
1781
|
+
self.console.print()
|
|
1782
|
+
try:
|
|
1783
|
+
input_formatted = json.dumps(json.loads(transaction['tool_input']), indent=2)
|
|
1784
|
+
except:
|
|
1785
|
+
input_formatted = transaction['tool_input']
|
|
1786
|
+
|
|
1787
|
+
self.console.print(Panel(
|
|
1788
|
+
input_formatted,
|
|
1789
|
+
title="[bold blue]Tool Input[/bold blue]",
|
|
1790
|
+
border_style="blue"
|
|
1791
|
+
))
|
|
1792
|
+
|
|
1793
|
+
# Tool response
|
|
1794
|
+
self.console.print()
|
|
1795
|
+
response_style = "red" if transaction['is_error'] else "green"
|
|
1796
|
+
response_text = transaction['tool_response']
|
|
1797
|
+
if len(response_text) > 500:
|
|
1798
|
+
response_text = response_text[:500] + "\n\n[... truncated for display ...]"
|
|
1799
|
+
|
|
1800
|
+
self.console.print(Panel(
|
|
1801
|
+
response_text,
|
|
1802
|
+
title=f"[bold {response_style}]Tool Response[/bold {response_style}]",
|
|
1803
|
+
border_style=response_style
|
|
1804
|
+
))
|
|
1805
|
+
|
|
1806
|
+
def display_mcp_stats(self, stats: Dict):
|
|
1807
|
+
"""
|
|
1808
|
+
Display MCP transaction statistics for security monitoring.
|
|
1809
|
+
|
|
1810
|
+
Args:
|
|
1811
|
+
stats: Statistics dictionary
|
|
1812
|
+
"""
|
|
1813
|
+
# Create stats table
|
|
1814
|
+
stats_table = Table(show_header=False, box=None, padding=(0, 2))
|
|
1815
|
+
stats_table.add_column("Metric", style="bold yellow")
|
|
1816
|
+
stats_table.add_column("Value", style="white")
|
|
1817
|
+
|
|
1818
|
+
stats_table.add_row("Total Transactions", f"{stats['total_transactions']:,}")
|
|
1819
|
+
stats_table.add_row("Errors", f"{stats['error_count']:,}")
|
|
1820
|
+
stats_table.add_row("Error Rate", f"{stats['error_rate']:.2f}%")
|
|
1821
|
+
|
|
1822
|
+
self.console.print()
|
|
1823
|
+
self.console.print(Panel(
|
|
1824
|
+
stats_table,
|
|
1825
|
+
title="[bold yellow]📊 MCP Transaction Statistics[/bold yellow]",
|
|
1826
|
+
border_style="yellow"
|
|
1827
|
+
))
|
|
1828
|
+
|
|
1829
|
+
# Top tools
|
|
1830
|
+
if stats['top_tools']:
|
|
1831
|
+
self.console.print()
|
|
1832
|
+
tools_table = Table(
|
|
1833
|
+
show_header=True,
|
|
1834
|
+
header_style="bold magenta",
|
|
1835
|
+
box=box.ROUNDED,
|
|
1836
|
+
border_style="green"
|
|
1837
|
+
)
|
|
1838
|
+
tools_table.add_column("Tool", style="cyan")
|
|
1839
|
+
tools_table.add_column("Usage Count", style="green", justify="right")
|
|
1840
|
+
|
|
1841
|
+
for tool in stats['top_tools']:
|
|
1842
|
+
tools_table.add_row(tool['tool'], str(tool['count']))
|
|
1843
|
+
|
|
1844
|
+
self.console.print(Panel(
|
|
1845
|
+
tools_table,
|
|
1846
|
+
title="[bold green]🔧 Most Used Tools[/bold green]",
|
|
1847
|
+
border_style="green"
|
|
1848
|
+
))
|
|
1849
|
+
|
|
1850
|
+
# Top conversations
|
|
1851
|
+
if stats['top_conversations']:
|
|
1852
|
+
self.console.print()
|
|
1853
|
+
conv_table = Table(
|
|
1854
|
+
show_header=True,
|
|
1855
|
+
header_style="bold magenta",
|
|
1856
|
+
box=box.ROUNDED,
|
|
1857
|
+
border_style="cyan"
|
|
1858
|
+
)
|
|
1859
|
+
conv_table.add_column("Conversation", style="cyan")
|
|
1860
|
+
conv_table.add_column("Tool Calls", style="green", justify="right")
|
|
1861
|
+
|
|
1862
|
+
for conv in stats['top_conversations']:
|
|
1863
|
+
conv_table.add_row(conv['conversation'], str(conv['count']))
|
|
1864
|
+
|
|
1865
|
+
self.console.print(Panel(
|
|
1866
|
+
conv_table,
|
|
1867
|
+
title="[bold cyan]💬 Conversations with Most Tool Usage[/bold cyan]",
|
|
1868
|
+
border_style="cyan"
|
|
1869
|
+
))
|
|
1870
|
+
|
|
1871
|
+
def display_mcp_server_states(self, server_states: List[Dict]) -> None:
|
|
1872
|
+
"""
|
|
1873
|
+
Display MCP server enabled/disabled states.
|
|
1874
|
+
|
|
1875
|
+
Args:
|
|
1876
|
+
server_states: List of dicts with 'server_name' and 'enabled' keys
|
|
1877
|
+
"""
|
|
1878
|
+
if not server_states:
|
|
1879
|
+
self.console.print("[yellow]No MCP servers available[/yellow]")
|
|
1880
|
+
return
|
|
1881
|
+
|
|
1882
|
+
self.console.print()
|
|
1883
|
+
self.console.print("[bold cyan]═══ MCP Server States ═══[/bold cyan]")
|
|
1884
|
+
self.console.print()
|
|
1885
|
+
|
|
1886
|
+
table = Table(
|
|
1887
|
+
show_header=True,
|
|
1888
|
+
header_style="bold magenta",
|
|
1889
|
+
box=box.ROUNDED,
|
|
1890
|
+
border_style="cyan"
|
|
1891
|
+
)
|
|
1892
|
+
table.add_column("Server Name", style="cyan")
|
|
1893
|
+
table.add_column("Status", justify="center")
|
|
1894
|
+
|
|
1895
|
+
for state in server_states:
|
|
1896
|
+
status = "[green]✓ Enabled[/green]" if state['enabled'] else "[red]✗ Disabled[/red]"
|
|
1897
|
+
table.add_row(state['server_name'], status)
|
|
1898
|
+
|
|
1899
|
+
self.console.print(table)
|
|
1900
|
+
|
|
1901
|
+
def display_prompt_violation(self, inspection_result) -> None:
|
|
1902
|
+
"""
|
|
1903
|
+
Display prompt security violation with details.
|
|
1904
|
+
|
|
1905
|
+
Args:
|
|
1906
|
+
inspection_result: InspectionResult from prompt inspector
|
|
1907
|
+
"""
|
|
1908
|
+
from rich.panel import Panel
|
|
1909
|
+
|
|
1910
|
+
self.console.print()
|
|
1911
|
+
|
|
1912
|
+
# Build violation message
|
|
1913
|
+
title = "[bold red]🛡️ Security Violation Detected[/bold red]"
|
|
1914
|
+
|
|
1915
|
+
content_parts = []
|
|
1916
|
+
|
|
1917
|
+
# Severity
|
|
1918
|
+
severity_colors = {
|
|
1919
|
+
'low': 'yellow',
|
|
1920
|
+
'medium': 'orange1',
|
|
1921
|
+
'high': 'red',
|
|
1922
|
+
'critical': 'bold red'
|
|
1923
|
+
}
|
|
1924
|
+
severity_color = severity_colors.get(inspection_result.severity, 'red')
|
|
1925
|
+
content_parts.append(f"[bold]Severity:[/bold] [{severity_color}]{inspection_result.severity.upper()}[/{severity_color}]")
|
|
1926
|
+
|
|
1927
|
+
# Violation types
|
|
1928
|
+
if inspection_result.violation_types:
|
|
1929
|
+
violations_text = ', '.join(inspection_result.violation_types)
|
|
1930
|
+
content_parts.append(f"[bold]Violations:[/bold] {violations_text}")
|
|
1931
|
+
|
|
1932
|
+
# Explanation
|
|
1933
|
+
content_parts.append(f"\n[bold]Details:[/bold]\n{inspection_result.explanation}")
|
|
1934
|
+
|
|
1935
|
+
# Detected patterns (sample)
|
|
1936
|
+
if inspection_result.detected_patterns:
|
|
1937
|
+
sample = inspection_result.detected_patterns[:2]
|
|
1938
|
+
patterns_text = ', '.join(f'"{p}"' for p in sample)
|
|
1939
|
+
if len(inspection_result.detected_patterns) > 2:
|
|
1940
|
+
patterns_text += f" (+{len(inspection_result.detected_patterns) - 2} more)"
|
|
1941
|
+
content_parts.append(f"\n[bold]Detected Patterns:[/bold] {patterns_text}")
|
|
1942
|
+
|
|
1943
|
+
# Detection method
|
|
1944
|
+
content_parts.append(f"\n[dim]Detection method: {inspection_result.inspection_method}[/dim]")
|
|
1945
|
+
|
|
1946
|
+
# Create panel
|
|
1947
|
+
panel = Panel(
|
|
1948
|
+
"\n".join(content_parts),
|
|
1949
|
+
title=title,
|
|
1950
|
+
border_style="red",
|
|
1951
|
+
padding=(1, 2)
|
|
1952
|
+
)
|
|
1953
|
+
|
|
1954
|
+
self.console.print(panel)
|
|
1955
|
+
self.console.print()
|
|
1956
|
+
self.console.print("[bold red]❌ This prompt has been blocked for security reasons.[/bold red]")
|
|
1957
|
+
self.console.print()
|
|
1958
|
+
|
|
1959
|
+
def confirm_risky_prompt(self, inspection_result) -> bool:
|
|
1960
|
+
"""
|
|
1961
|
+
Ask user to confirm they want to send a risky prompt.
|
|
1962
|
+
|
|
1963
|
+
Args:
|
|
1964
|
+
inspection_result: InspectionResult from prompt inspector
|
|
1965
|
+
|
|
1966
|
+
Returns:
|
|
1967
|
+
True if user confirms, False otherwise
|
|
1968
|
+
"""
|
|
1969
|
+
from rich.panel import Panel
|
|
1970
|
+
|
|
1971
|
+
self.console.print()
|
|
1972
|
+
|
|
1973
|
+
# Build warning message
|
|
1974
|
+
title = "[bold yellow]⚠️ Security Warning[/bold yellow]"
|
|
1975
|
+
|
|
1976
|
+
content_parts = []
|
|
1977
|
+
|
|
1978
|
+
# Severity
|
|
1979
|
+
severity_colors = {
|
|
1980
|
+
'low': 'yellow',
|
|
1981
|
+
'medium': 'orange1',
|
|
1982
|
+
'high': 'red',
|
|
1983
|
+
'critical': 'bold red'
|
|
1984
|
+
}
|
|
1985
|
+
severity_color = severity_colors.get(inspection_result.severity, 'yellow')
|
|
1986
|
+
content_parts.append(f"[bold]Severity:[/bold] [{severity_color}]{inspection_result.severity.upper()}[/{severity_color}]")
|
|
1987
|
+
|
|
1988
|
+
# Violation types
|
|
1989
|
+
if inspection_result.violation_types:
|
|
1990
|
+
violations_text = ', '.join(inspection_result.violation_types)
|
|
1991
|
+
content_parts.append(f"[bold]Potential Issues:[/bold] {violations_text}")
|
|
1992
|
+
|
|
1993
|
+
# Explanation
|
|
1994
|
+
content_parts.append(f"\n[bold]Details:[/bold]\n{inspection_result.explanation}")
|
|
1995
|
+
|
|
1996
|
+
# Detected patterns (sample)
|
|
1997
|
+
if inspection_result.detected_patterns:
|
|
1998
|
+
sample = inspection_result.detected_patterns[:2]
|
|
1999
|
+
patterns_text = ', '.join(f'"{p}"' for p in sample)
|
|
2000
|
+
if len(inspection_result.detected_patterns) > 2:
|
|
2001
|
+
patterns_text += f" (+{len(inspection_result.detected_patterns) - 2} more)"
|
|
2002
|
+
content_parts.append(f"\n[bold]Detected Patterns:[/bold] {patterns_text}")
|
|
2003
|
+
|
|
2004
|
+
# Sanitised version available?
|
|
2005
|
+
if inspection_result.sanitised_prompt:
|
|
2006
|
+
content_parts.append("\n[green]ℹ A sanitised version of your prompt is available.[/green]")
|
|
2007
|
+
|
|
2008
|
+
# Detection method
|
|
2009
|
+
content_parts.append(f"\n[dim]Detection method: {inspection_result.inspection_method}[/dim]")
|
|
2010
|
+
|
|
2011
|
+
# Create panel
|
|
2012
|
+
panel = Panel(
|
|
2013
|
+
"\n".join(content_parts),
|
|
2014
|
+
title=title,
|
|
2015
|
+
border_style="yellow",
|
|
2016
|
+
padding=(1, 2)
|
|
2017
|
+
)
|
|
2018
|
+
|
|
2019
|
+
self.console.print(panel)
|
|
2020
|
+
self.console.print()
|
|
2021
|
+
|
|
2022
|
+
# Prompt for confirmation
|
|
2023
|
+
response = Prompt.ask(
|
|
2024
|
+
"[bold yellow]Do you want to proceed with this prompt?[/bold yellow]",
|
|
2025
|
+
choices=["y", "n"],
|
|
2026
|
+
default="n"
|
|
2027
|
+
)
|
|
2028
|
+
|
|
2029
|
+
return response.lower() == 'y'
|
|
2030
|
+
|
|
2031
|
+
def select_mcp_server(self, server_states: List[Dict], action: str = "toggle") -> Optional[str]:
|
|
2032
|
+
"""
|
|
2033
|
+
Let user select an MCP server from a list.
|
|
2034
|
+
|
|
2035
|
+
Args:
|
|
2036
|
+
server_states: List of dicts with 'server_name' and 'enabled' keys
|
|
2037
|
+
action: Action description (e.g., "toggle", "enable", "disable")
|
|
2038
|
+
|
|
2039
|
+
Returns:
|
|
2040
|
+
Selected server name or None if cancelled
|
|
2041
|
+
"""
|
|
2042
|
+
if not server_states:
|
|
2043
|
+
return None
|
|
2044
|
+
|
|
2045
|
+
self.console.print(f"\n[bold cyan]Select a server to {action}:[/bold cyan]")
|
|
2046
|
+
for i, state in enumerate(server_states, 1):
|
|
2047
|
+
status = "[green]enabled[/green]" if state['enabled'] else "[red]disabled[/red]"
|
|
2048
|
+
self.console.print(f" [{i}] {state['server_name']} ({status})")
|
|
2049
|
+
self.console.print(" [0] Cancel")
|
|
2050
|
+
|
|
2051
|
+
choice = self.get_input("Enter choice")
|
|
2052
|
+
try:
|
|
2053
|
+
idx = int(choice)
|
|
2054
|
+
if idx == 0:
|
|
2055
|
+
return None
|
|
2056
|
+
if 1 <= idx <= len(server_states):
|
|
2057
|
+
return server_states[idx - 1]['server_name']
|
|
2058
|
+
else:
|
|
2059
|
+
self.print_error("Invalid choice")
|
|
2060
|
+
return None
|
|
2061
|
+
except ValueError:
|
|
2062
|
+
self.print_error("Invalid input")
|
|
2063
|
+
return None
|
|
2064
|
+
|
|
2065
|
+
# =========================================================================
|
|
2066
|
+
# Autonomous Actions Interface
|
|
2067
|
+
# =========================================================================
|
|
2068
|
+
|
|
2069
|
+
def display_autonomous_actions_menu(self, failed_action_count: int = 0) -> str:
|
|
2070
|
+
"""
|
|
2071
|
+
Display the autonomous actions submenu.
|
|
2072
|
+
|
|
2073
|
+
Args:
|
|
2074
|
+
failed_action_count: Number of failed/disabled actions to show as indicator
|
|
2075
|
+
|
|
2076
|
+
Returns:
|
|
2077
|
+
User's menu choice
|
|
2078
|
+
"""
|
|
2079
|
+
menu_content = Text()
|
|
2080
|
+
|
|
2081
|
+
# Show warning if there are failed actions
|
|
2082
|
+
if failed_action_count > 0:
|
|
2083
|
+
menu_content.append(f" ⚠️ {failed_action_count} action(s) disabled due to failures\n\n", style="yellow")
|
|
2084
|
+
|
|
2085
|
+
options = [
|
|
2086
|
+
('1', 'List Actions', 'list'),
|
|
2087
|
+
('2', 'Create New Action', 'create'),
|
|
2088
|
+
('3', 'View Action Runs', 'runs'),
|
|
2089
|
+
('4', 'Run Now (Manual)', 'run_now'),
|
|
2090
|
+
('5', 'Enable/Disable Action', 'toggle'),
|
|
2091
|
+
('6', 'Delete Action', 'delete'),
|
|
2092
|
+
('7', 'Export Run Results', 'export'),
|
|
2093
|
+
('8', 'Back to Main Menu', 'back')
|
|
2094
|
+
]
|
|
2095
|
+
|
|
2096
|
+
choice_map = {}
|
|
2097
|
+
for num, label, action in options:
|
|
2098
|
+
menu_content.append(" ", style="")
|
|
2099
|
+
menu_content.append(num, style="cyan")
|
|
2100
|
+
menu_content.append(f". {label}\n", style="")
|
|
2101
|
+
choice_map[num] = action
|
|
2102
|
+
|
|
2103
|
+
menu_panel = Panel(
|
|
2104
|
+
menu_content,
|
|
2105
|
+
title="[bold bright_magenta]AUTONOMOUS ACTIONS[/bold bright_magenta]",
|
|
2106
|
+
border_style="bold cyan",
|
|
2107
|
+
box=box.HEAVY,
|
|
2108
|
+
padding=(0, 1)
|
|
2109
|
+
)
|
|
2110
|
+
|
|
2111
|
+
self.console.print()
|
|
2112
|
+
self.console.print(menu_panel)
|
|
2113
|
+
self.console.print()
|
|
2114
|
+
|
|
2115
|
+
choice = self.get_input("Select an option")
|
|
2116
|
+
return choice_map.get(choice, 'invalid')
|
|
2117
|
+
|
|
2118
|
+
def select_action_creation_method(self) -> Optional[str]:
|
|
2119
|
+
"""
|
|
2120
|
+
Prompt user to select action creation method.
|
|
2121
|
+
|
|
2122
|
+
Returns:
|
|
2123
|
+
'manual', 'prompt_driven', or None if cancelled
|
|
2124
|
+
"""
|
|
2125
|
+
self.console.print("\n[bold cyan]Create Autonomous Action[/bold cyan]")
|
|
2126
|
+
self.console.print("─" * 40)
|
|
2127
|
+
self.console.print("Choose creation method:")
|
|
2128
|
+
self.console.print(" [cyan]1.[/cyan] Manual Wizard (step-by-step)")
|
|
2129
|
+
self.console.print(" [cyan]2.[/cyan] Prompt-Driven (conversational with AI)")
|
|
2130
|
+
self.console.print(" [cyan]3.[/cyan] Cancel")
|
|
2131
|
+
|
|
2132
|
+
choice = self.get_input("Select")
|
|
2133
|
+
if choice == "1":
|
|
2134
|
+
return "manual"
|
|
2135
|
+
elif choice == "2":
|
|
2136
|
+
return "prompt_driven"
|
|
2137
|
+
return None
|
|
2138
|
+
|
|
2139
|
+
def display_actions_list(self, actions: List[Dict]):
|
|
2140
|
+
"""
|
|
2141
|
+
Display a table of autonomous actions.
|
|
2142
|
+
|
|
2143
|
+
Args:
|
|
2144
|
+
actions: List of action dictionaries
|
|
2145
|
+
"""
|
|
2146
|
+
if not actions:
|
|
2147
|
+
self.print_info("No autonomous actions defined")
|
|
2148
|
+
return
|
|
2149
|
+
|
|
2150
|
+
table = Table(
|
|
2151
|
+
show_header=True,
|
|
2152
|
+
header_style="bold magenta",
|
|
2153
|
+
box=box.ROUNDED,
|
|
2154
|
+
border_style="cyan"
|
|
2155
|
+
)
|
|
2156
|
+
table.add_column("ID", style="dim", justify="right")
|
|
2157
|
+
table.add_column("Name", style="cyan")
|
|
2158
|
+
table.add_column("Schedule", style="green")
|
|
2159
|
+
table.add_column("Context", style="yellow")
|
|
2160
|
+
table.add_column("Status", justify="center")
|
|
2161
|
+
table.add_column("Last Run", style="dim")
|
|
2162
|
+
table.add_column("Failures", justify="right")
|
|
2163
|
+
|
|
2164
|
+
for action in actions:
|
|
2165
|
+
# Format schedule
|
|
2166
|
+
if action['schedule_type'] == 'one_off':
|
|
2167
|
+
config = action.get('schedule_config', {})
|
|
2168
|
+
run_date = config.get('run_date', 'N/A')
|
|
2169
|
+
if isinstance(run_date, str) and len(run_date) > 16:
|
|
2170
|
+
run_date = run_date[:16]
|
|
2171
|
+
schedule = f"One-off: {run_date}"
|
|
2172
|
+
else:
|
|
2173
|
+
config = action.get('schedule_config', {})
|
|
2174
|
+
cron = config.get('cron_expression', 'N/A')
|
|
2175
|
+
schedule = f"Cron: {cron}"
|
|
2176
|
+
|
|
2177
|
+
# Format status
|
|
2178
|
+
if action['is_enabled']:
|
|
2179
|
+
status = "[green]Enabled[/green]"
|
|
2180
|
+
else:
|
|
2181
|
+
status = "[red]Disabled[/red]"
|
|
2182
|
+
|
|
2183
|
+
# Format last run
|
|
2184
|
+
last_run = action.get('last_run_at', 'Never')
|
|
2185
|
+
if last_run and last_run != 'Never':
|
|
2186
|
+
if isinstance(last_run, str) and len(last_run) > 16:
|
|
2187
|
+
last_run = last_run[:16]
|
|
2188
|
+
|
|
2189
|
+
# Failure count with warning colour
|
|
2190
|
+
failures = action.get('failure_count', 0)
|
|
2191
|
+
max_failures = action.get('max_failures', 3)
|
|
2192
|
+
if failures >= max_failures:
|
|
2193
|
+
failures_str = f"[red]{failures}/{max_failures}[/red]"
|
|
2194
|
+
elif failures > 0:
|
|
2195
|
+
failures_str = f"[yellow]{failures}/{max_failures}[/yellow]"
|
|
2196
|
+
else:
|
|
2197
|
+
failures_str = f"{failures}/{max_failures}"
|
|
2198
|
+
|
|
2199
|
+
table.add_row(
|
|
2200
|
+
str(action['id']),
|
|
2201
|
+
action['name'][:30],
|
|
2202
|
+
schedule[:30],
|
|
2203
|
+
action.get('context_mode', 'fresh'),
|
|
2204
|
+
status,
|
|
2205
|
+
str(last_run) if last_run else 'Never',
|
|
2206
|
+
failures_str
|
|
2207
|
+
)
|
|
2208
|
+
|
|
2209
|
+
panel = Panel(
|
|
2210
|
+
table,
|
|
2211
|
+
title="[bold cyan]Autonomous Actions[/bold cyan]",
|
|
2212
|
+
border_style="cyan"
|
|
2213
|
+
)
|
|
2214
|
+
self.console.print()
|
|
2215
|
+
self.console.print(panel)
|
|
2216
|
+
self.console.print()
|
|
2217
|
+
|
|
2218
|
+
def display_action_runs(self, runs: List[Dict], action_name: str = None):
|
|
2219
|
+
"""
|
|
2220
|
+
Display a table of action runs.
|
|
2221
|
+
|
|
2222
|
+
Args:
|
|
2223
|
+
runs: List of run dictionaries
|
|
2224
|
+
action_name: Optional action name for title
|
|
2225
|
+
"""
|
|
2226
|
+
if not runs:
|
|
2227
|
+
self.print_info("No action runs found")
|
|
2228
|
+
return
|
|
2229
|
+
|
|
2230
|
+
table = Table(
|
|
2231
|
+
show_header=True,
|
|
2232
|
+
header_style="bold magenta",
|
|
2233
|
+
box=box.ROUNDED,
|
|
2234
|
+
border_style="cyan"
|
|
2235
|
+
)
|
|
2236
|
+
table.add_column("Run ID", style="dim", justify="right")
|
|
2237
|
+
if not action_name:
|
|
2238
|
+
table.add_column("Action", style="cyan")
|
|
2239
|
+
table.add_column("Started", style="green")
|
|
2240
|
+
table.add_column("Status", justify="center")
|
|
2241
|
+
table.add_column("Duration", style="dim", justify="right")
|
|
2242
|
+
table.add_column("Tokens", style="yellow", justify="right")
|
|
2243
|
+
|
|
2244
|
+
for run in runs:
|
|
2245
|
+
# Format status
|
|
2246
|
+
status = run.get('status', 'unknown')
|
|
2247
|
+
if status == 'completed':
|
|
2248
|
+
status_str = "[green]✓ Completed[/green]"
|
|
2249
|
+
elif status == 'failed':
|
|
2250
|
+
status_str = "[red]✗ Failed[/red]"
|
|
2251
|
+
elif status == 'running':
|
|
2252
|
+
status_str = "[yellow]⟳ Running[/yellow]"
|
|
2253
|
+
else:
|
|
2254
|
+
status_str = status
|
|
2255
|
+
|
|
2256
|
+
# Calculate duration
|
|
2257
|
+
started = run.get('started_at')
|
|
2258
|
+
completed = run.get('completed_at')
|
|
2259
|
+
if started and completed:
|
|
2260
|
+
try:
|
|
2261
|
+
if isinstance(started, str):
|
|
2262
|
+
started = datetime.fromisoformat(started.replace('Z', '+00:00'))
|
|
2263
|
+
if isinstance(completed, str):
|
|
2264
|
+
completed = datetime.fromisoformat(completed.replace('Z', '+00:00'))
|
|
2265
|
+
duration = (completed - started).total_seconds()
|
|
2266
|
+
duration_str = f"{duration:.1f}s"
|
|
2267
|
+
except:
|
|
2268
|
+
duration_str = "N/A"
|
|
2269
|
+
else:
|
|
2270
|
+
duration_str = "N/A"
|
|
2271
|
+
|
|
2272
|
+
# Format tokens
|
|
2273
|
+
input_tokens = run.get('input_tokens', 0)
|
|
2274
|
+
output_tokens = run.get('output_tokens', 0)
|
|
2275
|
+
tokens_str = f"{input_tokens:,}/{output_tokens:,}"
|
|
2276
|
+
|
|
2277
|
+
# Format started time
|
|
2278
|
+
started_str = str(started)[:19] if started else 'N/A'
|
|
2279
|
+
|
|
2280
|
+
row = [str(run['id'])]
|
|
2281
|
+
if not action_name:
|
|
2282
|
+
row.append(run.get('action_name', 'Unknown')[:20])
|
|
2283
|
+
row.extend([started_str, status_str, duration_str, tokens_str])
|
|
2284
|
+
|
|
2285
|
+
table.add_row(*row)
|
|
2286
|
+
|
|
2287
|
+
title = f"[bold cyan]Runs for '{action_name}'[/bold cyan]" if action_name else "[bold cyan]Recent Action Runs[/bold cyan]"
|
|
2288
|
+
panel = Panel(table, title=title, border_style="cyan")
|
|
2289
|
+
self.console.print()
|
|
2290
|
+
self.console.print(panel)
|
|
2291
|
+
self.console.print()
|
|
2292
|
+
|
|
2293
|
+
def display_run_details(self, run: Dict):
|
|
2294
|
+
"""
|
|
2295
|
+
Display detailed information about a single run.
|
|
2296
|
+
|
|
2297
|
+
Args:
|
|
2298
|
+
run: Run dictionary
|
|
2299
|
+
"""
|
|
2300
|
+
content_parts = []
|
|
2301
|
+
|
|
2302
|
+
content_parts.append(f"[bold]Run ID:[/bold] {run['id']}")
|
|
2303
|
+
content_parts.append(f"[bold]Action:[/bold] {run.get('action_name', 'Unknown')}")
|
|
2304
|
+
content_parts.append(f"[bold]Status:[/bold] {run['status']}")
|
|
2305
|
+
content_parts.append(f"[bold]Started:[/bold] {run.get('started_at', 'N/A')}")
|
|
2306
|
+
content_parts.append(f"[bold]Completed:[/bold] {run.get('completed_at', 'N/A')}")
|
|
2307
|
+
content_parts.append(f"[bold]Input Tokens:[/bold] {run.get('input_tokens', 0):,}")
|
|
2308
|
+
content_parts.append(f"[bold]Output Tokens:[/bold] {run.get('output_tokens', 0):,}")
|
|
2309
|
+
|
|
2310
|
+
if run.get('error_message'):
|
|
2311
|
+
content_parts.append(f"\n[bold red]Error:[/bold red] {run['error_message']}")
|
|
2312
|
+
|
|
2313
|
+
if run.get('result_text'):
|
|
2314
|
+
content_parts.append("\n[bold]Result:[/bold]")
|
|
2315
|
+
# Truncate long results
|
|
2316
|
+
result = run['result_text']
|
|
2317
|
+
if len(result) > 1000:
|
|
2318
|
+
result = result[:1000] + "\n... (truncated)"
|
|
2319
|
+
content_parts.append(result)
|
|
2320
|
+
|
|
2321
|
+
panel = Panel(
|
|
2322
|
+
"\n".join(content_parts),
|
|
2323
|
+
title="[bold cyan]Run Details[/bold cyan]",
|
|
2324
|
+
border_style="cyan",
|
|
2325
|
+
padding=(1, 2)
|
|
2326
|
+
)
|
|
2327
|
+
self.console.print()
|
|
2328
|
+
self.console.print(panel)
|
|
2329
|
+
self.console.print()
|
|
2330
|
+
|
|
2331
|
+
def select_action(self, actions: List[Dict], prompt: str = "Select an action") -> Optional[int]:
|
|
2332
|
+
"""
|
|
2333
|
+
Let user select an action from a list.
|
|
2334
|
+
|
|
2335
|
+
Args:
|
|
2336
|
+
actions: List of action dictionaries
|
|
2337
|
+
prompt: Prompt to display
|
|
2338
|
+
|
|
2339
|
+
Returns:
|
|
2340
|
+
Selected action ID or None if cancelled
|
|
2341
|
+
"""
|
|
2342
|
+
if not actions:
|
|
2343
|
+
self.print_warning("No actions available")
|
|
2344
|
+
return None
|
|
2345
|
+
|
|
2346
|
+
self.console.print(f"\n[bold cyan]{prompt}:[/bold cyan]")
|
|
2347
|
+
for i, action in enumerate(actions, 1):
|
|
2348
|
+
status = "[green]enabled[/green]" if action['is_enabled'] else "[red]disabled[/red]"
|
|
2349
|
+
self.console.print(f" [{i}] {action['name']} ({status})")
|
|
2350
|
+
self.console.print(" [0] Cancel")
|
|
2351
|
+
|
|
2352
|
+
choice = self.get_input("Enter choice")
|
|
2353
|
+
try:
|
|
2354
|
+
idx = int(choice)
|
|
2355
|
+
if idx == 0:
|
|
2356
|
+
return None
|
|
2357
|
+
if 1 <= idx <= len(actions):
|
|
2358
|
+
return actions[idx - 1]['id']
|
|
2359
|
+
else:
|
|
2360
|
+
self.print_error("Invalid choice")
|
|
2361
|
+
return None
|
|
2362
|
+
except ValueError:
|
|
2363
|
+
self.print_error("Invalid input")
|
|
2364
|
+
return None
|
|
2365
|
+
|
|
2366
|
+
def select_run(self, runs: List[Dict], prompt: str = "Select a run") -> Optional[int]:
|
|
2367
|
+
"""
|
|
2368
|
+
Let user select a run from a list.
|
|
2369
|
+
|
|
2370
|
+
Args:
|
|
2371
|
+
runs: List of run dictionaries
|
|
2372
|
+
prompt: Prompt to display
|
|
2373
|
+
|
|
2374
|
+
Returns:
|
|
2375
|
+
Selected run ID or None if cancelled
|
|
2376
|
+
"""
|
|
2377
|
+
if not runs:
|
|
2378
|
+
self.print_warning("No runs available")
|
|
2379
|
+
return None
|
|
2380
|
+
|
|
2381
|
+
self.console.print(f"\n[bold cyan]{prompt}:[/bold cyan]")
|
|
2382
|
+
for i, run in enumerate(runs, 1):
|
|
2383
|
+
status = run.get('status', 'unknown')
|
|
2384
|
+
started = str(run.get('started_at', ''))[:16]
|
|
2385
|
+
self.console.print(f" [{i}] Run {run['id']} - {status} ({started})")
|
|
2386
|
+
self.console.print(" [0] Cancel")
|
|
2387
|
+
|
|
2388
|
+
choice = self.get_input("Enter choice")
|
|
2389
|
+
try:
|
|
2390
|
+
idx = int(choice)
|
|
2391
|
+
if idx == 0:
|
|
2392
|
+
return None
|
|
2393
|
+
if 1 <= idx <= len(runs):
|
|
2394
|
+
return runs[idx - 1]['id']
|
|
2395
|
+
else:
|
|
2396
|
+
self.print_error("Invalid choice")
|
|
2397
|
+
return None
|
|
2398
|
+
except ValueError:
|
|
2399
|
+
self.print_error("Invalid input")
|
|
2400
|
+
return None
|
|
2401
|
+
|
|
2402
|
+
def select_export_format(self) -> Optional[str]:
|
|
2403
|
+
"""
|
|
2404
|
+
Let user select an export format.
|
|
2405
|
+
|
|
2406
|
+
Returns:
|
|
2407
|
+
'text', 'html', 'markdown', or None if cancelled
|
|
2408
|
+
"""
|
|
2409
|
+
self.console.print("\n[bold cyan]Select export format:[/bold cyan]")
|
|
2410
|
+
self.console.print(" [1] Plain Text")
|
|
2411
|
+
self.console.print(" [2] HTML")
|
|
2412
|
+
self.console.print(" [3] Markdown")
|
|
2413
|
+
self.console.print(" [0] Cancel")
|
|
2414
|
+
|
|
2415
|
+
choice = self.get_input("Enter choice")
|
|
2416
|
+
format_map = {'1': 'text', '2': 'html', '3': 'markdown'}
|
|
2417
|
+
return format_map.get(choice)
|
|
2418
|
+
|
|
2419
|
+
def create_action_wizard(self, available_models: List[Dict],
|
|
2420
|
+
available_tools: List[Dict]) -> Optional[Dict]:
|
|
2421
|
+
"""
|
|
2422
|
+
Interactive wizard for creating a new autonomous action.
|
|
2423
|
+
|
|
2424
|
+
Args:
|
|
2425
|
+
available_models: List of available model dictionaries
|
|
2426
|
+
available_tools: List of available tool dictionaries
|
|
2427
|
+
|
|
2428
|
+
Returns:
|
|
2429
|
+
Action configuration dictionary or None if cancelled
|
|
2430
|
+
"""
|
|
2431
|
+
self.console.print("\n[bold cyan]═══ Create New Autonomous Action ═══[/bold cyan]\n")
|
|
2432
|
+
|
|
2433
|
+
# Step 1: Name
|
|
2434
|
+
name = self.get_input("Action name (unique identifier)")
|
|
2435
|
+
if not name:
|
|
2436
|
+
self.print_error("Name is required")
|
|
2437
|
+
return None
|
|
2438
|
+
|
|
2439
|
+
# Step 2: Description
|
|
2440
|
+
description = self.get_input("Description (what this action does)")
|
|
2441
|
+
if not description:
|
|
2442
|
+
description = name
|
|
2443
|
+
|
|
2444
|
+
# Step 3: Action prompt
|
|
2445
|
+
self.console.print("\n[bold]Enter the action prompt:[/bold]")
|
|
2446
|
+
self.console.print("[dim]This is what the AI will execute each time the action runs.[/dim]")
|
|
2447
|
+
action_prompt = self.get_multiline_input("Action prompt")
|
|
2448
|
+
if not action_prompt:
|
|
2449
|
+
self.print_error("Action prompt is required")
|
|
2450
|
+
return None
|
|
2451
|
+
|
|
2452
|
+
# Step 4: Model selection
|
|
2453
|
+
self.console.print("\n[bold cyan]Select a model:[/bold cyan]")
|
|
2454
|
+
if not available_models:
|
|
2455
|
+
self.print_error("No models available")
|
|
2456
|
+
return None
|
|
2457
|
+
|
|
2458
|
+
for i, model in enumerate(available_models, 1):
|
|
2459
|
+
friendly_name = extract_friendly_model_name(model.get('id', ''))
|
|
2460
|
+
self.console.print(f" [{i}] {friendly_name}")
|
|
2461
|
+
|
|
2462
|
+
model_choice = self.get_input("Enter choice")
|
|
2463
|
+
try:
|
|
2464
|
+
model_idx = int(model_choice) - 1
|
|
2465
|
+
if model_idx < 0 or model_idx >= len(available_models):
|
|
2466
|
+
self.print_error("Invalid model selection")
|
|
2467
|
+
return None
|
|
2468
|
+
model_id = available_models[model_idx]['id']
|
|
2469
|
+
except ValueError:
|
|
2470
|
+
self.print_error("Invalid input")
|
|
2471
|
+
return None
|
|
2472
|
+
|
|
2473
|
+
# Step 5: Schedule type
|
|
2474
|
+
self.console.print("\n[bold cyan]Schedule type:[/bold cyan]")
|
|
2475
|
+
self.console.print(" [1] One-off (run once at specific time)")
|
|
2476
|
+
self.console.print(" [2] Recurring (run on schedule)")
|
|
2477
|
+
|
|
2478
|
+
schedule_choice = self.get_input("Enter choice")
|
|
2479
|
+
if schedule_choice == '1':
|
|
2480
|
+
schedule_type = 'one_off'
|
|
2481
|
+
self.console.print("\n[dim]Enter date/time in format: YYYY-MM-DD HH:MM[/dim]")
|
|
2482
|
+
run_date_str = self.get_input("Run date/time")
|
|
2483
|
+
try:
|
|
2484
|
+
run_date = datetime.strptime(run_date_str, "%Y-%m-%d %H:%M")
|
|
2485
|
+
schedule_config = {'run_date': run_date.isoformat()}
|
|
2486
|
+
except ValueError:
|
|
2487
|
+
self.print_error("Invalid date format")
|
|
2488
|
+
return None
|
|
2489
|
+
elif schedule_choice == '2':
|
|
2490
|
+
schedule_type = 'recurring'
|
|
2491
|
+
self.console.print("\n[dim]Enter cron expression (minute hour day month day_of_week)[/dim]")
|
|
2492
|
+
self.console.print("[dim]Examples: '0 9 * * *' (daily at 9am), '0 0 * * 0' (weekly on Sunday)[/dim]")
|
|
2493
|
+
cron_expr = self.get_input("Cron expression")
|
|
2494
|
+
if not cron_expr:
|
|
2495
|
+
self.print_error("Cron expression is required")
|
|
2496
|
+
return None
|
|
2497
|
+
schedule_config = {'cron_expression': cron_expr}
|
|
2498
|
+
else:
|
|
2499
|
+
self.print_error("Invalid schedule type")
|
|
2500
|
+
return None
|
|
2501
|
+
|
|
2502
|
+
# Step 6: Context mode
|
|
2503
|
+
self.console.print("\n[bold cyan]Context mode:[/bold cyan]")
|
|
2504
|
+
self.console.print(" [1] Fresh - Start with clean context each run")
|
|
2505
|
+
self.console.print(" [2] Cumulative - Carry context from previous runs")
|
|
2506
|
+
|
|
2507
|
+
context_choice = self.get_input("Enter choice")
|
|
2508
|
+
context_mode = 'cumulative' if context_choice == '2' else 'fresh'
|
|
2509
|
+
|
|
2510
|
+
# Step 7: Max failures
|
|
2511
|
+
max_failures_str = self.get_input("Max failures before auto-disable (default: 3)")
|
|
2512
|
+
try:
|
|
2513
|
+
max_failures = int(max_failures_str) if max_failures_str else 3
|
|
2514
|
+
except ValueError:
|
|
2515
|
+
max_failures = 3
|
|
2516
|
+
|
|
2517
|
+
# Step 8: Max tokens
|
|
2518
|
+
self.console.print("\n[bold cyan]Max tokens for LLM response:[/bold cyan]")
|
|
2519
|
+
self.console.print(" Use higher values for tasks that generate large content (e.g., reports)")
|
|
2520
|
+
self.console.print(" Recommended: 4096 (simple tasks), 8192 (default), 16384 (large reports)")
|
|
2521
|
+
max_tokens_str = self.get_input("Max tokens (default: 8192)")
|
|
2522
|
+
try:
|
|
2523
|
+
max_tokens = int(max_tokens_str) if max_tokens_str else 8192
|
|
2524
|
+
# Enforce reasonable limits
|
|
2525
|
+
max_tokens = max(1024, min(max_tokens, 32000))
|
|
2526
|
+
except ValueError:
|
|
2527
|
+
max_tokens = 8192
|
|
2528
|
+
|
|
2529
|
+
# Step 9: Tool selection
|
|
2530
|
+
selected_tools = []
|
|
2531
|
+
if available_tools:
|
|
2532
|
+
self.console.print("\n[bold cyan]Select tools to allow (enter numbers separated by commas, or 'none'):[/bold cyan]")
|
|
2533
|
+
for i, tool in enumerate(available_tools, 1):
|
|
2534
|
+
server = tool.get('server', 'unknown')
|
|
2535
|
+
self.console.print(f" [{i}] {tool['name']} ({server})")
|
|
2536
|
+
|
|
2537
|
+
tools_input = self.get_input("Tool numbers (e.g., 1,3,5) or 'none'")
|
|
2538
|
+
if tools_input.lower() != 'none' and tools_input:
|
|
2539
|
+
try:
|
|
2540
|
+
indices = [int(x.strip()) - 1 for x in tools_input.split(',')]
|
|
2541
|
+
for idx in indices:
|
|
2542
|
+
if 0 <= idx < len(available_tools):
|
|
2543
|
+
tool = available_tools[idx]
|
|
2544
|
+
selected_tools.append({
|
|
2545
|
+
'tool_name': tool['name'],
|
|
2546
|
+
'server_name': tool.get('server'),
|
|
2547
|
+
'permission_state': 'allowed'
|
|
2548
|
+
})
|
|
2549
|
+
except ValueError:
|
|
2550
|
+
self.print_warning("Invalid tool selection, proceeding without tools")
|
|
2551
|
+
|
|
2552
|
+
# Confirm
|
|
2553
|
+
self.console.print("\n[bold cyan]═══ Action Summary ═══[/bold cyan]")
|
|
2554
|
+
self.console.print(f" Name: {name}")
|
|
2555
|
+
self.console.print(f" Description: {description}")
|
|
2556
|
+
self.console.print(f" Model: {extract_friendly_model_name(model_id)}")
|
|
2557
|
+
self.console.print(f" Schedule: {schedule_type} - {schedule_config}")
|
|
2558
|
+
self.console.print(f" Context Mode: {context_mode}")
|
|
2559
|
+
self.console.print(f" Max Failures: {max_failures}")
|
|
2560
|
+
self.console.print(f" Max Tokens: {max_tokens}")
|
|
2561
|
+
self.console.print(f" Tools: {len(selected_tools)}")
|
|
2562
|
+
self.console.print()
|
|
2563
|
+
|
|
2564
|
+
if not self.confirm("Create this action?"):
|
|
2565
|
+
return None
|
|
2566
|
+
|
|
2567
|
+
return {
|
|
2568
|
+
'name': name,
|
|
2569
|
+
'description': description,
|
|
2570
|
+
'action_prompt': action_prompt,
|
|
2571
|
+
'model_id': model_id,
|
|
2572
|
+
'schedule_type': schedule_type,
|
|
2573
|
+
'schedule_config': schedule_config,
|
|
2574
|
+
'context_mode': context_mode,
|
|
2575
|
+
'max_failures': max_failures,
|
|
2576
|
+
'max_tokens': max_tokens,
|
|
2577
|
+
'tool_permissions': selected_tools
|
|
2578
|
+
}
|
|
2579
|
+
|
|
2580
|
+
def display_creation_conversation_message(
|
|
2581
|
+
self,
|
|
2582
|
+
role: str,
|
|
2583
|
+
content: str,
|
|
2584
|
+
is_final: bool = False
|
|
2585
|
+
) -> None:
|
|
2586
|
+
"""
|
|
2587
|
+
Display a message in the action creation conversation.
|
|
2588
|
+
|
|
2589
|
+
Args:
|
|
2590
|
+
role: Message role ('user' or 'assistant')
|
|
2591
|
+
content: Message content text
|
|
2592
|
+
is_final: Whether this is the final message in the conversation
|
|
2593
|
+
"""
|
|
2594
|
+
if role == "user":
|
|
2595
|
+
self.console.print(f"\n[bold purple]You:[/bold purple] {content}")
|
|
2596
|
+
else:
|
|
2597
|
+
self.console.print(f"\n[bold green]Assistant:[/bold green]")
|
|
2598
|
+
self.console.print(Markdown(content))
|
|
2599
|
+
|
|
2600
|
+
if is_final:
|
|
2601
|
+
self.console.print("─" * 40)
|
|
2602
|
+
|
|
2603
|
+
def display_creation_tool_call(self, tool_name: str, result: dict) -> None:
|
|
2604
|
+
"""
|
|
2605
|
+
Display a tool call result during action creation.
|
|
2606
|
+
|
|
2607
|
+
Args:
|
|
2608
|
+
tool_name: Name of the tool that was called
|
|
2609
|
+
result: Result dictionary from the tool
|
|
2610
|
+
"""
|
|
2611
|
+
self.console.print(f"\n[dim]↳ Called {tool_name}[/dim]")
|
|
2612
|
+
|
|
2613
|
+
if tool_name == 'list_available_tools':
|
|
2614
|
+
count = result.get('count', 0)
|
|
2615
|
+
self.console.print(f"[dim] Found {count} available tools[/dim]")
|
|
2616
|
+
|
|
2617
|
+
elif tool_name == 'validate_schedule':
|
|
2618
|
+
if result.get('valid'):
|
|
2619
|
+
human_readable = result.get('human_readable', 'Valid')
|
|
2620
|
+
self.console.print(f"[dim] Schedule: {human_readable}[/dim]")
|
|
2621
|
+
else:
|
|
2622
|
+
error = result.get('error', 'Invalid')
|
|
2623
|
+
self.console.print(f"[yellow] Validation failed: {error}[/yellow]")
|
|
2624
|
+
|
|
2625
|
+
elif tool_name == 'create_autonomous_action':
|
|
2626
|
+
if result.get('success'):
|
|
2627
|
+
name = result.get('name', 'Action')
|
|
2628
|
+
self.console.print(f"[green] ✓ Created action: {name}[/green]")
|
|
2629
|
+
else:
|
|
2630
|
+
error = result.get('error', 'Creation failed')
|
|
2631
|
+
self.console.print(f"[red] ✗ Error: {error}[/red]")
|
|
2632
|
+
|
|
2633
|
+
def display_creation_prompt_header(self) -> None:
|
|
2634
|
+
"""Display the header for prompt-driven action creation."""
|
|
2635
|
+
header = Panel(
|
|
2636
|
+
"[bold]Describe the task you want to schedule.[/bold]\n"
|
|
2637
|
+
"The AI will help you configure the action by asking clarifying questions.\n"
|
|
2638
|
+
"[dim]Type 'cancel' at any time to abort.[/dim]",
|
|
2639
|
+
title="[bold cyan]Prompt-Driven Action Creation[/bold cyan]",
|
|
2640
|
+
border_style="cyan",
|
|
2641
|
+
box=box.ROUNDED
|
|
2642
|
+
)
|
|
2643
|
+
self.console.print()
|
|
2644
|
+
self.console.print(header)
|
|
2645
|
+
self.console.print()
|