transcript-viewer 0.1.0
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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +154 -0
- data/assets/dark.json +85 -0
- data/assets/template.css +1078 -0
- data/lib/transcript_viewer/configuration.rb +17 -0
- data/lib/transcript_viewer/page.rb +42 -0
- data/lib/transcript_viewer/renderer.rb +367 -0
- data/lib/transcript_viewer/support.rb +38 -0
- data/lib/transcript_viewer/templates/app.html.erb +10 -0
- data/lib/transcript_viewer/templates/assistant_block.html.erb +10 -0
- data/lib/transcript_viewer/templates/compaction.html.erb +3 -0
- data/lib/transcript_viewer/templates/entry.html.erb +3 -0
- data/lib/transcript_viewer/templates/fetch_tool_table.html.erb +1 -0
- data/lib/transcript_viewer/templates/fork_bar.html.erb +1 -0
- data/lib/transcript_viewer/templates/hamburger.html.erb +3 -0
- data/lib/transcript_viewer/templates/header.html.erb +5 -0
- data/lib/transcript_viewer/templates/info_item.html.erb +1 -0
- data/lib/transcript_viewer/templates/main_content.html.erb +11 -0
- data/lib/transcript_viewer/templates/message_entry.html.erb +8 -0
- data/lib/transcript_viewer/templates/message_tree_content.html.erb +2 -0
- data/lib/transcript_viewer/templates/script.html.erb +80 -0
- data/lib/transcript_viewer/templates/server_tool_use.html.erb +4 -0
- data/lib/transcript_viewer/templates/session.html.erb +13 -0
- data/lib/transcript_viewer/templates/sidebar.html.erb +1 -0
- data/lib/transcript_viewer/templates/tool_call.html.erb +6 -0
- data/lib/transcript_viewer/templates/tree_content.html.erb +2 -0
- data/lib/transcript_viewer/templates/tree_node.html.erb +4 -0
- data/lib/transcript_viewer/tool_execution.rb +74 -0
- data/lib/transcript_viewer/tool_template_registry.rb +67 -0
- data/lib/transcript_viewer/version.rb +5 -0
- data/lib/transcript_viewer/view.rb +475 -0
- data/lib/transcript_viewer.rb +47 -0
- metadata +76 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "tool_template_registry"
|
|
4
|
+
|
|
5
|
+
module TranscriptViewer
|
|
6
|
+
class Configuration
|
|
7
|
+
attr_reader :tool_templates
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@tool_templates = ToolTemplateRegistry.new
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def register_tool_template(tool_name, template_path)
|
|
14
|
+
tool_templates.register(tool_name, template_path)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TranscriptViewer
|
|
4
|
+
class Page
|
|
5
|
+
def initialize(session_id:, events:, forked_transcript: false, ancestor_lookup: nil, export_href: nil, forked_transcript_href: nil, hide_forked_transcript_href: nil, asset_dir: nil, template_dir: nil, tool_templates: nil, before_html: nil, after_html: nil)
|
|
6
|
+
view = View.new(
|
|
7
|
+
session_id: session_id,
|
|
8
|
+
events: events,
|
|
9
|
+
forked_transcript: forked_transcript,
|
|
10
|
+
ancestor_lookup: ancestor_lookup,
|
|
11
|
+
export_href: export_href,
|
|
12
|
+
forked_transcript_href: forked_transcript_href,
|
|
13
|
+
hide_forked_transcript_href: hide_forked_transcript_href,
|
|
14
|
+
asset_dir: asset_dir,
|
|
15
|
+
).to_h
|
|
16
|
+
view[:before_html] = before_html.to_s
|
|
17
|
+
view[:after_html] = after_html.to_s
|
|
18
|
+
registered_tool_templates = TranscriptViewer.configuration.tool_templates.merge(tool_templates)
|
|
19
|
+
@renderer = Renderer.new(
|
|
20
|
+
view,
|
|
21
|
+
template_dir: template_dir,
|
|
22
|
+
tool_templates: registered_tool_templates,
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def render
|
|
27
|
+
@renderer.render
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def stylesheet
|
|
31
|
+
@renderer.stylesheet
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def app_html
|
|
35
|
+
@renderer.render_app
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def javascript
|
|
39
|
+
@renderer.render_javascript
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
require "erb"
|
|
5
|
+
require "json"
|
|
6
|
+
require "time"
|
|
7
|
+
|
|
8
|
+
require_relative "support"
|
|
9
|
+
require_relative "tool_execution"
|
|
10
|
+
require_relative "tool_template_registry"
|
|
11
|
+
|
|
12
|
+
module TranscriptViewer
|
|
13
|
+
class Renderer
|
|
14
|
+
def initialize(view, template_dir: nil, tool_templates: nil)
|
|
15
|
+
@view = view
|
|
16
|
+
@template_dir = template_dir
|
|
17
|
+
@tool_templates = ToolTemplateRegistry.new(tool_templates)
|
|
18
|
+
@tool_results_by_call_id = build_tool_result_index
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def render
|
|
22
|
+
render_template("session")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def stylesheet
|
|
26
|
+
@view.fetch(:css)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def render_app
|
|
30
|
+
render_template("app")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def render_javascript
|
|
34
|
+
render_script
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def render_template(name, locals = {})
|
|
40
|
+
render_template_file(template_path(name), locals)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def render_template_file(path, locals = {})
|
|
44
|
+
template_binding = binding
|
|
45
|
+
locals.each { |key, value| template_binding.local_variable_set(key, value) }
|
|
46
|
+
ERB.new(File.read(path), trim_mode: "-").result(template_binding)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def template_path(name)
|
|
50
|
+
relative_path = "#{name}.html.erb"
|
|
51
|
+
custom_path = File.expand_path(relative_path, @template_dir) if @template_dir
|
|
52
|
+
return custom_path if custom_path && File.file?(custom_path)
|
|
53
|
+
|
|
54
|
+
File.expand_path("templates/#{relative_path}", __dir__)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def render_hamburger
|
|
58
|
+
render_template("hamburger").strip
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def render_sidebar
|
|
62
|
+
render_template("sidebar")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def render_tree_node(entry)
|
|
66
|
+
render_template("tree_node", entry: entry)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def render_tree_content(entry)
|
|
70
|
+
render_template("tree_content", entry: entry)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def render_message_tree_content(msg)
|
|
74
|
+
render_template("message_tree_content", msg: msg)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def render_main_content
|
|
78
|
+
render_template("main_content")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def render_header
|
|
82
|
+
render_template("header")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def info_item(label, value)
|
|
86
|
+
render_template("info_item", label: label, value: value)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def render_fork_bar
|
|
90
|
+
render_template("fork_bar")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def render_entry(entry)
|
|
94
|
+
render_template("entry", entry: entry)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def render_message_entry(entry)
|
|
98
|
+
render_template("message_entry", entry: entry)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def render_assistant_block(block, blocks, index)
|
|
102
|
+
render_template("assistant_block", block: block, blocks: blocks, index: index)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def render_server_tool_use(block, result_block)
|
|
106
|
+
render_template("server_tool_use", block: block, result_block: result_block)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def render_compaction(entry)
|
|
110
|
+
render_template("compaction", entry: entry)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def render_tool_call(call)
|
|
114
|
+
tool = ToolExecution.new(call: call, result_entry: @tool_results_by_call_id[call[:id]])
|
|
115
|
+
custom_template_path = @tool_templates.resolve(tool.name)
|
|
116
|
+
return render_tool_fallback(tool) unless custom_template_path
|
|
117
|
+
|
|
118
|
+
unless File.file?(custom_template_path)
|
|
119
|
+
raise TemplateNotFoundError, "Registered template for #{tool.name.inspect} does not exist: #{custom_template_path}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
render_template_file(custom_template_path, tool: tool)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def render_tool_fallback(tool)
|
|
126
|
+
render_template("tool_call", tool: tool, call: tool.call, entries: @view[:entries])
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def render_fetch_tool_table(rows)
|
|
130
|
+
render_template("fetch_tool_table", rows: rows)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def timestamp_block(timestamp)
|
|
134
|
+
time = message_time(timestamp)
|
|
135
|
+
return "" unless present?(time)
|
|
136
|
+
|
|
137
|
+
%(<div class="message-timestamp">#{h(time)}</div>)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def timestamp_prefix(timestamp, inline: false)
|
|
141
|
+
time = message_time(timestamp)
|
|
142
|
+
return "" unless present?(time)
|
|
143
|
+
|
|
144
|
+
%(<span class="message-timestamp">#{h(time)}</span> · )
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def render_markdownish(text)
|
|
148
|
+
normalized = text.to_s.gsub(/\r\n?/, "\n")
|
|
149
|
+
return "" if normalized.empty?
|
|
150
|
+
|
|
151
|
+
normalized.split(/\n{2,}/).map do |paragraph|
|
|
152
|
+
%(<p>#{h(paragraph).gsub("\n", "<br>")}</p>)
|
|
153
|
+
end.join
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def format_tokens(value)
|
|
157
|
+
value = value.to_i
|
|
158
|
+
return "#{(value / 1000.0).round}k" if value >= 10_000
|
|
159
|
+
return format("%.1fk", value / 1000.0).sub(".0k", "k") if value >= 1_000
|
|
160
|
+
|
|
161
|
+
value.to_s
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def format_time(timestamp)
|
|
165
|
+
return "unknown" if blank?(timestamp)
|
|
166
|
+
|
|
167
|
+
time = Time.parse(timestamp).getlocal
|
|
168
|
+
hour = time.strftime("%I").sub(/^0/, "")
|
|
169
|
+
"#{time.month}/#{time.day}/#{time.year}, #{hour}:#{time.strftime("%M:%S %p")}"
|
|
170
|
+
rescue ArgumentError, TypeError
|
|
171
|
+
timestamp.to_s
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def message_time(timestamp)
|
|
175
|
+
return nil if blank?(timestamp)
|
|
176
|
+
|
|
177
|
+
format_time(timestamp).sub(%r{^\d+/\d+/\d+, }, "")
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def message_parts(stats)
|
|
181
|
+
[].tap do |parts|
|
|
182
|
+
parts << "#{stats[:developer]} developer" if stats[:developer].positive?
|
|
183
|
+
parts << "#{stats[:user]} user" if stats[:user].positive?
|
|
184
|
+
parts << "#{stats[:assistant]} assistant" if stats[:assistant].positive?
|
|
185
|
+
parts << "#{stats[:tool_results]} tool results" if stats[:tool_results].positive?
|
|
186
|
+
parts << "#{stats[:compactions]} compactions" if stats[:compactions].positive?
|
|
187
|
+
end.join(", ")
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def token_parts(stats)
|
|
191
|
+
tokens = stats[:tokens]
|
|
192
|
+
[
|
|
193
|
+
"↑#{format_tokens(tokens[:input])} (R#{format_tokens(tokens[:cacheRead])} W#{format_tokens(tokens[:cacheWrite])})",
|
|
194
|
+
"↓#{format_tokens(tokens[:output])}",
|
|
195
|
+
].join(" ")
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def tool_pair_text(entry)
|
|
199
|
+
input_text = truncate_tree_text(tool_input_text(entry), 90)
|
|
200
|
+
result_text = truncate_tree_text(extract_content(entry[:resultContent]), 80)
|
|
201
|
+
["[#{entry[:toolName]}: #{input_text}]", presence(result_text)].compact.join(" → ")
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def tool_input_text(entry)
|
|
205
|
+
input = entry[:input] || {}
|
|
206
|
+
command = input[:command] || input["command"]
|
|
207
|
+
path = input[:path] || input["path"]
|
|
208
|
+
return command if present?(command)
|
|
209
|
+
return path if present?(path)
|
|
210
|
+
|
|
211
|
+
display_json(input)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def display_json(value)
|
|
215
|
+
JSON.generate(value)
|
|
216
|
+
rescue JSON::GeneratorError, TypeError
|
|
217
|
+
value.to_s
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def extract_content(content)
|
|
221
|
+
return display_json(content) if content.is_a?(Hash)
|
|
222
|
+
return content.to_s unless content.is_a?(Array)
|
|
223
|
+
|
|
224
|
+
content.filter_map do |block|
|
|
225
|
+
case block[:type]
|
|
226
|
+
when "text"
|
|
227
|
+
block[:text]
|
|
228
|
+
when "thinking"
|
|
229
|
+
block[:thinking]
|
|
230
|
+
when "toolCall"
|
|
231
|
+
"$ #{block[:name]}"
|
|
232
|
+
when "serverToolUse"
|
|
233
|
+
input = block[:input]
|
|
234
|
+
label = input.is_a?(Hash) ? input[:command] || input["command"] || input[:path] || input["path"] : ""
|
|
235
|
+
"$ #{block[:name]} #{label}".strip
|
|
236
|
+
when "serverToolResult"
|
|
237
|
+
"[server tool result]"
|
|
238
|
+
when "image"
|
|
239
|
+
"[image]"
|
|
240
|
+
end
|
|
241
|
+
end.join(" ")
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def truncate_tree_text(text, max = 100)
|
|
245
|
+
normalized = text.to_s.gsub(/[\n\t]/, " ").strip
|
|
246
|
+
normalized.length > max ? "#{normalized[0, max]}..." : normalized
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def build_tool_result_index
|
|
250
|
+
@view[:entries].each_with_object({}) do |entry, index|
|
|
251
|
+
next unless entry.dig(:message, :role) == "toolResult"
|
|
252
|
+
|
|
253
|
+
index[entry.dig(:message, :toolCallId)] = entry
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def fetch_tool_call?(call)
|
|
258
|
+
call[:name].to_s.start_with?("fetch_")
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def pretty_tool_arguments(call)
|
|
262
|
+
JSON.pretty_generate(normalized_tool_arguments(call[:arguments] || {}))
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def tool_result_text(call, result)
|
|
266
|
+
output = tool_result_raw_text(result)
|
|
267
|
+
return output unless fetch_tool_call?(call)
|
|
268
|
+
|
|
269
|
+
JSON.pretty_generate(JSON.parse(output))
|
|
270
|
+
rescue JSON::ParserError
|
|
271
|
+
output
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def tool_result_raw_text(result)
|
|
275
|
+
Array(result&.dig(:message, :content)).filter_map { |block| block[:text] if block[:type] == "text" }.join("\n")
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def tool_result_images(result)
|
|
279
|
+
Array(result&.dig(:message, :content)).select { |block| block[:type] == "image" && present?(block[:data]) }
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def fetch_tool_result_rows(result)
|
|
283
|
+
parsed = JSON.parse(tool_result_raw_text(result))
|
|
284
|
+
return parsed if parsed.is_a?(Array) && parsed.all? { |row| row.is_a?(Hash) }
|
|
285
|
+
|
|
286
|
+
[]
|
|
287
|
+
rescue JSON::ParserError
|
|
288
|
+
[]
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def fetch_tool_result_headers(rows)
|
|
292
|
+
rows.flat_map(&:keys).uniq
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def fetch_tool_table_value(value)
|
|
296
|
+
return "" if value.nil?
|
|
297
|
+
return value.to_s unless value.is_a?(Array) || value.is_a?(Hash)
|
|
298
|
+
|
|
299
|
+
JSON.generate(value)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def normalized_tool_arguments(arguments)
|
|
303
|
+
arguments = Support.symbolize(arguments)
|
|
304
|
+
parameters = arguments[:parameters] || arguments["parameters"]
|
|
305
|
+
return arguments unless parameters.is_a?(String)
|
|
306
|
+
|
|
307
|
+
arguments[:parameters] = JSON.parse(parameters)
|
|
308
|
+
arguments
|
|
309
|
+
rescue JSON::ParserError
|
|
310
|
+
arguments
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def server_tool_label(block)
|
|
314
|
+
input = block[:input] || {}
|
|
315
|
+
command = input[:command] || input["command"]
|
|
316
|
+
path = input[:path] || input["path"]
|
|
317
|
+
return command if block[:name] == "bash_code_execution" && present?(command)
|
|
318
|
+
|
|
319
|
+
[block[:name], command || path].compact.join(" ")
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def image_src(image)
|
|
323
|
+
"data:#{image[:mimeType] || image[:media_type] || "image/png"};base64,#{image[:data]}"
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def server_tool_result_text(content)
|
|
327
|
+
return pretty_json(content) unless content.is_a?(Hash)
|
|
328
|
+
|
|
329
|
+
result = content[:content] || content["content"] || []
|
|
330
|
+
stdout = content[:stdout] || content["stdout"]
|
|
331
|
+
stderr = content[:stderr] || content["stderr"]
|
|
332
|
+
return [presence(stdout), presence(stderr)].compact.join("\n") if present?(stdout) || present?(stderr)
|
|
333
|
+
|
|
334
|
+
if result.is_a?(Array) && result.any?
|
|
335
|
+
return result.map { |item| item.is_a?(Hash) ? pretty_json(item) : item.to_s }.join("\n")
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
pretty_json(content)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def pretty_json(value)
|
|
342
|
+
JSON.pretty_generate(value)
|
|
343
|
+
rescue JSON::GeneratorError, TypeError
|
|
344
|
+
value.to_s
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def h(value)
|
|
348
|
+
CGI.escapeHTML(value.to_s)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def blank?(value)
|
|
352
|
+
Support.blank?(value)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def present?(value)
|
|
356
|
+
Support.present?(value)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def presence(value)
|
|
360
|
+
Support.presence(value)
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def render_script
|
|
364
|
+
render_template("script")
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TranscriptViewer
|
|
4
|
+
module Support
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def blank?(value)
|
|
8
|
+
value.nil? || (value.respond_to?(:empty?) && value.empty?)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def present?(value)
|
|
12
|
+
!blank?(value)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def presence(value)
|
|
16
|
+
present?(value) ? value : nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def symbolize(value)
|
|
20
|
+
case value
|
|
21
|
+
when Array
|
|
22
|
+
value.map { |item| symbolize(item) }
|
|
23
|
+
when Hash
|
|
24
|
+
value.each_with_object({}) do |(key, item), hash|
|
|
25
|
+
hash[symbolize_key(key)] = symbolize(item)
|
|
26
|
+
end
|
|
27
|
+
else
|
|
28
|
+
value
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def symbolize_key(key)
|
|
33
|
+
key.respond_to?(:to_sym) ? key.to_sym : key
|
|
34
|
+
rescue StandardError
|
|
35
|
+
key
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<%= @view[:before_html] %>
|
|
2
|
+
<%= render_hamburger %>
|
|
3
|
+
<div id="sidebar-overlay"></div>
|
|
4
|
+
<div id="app">
|
|
5
|
+
<%= render_sidebar %>
|
|
6
|
+
<div id="sidebar-resizer" role="separator"></div>
|
|
7
|
+
<%= render_main_content %>
|
|
8
|
+
<div id="image-modal" class="image-modal"><img id="modal-image" src="" alt=""></div>
|
|
9
|
+
</div>
|
|
10
|
+
<%= @view[:after_html] %>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<% case block[:type]
|
|
2
|
+
when "text"
|
|
3
|
+
if present?(block[:text].to_s.strip)
|
|
4
|
+
%><div class="assistant-text markdown-content"><%= render_markdownish(block[:text]) %></div><% end %><% when "thinking"
|
|
5
|
+
if present?(block[:thinking].to_s.strip)
|
|
6
|
+
%><div class="thinking-block"><div class="thinking-text"><%= h(block[:thinking]) %></div><div class="thinking-collapsed">Thinking ...</div></div><% end %><% when "toolCall" %><%= render_tool_call(block) %><% when "serverToolUse" %><%
|
|
7
|
+
result_block = Array(blocks[(index + 1)..]).find { |candidate| candidate[:type] == "serverToolResult" && candidate[:toolUseId] == block[:id] }
|
|
8
|
+
%><%= render_server_tool_use(block, result_block) %><% when "serverToolResult" %><%
|
|
9
|
+
previous_use = Array(blocks[0...index]).find { |candidate| candidate[:type] == "serverToolUse" && candidate[:id] == block[:toolUseId] }
|
|
10
|
+
%><% unless previous_use %><div class="tool-execution success"><div class="tool-command">[server tool result]</div><div class="tool-output"><pre><%= h(server_tool_result_text(block[:content])) %></pre></div></div><% end %><% end %>
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
<div class="compaction" id="<%= h(entry[:id]) %>"><%= timestamp_block(entry[:timestamp]) %><div class="compaction-label">[compaction]</div><div class="compaction-collapsed">Compacted from <%= h(entry[:tokensBefore].to_i) %> tokens</div><div class="compaction-content"><strong>Compacted from <%= h(entry[:tokensBefore].to_i) %> tokens</strong>
|
|
2
|
+
|
|
3
|
+
<%= h(entry[:summary]) %></div></div>
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
<% case entry[:type]
|
|
2
|
+
when "compaction"
|
|
3
|
+
%><%= render_compaction(entry) %><% when "reasoningChange" %><div class="model-change" id="<%= h(entry[:id]) %>"><%= timestamp_prefix(entry[:timestamp], inline: true) %>Reasoning: <span class="model-name"><%= h(presence(entry[:reasoning]) || "unknown") %></span></div><% when "modelChange" %><div class="model-change" id="<%= h(entry[:id]) %>"><%= timestamp_prefix(entry[:timestamp], inline: true) %>Model: <span class="model-name"><%= h(presence(entry[:modelId]) || "unknown") %></span></div><% when "message" %><%= render_message_entry(entry) %><% end %>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<% headers = fetch_tool_result_headers(rows) %><div class="tool-output fetch-tool-result"><table><thead><tr><% headers.each do |header| %><th><%= h(header) %></th><% end %></tr></thead><tbody><% rows.each do |row| %><tr><% headers.each do |header| %><td><%= h(fetch_tool_table_value(row[header])) %></td><% end %></tr><% end %></tbody></table></div>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<% if @view[:forked_transcript_available] %><% if @view[:forked_transcript_enabled] %><div class="forked-transcript-bar" id="fork-boundary"><span>Showing previous messages from the fork point.</span><a class="header-toggle-btn" href="<%= h(@view[:hide_forked_transcript_href]) %>">Hide previous messages</a></div><% else %><div class="forked-transcript-bar" id="fork-boundary"><span>This transcript starts from a message with earlier parent messages.</span><a class="header-toggle-btn" href="<%= h(@view[:forked_transcript_href]) %>">Load previous messages here</a></div><% end %><% end %>
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
<button id="hamburger" title="Open sidebar">
|
|
2
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><rect x="5" y="6" width="2" height="12"/><path d="M6 12h10c1 0 2 0 2-2V8"/></svg>
|
|
3
|
+
</button>
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<%
|
|
2
|
+
header = @view[:header]
|
|
3
|
+
stats = @view[:stats]
|
|
4
|
+
models = presence(stats[:models].to_a.join(", ")) || "unknown"
|
|
5
|
+
%><div class="header"><h1>Session: <%= h(header[:id] || "unknown") %></h1><div class="help-bar"><span class="help-hint">T toggle thinking · O toggle tools</span><div class="help-actions"><button type="button" class="header-toggle-btn" data-toggle="thinking" aria-pressed="false">Toggle thinking</button><button type="button" class="header-toggle-btn" data-toggle="tools" aria-pressed="false">Toggle tools</button><% if presence(@view[:export_href]) %><a class="download-json-btn" href="<%= h(@view[:export_href]) %>">↓ JSONL</a><% end %></div></div><div class="header-info"><%= info_item("Date:", format_time(header[:timestamp])) %><%= info_item("Models:", models) %><%= info_item("Messages:", message_parts(stats)) %><%= info_item("Tool Calls:", stats[:tool_calls]) %><%= info_item("Tokens:", token_parts(stats)) %><%= info_item("Cost:", "$0.000") %></div></div>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<div class="info-item"><span class="info-label"><%= h(label) %></span><span class="info-value"><%= h(value) %></span></div>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<%
|
|
2
|
+
path_entries = @view[:path_entries]
|
|
3
|
+
messages_html = if @view[:forked_transcript_enabled]
|
|
4
|
+
ancestor_count = @view[:forked_ancestor_count].to_i
|
|
5
|
+
path_entries.first(ancestor_count).map { |entry| render_entry(entry) }.join +
|
|
6
|
+
render_fork_bar +
|
|
7
|
+
path_entries.drop(ancestor_count).map { |entry| render_entry(entry) }.join
|
|
8
|
+
else
|
|
9
|
+
path_entries.map { |entry| render_entry(entry) }.join
|
|
10
|
+
end
|
|
11
|
+
%><main id="content"><div id="header-container"><%= render_header %></div><% unless @view[:forked_transcript_enabled] %><%= render_fork_bar %><% end %><div id="messages"><%= messages_html %></div></main>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<%
|
|
2
|
+
msg = entry[:message]
|
|
3
|
+
%><% case msg[:role]
|
|
4
|
+
when "user", "developer"
|
|
5
|
+
classes = ["user-message"]
|
|
6
|
+
classes << "developer-message" if msg[:role] == "developer"
|
|
7
|
+
text = Array(msg[:content]).filter_map { |block| block[:text] if block[:type] == "text" }.join("\n")
|
|
8
|
+
%><div class="<%= h(classes.join(" ")) %>" id="<%= h(entry[:id]) %>"><%= timestamp_block(entry[:timestamp]) %><% if msg[:role] == "developer" %><div class="compaction-label">[developer]</div><% end %><div class="markdown-content"><%= render_markdownish(text) %></div></div><% when "assistant" %><% blocks = Array(msg[:content]) %><div class="assistant-message" id="<%= h(entry[:id]) %>"><%= timestamp_block(entry[:timestamp]) %><%= blocks.each_with_index.map { |block, index| render_assistant_block(block, blocks, index) }.join %></div><% end %>
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
<% case msg[:role]
|
|
2
|
+
when "developer" %><span class="tree-muted">developer:</span> <%= h(truncate_tree_text(extract_content(msg[:content]))) %><% when "user" %><span class="tree-role-user">user:</span> <%= h(truncate_tree_text(extract_content(msg[:content]))) %><% when "assistant" %><% text = presence(truncate_tree_text(extract_content(msg[:content]))) || "(no text)" %><span class="tree-role-assistant">assistant:</span> <%= h(text) %><% when "toolResult" %><span class="tree-role-tool">[<%= h(msg[:toolName] || "tool") %>]</span> <%= h(truncate_tree_text(extract_content(msg[:content]))) %><% when "bashExecution" %><span class="tree-role-tool">[bash]:</span> <%= h(truncate_tree_text(msg[:command].to_s)) %><% else %><span class="tree-muted">[<%= h(msg[:role]) %>]</span><% end %>
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
(function(){
|
|
3
|
+
function setToggle(name, enabled){
|
|
4
|
+
document.body.classList.toggle('hide-' + name, enabled);
|
|
5
|
+
document.querySelectorAll('[data-toggle="' + name + '"]').forEach(function(button){
|
|
6
|
+
button.classList.toggle('active', enabled);
|
|
7
|
+
button.setAttribute('aria-pressed', enabled ? 'true' : 'false');
|
|
8
|
+
});
|
|
9
|
+
try { localStorage.setItem('agent-session-hide-' + name, enabled ? '1' : '0'); } catch(e) {}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function toggle(name){
|
|
13
|
+
setToggle(name, !document.body.classList.contains('hide-' + name));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
['thinking', 'tools'].forEach(function(name){
|
|
17
|
+
try { setToggle(name, localStorage.getItem('agent-session-hide-' + name) === '1'); } catch(e) {}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
var sidebar = document.getElementById('sidebar');
|
|
21
|
+
var overlay = document.getElementById('sidebar-overlay');
|
|
22
|
+
var hamburger = document.getElementById('hamburger');
|
|
23
|
+
var close = document.getElementById('sidebar-close');
|
|
24
|
+
function setSidebar(open){
|
|
25
|
+
if(!sidebar || !overlay) return;
|
|
26
|
+
sidebar.classList.toggle('open', open);
|
|
27
|
+
overlay.classList.toggle('open', open);
|
|
28
|
+
}
|
|
29
|
+
if(hamburger) hamburger.addEventListener('click', function(){ setSidebar(true); });
|
|
30
|
+
if(close) close.addEventListener('click', function(){ setSidebar(false); });
|
|
31
|
+
if(overlay) overlay.addEventListener('click', function(){ setSidebar(false); });
|
|
32
|
+
|
|
33
|
+
var search = document.getElementById('tree-search');
|
|
34
|
+
var status = document.getElementById('tree-status');
|
|
35
|
+
function updateSearch(){
|
|
36
|
+
if(!search) return;
|
|
37
|
+
var q = search.value.toLowerCase();
|
|
38
|
+
var shown = 0;
|
|
39
|
+
var total = 0;
|
|
40
|
+
document.querySelectorAll('.tree-node').forEach(function(node){
|
|
41
|
+
total++;
|
|
42
|
+
var match = !q || node.textContent.toLowerCase().indexOf(q) !== -1;
|
|
43
|
+
node.style.display = match ? '' : 'none';
|
|
44
|
+
if(match) shown++;
|
|
45
|
+
});
|
|
46
|
+
if(status) status.textContent = shown + ' / ' + total + ' entries';
|
|
47
|
+
}
|
|
48
|
+
if(search) search.addEventListener('input', updateSearch);
|
|
49
|
+
|
|
50
|
+
var resizer = document.getElementById('sidebar-resizer');
|
|
51
|
+
if(resizer && sidebar){
|
|
52
|
+
var resizing = false;
|
|
53
|
+
resizer.addEventListener('mousedown', function(e){ resizing = true; document.body.classList.add('sidebar-resizing'); e.preventDefault(); });
|
|
54
|
+
document.addEventListener('mousemove', function(e){
|
|
55
|
+
if(!resizing) return;
|
|
56
|
+
var width = Math.max(240, Math.min(840, e.clientX));
|
|
57
|
+
document.documentElement.style.setProperty('--sidebar-width', width + 'px');
|
|
58
|
+
});
|
|
59
|
+
document.addEventListener('mouseup', function(){ resizing = false; document.body.classList.remove('sidebar-resizing'); });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
document.addEventListener('click',function(e){
|
|
63
|
+
var toggleButton = e.target.closest('[data-toggle]');
|
|
64
|
+
if(toggleButton){
|
|
65
|
+
toggle(toggleButton.getAttribute('data-toggle'));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
var x=e.target.closest('.expandable,.thinking-block,.compaction');
|
|
70
|
+
if(x&&!getSelection().toString())x.classList.toggle('expanded');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
document.addEventListener('keydown', function(e){
|
|
74
|
+
if(e.metaKey || e.ctrlKey || e.altKey) return;
|
|
75
|
+
if(e.target && ['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)) return;
|
|
76
|
+
if(e.key === 't' || e.key === 'T') toggle('thinking');
|
|
77
|
+
if(e.key === 'o' || e.key === 'O') toggle('tools');
|
|
78
|
+
});
|
|
79
|
+
})();
|
|
80
|
+
</script>
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
<%
|
|
2
|
+
input = block[:input] || {}
|
|
3
|
+
file_text = input[:file_text] || input["file_text"]
|
|
4
|
+
%><div class="tool-execution <%= result_block ? "success" : "pending" %>"><div class="tool-command">$ <%= h(server_tool_label(block)) %></div><% if present?(file_text) %><div class="tool-output"><pre><%= h(file_text) %></pre></div><% elsif present?(input) && block[:name] != "bash_code_execution" %><div class="tool-output"><pre><%= h(pretty_json(input)) %></pre></div><% end %><% if result_block %><div class="tool-output"><pre><%= h(server_tool_result_text(result_block[:content])) %></pre></div><% end %></div>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Session Export</title>
|
|
7
|
+
<style><%= @view[:css] %></style>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<%= render_app %>
|
|
11
|
+
<%= render_javascript %>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<% sidebar_entries = @view[:sidebar_entries] %><aside id="sidebar"><div class="sidebar-header"><div class="sidebar-controls"><input type="text" class="sidebar-search" id="tree-search" placeholder="Search..."></div><div class="sidebar-filters"><button class="filter-btn active" type="button">Default</button><button class="filter-btn" type="button">No-tools</button><button class="filter-btn" type="button">User</button><button class="filter-btn" type="button">Labeled</button><button class="filter-btn" type="button">All</button><button class="sidebar-close" id="sidebar-close" title="Close" type="button">✕</button></div></div><div class="tree-container" id="tree-container"><%= sidebar_entries.map { |entry| render_tree_node(entry) }.join %></div><div class="tree-status" id="tree-status"><%= sidebar_entries.length %> / <%= sidebar_entries.length %> entries</div></aside>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<%
|
|
2
|
+
call = tool.call
|
|
3
|
+
result = tool.result_entry
|
|
4
|
+
args = tool.input
|
|
5
|
+
command = args[:command] || args["command"] || pretty_json(args)
|
|
6
|
+
%><div class="tool-execution <%= result ? "success" : "pending" %>"><div class="tool-command">$ <%= h(presence(call[:name]) || command) %></div><% if fetch_tool_call?(call) %><div class="tool-output"><pre><%= h(pretty_tool_arguments(call)) %></pre></div><% elsif present?(args) %><div class="tool-output"><pre><%= h(pretty_json(args)) %></pre></div><% end %><% if result %><% images = tool_result_images(result) %><% if images.any? %><div class="tool-images"><% images.each do |image| %><img class="tool-image" src="<%= h(image_src(image)) %>" alt="<%= h("#{call[:name]} generated image") %>"><% end %></div><% end %><% rows = fetch_tool_call?(call) ? fetch_tool_result_rows(result) : [] %><% if rows.any? %><%= render_fetch_tool_table(rows) %><% else %><% output = tool_result_text(call, result) %><% if present?(output) %><div class="tool-output"><pre><%= h(output) %></pre></div><% end %><% end %><% end %></div>
|