agenthub-python 0.1.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.
- agenthub/__init__.py +20 -0
- agenthub/auto_client.py +96 -0
- agenthub/base_client.py +185 -0
- agenthub/claude4_5/__init__.py +18 -0
- agenthub/claude4_5/client.py +315 -0
- agenthub/gemini3/__init__.py +18 -0
- agenthub/gemini3/client.py +231 -0
- agenthub/tracer.py +722 -0
- agenthub/types.py +134 -0
- agenthub_python-0.1.0.dist-info/METADATA +9 -0
- agenthub_python-0.1.0.dist-info/RECORD +12 -0
- agenthub_python-0.1.0.dist-info/WHEEL +4 -0
agenthub/tracer.py
ADDED
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
# Copyright 2025 Prism Shadow. and/or its affiliates
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""
|
|
16
|
+
Conversation tracer module for saving and viewing conversation history.
|
|
17
|
+
|
|
18
|
+
This module provides functionality to save conversation history to local files
|
|
19
|
+
and serve them via a web interface for real-time monitoring.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import base64
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
from datetime import datetime
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from flask import Flask, Response, render_template_string
|
|
30
|
+
|
|
31
|
+
from .types import UniMessage
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Tracer:
|
|
35
|
+
"""
|
|
36
|
+
Tracer for saving conversation history to local files.
|
|
37
|
+
|
|
38
|
+
This class handles saving conversation history to files in a cache directory
|
|
39
|
+
and provides a web server for browsing and viewing the saved conversations.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, cache_dir: str | None = None):
|
|
43
|
+
"""
|
|
44
|
+
Initialize the tracer.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
cache_dir: Directory to store conversation history files
|
|
48
|
+
"""
|
|
49
|
+
cache_dir = cache_dir or os.getenv("AGENTHUB_CACHE_DIR", "cache")
|
|
50
|
+
self.cache_dir = Path(cache_dir).absolute()
|
|
51
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
|
|
53
|
+
def _serialize_for_json(self, obj: Any) -> Any:
|
|
54
|
+
"""
|
|
55
|
+
Recursively serialize objects for JSON, converting bytes to base64.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
obj: Object to serialize
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
JSON-serializable object
|
|
62
|
+
"""
|
|
63
|
+
if isinstance(obj, bytes):
|
|
64
|
+
return base64.b64encode(obj).decode("utf-8")
|
|
65
|
+
elif isinstance(obj, dict):
|
|
66
|
+
return {k: self._serialize_for_json(v) for k, v in obj.items()}
|
|
67
|
+
elif isinstance(obj, list):
|
|
68
|
+
return [self._serialize_for_json(item) for item in obj]
|
|
69
|
+
return obj
|
|
70
|
+
|
|
71
|
+
def save_history(self, history: list[UniMessage], file_id: str, config: dict[str, Any]) -> None:
|
|
72
|
+
"""
|
|
73
|
+
Save conversation history to files.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
history: List of UniMessage objects representing the conversation
|
|
77
|
+
file_id: File identifier without extension (e.g., "agent1/00001")
|
|
78
|
+
config: The UniConfig used for this conversation
|
|
79
|
+
"""
|
|
80
|
+
# Create directory if needed
|
|
81
|
+
file_path_base = self.cache_dir / file_id
|
|
82
|
+
file_path_base.parent.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
|
|
84
|
+
# Save as JSON
|
|
85
|
+
json_path = file_path_base.with_suffix(".json")
|
|
86
|
+
json_data = {
|
|
87
|
+
"history": self._serialize_for_json(history),
|
|
88
|
+
"config": self._serialize_for_json(config),
|
|
89
|
+
"timestamp": datetime.now().isoformat(),
|
|
90
|
+
}
|
|
91
|
+
with open(json_path, "w", encoding="utf-8") as f:
|
|
92
|
+
json.dump(json_data, f, indent=2, ensure_ascii=False)
|
|
93
|
+
|
|
94
|
+
# Save as human-readable text
|
|
95
|
+
txt_path = file_path_base.with_suffix(".txt")
|
|
96
|
+
formatted_content = self._format_history(history, config)
|
|
97
|
+
with open(txt_path, "w", encoding="utf-8") as f:
|
|
98
|
+
f.write(formatted_content)
|
|
99
|
+
|
|
100
|
+
def _format_history(self, history: list[UniMessage], config: dict[str, Any]) -> str:
|
|
101
|
+
"""
|
|
102
|
+
Format conversation history in a readable text format.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
history: List of UniMessage objects
|
|
106
|
+
config: The UniConfig used for this conversation
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
Formatted string representation of the conversation
|
|
110
|
+
"""
|
|
111
|
+
lines = []
|
|
112
|
+
lines.append("=" * 80)
|
|
113
|
+
lines.append(f"Conversation History - {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
|
114
|
+
lines.append("=" * 80)
|
|
115
|
+
lines.append("")
|
|
116
|
+
|
|
117
|
+
# Add config information
|
|
118
|
+
lines.append("Configuration:")
|
|
119
|
+
for key, value in config.items():
|
|
120
|
+
if key != "trace_id": # Don't include trace_id itself
|
|
121
|
+
lines.append(f" {key}: {value}")
|
|
122
|
+
lines.append("")
|
|
123
|
+
|
|
124
|
+
for i, message in enumerate(history, 1):
|
|
125
|
+
role = message["role"].upper()
|
|
126
|
+
lines.append(f"[{i}] {role}:")
|
|
127
|
+
lines.append("-" * 80)
|
|
128
|
+
|
|
129
|
+
for item in message["content_items"]:
|
|
130
|
+
if item["type"] == "text":
|
|
131
|
+
lines.append(f"Text: {item['text']}")
|
|
132
|
+
elif item["type"] == "thinking":
|
|
133
|
+
lines.append(f"Thinking: {item['thinking']}")
|
|
134
|
+
elif item["type"] == "image_url":
|
|
135
|
+
lines.append(f"Image URL: {item['image_url']}")
|
|
136
|
+
elif item["type"] == "tool_call":
|
|
137
|
+
lines.append(f"Tool Call: {item['name']}")
|
|
138
|
+
lines.append(f" Arguments: {json.dumps(item['argument'], indent=2)}")
|
|
139
|
+
lines.append(f" Tool Call ID: {item['tool_call_id']}")
|
|
140
|
+
elif item["type"] == "tool_result":
|
|
141
|
+
lines.append(f"Tool Result (ID: {item['tool_call_id']}): {item['result']}")
|
|
142
|
+
|
|
143
|
+
# Add usage metadata if available
|
|
144
|
+
if "usage_metadata" in message and message["usage_metadata"]:
|
|
145
|
+
metadata = message["usage_metadata"]
|
|
146
|
+
lines.append("\nUsage Metadata:")
|
|
147
|
+
if metadata.get("prompt_tokens"):
|
|
148
|
+
lines.append(f" Prompt Tokens: {metadata['prompt_tokens']}")
|
|
149
|
+
if metadata.get("thoughts_tokens"):
|
|
150
|
+
lines.append(f" Thoughts Tokens: {metadata['thoughts_tokens']}")
|
|
151
|
+
if metadata.get("response_tokens"):
|
|
152
|
+
lines.append(f" Response Tokens: {metadata['response_tokens']}")
|
|
153
|
+
|
|
154
|
+
# Add finish reason if available
|
|
155
|
+
if "finish_reason" in message:
|
|
156
|
+
lines.append(f"\nFinish Reason: {message['finish_reason']}")
|
|
157
|
+
|
|
158
|
+
lines.append("")
|
|
159
|
+
|
|
160
|
+
return "\n".join(lines)
|
|
161
|
+
|
|
162
|
+
def create_web_app(self) -> Flask:
|
|
163
|
+
"""
|
|
164
|
+
Create a Flask web application for browsing conversation files.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
Flask application instance
|
|
168
|
+
"""
|
|
169
|
+
app = Flask(__name__)
|
|
170
|
+
|
|
171
|
+
# HTML template for directory listing
|
|
172
|
+
DIRECTORY_TEMPLATE = """
|
|
173
|
+
<!DOCTYPE html>
|
|
174
|
+
<html>
|
|
175
|
+
<head>
|
|
176
|
+
<title>Tracer</title>
|
|
177
|
+
<meta charset="utf-8">
|
|
178
|
+
<style>
|
|
179
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
180
|
+
body {
|
|
181
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
182
|
+
max-width: 1000px;
|
|
183
|
+
margin: 0 auto;
|
|
184
|
+
padding: 24px;
|
|
185
|
+
background-color: #fafafa;
|
|
186
|
+
color: #24292f;
|
|
187
|
+
}
|
|
188
|
+
h1 {
|
|
189
|
+
font-size: 24px;
|
|
190
|
+
font-weight: 600;
|
|
191
|
+
margin-bottom: 16px;
|
|
192
|
+
color: #1f2937;
|
|
193
|
+
}
|
|
194
|
+
.breadcrumb {
|
|
195
|
+
margin-bottom: 20px;
|
|
196
|
+
padding: 12px;
|
|
197
|
+
background-color: #fff;
|
|
198
|
+
border-radius: 6px;
|
|
199
|
+
border: 1px solid #d0d7de;
|
|
200
|
+
font-size: 14px;
|
|
201
|
+
}
|
|
202
|
+
.breadcrumb a {
|
|
203
|
+
color: #0969da;
|
|
204
|
+
text-decoration: none;
|
|
205
|
+
}
|
|
206
|
+
.breadcrumb a:hover { text-decoration: underline; }
|
|
207
|
+
.file-list {
|
|
208
|
+
background-color: #fff;
|
|
209
|
+
border-radius: 6px;
|
|
210
|
+
border: 1px solid #d0d7de;
|
|
211
|
+
overflow: hidden;
|
|
212
|
+
}
|
|
213
|
+
.file-item, .dir-item {
|
|
214
|
+
padding: 12px 16px;
|
|
215
|
+
border-bottom: 1px solid #d0d7de;
|
|
216
|
+
display: flex;
|
|
217
|
+
align-items: center;
|
|
218
|
+
justify-content: space-between;
|
|
219
|
+
transition: background-color 0.1s;
|
|
220
|
+
}
|
|
221
|
+
.file-item:last-child, .dir-item:last-child { border-bottom: none; }
|
|
222
|
+
.file-item:hover, .dir-item:hover { background-color: #f6f8fa; }
|
|
223
|
+
.file-item a, .dir-item a {
|
|
224
|
+
color: #0969da;
|
|
225
|
+
text-decoration: none;
|
|
226
|
+
flex-grow: 1;
|
|
227
|
+
font-size: 14px;
|
|
228
|
+
}
|
|
229
|
+
.dir-item a::before { content: "📁 "; margin-right: 8px; }
|
|
230
|
+
.file-item a::before { content: "📄 "; margin-right: 8px; }
|
|
231
|
+
.file-size {
|
|
232
|
+
color: #656d76;
|
|
233
|
+
font-size: 12px;
|
|
234
|
+
margin-left: 12px;
|
|
235
|
+
}
|
|
236
|
+
.empty {
|
|
237
|
+
color: #656d76;
|
|
238
|
+
font-style: italic;
|
|
239
|
+
padding: 32px;
|
|
240
|
+
text-align: center;
|
|
241
|
+
}
|
|
242
|
+
</style>
|
|
243
|
+
</head>
|
|
244
|
+
<body>
|
|
245
|
+
<h1>Tracer</h1>
|
|
246
|
+
<div class="breadcrumb">
|
|
247
|
+
<strong>Path:</strong> {{ breadcrumb|safe }}
|
|
248
|
+
</div>
|
|
249
|
+
<div class="file-list">
|
|
250
|
+
{% if items %}
|
|
251
|
+
{% for item in items %}
|
|
252
|
+
{% if item.is_dir %}
|
|
253
|
+
<div class="dir-item">
|
|
254
|
+
<a href="{{ item.url }}">{{ item.name }}</a>
|
|
255
|
+
</div>
|
|
256
|
+
{% else %}
|
|
257
|
+
<div class="file-item">
|
|
258
|
+
<a href="{{ item.url }}">{{ item.name }}</a>
|
|
259
|
+
<span class="file-size">{{ item.size }}</span>
|
|
260
|
+
</div>
|
|
261
|
+
{% endif %}
|
|
262
|
+
{% endfor %}
|
|
263
|
+
{% else %}
|
|
264
|
+
<div class="empty">No files or directories found.</div>
|
|
265
|
+
{% endif %}
|
|
266
|
+
</div>
|
|
267
|
+
</body>
|
|
268
|
+
</html>
|
|
269
|
+
"""
|
|
270
|
+
|
|
271
|
+
# HTML template for JSON conversation viewing
|
|
272
|
+
JSON_VIEWER_TEMPLATE = """
|
|
273
|
+
<!DOCTYPE html>
|
|
274
|
+
<html>
|
|
275
|
+
<head>
|
|
276
|
+
<title>{{ filename }}</title>
|
|
277
|
+
<meta charset="utf-8">
|
|
278
|
+
<style>
|
|
279
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
280
|
+
body {
|
|
281
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
282
|
+
max-width: 1000px;
|
|
283
|
+
margin: 0 auto;
|
|
284
|
+
padding: 24px;
|
|
285
|
+
background-color: #fafafa;
|
|
286
|
+
color: #24292f;
|
|
287
|
+
}
|
|
288
|
+
h1 {
|
|
289
|
+
font-size: 24px;
|
|
290
|
+
font-weight: 600;
|
|
291
|
+
margin-bottom: 8px;
|
|
292
|
+
color: #1f2937;
|
|
293
|
+
}
|
|
294
|
+
.breadcrumb {
|
|
295
|
+
margin-bottom: 20px;
|
|
296
|
+
padding: 12px;
|
|
297
|
+
background-color: #fff;
|
|
298
|
+
border-radius: 6px;
|
|
299
|
+
border: 1px solid #d0d7de;
|
|
300
|
+
font-size: 14px;
|
|
301
|
+
}
|
|
302
|
+
.breadcrumb a {
|
|
303
|
+
color: #0969da;
|
|
304
|
+
text-decoration: none;
|
|
305
|
+
}
|
|
306
|
+
.breadcrumb a:hover { text-decoration: underline; }
|
|
307
|
+
.back-button {
|
|
308
|
+
display: inline-block;
|
|
309
|
+
margin-bottom: 20px;
|
|
310
|
+
padding: 8px 16px;
|
|
311
|
+
background-color: #f6f8fa;
|
|
312
|
+
color: #24292f;
|
|
313
|
+
text-decoration: none;
|
|
314
|
+
border-radius: 6px;
|
|
315
|
+
border: 1px solid #d0d7de;
|
|
316
|
+
font-size: 14px;
|
|
317
|
+
}
|
|
318
|
+
.back-button:hover {
|
|
319
|
+
background-color: #e7ebef;
|
|
320
|
+
}
|
|
321
|
+
.config-box {
|
|
322
|
+
background-color: #fff;
|
|
323
|
+
border-radius: 6px;
|
|
324
|
+
border: 1px solid #d0d7de;
|
|
325
|
+
padding: 16px;
|
|
326
|
+
margin-bottom: 20px;
|
|
327
|
+
font-size: 14px;
|
|
328
|
+
}
|
|
329
|
+
.config-box h2 {
|
|
330
|
+
font-size: 16px;
|
|
331
|
+
font-weight: 600;
|
|
332
|
+
margin-bottom: 12px;
|
|
333
|
+
color: #1f2937;
|
|
334
|
+
}
|
|
335
|
+
.config-item {
|
|
336
|
+
padding: 6px 0;
|
|
337
|
+
color: #656d76;
|
|
338
|
+
}
|
|
339
|
+
.config-item strong {
|
|
340
|
+
color: #24292f;
|
|
341
|
+
}
|
|
342
|
+
.message-card {
|
|
343
|
+
background-color: #fff;
|
|
344
|
+
border-radius: 6px;
|
|
345
|
+
border: 1px solid #d0d7de;
|
|
346
|
+
margin-bottom: 16px;
|
|
347
|
+
overflow: hidden;
|
|
348
|
+
}
|
|
349
|
+
.message-header {
|
|
350
|
+
padding: 12px 16px;
|
|
351
|
+
background-color: #f6f8fa;
|
|
352
|
+
border-bottom: 1px solid #d0d7de;
|
|
353
|
+
display: flex;
|
|
354
|
+
justify-content: space-between;
|
|
355
|
+
align-items: center;
|
|
356
|
+
cursor: pointer;
|
|
357
|
+
user-select: none;
|
|
358
|
+
}
|
|
359
|
+
.message-header:hover {
|
|
360
|
+
background-color: #e7ebef;
|
|
361
|
+
}
|
|
362
|
+
.message-role {
|
|
363
|
+
font-weight: 600;
|
|
364
|
+
font-size: 14px;
|
|
365
|
+
text-transform: uppercase;
|
|
366
|
+
}
|
|
367
|
+
.role-user { color: #0969da; }
|
|
368
|
+
.role-assistant { color: #1a7f37; }
|
|
369
|
+
.message-metadata {
|
|
370
|
+
font-size: 12px;
|
|
371
|
+
color: #656d76;
|
|
372
|
+
}
|
|
373
|
+
.message-content {
|
|
374
|
+
padding: 16px;
|
|
375
|
+
display: none;
|
|
376
|
+
}
|
|
377
|
+
.message-content.expanded {
|
|
378
|
+
display: block;
|
|
379
|
+
}
|
|
380
|
+
.content-item {
|
|
381
|
+
margin-bottom: 12px;
|
|
382
|
+
padding-bottom: 12px;
|
|
383
|
+
border-bottom: 1px solid #f6f8fa;
|
|
384
|
+
}
|
|
385
|
+
.content-item:last-child {
|
|
386
|
+
border-bottom: none;
|
|
387
|
+
margin-bottom: 0;
|
|
388
|
+
padding-bottom: 0;
|
|
389
|
+
}
|
|
390
|
+
.content-type {
|
|
391
|
+
font-size: 11px;
|
|
392
|
+
font-weight: 600;
|
|
393
|
+
color: #656d76;
|
|
394
|
+
text-transform: uppercase;
|
|
395
|
+
letter-spacing: 0.5px;
|
|
396
|
+
margin-bottom: 6px;
|
|
397
|
+
}
|
|
398
|
+
.content-text {
|
|
399
|
+
font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
|
|
400
|
+
font-size: 13px;
|
|
401
|
+
line-height: 1.6;
|
|
402
|
+
white-space: pre-wrap;
|
|
403
|
+
word-wrap: break-word;
|
|
404
|
+
background-color: #f6f8fa;
|
|
405
|
+
padding: 12px;
|
|
406
|
+
border-radius: 4px;
|
|
407
|
+
color: #24292f;
|
|
408
|
+
}
|
|
409
|
+
.tool-call, .tool-result {
|
|
410
|
+
background-color: #fff8c5;
|
|
411
|
+
padding: 12px;
|
|
412
|
+
border-radius: 4px;
|
|
413
|
+
border-left: 3px solid #d4a72c;
|
|
414
|
+
}
|
|
415
|
+
.thinking {
|
|
416
|
+
background-color: #ddf4ff;
|
|
417
|
+
padding: 12px;
|
|
418
|
+
border-radius: 4px;
|
|
419
|
+
border-left: 3px solid #0969da;
|
|
420
|
+
}
|
|
421
|
+
.usage-box {
|
|
422
|
+
margin-top: 12px;
|
|
423
|
+
padding: 12px;
|
|
424
|
+
background-color: #f6f8fa;
|
|
425
|
+
border-radius: 4px;
|
|
426
|
+
font-size: 12px;
|
|
427
|
+
color: #656d76;
|
|
428
|
+
text-align: right;
|
|
429
|
+
}
|
|
430
|
+
.toggle-icon {
|
|
431
|
+
transition: transform 0.2s;
|
|
432
|
+
display: inline-block;
|
|
433
|
+
}
|
|
434
|
+
.toggle-icon.expanded {
|
|
435
|
+
transform: rotate(90deg);
|
|
436
|
+
}
|
|
437
|
+
</style>
|
|
438
|
+
</head>
|
|
439
|
+
<body>
|
|
440
|
+
<h1>{{ filename }}</h1>
|
|
441
|
+
<div class="breadcrumb">
|
|
442
|
+
<strong>Path:</strong> {{ breadcrumb|safe }}
|
|
443
|
+
</div>
|
|
444
|
+
<a href="{{ back_url }}" class="back-button">← Back to Directory</a>
|
|
445
|
+
|
|
446
|
+
{% if config %}
|
|
447
|
+
<div class="config-box">
|
|
448
|
+
<h2>Configuration</h2>
|
|
449
|
+
{% for key, value in config.items() %}
|
|
450
|
+
{% if key != 'trace_id' %}
|
|
451
|
+
<div class="config-item">
|
|
452
|
+
<strong>{{ key|e }}:</strong>
|
|
453
|
+
{% if key == 'tools' and value is iterable and value is not string %}
|
|
454
|
+
<pre style="margin: 4px 0 0 0; padding: 8px; background-color: #f6f8fa; border-radius: 4px; font-size: 12px; overflow-x: auto;">{{ value|tojson(indent=2)|e }}</pre>
|
|
455
|
+
{% else %}
|
|
456
|
+
{{ value|e }}
|
|
457
|
+
{% endif %}
|
|
458
|
+
</div>
|
|
459
|
+
{% endif %}
|
|
460
|
+
{% endfor %}
|
|
461
|
+
</div>
|
|
462
|
+
{% endif %}
|
|
463
|
+
|
|
464
|
+
{% for msg_idx, message in enumerate(history) %}
|
|
465
|
+
<div class="message-card">
|
|
466
|
+
<div class="message-header" onclick="toggleMessage({{ msg_idx }})">
|
|
467
|
+
<div>
|
|
468
|
+
<span class="message-role role-{{ message.role }}">{{ message.role }}</span>
|
|
469
|
+
<span class="message-metadata"> • {{ message.content_items|length }} item(s)</span>
|
|
470
|
+
</div>
|
|
471
|
+
<span class="toggle-icon" id="icon-{{ msg_idx }}">▶</span>
|
|
472
|
+
</div>
|
|
473
|
+
<div class="message-content" id="content-{{ msg_idx }}">
|
|
474
|
+
{% for item in message.content_items %}
|
|
475
|
+
<div class="content-item">
|
|
476
|
+
<div class="content-type">{{ item.type|e }}</div>
|
|
477
|
+
{% if item.type == 'text' %}
|
|
478
|
+
<div class="content-text">{{ item.text|e }}</div>
|
|
479
|
+
{% elif item.type == 'thinking' %}
|
|
480
|
+
<div class="content-text thinking">{{ item.thinking|e }}</div>
|
|
481
|
+
{% elif item.type == 'tool_call' %}
|
|
482
|
+
<div class="tool-call">
|
|
483
|
+
<div class="content-text">{{ item.name|e }}({% for key, value in item.argument.items() %}{{ key|e }}={{ value|e|tojson }}{% if not loop.last %}, {% endif %}{% endfor %})</div>
|
|
484
|
+
</div>
|
|
485
|
+
{% elif item.type == 'tool_result' %}
|
|
486
|
+
<div class="tool-result">
|
|
487
|
+
<strong>Result:</strong> {{ item.result|e }}<br>
|
|
488
|
+
<strong>Call ID:</strong> {{ item.tool_call_id|e }}
|
|
489
|
+
</div>
|
|
490
|
+
{% elif item.type == 'image_url' %}
|
|
491
|
+
<div class="content-text">
|
|
492
|
+
<img src="{{ item.image_url|e }}" style="max-width: 200px; max-height: 200px; border-radius: 4px;" alt="Preview">
|
|
493
|
+
</div>
|
|
494
|
+
{% endif %}
|
|
495
|
+
</div>
|
|
496
|
+
{% endfor %}
|
|
497
|
+
|
|
498
|
+
{% if message.usage_metadata or message.finish_reason %}
|
|
499
|
+
<div class="usage-box">
|
|
500
|
+
{% if message.usage_metadata %}
|
|
501
|
+
{% if message.usage_metadata.prompt_tokens %}
|
|
502
|
+
Prompt: {{ message.usage_metadata.prompt_tokens }} tokens
|
|
503
|
+
{% endif %}
|
|
504
|
+
{% if message.usage_metadata.thoughts_tokens %}
|
|
505
|
+
• Thoughts: {{ message.usage_metadata.thoughts_tokens }} tokens
|
|
506
|
+
{% endif %}
|
|
507
|
+
{% if message.usage_metadata.response_tokens %}
|
|
508
|
+
• Response: {{ message.usage_metadata.response_tokens }} tokens
|
|
509
|
+
{% endif %}
|
|
510
|
+
{% endif %}
|
|
511
|
+
{% if message.finish_reason %}
|
|
512
|
+
{% if message.usage_metadata %} • {% endif %}Finish: {{ message.finish_reason|e }}
|
|
513
|
+
{% endif %}
|
|
514
|
+
</div>
|
|
515
|
+
{% endif %}
|
|
516
|
+
</div>
|
|
517
|
+
</div>
|
|
518
|
+
{% endfor %}
|
|
519
|
+
|
|
520
|
+
<script>
|
|
521
|
+
function toggleMessage(idx) {
|
|
522
|
+
const content = document.getElementById('content-' + idx);
|
|
523
|
+
const icon = document.getElementById('icon-' + idx);
|
|
524
|
+
content.classList.toggle('expanded');
|
|
525
|
+
icon.classList.toggle('expanded');
|
|
526
|
+
}
|
|
527
|
+
// Expand all messages by default
|
|
528
|
+
const numMessages = {{ history|length }};
|
|
529
|
+
for (let i = 0; i < numMessages; i++) {
|
|
530
|
+
if (document.getElementById('content-' + i)) {
|
|
531
|
+
toggleMessage(i);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
</script>
|
|
535
|
+
</body>
|
|
536
|
+
</html>
|
|
537
|
+
"""
|
|
538
|
+
|
|
539
|
+
# HTML template for text file viewing
|
|
540
|
+
TEXT_VIEWER_TEMPLATE = """
|
|
541
|
+
<!DOCTYPE html>
|
|
542
|
+
<html>
|
|
543
|
+
<head>
|
|
544
|
+
<title>{{ filename }}</title>
|
|
545
|
+
<meta charset="utf-8">
|
|
546
|
+
<style>
|
|
547
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
548
|
+
body {
|
|
549
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
550
|
+
max-width: 1000px;
|
|
551
|
+
margin: 0 auto;
|
|
552
|
+
padding: 24px;
|
|
553
|
+
background-color: #fafafa;
|
|
554
|
+
color: #24292f;
|
|
555
|
+
}
|
|
556
|
+
h1 {
|
|
557
|
+
font-size: 24px;
|
|
558
|
+
font-weight: 600;
|
|
559
|
+
margin-bottom: 8px;
|
|
560
|
+
color: #1f2937;
|
|
561
|
+
}
|
|
562
|
+
.breadcrumb {
|
|
563
|
+
margin-bottom: 20px;
|
|
564
|
+
padding: 12px;
|
|
565
|
+
background-color: #fff;
|
|
566
|
+
border-radius: 6px;
|
|
567
|
+
border: 1px solid #d0d7de;
|
|
568
|
+
font-size: 14px;
|
|
569
|
+
}
|
|
570
|
+
.breadcrumb a {
|
|
571
|
+
color: #0969da;
|
|
572
|
+
text-decoration: none;
|
|
573
|
+
}
|
|
574
|
+
.breadcrumb a:hover { text-decoration: underline; }
|
|
575
|
+
.back-button {
|
|
576
|
+
display: inline-block;
|
|
577
|
+
margin-bottom: 20px;
|
|
578
|
+
padding: 8px 16px;
|
|
579
|
+
background-color: #f6f8fa;
|
|
580
|
+
color: #24292f;
|
|
581
|
+
text-decoration: none;
|
|
582
|
+
border-radius: 6px;
|
|
583
|
+
border: 1px solid #d0d7de;
|
|
584
|
+
font-size: 14px;
|
|
585
|
+
}
|
|
586
|
+
.back-button:hover { background-color: #e7ebef; }
|
|
587
|
+
.file-content {
|
|
588
|
+
background-color: #fff;
|
|
589
|
+
border-radius: 6px;
|
|
590
|
+
border: 1px solid #d0d7de;
|
|
591
|
+
padding: 20px;
|
|
592
|
+
white-space: pre-wrap;
|
|
593
|
+
font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
|
|
594
|
+
font-size: 13px;
|
|
595
|
+
line-height: 1.6;
|
|
596
|
+
overflow-x: auto;
|
|
597
|
+
}
|
|
598
|
+
</style>
|
|
599
|
+
</head>
|
|
600
|
+
<body>
|
|
601
|
+
<h1>{{ filename }}</h1>
|
|
602
|
+
<div class="breadcrumb">
|
|
603
|
+
<strong>Path:</strong> {{ breadcrumb|safe }}
|
|
604
|
+
</div>
|
|
605
|
+
<a href="{{ back_url|e }}" class="back-button">← Back to Directory</a>
|
|
606
|
+
<div class="file-content">{{ content|e }}</div>
|
|
607
|
+
</body>
|
|
608
|
+
</html>
|
|
609
|
+
"""
|
|
610
|
+
|
|
611
|
+
@app.route("/")
|
|
612
|
+
@app.route("/<path:subpath>")
|
|
613
|
+
def browse(subpath: str = "") -> str | Response:
|
|
614
|
+
"""Browse files and directories in the cache folder."""
|
|
615
|
+
full_path = self.cache_dir / subpath
|
|
616
|
+
full_path = full_path.resolve()
|
|
617
|
+
|
|
618
|
+
# Security check: ensure path is within cache_dir
|
|
619
|
+
if not str(full_path).startswith(str(self.cache_dir.resolve())):
|
|
620
|
+
return "Access denied", 403
|
|
621
|
+
|
|
622
|
+
# If path doesn't exist
|
|
623
|
+
if not full_path.exists():
|
|
624
|
+
return "Path not found", 404
|
|
625
|
+
|
|
626
|
+
# If it's a file, display its content
|
|
627
|
+
if full_path.is_file():
|
|
628
|
+
try:
|
|
629
|
+
# Build breadcrumb
|
|
630
|
+
parts = subpath.split("/") if subpath else []
|
|
631
|
+
breadcrumb_parts = ['<a href="/">cache</a>']
|
|
632
|
+
for i, part in enumerate(parts[:-1]):
|
|
633
|
+
path_to_part = "/".join(parts[: i + 1])
|
|
634
|
+
breadcrumb_parts.append(f'<a href="/{path_to_part}">{part}</a>')
|
|
635
|
+
breadcrumb_parts.append(f"<strong>{parts[-1]}</strong>" if parts else "")
|
|
636
|
+
breadcrumb = " / ".join(breadcrumb_parts)
|
|
637
|
+
|
|
638
|
+
# Determine back URL
|
|
639
|
+
back_url = "/" + "/".join(parts[:-1]) if len(parts) > 1 else "/"
|
|
640
|
+
|
|
641
|
+
# If it's a JSON file, render with the JSON viewer
|
|
642
|
+
if full_path.suffix == ".json":
|
|
643
|
+
with open(full_path, "r", encoding="utf-8") as f:
|
|
644
|
+
data = json.load(f)
|
|
645
|
+
|
|
646
|
+
return render_template_string(
|
|
647
|
+
JSON_VIEWER_TEMPLATE,
|
|
648
|
+
filename=full_path.name,
|
|
649
|
+
breadcrumb=breadcrumb,
|
|
650
|
+
back_url=back_url,
|
|
651
|
+
history=data.get("history", []),
|
|
652
|
+
config=data.get("config", {}),
|
|
653
|
+
enumerate=enumerate,
|
|
654
|
+
)
|
|
655
|
+
else:
|
|
656
|
+
# For text files, use simple viewer
|
|
657
|
+
with open(full_path, "r", encoding="utf-8") as f:
|
|
658
|
+
content = f.read()
|
|
659
|
+
|
|
660
|
+
return render_template_string(
|
|
661
|
+
TEXT_VIEWER_TEMPLATE,
|
|
662
|
+
filename=full_path.name,
|
|
663
|
+
content=content,
|
|
664
|
+
breadcrumb=breadcrumb,
|
|
665
|
+
back_url=back_url,
|
|
666
|
+
)
|
|
667
|
+
except Exception as e:
|
|
668
|
+
return f"Error reading file: {str(e)}", 500
|
|
669
|
+
|
|
670
|
+
# If it's a directory, list its contents
|
|
671
|
+
items = []
|
|
672
|
+
try:
|
|
673
|
+
for entry in sorted(full_path.iterdir(), key=lambda x: (not x.is_dir(), x.name)):
|
|
674
|
+
# Calculate relative path from cache_dir
|
|
675
|
+
try:
|
|
676
|
+
relative_path = entry.resolve().relative_to(self.cache_dir.resolve())
|
|
677
|
+
except ValueError:
|
|
678
|
+
# If relative_to fails, skip this entry for security
|
|
679
|
+
continue
|
|
680
|
+
item_info: dict[str, Any] = {
|
|
681
|
+
"name": entry.name,
|
|
682
|
+
"is_dir": entry.is_dir(),
|
|
683
|
+
"url": f"/{relative_path}",
|
|
684
|
+
}
|
|
685
|
+
if entry.is_file():
|
|
686
|
+
size = entry.stat().st_size
|
|
687
|
+
if size < 1024:
|
|
688
|
+
item_info["size"] = f"{size} B"
|
|
689
|
+
elif size < 1024 * 1024:
|
|
690
|
+
item_info["size"] = f"{size / 1024:.1f} KB"
|
|
691
|
+
else:
|
|
692
|
+
item_info["size"] = f"{size / (1024 * 1024):.1f} MB"
|
|
693
|
+
items.append(item_info)
|
|
694
|
+
except Exception as e:
|
|
695
|
+
return f"Error listing directory: {str(e)}", 500
|
|
696
|
+
|
|
697
|
+
# Build breadcrumb
|
|
698
|
+
parts = subpath.split("/") if subpath else []
|
|
699
|
+
breadcrumb_parts = ['<a href="/">cache</a>']
|
|
700
|
+
for i, part in enumerate(parts):
|
|
701
|
+
if part:
|
|
702
|
+
path_to_part = "/".join(parts[: i + 1])
|
|
703
|
+
breadcrumb_parts.append(f'<a href="/{path_to_part}">{part}</a>')
|
|
704
|
+
breadcrumb = " / ".join(breadcrumb_parts)
|
|
705
|
+
|
|
706
|
+
return render_template_string(DIRECTORY_TEMPLATE, items=items, breadcrumb=breadcrumb)
|
|
707
|
+
|
|
708
|
+
return app
|
|
709
|
+
|
|
710
|
+
def start_web_server(self, host: str = "127.0.0.1", port: int = 5000, debug: bool = False) -> None:
|
|
711
|
+
"""
|
|
712
|
+
Start the web server for browsing conversation files.
|
|
713
|
+
|
|
714
|
+
Args:
|
|
715
|
+
host: Host address to bind to
|
|
716
|
+
port: Port number to listen on
|
|
717
|
+
debug: Enable debug mode
|
|
718
|
+
"""
|
|
719
|
+
app = self.create_web_app()
|
|
720
|
+
print(f"Starting tracer web server at http://{host}:{port}")
|
|
721
|
+
print(f"Cache directory: {self.cache_dir.resolve()}")
|
|
722
|
+
app.run(host=host, port=port, debug=debug)
|