ruby_llm-agents 0.2.3 → 0.3.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 +4 -4
- data/README.md +273 -0
- data/app/channels/ruby_llm/agents/executions_channel.rb +24 -1
- data/app/controllers/concerns/ruby_llm/agents/filterable.rb +81 -0
- data/app/controllers/concerns/ruby_llm/agents/paginatable.rb +51 -0
- data/app/controllers/ruby_llm/agents/agents_controller.rb +228 -59
- data/app/controllers/ruby_llm/agents/dashboard_controller.rb +167 -12
- data/app/controllers/ruby_llm/agents/executions_controller.rb +189 -31
- data/app/controllers/ruby_llm/agents/settings_controller.rb +20 -0
- data/app/helpers/ruby_llm/agents/application_helper.rb +307 -7
- data/app/models/ruby_llm/agents/execution/analytics.rb +224 -20
- data/app/models/ruby_llm/agents/execution/metrics.rb +41 -25
- data/app/models/ruby_llm/agents/execution/scopes.rb +234 -14
- data/app/models/ruby_llm/agents/execution.rb +259 -16
- data/app/services/ruby_llm/agents/agent_registry.rb +49 -12
- data/app/views/layouts/rubyllm/agents/application.html.erb +351 -85
- data/app/views/rubyllm/agents/agents/_version_comparison.html.erb +186 -0
- data/app/views/rubyllm/agents/agents/show.html.erb +233 -10
- data/app/views/rubyllm/agents/dashboard/_action_center.html.erb +62 -0
- data/app/views/rubyllm/agents/dashboard/_alerts_feed.html.erb +62 -0
- data/app/views/rubyllm/agents/dashboard/_breaker_strip.html.erb +47 -0
- data/app/views/rubyllm/agents/dashboard/_budgets_bar.html.erb +165 -0
- data/app/views/rubyllm/agents/dashboard/_now_strip.html.erb +10 -0
- data/app/views/rubyllm/agents/dashboard/_now_strip_values.html.erb +71 -0
- data/app/views/rubyllm/agents/dashboard/index.html.erb +215 -109
- data/app/views/rubyllm/agents/executions/_filters.html.erb +152 -155
- data/app/views/rubyllm/agents/executions/_list.html.erb +103 -12
- data/app/views/rubyllm/agents/executions/dry_run.html.erb +149 -0
- data/app/views/rubyllm/agents/executions/index.html.erb +17 -72
- data/app/views/rubyllm/agents/executions/index.turbo_stream.erb +16 -2
- data/app/views/rubyllm/agents/executions/show.html.erb +693 -14
- data/app/views/rubyllm/agents/settings/show.html.erb +369 -0
- data/app/views/rubyllm/agents/shared/_filter_dropdown.html.erb +121 -0
- data/app/views/rubyllm/agents/shared/_select_dropdown.html.erb +85 -0
- data/config/routes.rb +7 -0
- data/lib/generators/ruby_llm_agents/templates/add_attempts_migration.rb.tt +27 -0
- data/lib/generators/ruby_llm_agents/templates/add_caching_migration.rb.tt +23 -0
- data/lib/generators/ruby_llm_agents/templates/add_finish_reason_migration.rb.tt +19 -0
- data/lib/generators/ruby_llm_agents/templates/add_routing_migration.rb.tt +19 -0
- data/lib/generators/ruby_llm_agents/templates/add_streaming_migration.rb.tt +8 -0
- data/lib/generators/ruby_llm_agents/templates/add_tracing_migration.rb.tt +34 -0
- data/lib/generators/ruby_llm_agents/templates/agent.rb.tt +66 -4
- data/lib/generators/ruby_llm_agents/templates/application_agent.rb.tt +53 -6
- data/lib/generators/ruby_llm_agents/templates/initializer.rb.tt +139 -8
- data/lib/generators/ruby_llm_agents/templates/migration.rb.tt +38 -1
- data/lib/generators/ruby_llm_agents/upgrade_generator.rb +78 -0
- data/lib/ruby_llm/agents/alert_manager.rb +207 -0
- data/lib/ruby_llm/agents/attempt_tracker.rb +295 -0
- data/lib/ruby_llm/agents/base.rb +580 -112
- data/lib/ruby_llm/agents/budget_tracker.rb +360 -0
- data/lib/ruby_llm/agents/circuit_breaker.rb +197 -0
- data/lib/ruby_llm/agents/configuration.rb +279 -1
- data/lib/ruby_llm/agents/engine.rb +59 -6
- data/lib/ruby_llm/agents/execution_logger_job.rb +17 -6
- data/lib/ruby_llm/agents/inflections.rb +13 -2
- data/lib/ruby_llm/agents/instrumentation.rb +538 -87
- data/lib/ruby_llm/agents/redactor.rb +130 -0
- data/lib/ruby_llm/agents/reliability.rb +185 -0
- data/lib/ruby_llm/agents/version.rb +3 -1
- data/lib/ruby_llm/agents.rb +52 -0
- metadata +41 -2
- data/app/controllers/ruby_llm/agents/application_controller.rb +0 -37
|
@@ -2,13 +2,42 @@
|
|
|
2
2
|
|
|
3
3
|
module RubyLLM
|
|
4
4
|
module Agents
|
|
5
|
+
# View helpers for the RubyLLM::Agents dashboard
|
|
6
|
+
#
|
|
7
|
+
# Provides formatting utilities for displaying execution data,
|
|
8
|
+
# including number formatting, URL helpers, and JSON syntax highlighting.
|
|
9
|
+
#
|
|
10
|
+
# @api public
|
|
5
11
|
module ApplicationHelper
|
|
6
12
|
include Chartkick::Helper if defined?(Chartkick)
|
|
7
13
|
|
|
14
|
+
# Returns the URL helpers for the engine's routes
|
|
15
|
+
#
|
|
16
|
+
# Use this to generate paths and URLs within the dashboard views.
|
|
17
|
+
#
|
|
18
|
+
# @return [Module] URL helpers module with path/url methods
|
|
19
|
+
# @example Generate execution path
|
|
20
|
+
# ruby_llm_agents.execution_path(execution)
|
|
21
|
+
# @example Generate agents index URL
|
|
22
|
+
# ruby_llm_agents.agents_url
|
|
8
23
|
def ruby_llm_agents
|
|
9
24
|
RubyLLM::Agents::Engine.routes.url_helpers
|
|
10
25
|
end
|
|
11
26
|
|
|
27
|
+
# Formats large numbers with human-readable suffixes (K, M, B)
|
|
28
|
+
#
|
|
29
|
+
# @param number [Numeric, nil] The number to format
|
|
30
|
+
# @param prefix [String, nil] Optional prefix (e.g., "$" for currency)
|
|
31
|
+
# @param precision [Integer] Decimal places to show (default: 1)
|
|
32
|
+
# @return [String] Formatted number with suffix
|
|
33
|
+
# @example Basic usage
|
|
34
|
+
# number_to_human_short(1234567) #=> "1.2M"
|
|
35
|
+
# @example With currency prefix
|
|
36
|
+
# number_to_human_short(1500, prefix: "$") #=> "$1.5K"
|
|
37
|
+
# @example With custom precision
|
|
38
|
+
# number_to_human_short(1234567, precision: 2) #=> "1.23M"
|
|
39
|
+
# @example Small numbers
|
|
40
|
+
# number_to_human_short(0.00123, precision: 1) #=> "0.0012"
|
|
12
41
|
def number_to_human_short(number, prefix: nil, precision: 1)
|
|
13
42
|
return "#{prefix}0" if number.nil? || number.zero?
|
|
14
43
|
|
|
@@ -28,6 +57,67 @@ module RubyLLM
|
|
|
28
57
|
"#{prefix}#{formatted}"
|
|
29
58
|
end
|
|
30
59
|
|
|
60
|
+
# Renders an enabled/disabled badge
|
|
61
|
+
#
|
|
62
|
+
# @param enabled [Boolean] Whether the feature is enabled
|
|
63
|
+
# @return [ActiveSupport::SafeBuffer] HTML badge element
|
|
64
|
+
def render_enabled_badge(enabled)
|
|
65
|
+
if enabled
|
|
66
|
+
'<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-green-100 dark:bg-green-900/50 text-green-700 dark:text-green-300">Enabled</span>'.html_safe
|
|
67
|
+
else
|
|
68
|
+
'<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400">Disabled</span>'.html_safe
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Renders a configured/not configured badge
|
|
73
|
+
#
|
|
74
|
+
# @param configured [Boolean] Whether the setting is configured
|
|
75
|
+
# @return [ActiveSupport::SafeBuffer] HTML badge element
|
|
76
|
+
def render_configured_badge(configured)
|
|
77
|
+
if configured
|
|
78
|
+
'<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-green-100 dark:bg-green-900/50 text-green-700 dark:text-green-300">Configured</span>'.html_safe
|
|
79
|
+
else
|
|
80
|
+
'<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400">Not configured</span>'.html_safe
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Redacts sensitive data from an object for display
|
|
85
|
+
#
|
|
86
|
+
# Uses the configured redaction rules to mask sensitive fields
|
|
87
|
+
# and patterns in the data.
|
|
88
|
+
#
|
|
89
|
+
# @param obj [Object] The object to redact (Hash, Array, or primitive)
|
|
90
|
+
# @return [Object] The redacted object
|
|
91
|
+
# @example
|
|
92
|
+
# redact_for_display({ password: "secret", name: "John" })
|
|
93
|
+
# #=> { password: "[REDACTED]", name: "John" }
|
|
94
|
+
def redact_for_display(obj)
|
|
95
|
+
Redactor.redact(obj)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Syntax-highlights a redacted Ruby object as pretty-printed JSON
|
|
99
|
+
#
|
|
100
|
+
# Combines redaction and highlighting in one call.
|
|
101
|
+
#
|
|
102
|
+
# @param obj [Object] Any JSON-serializable Ruby object
|
|
103
|
+
# @return [ActiveSupport::SafeBuffer] HTML-safe highlighted redacted JSON
|
|
104
|
+
def highlight_json_redacted(obj)
|
|
105
|
+
return "" if obj.nil?
|
|
106
|
+
|
|
107
|
+
redacted = redact_for_display(obj)
|
|
108
|
+
highlight_json(redacted)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Syntax-highlights a Ruby object as pretty-printed JSON
|
|
112
|
+
#
|
|
113
|
+
# Converts the object to JSON and applies color highlighting
|
|
114
|
+
# using Tailwind CSS classes.
|
|
115
|
+
#
|
|
116
|
+
# @param obj [Object] Any JSON-serializable Ruby object
|
|
117
|
+
# @return [ActiveSupport::SafeBuffer] HTML-safe highlighted JSON string
|
|
118
|
+
# @see #highlight_json_string
|
|
119
|
+
# @example
|
|
120
|
+
# highlight_json({ name: "test", count: 42 })
|
|
31
121
|
def highlight_json(obj)
|
|
32
122
|
return "" if obj.nil?
|
|
33
123
|
|
|
@@ -35,9 +125,209 @@ module RubyLLM
|
|
|
35
125
|
highlight_json_string(json_string)
|
|
36
126
|
end
|
|
37
127
|
|
|
128
|
+
# Renders an SVG sparkline chart from trend data
|
|
129
|
+
#
|
|
130
|
+
# Creates a simple polyline SVG for inline trend visualization.
|
|
131
|
+
# Used in version comparison to show historical performance.
|
|
132
|
+
#
|
|
133
|
+
# @param trend_data [Array<Hash>] Array of daily data points
|
|
134
|
+
# @param metric_key [Symbol] The metric to chart (:count, :success_rate, :avg_cost, etc.)
|
|
135
|
+
# @param color_class [String] Tailwind color class for the line
|
|
136
|
+
# @return [ActiveSupport::SafeBuffer] SVG sparkline element
|
|
137
|
+
def render_sparkline(trend_data, metric_key, color_class: "text-blue-500")
|
|
138
|
+
return "".html_safe if trend_data.blank? || trend_data.length < 2
|
|
139
|
+
|
|
140
|
+
values = trend_data.map { |d| d[metric_key].to_f || 0 }
|
|
141
|
+
max_val = values.max || 1
|
|
142
|
+
min_val = values.min || 0
|
|
143
|
+
range = max_val - min_val
|
|
144
|
+
range = 1 if range.zero?
|
|
145
|
+
|
|
146
|
+
# Generate SVG polyline points
|
|
147
|
+
points = values.each_with_index.map do |val, i|
|
|
148
|
+
x = (i.to_f / (values.length - 1)) * 100
|
|
149
|
+
y = 28 - ((val - min_val) / range * 24) + 2 # 2px padding top/bottom
|
|
150
|
+
"#{x.round(2)},#{y.round(2)}"
|
|
151
|
+
end.join(" ")
|
|
152
|
+
|
|
153
|
+
content_tag(:svg, class: "w-full h-8", viewBox: "0 0 100 30", preserveAspectRatio: "none") do
|
|
154
|
+
content_tag(:polyline, nil,
|
|
155
|
+
points: points,
|
|
156
|
+
fill: "none",
|
|
157
|
+
stroke: "currentColor",
|
|
158
|
+
"stroke-width": "2",
|
|
159
|
+
"stroke-linecap": "round",
|
|
160
|
+
"stroke-linejoin": "round",
|
|
161
|
+
class: color_class
|
|
162
|
+
)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Renders a comparison badge based on change percentage
|
|
167
|
+
#
|
|
168
|
+
# Determines if a metric change is significant and returns an appropriate
|
|
169
|
+
# badge indicating improvement, regression, or stability.
|
|
170
|
+
#
|
|
171
|
+
# @param change_pct [Float] Percentage change between versions
|
|
172
|
+
# @param metric_type [Symbol] Type of metric (:success_rate, :cost, :tokens, :duration, :count)
|
|
173
|
+
# @return [ActiveSupport::SafeBuffer] HTML badge element
|
|
174
|
+
def comparison_badge(change_pct, metric_type)
|
|
175
|
+
threshold = case metric_type
|
|
176
|
+
when :success_rate then 5
|
|
177
|
+
when :cost, :tokens then 15
|
|
178
|
+
when :duration then 20
|
|
179
|
+
when :count then 25
|
|
180
|
+
else 10
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Determine what "improvement" means for this metric
|
|
184
|
+
# For cost/tokens/duration: negative change is good (lower is better)
|
|
185
|
+
# For success_rate/count: positive change is good (higher is better)
|
|
186
|
+
is_improvement = case metric_type
|
|
187
|
+
when :success_rate, :count then change_pct > threshold
|
|
188
|
+
when :cost, :tokens, :duration then change_pct < -threshold
|
|
189
|
+
else false
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
is_regression = case metric_type
|
|
193
|
+
when :success_rate, :count then change_pct < -threshold
|
|
194
|
+
when :cost, :tokens, :duration then change_pct > threshold
|
|
195
|
+
else false
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
if is_improvement
|
|
199
|
+
content_tag(:span, class: "inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium text-green-700 dark:text-green-300 bg-green-100 dark:bg-green-900/50 rounded-full") do
|
|
200
|
+
safe_join([
|
|
201
|
+
content_tag(:svg, class: "w-3 h-3", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24") do
|
|
202
|
+
content_tag(:path, nil, "stroke-linecap": "round", "stroke-linejoin": "round", "stroke-width": "2", d: "M5 10l7-7m0 0l7 7m-7-7v18")
|
|
203
|
+
end,
|
|
204
|
+
"Improved"
|
|
205
|
+
])
|
|
206
|
+
end
|
|
207
|
+
elsif is_regression
|
|
208
|
+
content_tag(:span, class: "inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium text-red-700 dark:text-red-300 bg-red-100 dark:bg-red-900/50 rounded-full") do
|
|
209
|
+
safe_join([
|
|
210
|
+
content_tag(:svg, class: "w-3 h-3", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24") do
|
|
211
|
+
content_tag(:path, nil, "stroke-linecap": "round", "stroke-linejoin": "round", "stroke-width": "2", d: "M19 14l-7 7m0 0l-7-7m7 7V3")
|
|
212
|
+
end,
|
|
213
|
+
"Regressed"
|
|
214
|
+
])
|
|
215
|
+
end
|
|
216
|
+
else
|
|
217
|
+
content_tag(:span, class: "inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 rounded-full") do
|
|
218
|
+
safe_join([
|
|
219
|
+
content_tag(:svg, class: "w-3 h-3", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24") do
|
|
220
|
+
content_tag(:path, nil, "stroke-linecap": "round", "stroke-linejoin": "round", "stroke-width": "2", d: "M5 12h14")
|
|
221
|
+
end,
|
|
222
|
+
"Stable"
|
|
223
|
+
])
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Returns the appropriate row background class based on change significance
|
|
229
|
+
#
|
|
230
|
+
# @param change_pct [Float] Percentage change
|
|
231
|
+
# @param metric_type [Symbol] Type of metric
|
|
232
|
+
# @return [String] Tailwind CSS classes for row background
|
|
233
|
+
def comparison_row_class(change_pct, metric_type)
|
|
234
|
+
threshold = case metric_type
|
|
235
|
+
when :success_rate then 5
|
|
236
|
+
when :cost, :tokens then 15
|
|
237
|
+
when :duration then 20
|
|
238
|
+
when :count then 25
|
|
239
|
+
else 10
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
is_improvement = case metric_type
|
|
243
|
+
when :success_rate, :count then change_pct > threshold
|
|
244
|
+
when :cost, :tokens, :duration then change_pct < -threshold
|
|
245
|
+
else false
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
is_regression = case metric_type
|
|
249
|
+
when :success_rate, :count then change_pct < -threshold
|
|
250
|
+
when :cost, :tokens, :duration then change_pct > threshold
|
|
251
|
+
else false
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
if is_improvement
|
|
255
|
+
"bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800"
|
|
256
|
+
elsif is_regression
|
|
257
|
+
"bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800"
|
|
258
|
+
else
|
|
259
|
+
"bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-700"
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Generates an overall comparison summary based on multiple metrics
|
|
264
|
+
#
|
|
265
|
+
# @param metrics [Array<Hash>] Array of metric comparison results
|
|
266
|
+
# @return [ActiveSupport::SafeBuffer] HTML summary banner
|
|
267
|
+
def comparison_summary_badge(improvements_count, regressions_count, v2_label)
|
|
268
|
+
if improvements_count >= 3 && regressions_count == 0
|
|
269
|
+
content_tag(:span, class: "inline-flex items-center gap-1 px-3 py-1 text-sm font-medium text-green-700 dark:text-green-300 bg-green-100 dark:bg-green-900/50 rounded-lg") do
|
|
270
|
+
safe_join([
|
|
271
|
+
content_tag(:svg, class: "w-4 h-4", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24") do
|
|
272
|
+
content_tag(:path, nil, "stroke-linecap": "round", "stroke-linejoin": "round", "stroke-width": "2", d: "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z")
|
|
273
|
+
end,
|
|
274
|
+
"v#{v2_label} shows overall improvement"
|
|
275
|
+
])
|
|
276
|
+
end
|
|
277
|
+
elsif regressions_count >= 3 && improvements_count == 0
|
|
278
|
+
content_tag(:span, class: "inline-flex items-center gap-1 px-3 py-1 text-sm font-medium text-red-700 dark:text-red-300 bg-red-100 dark:bg-red-900/50 rounded-lg") do
|
|
279
|
+
safe_join([
|
|
280
|
+
content_tag(:svg, class: "w-4 h-4", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24") do
|
|
281
|
+
content_tag(:path, nil, "stroke-linecap": "round", "stroke-linejoin": "round", "stroke-width": "2", d: "M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z")
|
|
282
|
+
end,
|
|
283
|
+
"v#{v2_label} shows overall regression"
|
|
284
|
+
])
|
|
285
|
+
end
|
|
286
|
+
elsif improvements_count > 0 || regressions_count > 0
|
|
287
|
+
content_tag(:span, class: "inline-flex items-center gap-1 px-3 py-1 text-sm font-medium text-amber-700 dark:text-amber-300 bg-amber-100 dark:bg-amber-900/50 rounded-lg") do
|
|
288
|
+
safe_join([
|
|
289
|
+
content_tag(:svg, class: "w-4 h-4", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24") do
|
|
290
|
+
content_tag(:path, nil, "stroke-linecap": "round", "stroke-linejoin": "round", "stroke-width": "2", d: "M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4")
|
|
291
|
+
end,
|
|
292
|
+
"v#{v2_label} shows mixed results"
|
|
293
|
+
])
|
|
294
|
+
end
|
|
295
|
+
else
|
|
296
|
+
content_tag(:span, class: "inline-flex items-center gap-1 px-3 py-1 text-sm font-medium text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 rounded-lg") do
|
|
297
|
+
safe_join([
|
|
298
|
+
content_tag(:svg, class: "w-4 h-4", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24") do
|
|
299
|
+
content_tag(:path, nil, "stroke-linecap": "round", "stroke-linejoin": "round", "stroke-width": "2", d: "M5 12h14")
|
|
300
|
+
end,
|
|
301
|
+
"No significant changes"
|
|
302
|
+
])
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Syntax-highlights a JSON string with Tailwind CSS colors
|
|
308
|
+
#
|
|
309
|
+
# Tokenizes the JSON and wraps each token type in a span with
|
|
310
|
+
# appropriate color classes:
|
|
311
|
+
# - Purple (text-purple-600): Object keys
|
|
312
|
+
# - Green (text-green-600): String values
|
|
313
|
+
# - Blue (text-blue-600): Numbers
|
|
314
|
+
# - Amber (text-amber-600): Booleans (true/false)
|
|
315
|
+
# - Gray (text-gray-400): null values
|
|
316
|
+
#
|
|
317
|
+
# The tokenizer uses a character-by-character approach:
|
|
318
|
+
# 1. Identifies token type by first character
|
|
319
|
+
# 2. Parses complete token (handling escapes in strings)
|
|
320
|
+
# 3. Determines if strings are keys (followed by colon)
|
|
321
|
+
# 4. Wraps each token in appropriate span
|
|
322
|
+
#
|
|
323
|
+
# @param json_string [String] A valid JSON string
|
|
324
|
+
# @return [ActiveSupport::SafeBuffer] HTML-safe highlighted output
|
|
325
|
+
# @api private
|
|
38
326
|
def highlight_json_string(json_string)
|
|
39
327
|
return "" if json_string.blank?
|
|
40
328
|
|
|
329
|
+
# Phase 1: Tokenization
|
|
330
|
+
# Convert JSON string into array of typed tokens for later rendering
|
|
41
331
|
tokens = []
|
|
42
332
|
i = 0
|
|
43
333
|
chars = json_string.chars
|
|
@@ -47,7 +337,8 @@ module RubyLLM
|
|
|
47
337
|
|
|
48
338
|
case char
|
|
49
339
|
when '"'
|
|
50
|
-
#
|
|
340
|
+
# String token: starts with quote, ends with unescaped quote
|
|
341
|
+
# Handles escape sequences like \" and \\
|
|
51
342
|
str_start = i
|
|
52
343
|
i += 1
|
|
53
344
|
while i < chars.length
|
|
@@ -62,7 +353,7 @@ module RubyLLM
|
|
|
62
353
|
end
|
|
63
354
|
tokens << { type: :string, value: chars[str_start...i].join }
|
|
64
355
|
when /[0-9\-]/
|
|
65
|
-
#
|
|
356
|
+
# Number token: starts with digit or minus, continues with digits/decimals/exponents
|
|
66
357
|
num_start = i
|
|
67
358
|
i += 1
|
|
68
359
|
while i < chars.length && chars[i] =~ /[0-9.eE+\-]/
|
|
@@ -70,7 +361,7 @@ module RubyLLM
|
|
|
70
361
|
end
|
|
71
362
|
tokens << { type: :number, value: chars[num_start...i].join }
|
|
72
363
|
when 't'
|
|
73
|
-
# true
|
|
364
|
+
# Boolean token: check for "true" keyword
|
|
74
365
|
if chars[i, 4].join == 'true'
|
|
75
366
|
tokens << { type: :boolean, value: 'true' }
|
|
76
367
|
i += 4
|
|
@@ -79,7 +370,7 @@ module RubyLLM
|
|
|
79
370
|
i += 1
|
|
80
371
|
end
|
|
81
372
|
when 'f'
|
|
82
|
-
# false
|
|
373
|
+
# Boolean token: check for "false" keyword
|
|
83
374
|
if chars[i, 5].join == 'false'
|
|
84
375
|
tokens << { type: :boolean, value: 'false' }
|
|
85
376
|
i += 5
|
|
@@ -88,7 +379,7 @@ module RubyLLM
|
|
|
88
379
|
i += 1
|
|
89
380
|
end
|
|
90
381
|
when 'n'
|
|
91
|
-
# null
|
|
382
|
+
# Null token: check for "null" keyword
|
|
92
383
|
if chars[i, 4].join == 'null'
|
|
93
384
|
tokens << { type: :null, value: 'null' }
|
|
94
385
|
i += 4
|
|
@@ -97,20 +388,27 @@ module RubyLLM
|
|
|
97
388
|
i += 1
|
|
98
389
|
end
|
|
99
390
|
when ':', ',', '{', '}', '[', ']', ' ', "\n", "\t"
|
|
391
|
+
# Punctuation token: structural characters and whitespace
|
|
100
392
|
tokens << { type: :punct, value: char }
|
|
101
393
|
i += 1
|
|
102
394
|
else
|
|
395
|
+
# Fallback for unexpected characters
|
|
103
396
|
tokens << { type: :text, value: char }
|
|
104
397
|
i += 1
|
|
105
398
|
end
|
|
106
399
|
end
|
|
107
400
|
|
|
108
|
-
#
|
|
401
|
+
# Phase 2: Rendering
|
|
402
|
+
# Convert tokens to HTML with color classes
|
|
403
|
+
# Key detection: strings followed by colon are object keys (purple)
|
|
404
|
+
# Value strings get different color (green)
|
|
109
405
|
result = []
|
|
110
406
|
tokens.each_with_index do |token, idx|
|
|
111
407
|
case token[:type]
|
|
112
408
|
when :string
|
|
113
|
-
#
|
|
409
|
+
# Key detection algorithm:
|
|
410
|
+
# Look ahead past any whitespace tokens to find next punctuation
|
|
411
|
+
# If next non-whitespace punct is ':', this string is an object key
|
|
114
412
|
is_key = false
|
|
115
413
|
(idx + 1...tokens.length).each do |j|
|
|
116
414
|
if tokens[j][:type] == :punct
|
|
@@ -118,8 +416,10 @@ module RubyLLM
|
|
|
118
416
|
is_key = true
|
|
119
417
|
break
|
|
120
418
|
elsif tokens[j][:value] !~ /\s/
|
|
419
|
+
# Non-whitespace punct that isn't colon - not a key
|
|
121
420
|
break
|
|
122
421
|
end
|
|
422
|
+
# Skip whitespace and continue looking
|
|
123
423
|
else
|
|
124
424
|
break
|
|
125
425
|
end
|