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.
@@ -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}")