langroid 0.56.19__py3-none-any.whl → 0.58.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- langroid/agent/chat_document.py +67 -19
- langroid/agent/task.py +96 -1
- langroid/parsing/url_loader.py +234 -1
- langroid/utils/html_logger.py +825 -0
- {langroid-0.56.19.dist-info → langroid-0.58.0.dist-info}/METADATA +6 -1
- {langroid-0.56.19.dist-info → langroid-0.58.0.dist-info}/RECORD +8 -7
- {langroid-0.56.19.dist-info → langroid-0.58.0.dist-info}/WHEEL +0 -0
- {langroid-0.56.19.dist-info → langroid-0.58.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,825 @@
|
|
1
|
+
"""HTML Logger for Langroid Task System.
|
2
|
+
|
3
|
+
This module provides an HTML logger that creates self-contained HTML files
|
4
|
+
with collapsible log entries for better visualization of agent interactions.
|
5
|
+
"""
|
6
|
+
|
7
|
+
import html
|
8
|
+
import json
|
9
|
+
from datetime import datetime
|
10
|
+
from pathlib import Path
|
11
|
+
from typing import Any, Dict, List
|
12
|
+
|
13
|
+
from langroid.pydantic_v1 import BaseModel
|
14
|
+
from langroid.utils.logging import setup_logger
|
15
|
+
|
16
|
+
|
17
|
+
class HTMLLogger:
|
18
|
+
"""Logger that outputs task logs as interactive HTML files."""
|
19
|
+
|
20
|
+
def __init__(
|
21
|
+
self,
|
22
|
+
filename: str,
|
23
|
+
log_dir: str = "logs",
|
24
|
+
model_info: str = "",
|
25
|
+
append: bool = False,
|
26
|
+
):
|
27
|
+
"""Initialize the HTML logger.
|
28
|
+
|
29
|
+
Args:
|
30
|
+
filename: Base name for the log file (without extension)
|
31
|
+
log_dir: Directory to store log files
|
32
|
+
model_info: Information about the model being used
|
33
|
+
append: Whether to append to existing file
|
34
|
+
"""
|
35
|
+
self.filename = filename
|
36
|
+
self.log_dir = Path(log_dir)
|
37
|
+
self.log_dir.mkdir(parents=True, exist_ok=True)
|
38
|
+
self.file_path = self.log_dir / f"{filename}.html"
|
39
|
+
self.model_info = model_info
|
40
|
+
self.entries: List[Dict[str, Any]] = []
|
41
|
+
self.entry_counter = 0
|
42
|
+
self.tool_counter = 0
|
43
|
+
|
44
|
+
# Logger for errors
|
45
|
+
self.logger = setup_logger(__name__)
|
46
|
+
|
47
|
+
if not append or not self.file_path.exists():
|
48
|
+
self._write_header()
|
49
|
+
|
50
|
+
def _write_header(self) -> None:
|
51
|
+
"""Write the HTML header with CSS and JavaScript."""
|
52
|
+
timestamp = datetime.now().strftime("%m/%d/%Y, %I:%M:%S %p")
|
53
|
+
|
54
|
+
html_content = f"""<!DOCTYPE html>
|
55
|
+
<html lang="en">
|
56
|
+
<head>
|
57
|
+
<meta charset="UTF-8">
|
58
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
59
|
+
<meta http-equiv="refresh" content="2">
|
60
|
+
<title>{self.filename} - Langroid Task Log</title>
|
61
|
+
<style>
|
62
|
+
body {{
|
63
|
+
background-color: #1e1e1e;
|
64
|
+
color: #f0f0f0;
|
65
|
+
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
66
|
+
font-size: 14px;
|
67
|
+
margin: 0;
|
68
|
+
padding: 20px;
|
69
|
+
line-height: 1.6;
|
70
|
+
}}
|
71
|
+
|
72
|
+
.header {{
|
73
|
+
border: 2px solid #d4a017;
|
74
|
+
padding: 15px;
|
75
|
+
margin-bottom: 20px;
|
76
|
+
color: #d4a017;
|
77
|
+
background-color: #2b2b2b;
|
78
|
+
border-radius: 5px;
|
79
|
+
}}
|
80
|
+
|
81
|
+
.header-line {{
|
82
|
+
display: flex;
|
83
|
+
justify-content: space-between;
|
84
|
+
align-items: center;
|
85
|
+
}}
|
86
|
+
|
87
|
+
.separator {{
|
88
|
+
border-bottom: 2px solid #d4a017;
|
89
|
+
margin: 20px 0;
|
90
|
+
}}
|
91
|
+
|
92
|
+
.controls {{
|
93
|
+
margin-bottom: 20px;
|
94
|
+
}}
|
95
|
+
|
96
|
+
.controls {{
|
97
|
+
display: flex;
|
98
|
+
align-items: center;
|
99
|
+
gap: 20px;
|
100
|
+
}}
|
101
|
+
|
102
|
+
.controls button {{
|
103
|
+
background-color: #333;
|
104
|
+
color: #f0f0f0;
|
105
|
+
border: 1px solid #555;
|
106
|
+
padding: 8px 16px;
|
107
|
+
cursor: pointer;
|
108
|
+
border-radius: 3px;
|
109
|
+
font-family: inherit;
|
110
|
+
}}
|
111
|
+
|
112
|
+
.controls button:hover {{
|
113
|
+
background-color: #444;
|
114
|
+
border-color: #d4a017;
|
115
|
+
}}
|
116
|
+
|
117
|
+
.controls label {{
|
118
|
+
color: #f0f0f0;
|
119
|
+
display: flex;
|
120
|
+
align-items: center;
|
121
|
+
gap: 8px;
|
122
|
+
cursor: pointer;
|
123
|
+
}}
|
124
|
+
|
125
|
+
.controls input[type="checkbox"] {{
|
126
|
+
cursor: pointer;
|
127
|
+
}}
|
128
|
+
|
129
|
+
.hidden {{
|
130
|
+
display: none !important;
|
131
|
+
}}
|
132
|
+
|
133
|
+
.entry {{
|
134
|
+
margin-bottom: 15px;
|
135
|
+
padding-left: 10px;
|
136
|
+
}}
|
137
|
+
|
138
|
+
.entry.faded {{
|
139
|
+
opacity: 0.4;
|
140
|
+
}}
|
141
|
+
|
142
|
+
.entry.important {{
|
143
|
+
opacity: 1.0;
|
144
|
+
}}
|
145
|
+
|
146
|
+
.entry.user .entity-header {{
|
147
|
+
color: #00bfff;
|
148
|
+
}}
|
149
|
+
|
150
|
+
.entry.assistant .entity-header {{
|
151
|
+
color: #ff6b6b;
|
152
|
+
}}
|
153
|
+
|
154
|
+
.entry.llm .entity-header {{
|
155
|
+
color: #00ff00;
|
156
|
+
}}
|
157
|
+
|
158
|
+
.entry.agent .entity-header {{
|
159
|
+
color: #ff9500;
|
160
|
+
}}
|
161
|
+
|
162
|
+
.entry.system .entity-header {{
|
163
|
+
color: #888;
|
164
|
+
}}
|
165
|
+
|
166
|
+
.entry.other .entity-header {{
|
167
|
+
color: #999;
|
168
|
+
}}
|
169
|
+
|
170
|
+
.entity-header {{
|
171
|
+
font-weight: bold;
|
172
|
+
margin-bottom: 5px;
|
173
|
+
cursor: pointer;
|
174
|
+
}}
|
175
|
+
|
176
|
+
.entity-header:hover {{
|
177
|
+
opacity: 0.8;
|
178
|
+
}}
|
179
|
+
|
180
|
+
.header-main {{
|
181
|
+
/* Removed text-transform to preserve tool name casing */
|
182
|
+
display: inline;
|
183
|
+
}}
|
184
|
+
|
185
|
+
.header-content {{
|
186
|
+
margin-left: 30px;
|
187
|
+
opacity: 0.7;
|
188
|
+
font-weight: normal;
|
189
|
+
font-style: italic;
|
190
|
+
display: block;
|
191
|
+
}}
|
192
|
+
|
193
|
+
.entry-content {{
|
194
|
+
margin-left: 20px;
|
195
|
+
margin-top: 5px;
|
196
|
+
}}
|
197
|
+
|
198
|
+
.entry-content.collapsed {{
|
199
|
+
display: none;
|
200
|
+
}}
|
201
|
+
|
202
|
+
.collapsible {{
|
203
|
+
margin: 5px 0;
|
204
|
+
margin-left: 20px;
|
205
|
+
}}
|
206
|
+
|
207
|
+
.toggle {{
|
208
|
+
cursor: pointer;
|
209
|
+
user-select: none;
|
210
|
+
color: #00ff00;
|
211
|
+
display: inline-block;
|
212
|
+
width: 25px;
|
213
|
+
font-family: monospace;
|
214
|
+
margin-right: 5px;
|
215
|
+
}}
|
216
|
+
|
217
|
+
.toggle:hover {{
|
218
|
+
color: #00ff00;
|
219
|
+
text-shadow: 0 0 5px #00ff00;
|
220
|
+
}}
|
221
|
+
|
222
|
+
.content {{
|
223
|
+
margin-left: 25px;
|
224
|
+
margin-top: 5px;
|
225
|
+
white-space: pre-wrap;
|
226
|
+
word-wrap: break-word;
|
227
|
+
}}
|
228
|
+
|
229
|
+
.main-content {{
|
230
|
+
margin-top: 10px;
|
231
|
+
white-space: pre-wrap;
|
232
|
+
word-wrap: break-word;
|
233
|
+
}}
|
234
|
+
|
235
|
+
.collapsed .content {{
|
236
|
+
display: none;
|
237
|
+
}}
|
238
|
+
|
239
|
+
.tool-section {{
|
240
|
+
margin: 10px 0;
|
241
|
+
margin-left: 20px;
|
242
|
+
}}
|
243
|
+
|
244
|
+
.tool-name {{
|
245
|
+
color: #d4a017;
|
246
|
+
font-weight: bold;
|
247
|
+
}}
|
248
|
+
|
249
|
+
.tool-result {{
|
250
|
+
margin-left: 25px;
|
251
|
+
}}
|
252
|
+
|
253
|
+
.tool-result.success {{
|
254
|
+
color: #00ff00;
|
255
|
+
}}
|
256
|
+
|
257
|
+
.tool-result.error {{
|
258
|
+
color: #ff0000;
|
259
|
+
}}
|
260
|
+
|
261
|
+
.code-block {{
|
262
|
+
background-color: #2b2b2b;
|
263
|
+
border: 1px solid #444;
|
264
|
+
padding: 10px;
|
265
|
+
margin: 5px 0;
|
266
|
+
border-radius: 3px;
|
267
|
+
overflow-x: auto;
|
268
|
+
}}
|
269
|
+
|
270
|
+
.metadata {{
|
271
|
+
color: #888;
|
272
|
+
font-size: 0.9em;
|
273
|
+
margin-left: 25px;
|
274
|
+
}}
|
275
|
+
|
276
|
+
|
277
|
+
pre {{
|
278
|
+
margin: 0;
|
279
|
+
white-space: pre-wrap;
|
280
|
+
word-wrap: break-word;
|
281
|
+
}}
|
282
|
+
</style>
|
283
|
+
<script>
|
284
|
+
function toggleEntry(entryId) {{
|
285
|
+
const contentElement = document.getElementById(entryId + '_content');
|
286
|
+
const toggleElement = document.querySelector(
|
287
|
+
'#' + entryId + ' .entity-header .toggle'
|
288
|
+
);
|
289
|
+
|
290
|
+
if (!contentElement || !toggleElement) return;
|
291
|
+
|
292
|
+
if (contentElement.classList.contains('collapsed')) {{
|
293
|
+
contentElement.classList.remove('collapsed');
|
294
|
+
toggleElement.textContent = '[-]';
|
295
|
+
// Save expanded state
|
296
|
+
localStorage.setItem('expanded_' + entryId, 'true');
|
297
|
+
}} else {{
|
298
|
+
contentElement.classList.add('collapsed');
|
299
|
+
toggleElement.textContent = '[+]';
|
300
|
+
// Save collapsed state
|
301
|
+
localStorage.setItem('expanded_' + entryId, 'false');
|
302
|
+
}}
|
303
|
+
}}
|
304
|
+
|
305
|
+
function toggle(id) {{
|
306
|
+
const element = document.getElementById(id);
|
307
|
+
if (!element) return;
|
308
|
+
|
309
|
+
element.classList.toggle('collapsed');
|
310
|
+
const toggle = element.querySelector('.toggle');
|
311
|
+
if (toggle) {{
|
312
|
+
toggle.textContent = element.classList.contains('collapsed')
|
313
|
+
? '[+]' : '[-]';
|
314
|
+
}}
|
315
|
+
|
316
|
+
// Save collapsed state for collapsible sections
|
317
|
+
localStorage.setItem(
|
318
|
+
'collapsed_' + id, element.classList.contains('collapsed')
|
319
|
+
);
|
320
|
+
}}
|
321
|
+
|
322
|
+
let allExpanded = false;
|
323
|
+
|
324
|
+
function toggleAll() {{
|
325
|
+
const btn = document.getElementById('toggleAllBtn');
|
326
|
+
if (allExpanded) {{
|
327
|
+
collapseAll();
|
328
|
+
btn.textContent = 'Expand All';
|
329
|
+
allExpanded = false;
|
330
|
+
}} else {{
|
331
|
+
expandAll();
|
332
|
+
btn.textContent = 'Collapse All';
|
333
|
+
allExpanded = true;
|
334
|
+
}}
|
335
|
+
}}
|
336
|
+
|
337
|
+
function expandAll() {{
|
338
|
+
// Expand all visible main entries
|
339
|
+
const entries = document.querySelectorAll(
|
340
|
+
'.entry:not(.hidden) .entry-content'
|
341
|
+
);
|
342
|
+
entries.forEach(element => {{
|
343
|
+
element.classList.remove('collapsed');
|
344
|
+
}});
|
345
|
+
|
346
|
+
// Update all visible main entry toggles
|
347
|
+
const entryToggles = document.querySelectorAll(
|
348
|
+
'.entry:not(.hidden) .entity-header .toggle'
|
349
|
+
);
|
350
|
+
entryToggles.forEach(toggle => {{
|
351
|
+
toggle.textContent = '[-]';
|
352
|
+
}});
|
353
|
+
|
354
|
+
// Expand all visible sub-sections
|
355
|
+
const collapsibles = document.querySelectorAll(
|
356
|
+
'.entry:not(.hidden) .collapsible'
|
357
|
+
);
|
358
|
+
collapsibles.forEach(element => {{
|
359
|
+
element.classList.remove('collapsed');
|
360
|
+
const toggle = element.querySelector('.toggle');
|
361
|
+
if (toggle) {{
|
362
|
+
toggle.textContent = '[-]';
|
363
|
+
}}
|
364
|
+
}});
|
365
|
+
}}
|
366
|
+
|
367
|
+
function collapseAll() {{
|
368
|
+
// Collapse all visible entries
|
369
|
+
const entries = document.querySelectorAll(
|
370
|
+
'.entry:not(.hidden) .entry-content'
|
371
|
+
);
|
372
|
+
entries.forEach(element => {{
|
373
|
+
element.classList.add('collapsed');
|
374
|
+
}});
|
375
|
+
|
376
|
+
// Update all visible entry toggles
|
377
|
+
const entryToggles = document.querySelectorAll(
|
378
|
+
'.entry:not(.hidden) .entity-header .toggle'
|
379
|
+
);
|
380
|
+
entryToggles.forEach(toggle => {{
|
381
|
+
toggle.textContent = '[+]';
|
382
|
+
}});
|
383
|
+
|
384
|
+
// Collapse all visible sub-sections
|
385
|
+
const collapsibles = document.querySelectorAll(
|
386
|
+
'.entry:not(.hidden) .collapsible'
|
387
|
+
);
|
388
|
+
collapsibles.forEach(element => {{
|
389
|
+
element.classList.add('collapsed');
|
390
|
+
const toggle = element.querySelector('.toggle');
|
391
|
+
if (toggle) {{
|
392
|
+
toggle.textContent = '[+]';
|
393
|
+
}}
|
394
|
+
}});
|
395
|
+
}}
|
396
|
+
|
397
|
+
function filterEntries() {{
|
398
|
+
const checkbox = document.getElementById('filterCheckbox');
|
399
|
+
const entries = document.querySelectorAll('.entry');
|
400
|
+
|
401
|
+
// Save checkbox state to localStorage
|
402
|
+
localStorage.setItem('filterImportant', checkbox.checked);
|
403
|
+
|
404
|
+
if (checkbox.checked) {{
|
405
|
+
// Show only important entries
|
406
|
+
entries.forEach(entry => {{
|
407
|
+
const isImportant = entry.classList.contains('important');
|
408
|
+
if (isImportant) {{
|
409
|
+
entry.classList.remove('hidden');
|
410
|
+
}} else {{
|
411
|
+
entry.classList.add('hidden');
|
412
|
+
}}
|
413
|
+
}});
|
414
|
+
}} else {{
|
415
|
+
// Show all entries
|
416
|
+
entries.forEach(entry => {{
|
417
|
+
entry.classList.remove('hidden');
|
418
|
+
}});
|
419
|
+
}}
|
420
|
+
|
421
|
+
// Reset toggle button state
|
422
|
+
allExpanded = false;
|
423
|
+
document.getElementById('toggleAllBtn').textContent = 'Expand All';
|
424
|
+
}}
|
425
|
+
|
426
|
+
// Initialize all as collapsed on load
|
427
|
+
document.addEventListener('DOMContentLoaded', function() {{
|
428
|
+
collapseAll();
|
429
|
+
|
430
|
+
// Restore checkbox state from localStorage
|
431
|
+
const checkbox = document.getElementById('filterCheckbox');
|
432
|
+
const savedState = localStorage.getItem('filterImportant');
|
433
|
+
if (savedState !== null) {{
|
434
|
+
// Use saved state if it exists
|
435
|
+
checkbox.checked = savedState === 'true';
|
436
|
+
}}
|
437
|
+
// Apply filter based on checkbox state (default is checked)
|
438
|
+
if (checkbox.checked) {{
|
439
|
+
filterEntries();
|
440
|
+
}}
|
441
|
+
|
442
|
+
// Restore expanded states from localStorage
|
443
|
+
const entries = document.querySelectorAll('.entry');
|
444
|
+
entries.forEach(entry => {{
|
445
|
+
const entryId = entry.id;
|
446
|
+
const expandedState = localStorage.getItem('expanded_' + entryId);
|
447
|
+
if (expandedState === 'true') {{
|
448
|
+
const contentElement = document.getElementById(
|
449
|
+
entryId + '_content'
|
450
|
+
);
|
451
|
+
const toggleElement = entry.querySelector('.entity-header .toggle');
|
452
|
+
if (contentElement && toggleElement) {{
|
453
|
+
contentElement.classList.remove('collapsed');
|
454
|
+
toggleElement.textContent = '[-]';
|
455
|
+
}}
|
456
|
+
}}
|
457
|
+
}});
|
458
|
+
|
459
|
+
// Restore collapsible section states
|
460
|
+
const collapsibles = document.querySelectorAll('.collapsible');
|
461
|
+
collapsibles.forEach(collapsible => {{
|
462
|
+
const id = collapsible.id;
|
463
|
+
const collapsedState = localStorage.getItem('collapsed_' + id);
|
464
|
+
if (collapsedState === 'false') {{
|
465
|
+
collapsible.classList.remove('collapsed');
|
466
|
+
const toggle = collapsible.querySelector('.toggle');
|
467
|
+
if (toggle) {{
|
468
|
+
toggle.textContent = '[-]';
|
469
|
+
}}
|
470
|
+
}}
|
471
|
+
}});
|
472
|
+
}});
|
473
|
+
</script>
|
474
|
+
</head>
|
475
|
+
<body>
|
476
|
+
<div class="header">
|
477
|
+
<div class="header-line">
|
478
|
+
<div>{self.filename}</div>
|
479
|
+
<div id="timestamp">{timestamp}</div>
|
480
|
+
</div>
|
481
|
+
</div>
|
482
|
+
|
483
|
+
<div class="separator"></div>
|
484
|
+
|
485
|
+
<div class="controls">
|
486
|
+
<button id="toggleAllBtn" onclick="toggleAll()">Expand All</button>
|
487
|
+
<label style="margin-left: 20px;">
|
488
|
+
<input type="checkbox" id="filterCheckbox"
|
489
|
+
onchange="filterEntries()" checked>
|
490
|
+
Show only important responses
|
491
|
+
</label>
|
492
|
+
</div>
|
493
|
+
|
494
|
+
<div id="content">
|
495
|
+
"""
|
496
|
+
try:
|
497
|
+
with open(self.file_path, "w", encoding="utf-8") as f:
|
498
|
+
f.write(html_content)
|
499
|
+
except Exception as e:
|
500
|
+
self.logger.error(f"Failed to write HTML header: {e}")
|
501
|
+
|
502
|
+
def log(self, fields: BaseModel) -> None:
|
503
|
+
"""Log a message entry.
|
504
|
+
|
505
|
+
Args:
|
506
|
+
fields: ChatDocLoggerFields containing all log information
|
507
|
+
"""
|
508
|
+
try:
|
509
|
+
entry_html = self._format_entry(fields)
|
510
|
+
self._append_to_file(entry_html)
|
511
|
+
self.entry_counter += 1
|
512
|
+
except Exception as e:
|
513
|
+
self.logger.error(f"Failed to log entry: {e}")
|
514
|
+
|
515
|
+
def _format_entry(self, fields: BaseModel) -> str:
|
516
|
+
"""Format a log entry as HTML.
|
517
|
+
|
518
|
+
Args:
|
519
|
+
fields: ChatDocLoggerFields containing all log information
|
520
|
+
|
521
|
+
Returns:
|
522
|
+
HTML string for the entry
|
523
|
+
"""
|
524
|
+
entry_id = f"entry_{self.entry_counter}"
|
525
|
+
|
526
|
+
# Get all relevant fields
|
527
|
+
responder = str(getattr(fields, "responder", "UNKNOWN"))
|
528
|
+
task_name = getattr(fields, "task_name", "root")
|
529
|
+
# TODO (CLAUDE) display sender_entity in parens right after responder,
|
530
|
+
# other than LLM, e.g. AGENT (USER)
|
531
|
+
sender_entity = str(getattr(fields, "sender_entity", ""))
|
532
|
+
tool = getattr(fields, "tool", "")
|
533
|
+
tool_type = getattr(fields, "tool_type", "")
|
534
|
+
content = getattr(fields, "content", "")
|
535
|
+
recipient = getattr(fields, "recipient", "")
|
536
|
+
|
537
|
+
# Determine CSS class based on responder
|
538
|
+
responder_upper = responder.upper()
|
539
|
+
if "USER" in responder_upper:
|
540
|
+
css_class = "user"
|
541
|
+
elif "LLM" in responder_upper:
|
542
|
+
css_class = "llm"
|
543
|
+
elif "AGENT" in responder_upper:
|
544
|
+
css_class = "agent"
|
545
|
+
elif "SYSTEM" in responder_upper:
|
546
|
+
css_class = "system"
|
547
|
+
else:
|
548
|
+
css_class = "other"
|
549
|
+
|
550
|
+
# Determine opacity class based on mark
|
551
|
+
mark = getattr(fields, "mark", "")
|
552
|
+
opacity_class = "important" if mark == "*" else "faded"
|
553
|
+
|
554
|
+
# Start building the entry
|
555
|
+
html_parts = [
|
556
|
+
f'<div class="entry {css_class} {opacity_class}" id="{entry_id}">'
|
557
|
+
]
|
558
|
+
|
559
|
+
# Build smart header
|
560
|
+
entity_parts = [] # Main header line with entity info
|
561
|
+
content_preview = "" # Second line with content preview
|
562
|
+
|
563
|
+
# Add task name if not root
|
564
|
+
if task_name and task_name != "root":
|
565
|
+
entity_parts.append(task_name)
|
566
|
+
|
567
|
+
# Handle different responder types
|
568
|
+
if "USER" in responder_upper:
|
569
|
+
# Add responder with sender_entity in parens if different
|
570
|
+
if sender_entity and sender_entity != responder:
|
571
|
+
entity_parts.append(f"USER ({sender_entity})")
|
572
|
+
else:
|
573
|
+
entity_parts.append("USER")
|
574
|
+
# Show user input preview on second line
|
575
|
+
if content:
|
576
|
+
preview = content.replace("\n", " ")[:60]
|
577
|
+
if len(content) > 60:
|
578
|
+
preview += "..."
|
579
|
+
content_preview = f'"{preview}"'
|
580
|
+
|
581
|
+
elif "LLM" in responder_upper:
|
582
|
+
# Get model info from instance - don't uppercase it
|
583
|
+
model_label = "LLM"
|
584
|
+
if self.model_info:
|
585
|
+
model_label = f"LLM ({self.model_info})"
|
586
|
+
|
587
|
+
if tool and tool_type:
|
588
|
+
# LLM making a tool call - don't uppercase tool names
|
589
|
+
entity_parts.append(f"{model_label} → {tool_type}[{tool}]")
|
590
|
+
else:
|
591
|
+
# LLM generating plain text response
|
592
|
+
entity_parts.append(model_label)
|
593
|
+
if content:
|
594
|
+
# Show first line or first 60 chars on second line
|
595
|
+
first_line = content.split("\n")[0].strip()
|
596
|
+
if first_line:
|
597
|
+
preview = first_line[:60]
|
598
|
+
if len(first_line) > 60:
|
599
|
+
preview += "..."
|
600
|
+
content_preview = f'"{preview}"'
|
601
|
+
|
602
|
+
elif "AGENT" in responder_upper:
|
603
|
+
# Add responder with sender_entity in parens if different
|
604
|
+
agent_label = "AGENT"
|
605
|
+
if sender_entity and sender_entity != responder:
|
606
|
+
agent_label = f"AGENT ({sender_entity})"
|
607
|
+
|
608
|
+
# Agent responding (usually tool handling)
|
609
|
+
if tool:
|
610
|
+
entity_parts.append(f"{agent_label}[{tool}]")
|
611
|
+
# Show tool result preview on second line if available
|
612
|
+
if content:
|
613
|
+
preview = content.replace("\n", " ")[:40]
|
614
|
+
if len(content) > 40:
|
615
|
+
preview += "..."
|
616
|
+
content_preview = f"→ {preview}"
|
617
|
+
else:
|
618
|
+
entity_parts.append(agent_label)
|
619
|
+
if content:
|
620
|
+
preview = content[:50]
|
621
|
+
if len(content) > 50:
|
622
|
+
preview += "..."
|
623
|
+
content_preview = f'"{preview}"'
|
624
|
+
|
625
|
+
elif "SYSTEM" in responder_upper:
|
626
|
+
entity_parts.append("SYSTEM")
|
627
|
+
if content:
|
628
|
+
preview = content[:50]
|
629
|
+
if len(content) > 50:
|
630
|
+
preview += "..."
|
631
|
+
content_preview = f'"{preview}"'
|
632
|
+
else:
|
633
|
+
# Other responder types (like Task)
|
634
|
+
entity_parts.append(responder)
|
635
|
+
|
636
|
+
# Add recipient info if present
|
637
|
+
if recipient:
|
638
|
+
entity_parts.append(f"→ {recipient}")
|
639
|
+
|
640
|
+
# Construct the two-line header
|
641
|
+
header_main = " ".join(entity_parts)
|
642
|
+
|
643
|
+
# Build the header HTML with toggle, mark, and main content on same line
|
644
|
+
header_html = '<span class="toggle">[+]</span> '
|
645
|
+
|
646
|
+
# Note: opacity_class already determined above
|
647
|
+
|
648
|
+
# Add the main header content
|
649
|
+
header_html += f'<span class="header-main">{html.escape(header_main)}</span>'
|
650
|
+
|
651
|
+
# Add preview on second line if present
|
652
|
+
if content_preview:
|
653
|
+
header_html += (
|
654
|
+
f'\n <div class="header-content">'
|
655
|
+
f"{html.escape(content_preview)}</div>"
|
656
|
+
)
|
657
|
+
|
658
|
+
# Add expandable header
|
659
|
+
html_parts.append(
|
660
|
+
f"""
|
661
|
+
<div class="entity-header" onclick="toggleEntry('{entry_id}')">
|
662
|
+
{header_html}
|
663
|
+
</div>
|
664
|
+
<div id="{entry_id}_content" class="entry-content collapsed">"""
|
665
|
+
)
|
666
|
+
|
667
|
+
# Add collapsible sections
|
668
|
+
|
669
|
+
# System messages (if any)
|
670
|
+
system_content = self._extract_system_content(fields)
|
671
|
+
if system_content:
|
672
|
+
for idx, (label, content) in enumerate(system_content):
|
673
|
+
section_id = f"{entry_id}_system_{idx}"
|
674
|
+
html_parts.append(
|
675
|
+
self._create_collapsible_section(section_id, label, content)
|
676
|
+
)
|
677
|
+
|
678
|
+
# Tool information
|
679
|
+
tool = getattr(fields, "tool", None)
|
680
|
+
# Only add tool section if tool exists and is not empty
|
681
|
+
if tool and tool.strip():
|
682
|
+
tool_html = self._format_tool_section(fields, entry_id)
|
683
|
+
html_parts.append(tool_html)
|
684
|
+
|
685
|
+
# Main content
|
686
|
+
content = getattr(fields, "content", "")
|
687
|
+
if content and not (
|
688
|
+
tool and tool.strip()
|
689
|
+
): # Don't duplicate content if it's a tool
|
690
|
+
html_parts.append(f'<div class="main-content">{html.escape(content)}</div>')
|
691
|
+
|
692
|
+
# Metadata (recipient, blocked)
|
693
|
+
metadata_parts = []
|
694
|
+
recipient = getattr(fields, "recipient", None)
|
695
|
+
if recipient:
|
696
|
+
metadata_parts.append(f"Recipient: {recipient}")
|
697
|
+
|
698
|
+
block = getattr(fields, "block", None)
|
699
|
+
if block:
|
700
|
+
metadata_parts.append(f"Blocked: {block}")
|
701
|
+
|
702
|
+
if metadata_parts:
|
703
|
+
html_parts.append(
|
704
|
+
f'<div class="metadata">{" | ".join(metadata_parts)}</div>'
|
705
|
+
)
|
706
|
+
|
707
|
+
# Close entry content div
|
708
|
+
html_parts.append("</div>") # Close entry-content
|
709
|
+
html_parts.append("</div>") # Close entry
|
710
|
+
return "\n".join(html_parts)
|
711
|
+
|
712
|
+
def _extract_system_content(self, fields: BaseModel) -> List[tuple[str, str]]:
|
713
|
+
"""Extract system-related content from fields.
|
714
|
+
|
715
|
+
Returns:
|
716
|
+
List of (label, content) tuples
|
717
|
+
"""
|
718
|
+
system_content = []
|
719
|
+
|
720
|
+
# Check for common system message patterns in content
|
721
|
+
content = getattr(fields, "content", "")
|
722
|
+
if content:
|
723
|
+
# Look for patterns like "[System Prompt]" or "System Reminder:"
|
724
|
+
if "[System Prompt]" in content or "System Prompt" in content:
|
725
|
+
system_content.append(("System Prompt", content))
|
726
|
+
elif "[System Reminder]" in content or "System Reminder" in content:
|
727
|
+
system_content.append(("System Reminder", content))
|
728
|
+
|
729
|
+
return system_content
|
730
|
+
|
731
|
+
def _create_collapsible_section(
|
732
|
+
self, section_id: str, label: str, content: str
|
733
|
+
) -> str:
|
734
|
+
"""Create a collapsible section.
|
735
|
+
|
736
|
+
Args:
|
737
|
+
section_id: Unique ID for the section
|
738
|
+
label: Label to display
|
739
|
+
content: Content to show when expanded
|
740
|
+
|
741
|
+
Returns:
|
742
|
+
HTML string for the collapsible section
|
743
|
+
"""
|
744
|
+
return f"""
|
745
|
+
<div class="collapsible collapsed" id="{section_id}">
|
746
|
+
<span class="toggle" onclick="toggle('{section_id}')">[+]</span> {label}
|
747
|
+
<div class="content">{html.escape(content)}</div>
|
748
|
+
</div>"""
|
749
|
+
|
750
|
+
def _format_tool_section(self, fields: BaseModel, entry_id: str) -> str:
|
751
|
+
"""Format tool-related information.
|
752
|
+
|
753
|
+
Args:
|
754
|
+
fields: ChatDocLoggerFields containing tool information
|
755
|
+
entry_id: Parent entry ID
|
756
|
+
|
757
|
+
Returns:
|
758
|
+
HTML string for the tool section
|
759
|
+
"""
|
760
|
+
tool = getattr(fields, "tool", "")
|
761
|
+
tool_type = getattr(fields, "tool_type", "")
|
762
|
+
content = getattr(fields, "content", "")
|
763
|
+
|
764
|
+
tool_id = f"{entry_id}_tool_{self.tool_counter}"
|
765
|
+
self.tool_counter += 1
|
766
|
+
|
767
|
+
# Try to parse content as JSON for better formatting
|
768
|
+
try:
|
769
|
+
if content.strip().startswith("{"):
|
770
|
+
content_dict = json.loads(content)
|
771
|
+
formatted_content = json.dumps(content_dict, indent=2)
|
772
|
+
content_html = (
|
773
|
+
f'<pre class="code-block">{html.escape(formatted_content)}</pre>'
|
774
|
+
)
|
775
|
+
else:
|
776
|
+
content_html = html.escape(content)
|
777
|
+
except Exception:
|
778
|
+
content_html = html.escape(content)
|
779
|
+
|
780
|
+
# Build tool section
|
781
|
+
tool_name = f"{tool_type}({tool})" if tool_type else tool
|
782
|
+
|
783
|
+
return f"""
|
784
|
+
<div class="tool-section">
|
785
|
+
<div class="collapsible collapsed" id="{tool_id}">
|
786
|
+
<span class="toggle" onclick="toggle('{tool_id}')">[+]</span>
|
787
|
+
<span class="tool-name">{html.escape(tool_name)}</span>
|
788
|
+
<div class="content">{content_html}</div>
|
789
|
+
</div>
|
790
|
+
</div>"""
|
791
|
+
|
792
|
+
def _append_to_file(self, content: str) -> None:
|
793
|
+
"""Append content to the HTML file.
|
794
|
+
|
795
|
+
Args:
|
796
|
+
content: HTML content to append
|
797
|
+
"""
|
798
|
+
try:
|
799
|
+
with open(self.file_path, "a", encoding="utf-8") as f:
|
800
|
+
f.write(content + "\n")
|
801
|
+
f.flush()
|
802
|
+
except Exception as e:
|
803
|
+
self.logger.error(f"Failed to append to file: {e}")
|
804
|
+
|
805
|
+
def close(self) -> None:
|
806
|
+
"""Close the HTML file with footer."""
|
807
|
+
footer = """
|
808
|
+
</div>
|
809
|
+
<script>
|
810
|
+
// Update message count
|
811
|
+
const header = document.querySelector('.header-line div:last-child');
|
812
|
+
if (header) {
|
813
|
+
const messageCount = document.querySelectorAll('.entry').length;
|
814
|
+
header.textContent = header.textContent.replace(
|
815
|
+
/\\d+ messages/, messageCount + ' messages'
|
816
|
+
);
|
817
|
+
}
|
818
|
+
</script>
|
819
|
+
</body>
|
820
|
+
</html>"""
|
821
|
+
try:
|
822
|
+
with open(self.file_path, "a", encoding="utf-8") as f:
|
823
|
+
f.write(footer)
|
824
|
+
except Exception as e:
|
825
|
+
self.logger.error(f"Failed to write HTML footer: {e}")
|