ruby_llm-agents 3.5.4 → 3.6.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 +4 -0
- data/app/controllers/ruby_llm/agents/dashboard_controller.rb +155 -10
- data/app/helpers/ruby_llm/agents/application_helper.rb +15 -1
- data/app/models/ruby_llm/agents/execution/replayable.rb +124 -0
- data/app/models/ruby_llm/agents/execution/scopes.rb +42 -1
- data/app/models/ruby_llm/agents/execution.rb +50 -1
- data/app/models/ruby_llm/agents/tenant/budgetable.rb +28 -4
- data/app/views/layouts/ruby_llm/agents/application.html.erb +41 -28
- data/app/views/ruby_llm/agents/agents/show.html.erb +16 -1
- data/app/views/ruby_llm/agents/dashboard/_top_tenants.html.erb +47 -0
- data/app/views/ruby_llm/agents/dashboard/index.html.erb +397 -100
- data/lib/generators/ruby_llm_agents/rename_agent_generator.rb +53 -0
- data/lib/generators/ruby_llm_agents/templates/rename_agent_migration.rb.tt +19 -0
- data/lib/ruby_llm/agents/agent_tool.rb +125 -0
- data/lib/ruby_llm/agents/audio/speaker.rb +5 -3
- data/lib/ruby_llm/agents/audio/speech_pricing.rb +63 -187
- data/lib/ruby_llm/agents/audio/transcriber.rb +5 -3
- data/lib/ruby_llm/agents/audio/transcription_pricing.rb +5 -7
- data/lib/ruby_llm/agents/base_agent.rb +144 -5
- data/lib/ruby_llm/agents/core/configuration.rb +178 -53
- data/lib/ruby_llm/agents/core/errors.rb +3 -77
- data/lib/ruby_llm/agents/core/instrumentation.rb +0 -17
- data/lib/ruby_llm/agents/core/version.rb +1 -1
- data/lib/ruby_llm/agents/dsl/base.rb +0 -8
- data/lib/ruby_llm/agents/dsl/queryable.rb +124 -0
- data/lib/ruby_llm/agents/dsl.rb +1 -0
- data/lib/ruby_llm/agents/image/concerns/image_operation_execution.rb +2 -1
- data/lib/ruby_llm/agents/image/generator/pricing.rb +75 -217
- data/lib/ruby_llm/agents/image/generator.rb +5 -3
- data/lib/ruby_llm/agents/infrastructure/attempt_tracker.rb +8 -0
- data/lib/ruby_llm/agents/infrastructure/circuit_breaker.rb +4 -2
- data/lib/ruby_llm/agents/pipeline/builder.rb +43 -0
- data/lib/ruby_llm/agents/pipeline/context.rb +11 -1
- data/lib/ruby_llm/agents/pipeline/executor.rb +1 -25
- data/lib/ruby_llm/agents/pipeline/middleware/budget.rb +26 -1
- data/lib/ruby_llm/agents/pipeline/middleware/cache.rb +18 -0
- data/lib/ruby_llm/agents/pipeline/middleware/instrumentation.rb +130 -3
- data/lib/ruby_llm/agents/pipeline/middleware/reliability.rb +29 -0
- data/lib/ruby_llm/agents/pipeline/middleware/tenant.rb +11 -4
- data/lib/ruby_llm/agents/pipeline.rb +0 -92
- data/lib/ruby_llm/agents/results/background_removal_result.rb +11 -1
- data/lib/ruby_llm/agents/results/base.rb +23 -1
- data/lib/ruby_llm/agents/results/embedding_result.rb +14 -1
- data/lib/ruby_llm/agents/results/image_analysis_result.rb +11 -1
- data/lib/ruby_llm/agents/results/image_edit_result.rb +11 -1
- data/lib/ruby_llm/agents/results/image_generation_result.rb +12 -3
- data/lib/ruby_llm/agents/results/image_pipeline_result.rb +11 -1
- data/lib/ruby_llm/agents/results/image_transform_result.rb +11 -1
- data/lib/ruby_llm/agents/results/image_upscale_result.rb +11 -1
- data/lib/ruby_llm/agents/results/image_variation_result.rb +11 -1
- data/lib/ruby_llm/agents/results/speech_result.rb +20 -1
- data/lib/ruby_llm/agents/results/transcription_result.rb +20 -1
- data/lib/ruby_llm/agents/text/embedder.rb +23 -18
- data/lib/ruby_llm/agents.rb +70 -5
- data/lib/tasks/ruby_llm_agents.rake +21 -0
- metadata +7 -6
- data/lib/ruby_llm/agents/infrastructure/reliability/breaker_manager.rb +0 -80
- data/lib/ruby_llm/agents/infrastructure/reliability/execution_constraints.rb +0 -69
- data/lib/ruby_llm/agents/infrastructure/reliability/executor.rb +0 -125
- data/lib/ruby_llm/agents/infrastructure/reliability/fallback_routing.rb +0 -72
- data/lib/ruby_llm/agents/infrastructure/reliability/retry_strategy.rb +0 -82
|
@@ -12,111 +12,55 @@
|
|
|
12
12
|
<span class="<%= @now_strip[:errors_today] > 0 ? 'text-red-500' : 'text-gray-800 dark:text-gray-200' %>"><%= @now_strip[:errors_today] %></span> errors<% if total > 0 && @now_strip[:errors_today] > 0 %> <span class="text-gray-300 dark:text-gray-600">(<%= (@now_strip[:errors_today].to_f / total * 100).round(1) %>%)</span><% end %>
|
|
13
13
|
<span class="text-gray-300 dark:text-gray-700">·</span>
|
|
14
14
|
<span class="text-gray-800 dark:text-gray-200">$<%= number_with_precision(@now_strip[:cost_today], precision: 2) %></span>
|
|
15
|
+
<% if @cache_savings[:count] > 0 %>
|
|
16
|
+
<span class="text-gray-300 dark:text-gray-700">·</span>
|
|
17
|
+
<span class="text-green-500"><%= number_with_delimiter(@cache_savings[:count]) %></span> cache hits
|
|
18
|
+
<span class="text-gray-300 dark:text-gray-700">·</span>
|
|
19
|
+
<span class="text-green-500">$<%= number_with_precision(@cache_savings[:estimated_savings], precision: 2) %></span> saved
|
|
20
|
+
<span class="text-gray-300 dark:text-gray-700">·</span>
|
|
21
|
+
<span class="text-gray-800 dark:text-gray-200"><%= @cache_savings[:hit_rate] %>%</span> hit rate
|
|
22
|
+
<% end %>
|
|
15
23
|
</div>
|
|
16
24
|
</div>
|
|
17
|
-
<div class="
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
25
|
+
<div class="relative font-mono text-xs" x-data="{ open: false, showCustom: false }" @click.outside="open = false; showCustom = false">
|
|
26
|
+
<button @click="open = !open" class="flex items-center gap-1 px-2 py-0.5 text-gray-900 dark:text-gray-100 hover:text-gray-600 dark:hover:text-gray-300">
|
|
27
|
+
<% if @selected_range == "custom" && @custom_from && @custom_to %>
|
|
28
|
+
<%= @custom_from.strftime("%b %-d") %> – <%= @custom_to.strftime("%b %-d") %>
|
|
29
|
+
<% else %>
|
|
30
|
+
<%= range_display_name(@selected_range) %>
|
|
31
|
+
<% end %>
|
|
32
|
+
<svg class="w-3 h-3 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
|
|
33
|
+
</button>
|
|
34
|
+
<div x-show="open" x-cloak x-transition:enter="transition ease-out duration-100" x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100" x-transition:leave="transition ease-in duration-75" x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
|
|
35
|
+
class="absolute right-0 mt-1 w-48 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded shadow-lg z-50 py-1">
|
|
36
|
+
<% range_presets.each do |preset| %>
|
|
37
|
+
<%= link_to preset[:label], ruby_llm_agents.root_path(range: preset[:value]),
|
|
38
|
+
class: "block px-3 py-1.5 #{@selected_range == preset[:value] ? 'text-gray-900 dark:text-gray-100 bg-gray-100 dark:bg-gray-800' : 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-800/50'}" %>
|
|
39
|
+
<% end %>
|
|
40
|
+
<div class="border-t border-gray-200 dark:border-gray-700 my-1"></div>
|
|
41
|
+
<button @click.stop="showCustom = !showCustom" class="w-full text-left px-3 py-1.5 <%= @selected_range == 'custom' ? 'text-gray-900 dark:text-gray-100 bg-gray-100 dark:bg-gray-800' : 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-800/50' %>">
|
|
42
|
+
Custom range…
|
|
43
|
+
</button>
|
|
44
|
+
<div x-show="showCustom" x-cloak class="px-3 py-2 border-t border-gray-200 dark:border-gray-700">
|
|
45
|
+
<form action="<%= ruby_llm_agents.root_path %>" method="get" class="space-y-2">
|
|
46
|
+
<input type="hidden" name="range" value="custom">
|
|
47
|
+
<label class="block text-[10px] text-gray-400 dark:text-gray-500 uppercase">From</label>
|
|
48
|
+
<input type="date" name="from" value="<%= @custom_from %>" max="<%= Date.current %>"
|
|
49
|
+
class="w-full px-2 py-1 text-xs bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded text-gray-900 dark:text-gray-100 font-mono">
|
|
50
|
+
<label class="block text-[10px] text-gray-400 dark:text-gray-500 uppercase">To</label>
|
|
51
|
+
<input type="date" name="to" value="<%= @custom_to %>" max="<%= Date.current %>"
|
|
52
|
+
class="w-full px-2 py-1 text-xs bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded text-gray-900 dark:text-gray-100 font-mono">
|
|
53
|
+
<button type="submit" class="w-full px-2 py-1 text-xs bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 rounded hover:bg-gray-700 dark:hover:bg-gray-300">
|
|
54
|
+
Apply
|
|
55
|
+
</button>
|
|
56
|
+
</form>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
24
59
|
</div>
|
|
25
60
|
</div>
|
|
26
61
|
|
|
27
62
|
<!-- Activity Chart -->
|
|
28
|
-
<div id="activity-chart" style="width: 100%; height:
|
|
29
|
-
|
|
30
|
-
<script>
|
|
31
|
-
(function() {
|
|
32
|
-
const range = '<%= @selected_range %>';
|
|
33
|
-
const chartUrl = '<%= ruby_llm_agents.chart_data_path %>?range=' + range;
|
|
34
|
-
const hourMs = 3600000;
|
|
35
|
-
const dayMs = 24 * hourMs;
|
|
36
|
-
|
|
37
|
-
function getTimeRangeMs(r) {
|
|
38
|
-
if (r === 'today') return dayMs;
|
|
39
|
-
if (r === '7d') return 7 * dayMs;
|
|
40
|
-
return 30 * dayMs;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function toDatetimePoints(data, r) {
|
|
44
|
-
const now = Date.now();
|
|
45
|
-
const step = r === 'today' ? hourMs : dayMs;
|
|
46
|
-
return data.map((val, i) => [now - (data.length - 1 - i) * step, val]);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function initChart() {
|
|
50
|
-
fetch(chartUrl)
|
|
51
|
-
.then(res => res.json())
|
|
52
|
-
.then(data => {
|
|
53
|
-
const now = Date.now();
|
|
54
|
-
const fmt = range === 'today' ? '%H:%M' : '%b %d';
|
|
55
|
-
|
|
56
|
-
Highcharts.chart('activity-chart', {
|
|
57
|
-
chart: { type: 'areaspline', backgroundColor: 'transparent', spacing: [5, 0, 0, 0] },
|
|
58
|
-
title: { text: null },
|
|
59
|
-
xAxis: {
|
|
60
|
-
type: 'datetime',
|
|
61
|
-
min: now - getTimeRangeMs(range),
|
|
62
|
-
max: now,
|
|
63
|
-
labels: { style: { color: chartColor('#6B7280', '#928374'), fontSize: '9px', fontFamily: 'ui-monospace, monospace' }, format: '{value:' + fmt + '}' },
|
|
64
|
-
lineColor: 'transparent',
|
|
65
|
-
tickLength: 0,
|
|
66
|
-
gridLineWidth: 0
|
|
67
|
-
},
|
|
68
|
-
yAxis: {
|
|
69
|
-
title: { text: null },
|
|
70
|
-
min: 0,
|
|
71
|
-
allowDecimals: false,
|
|
72
|
-
labels: { style: { color: chartColor('#6B7280', '#928374'), fontSize: '9px', fontFamily: 'ui-monospace, monospace' } },
|
|
73
|
-
gridLineColor: chartColorAlpha('rgba(107, 114, 128, 0.08)', 146, 131, 116, 0.08)
|
|
74
|
-
},
|
|
75
|
-
legend: { enabled: false },
|
|
76
|
-
credits: { enabled: false },
|
|
77
|
-
tooltip: {
|
|
78
|
-
backgroundColor: chartColorAlpha('rgba(0, 0, 0, 0.85)', 40, 40, 40, 0.95),
|
|
79
|
-
borderColor: 'transparent',
|
|
80
|
-
borderRadius: 3,
|
|
81
|
-
style: { color: chartColor('#E5E7EB', '#ebdbb2'), fontSize: '10px', fontFamily: 'ui-monospace, monospace' },
|
|
82
|
-
shared: true,
|
|
83
|
-
formatter: function() {
|
|
84
|
-
let html = '<span style="color:' + chartColor('#9CA3AF', '#bdae93') + '">' + Highcharts.dateFormat(fmt, this.x) + '</span>';
|
|
85
|
-
this.points.forEach(p => html += '<br/>' + p.series.name + ': <b>' + p.y + '</b>');
|
|
86
|
-
return html;
|
|
87
|
-
}
|
|
88
|
-
},
|
|
89
|
-
plotOptions: {
|
|
90
|
-
areaspline: {
|
|
91
|
-
stacking: 'normal',
|
|
92
|
-
lineWidth: 1.5,
|
|
93
|
-
marker: { enabled: false, states: { hover: { enabled: true, radius: 2 } } }
|
|
94
|
-
}
|
|
95
|
-
},
|
|
96
|
-
series: [
|
|
97
|
-
{
|
|
98
|
-
name: 'errors',
|
|
99
|
-
data: toDatetimePoints(data.series[1].data, range),
|
|
100
|
-
color: chartColor('#EF4444', '#fb4934'),
|
|
101
|
-
fillColor: { linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 }, stops: [[0, chartColorAlpha('rgba(239, 68, 68, 0.08)', 251, 73, 52, 0.08)], [1, chartColorAlpha('rgba(239, 68, 68, 0)', 251, 73, 52, 0)]] }
|
|
102
|
-
},
|
|
103
|
-
{
|
|
104
|
-
name: 'success',
|
|
105
|
-
data: toDatetimePoints(data.series[0].data, range),
|
|
106
|
-
color: chartColor('#10B981', '#b8bb26'),
|
|
107
|
-
fillColor: { linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 }, stops: [[0, chartColorAlpha('rgba(16, 185, 129, 0.08)', 184, 187, 38, 0.08)], [1, chartColorAlpha('rgba(16, 185, 129, 0)', 184, 187, 38, 0)]] }
|
|
108
|
-
}
|
|
109
|
-
]
|
|
110
|
-
});
|
|
111
|
-
})
|
|
112
|
-
.catch(err => console.log('[RubyLLM::Agents] Chart error:', err));
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
document.readyState === 'loading'
|
|
116
|
-
? document.addEventListener('DOMContentLoaded', initChart)
|
|
117
|
-
: initChart();
|
|
118
|
-
})();
|
|
119
|
-
</script>
|
|
63
|
+
<div id="activity-chart" style="width: 100%; height: 160px;"></div>
|
|
120
64
|
|
|
121
65
|
<!-- Tenant Budget (when viewing specific tenant) -->
|
|
122
66
|
<%= render partial: "ruby_llm/agents/dashboard/tenant_budget", locals: { tenant_budget: @tenant_budget } %>
|
|
@@ -133,7 +77,6 @@
|
|
|
133
77
|
<% @recent_executions.first(5).each do |execution| %>
|
|
134
78
|
<div class="group flex items-center gap-3 py-1.5 px-2 -mx-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800/50 cursor-pointer"
|
|
135
79
|
onclick="window.location='<%= ruby_llm_agents.execution_path(execution) %>'">
|
|
136
|
-
<span class="text-gray-300 dark:text-gray-700">▸</span>
|
|
137
80
|
<span class="w-28 truncate text-gray-900 dark:text-gray-200"><%= execution.agent_type.gsub(/Agent$/, '') %></span>
|
|
138
81
|
<%# Status dot %>
|
|
139
82
|
<% dot_color = if execution.status_running?
|
|
@@ -249,3 +192,357 @@
|
|
|
249
192
|
<div class="font-mono text-xs text-gray-400 dark:text-gray-600 py-2 px-2">—</div>
|
|
250
193
|
<% end %>
|
|
251
194
|
</div>
|
|
195
|
+
|
|
196
|
+
<!-- ── spending ──────────────────────────────── -->
|
|
197
|
+
<%
|
|
198
|
+
# Prepare cost-by-agent data (top 8 by cost, all agent types merged)
|
|
199
|
+
cost_by_agent_data = [@agent_stats, @embedder_stats, @transcriber_stats, @speaker_stats, @image_generator_stats]
|
|
200
|
+
.flatten.compact
|
|
201
|
+
.select { |a| a[:total_cost].to_f > 0 }
|
|
202
|
+
.sort_by { |a| -a[:total_cost].to_f }
|
|
203
|
+
.first(8)
|
|
204
|
+
|
|
205
|
+
# Prepare cost-by-model data (top 6)
|
|
206
|
+
cost_by_model_data = @model_stats
|
|
207
|
+
.select { |m| m[:total_cost].to_f > 0 }
|
|
208
|
+
.first(6)
|
|
209
|
+
%>
|
|
210
|
+
<div class="mt-8">
|
|
211
|
+
<div class="flex items-center gap-3 mb-3">
|
|
212
|
+
<span class="text-[10px] font-medium text-gray-400 dark:text-gray-600 uppercase tracking-widest font-mono">spending</span>
|
|
213
|
+
<div class="flex-1 border-t border-gray-200 dark:border-gray-800"></div>
|
|
214
|
+
</div>
|
|
215
|
+
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
216
|
+
<div id="cost-over-time-chart" style="width: 100%; height: 160px;"></div>
|
|
217
|
+
<div id="tokens-over-time-chart" style="width: 100%; height: 160px;"></div>
|
|
218
|
+
</div>
|
|
219
|
+
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-4">
|
|
220
|
+
<div id="cost-by-agent-chart" style="width: 100%; height: 200px;"></div>
|
|
221
|
+
<div id="cost-by-model-chart" style="width: 100%; height: 200px;"></div>
|
|
222
|
+
</div>
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
<%# Top tenants overview %>
|
|
226
|
+
<%= render partial: "ruby_llm/agents/dashboard/top_tenants", locals: { top_tenants: @top_tenants } %>
|
|
227
|
+
|
|
228
|
+
<script>
|
|
229
|
+
(function() {
|
|
230
|
+
const range = '<%= @selected_range %>';
|
|
231
|
+
const customFrom = '<%= @custom_from %>';
|
|
232
|
+
const customTo = '<%= @custom_to %>';
|
|
233
|
+
var chartUrl = '<%= ruby_llm_agents.chart_data_path %>?range=' + range;
|
|
234
|
+
if (range === 'custom' && customFrom && customTo) {
|
|
235
|
+
chartUrl += '&from=' + customFrom + '&to=' + customTo;
|
|
236
|
+
}
|
|
237
|
+
const hourMs = 3600000;
|
|
238
|
+
const dayMs = 24 * hourMs;
|
|
239
|
+
|
|
240
|
+
function getTimeRangeMs(r) {
|
|
241
|
+
if (r === 'today') return dayMs;
|
|
242
|
+
if (r === '7d') return 7 * dayMs;
|
|
243
|
+
if (r === '90d') return 90 * dayMs;
|
|
244
|
+
if (r === 'custom' && customFrom && customTo) {
|
|
245
|
+
return (new Date(customTo).getTime() - new Date(customFrom).getTime()) + dayMs;
|
|
246
|
+
}
|
|
247
|
+
return 30 * dayMs;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function toDatetimePoints(data, r) {
|
|
251
|
+
var baseTime, step;
|
|
252
|
+
if (r === 'today') {
|
|
253
|
+
step = hourMs;
|
|
254
|
+
baseTime = Date.now();
|
|
255
|
+
} else if (r === 'custom' && customFrom) {
|
|
256
|
+
step = dayMs;
|
|
257
|
+
baseTime = new Date(customTo || customFrom).getTime() + dayMs;
|
|
258
|
+
} else {
|
|
259
|
+
step = dayMs;
|
|
260
|
+
baseTime = Date.now();
|
|
261
|
+
}
|
|
262
|
+
return data.map((val, i) => [baseTime - (data.length - 1 - i) * step, val]);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Shared chart options
|
|
266
|
+
function baseXAxis(now, fmt) {
|
|
267
|
+
var xMin, xMax;
|
|
268
|
+
if (range === 'custom' && customFrom && customTo) {
|
|
269
|
+
xMin = new Date(customFrom).getTime();
|
|
270
|
+
xMax = new Date(customTo).getTime() + dayMs;
|
|
271
|
+
} else {
|
|
272
|
+
xMin = now - getTimeRangeMs(range);
|
|
273
|
+
xMax = now;
|
|
274
|
+
}
|
|
275
|
+
return {
|
|
276
|
+
type: 'datetime',
|
|
277
|
+
min: xMin,
|
|
278
|
+
max: xMax,
|
|
279
|
+
labels: { style: { color: chartColor('#6B7280', '#928374'), fontSize: '9px', fontFamily: 'ui-monospace, monospace' }, format: '{value:' + fmt + '}' },
|
|
280
|
+
lineColor: 'transparent',
|
|
281
|
+
tickLength: 0,
|
|
282
|
+
gridLineWidth: 0
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function baseTooltip(fmt, valueFormatter) {
|
|
287
|
+
return {
|
|
288
|
+
backgroundColor: chartColorAlpha('rgba(0, 0, 0, 0.85)', 40, 40, 40, 0.95),
|
|
289
|
+
borderColor: 'transparent',
|
|
290
|
+
borderRadius: 3,
|
|
291
|
+
style: { color: chartColor('#E5E7EB', '#ebdbb2'), fontSize: '10px', fontFamily: 'ui-monospace, monospace' },
|
|
292
|
+
shared: true,
|
|
293
|
+
formatter: function() {
|
|
294
|
+
var html = '<span style="color:' + chartColor('#9CA3AF', '#bdae93') + '">' + Highcharts.dateFormat(fmt, this.x) + '</span>';
|
|
295
|
+
this.points.forEach(function(p) { html += '<br/>' + p.series.name + ': <b>' + valueFormatter(p.y) + '</b>'; });
|
|
296
|
+
return html;
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function humanTokens(n) {
|
|
302
|
+
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
|
|
303
|
+
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
|
|
304
|
+
return n.toString();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Activity chart
|
|
308
|
+
function renderActivityChart(data) {
|
|
309
|
+
var now = Date.now();
|
|
310
|
+
var fmt = range === 'today' ? '%H:%M' : '%b %d';
|
|
311
|
+
|
|
312
|
+
Highcharts.chart('activity-chart', {
|
|
313
|
+
chart: { type: 'areaspline', backgroundColor: 'transparent', spacing: [5, 0, 0, 0] },
|
|
314
|
+
title: { text: null },
|
|
315
|
+
xAxis: baseXAxis(now, fmt),
|
|
316
|
+
yAxis: {
|
|
317
|
+
title: { text: null },
|
|
318
|
+
min: 0,
|
|
319
|
+
allowDecimals: false,
|
|
320
|
+
labels: { style: { color: chartColor('#6B7280', '#928374'), fontSize: '9px', fontFamily: 'ui-monospace, monospace' } },
|
|
321
|
+
gridLineColor: chartColorAlpha('rgba(107, 114, 128, 0.08)', 146, 131, 116, 0.08)
|
|
322
|
+
},
|
|
323
|
+
legend: { enabled: false },
|
|
324
|
+
credits: { enabled: false },
|
|
325
|
+
tooltip: baseTooltip(fmt, function(v) { return v; }),
|
|
326
|
+
plotOptions: {
|
|
327
|
+
areaspline: {
|
|
328
|
+
stacking: 'normal',
|
|
329
|
+
lineWidth: 1.5,
|
|
330
|
+
marker: { enabled: false, states: { hover: { enabled: true, radius: 2 } } }
|
|
331
|
+
}
|
|
332
|
+
},
|
|
333
|
+
series: [
|
|
334
|
+
{
|
|
335
|
+
name: 'errors',
|
|
336
|
+
data: toDatetimePoints(data.series[1].data, range),
|
|
337
|
+
color: chartColor('#EF4444', '#fb4934'),
|
|
338
|
+
fillColor: { linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 }, stops: [[0, chartColorAlpha('rgba(239, 68, 68, 0.08)', 251, 73, 52, 0.08)], [1, chartColorAlpha('rgba(239, 68, 68, 0)', 251, 73, 52, 0)]] }
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
name: 'success',
|
|
342
|
+
data: toDatetimePoints(data.series[0].data, range),
|
|
343
|
+
color: chartColor('#10B981', '#b8bb26'),
|
|
344
|
+
fillColor: { linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 }, stops: [[0, chartColorAlpha('rgba(16, 185, 129, 0.08)', 184, 187, 38, 0.08)], [1, chartColorAlpha('rgba(16, 185, 129, 0)', 184, 187, 38, 0)]] }
|
|
345
|
+
}
|
|
346
|
+
]
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Cost over time chart
|
|
351
|
+
function renderCostChart(data) {
|
|
352
|
+
if (!data.series[2]) return;
|
|
353
|
+
var now = Date.now();
|
|
354
|
+
var fmt = range === 'today' ? '%H:%M' : '%b %d';
|
|
355
|
+
|
|
356
|
+
Highcharts.chart('cost-over-time-chart', {
|
|
357
|
+
chart: { type: 'areaspline', backgroundColor: 'transparent', spacing: [5, 0, 0, 0] },
|
|
358
|
+
title: { text: 'cost', align: 'left', style: { color: chartColor('#9CA3AF', '#928374'), fontSize: '9px', fontFamily: 'ui-monospace, monospace', fontWeight: 'normal', textTransform: 'uppercase', letterSpacing: '0.05em' } },
|
|
359
|
+
xAxis: baseXAxis(now, fmt),
|
|
360
|
+
yAxis: {
|
|
361
|
+
title: { text: null },
|
|
362
|
+
min: 0,
|
|
363
|
+
labels: {
|
|
364
|
+
style: { color: chartColor('#6B7280', '#928374'), fontSize: '9px', fontFamily: 'ui-monospace, monospace' },
|
|
365
|
+
formatter: function() { return '$' + this.value.toFixed(2); }
|
|
366
|
+
},
|
|
367
|
+
gridLineColor: chartColorAlpha('rgba(107, 114, 128, 0.08)', 146, 131, 116, 0.08)
|
|
368
|
+
},
|
|
369
|
+
legend: { enabled: false },
|
|
370
|
+
credits: { enabled: false },
|
|
371
|
+
tooltip: baseTooltip(fmt, function(v) { return '$' + v.toFixed(4); }),
|
|
372
|
+
plotOptions: {
|
|
373
|
+
areaspline: {
|
|
374
|
+
lineWidth: 1.5,
|
|
375
|
+
marker: { enabled: false, states: { hover: { enabled: true, radius: 2 } } }
|
|
376
|
+
}
|
|
377
|
+
},
|
|
378
|
+
series: [{
|
|
379
|
+
name: 'cost',
|
|
380
|
+
data: toDatetimePoints(data.series[2].data, range),
|
|
381
|
+
color: chartColor('#F59E0B', '#fabd2f'),
|
|
382
|
+
fillColor: { linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 }, stops: [[0, chartColorAlpha('rgba(245, 158, 11, 0.12)', 250, 189, 47, 0.12)], [1, chartColorAlpha('rgba(245, 158, 11, 0)', 250, 189, 47, 0)]] }
|
|
383
|
+
}]
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Tokens over time chart
|
|
388
|
+
function renderTokensChart(data) {
|
|
389
|
+
if (!data.series[4]) return;
|
|
390
|
+
var now = Date.now();
|
|
391
|
+
var fmt = range === 'today' ? '%H:%M' : '%b %d';
|
|
392
|
+
|
|
393
|
+
Highcharts.chart('tokens-over-time-chart', {
|
|
394
|
+
chart: { type: 'areaspline', backgroundColor: 'transparent', spacing: [5, 0, 0, 0] },
|
|
395
|
+
title: { text: 'tokens', align: 'left', style: { color: chartColor('#9CA3AF', '#928374'), fontSize: '9px', fontFamily: 'ui-monospace, monospace', fontWeight: 'normal', textTransform: 'uppercase', letterSpacing: '0.05em' } },
|
|
396
|
+
xAxis: baseXAxis(now, fmt),
|
|
397
|
+
yAxis: {
|
|
398
|
+
title: { text: null },
|
|
399
|
+
min: 0,
|
|
400
|
+
labels: {
|
|
401
|
+
style: { color: chartColor('#6B7280', '#928374'), fontSize: '9px', fontFamily: 'ui-monospace, monospace' },
|
|
402
|
+
formatter: function() { return humanTokens(this.value); }
|
|
403
|
+
},
|
|
404
|
+
gridLineColor: chartColorAlpha('rgba(107, 114, 128, 0.08)', 146, 131, 116, 0.08)
|
|
405
|
+
},
|
|
406
|
+
legend: { enabled: false },
|
|
407
|
+
credits: { enabled: false },
|
|
408
|
+
tooltip: baseTooltip(fmt, function(v) { return humanTokens(v); }),
|
|
409
|
+
plotOptions: {
|
|
410
|
+
areaspline: {
|
|
411
|
+
lineWidth: 1.5,
|
|
412
|
+
marker: { enabled: false, states: { hover: { enabled: true, radius: 2 } } }
|
|
413
|
+
}
|
|
414
|
+
},
|
|
415
|
+
series: [{
|
|
416
|
+
name: 'tokens',
|
|
417
|
+
data: toDatetimePoints(data.series[4].data, range),
|
|
418
|
+
color: chartColor('#3B82F6', '#83a598'),
|
|
419
|
+
fillColor: { linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 }, stops: [[0, chartColorAlpha('rgba(59, 130, 246, 0.12)', 131, 165, 152, 0.12)], [1, chartColorAlpha('rgba(59, 130, 246, 0)', 131, 165, 152, 0)]] }
|
|
420
|
+
}]
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Cost by Agent chart (horizontal bar, data embedded via ERB)
|
|
425
|
+
function renderCostByAgentChart() {
|
|
426
|
+
var agentData = <%= raw cost_by_agent_data.map { |a| { name: a[:agent_type].to_s.demodulize, y: a[:total_cost].to_f.round(4) } }.to_json %>;
|
|
427
|
+
if (agentData.length === 0) {
|
|
428
|
+
document.getElementById('cost-by-agent-chart').innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;font-family:ui-monospace,monospace;font-size:11px;color:' + chartColor('#9CA3AF', '#928374') + '">—</div>';
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
var categories = agentData.map(function(d) { return d.name; });
|
|
432
|
+
var values = agentData.map(function(d) { return d.y; });
|
|
433
|
+
|
|
434
|
+
Highcharts.chart('cost-by-agent-chart', {
|
|
435
|
+
chart: { type: 'bar', backgroundColor: 'transparent', spacing: [5, 10, 5, 0] },
|
|
436
|
+
title: { text: 'cost by agent', align: 'left', style: { color: chartColor('#9CA3AF', '#928374'), fontSize: '9px', fontFamily: 'ui-monospace, monospace', fontWeight: 'normal', textTransform: 'uppercase', letterSpacing: '0.05em' } },
|
|
437
|
+
xAxis: {
|
|
438
|
+
categories: categories,
|
|
439
|
+
labels: { style: { color: chartColor('#6B7280', '#928374'), fontSize: '9px', fontFamily: 'ui-monospace, monospace' } },
|
|
440
|
+
lineColor: 'transparent',
|
|
441
|
+
tickLength: 0
|
|
442
|
+
},
|
|
443
|
+
yAxis: {
|
|
444
|
+
title: { text: null },
|
|
445
|
+
min: 0,
|
|
446
|
+
labels: {
|
|
447
|
+
style: { color: chartColor('#6B7280', '#928374'), fontSize: '9px', fontFamily: 'ui-monospace, monospace' },
|
|
448
|
+
formatter: function() { return '$' + this.value.toFixed(2); }
|
|
449
|
+
},
|
|
450
|
+
gridLineColor: chartColorAlpha('rgba(107, 114, 128, 0.08)', 146, 131, 116, 0.08)
|
|
451
|
+
},
|
|
452
|
+
legend: { enabled: false },
|
|
453
|
+
credits: { enabled: false },
|
|
454
|
+
tooltip: {
|
|
455
|
+
backgroundColor: chartColorAlpha('rgba(0, 0, 0, 0.85)', 40, 40, 40, 0.95),
|
|
456
|
+
borderColor: 'transparent',
|
|
457
|
+
borderRadius: 3,
|
|
458
|
+
style: { color: chartColor('#E5E7EB', '#ebdbb2'), fontSize: '10px', fontFamily: 'ui-monospace, monospace' },
|
|
459
|
+
pointFormat: '<b>${point.y:.4f}</b>'
|
|
460
|
+
},
|
|
461
|
+
plotOptions: {
|
|
462
|
+
bar: {
|
|
463
|
+
borderWidth: 0,
|
|
464
|
+
borderRadius: 2,
|
|
465
|
+
color: chartColor('#F59E0B', '#fabd2f')
|
|
466
|
+
}
|
|
467
|
+
},
|
|
468
|
+
series: [{ name: 'cost', data: values }]
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Cost by Model chart (donut/pie, data embedded via ERB)
|
|
473
|
+
function renderCostByModelChart() {
|
|
474
|
+
var modelData = <%= raw cost_by_model_data.map { |m| { name: m[:model_id].to_s.split("/").last.truncate(28), y: m[:total_cost].to_f.round(4), pct: m[:cost_percentage] } }.to_json %>;
|
|
475
|
+
if (modelData.length === 0) {
|
|
476
|
+
document.getElementById('cost-by-model-chart').innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;font-family:ui-monospace,monospace;font-size:11px;color:' + chartColor('#9CA3AF', '#928374') + '">—</div>';
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
var colors = [
|
|
480
|
+
chartColor('#F59E0B', '#fabd2f'),
|
|
481
|
+
chartColor('#3B82F6', '#83a598'),
|
|
482
|
+
chartColor('#10B981', '#b8bb26'),
|
|
483
|
+
chartColor('#EF4444', '#fb4934'),
|
|
484
|
+
chartColor('#8B5CF6', '#d3869b'),
|
|
485
|
+
chartColor('#F97316', '#fe8019')
|
|
486
|
+
];
|
|
487
|
+
|
|
488
|
+
Highcharts.chart('cost-by-model-chart', {
|
|
489
|
+
chart: { type: 'pie', backgroundColor: 'transparent', spacing: [5, 0, 5, 0] },
|
|
490
|
+
title: { text: 'cost by model', align: 'left', style: { color: chartColor('#9CA3AF', '#928374'), fontSize: '9px', fontFamily: 'ui-monospace, monospace', fontWeight: 'normal', textTransform: 'uppercase', letterSpacing: '0.05em' } },
|
|
491
|
+
legend: { enabled: false },
|
|
492
|
+
credits: { enabled: false },
|
|
493
|
+
tooltip: {
|
|
494
|
+
backgroundColor: chartColorAlpha('rgba(0, 0, 0, 0.85)', 40, 40, 40, 0.95),
|
|
495
|
+
borderColor: 'transparent',
|
|
496
|
+
borderRadius: 3,
|
|
497
|
+
style: { color: chartColor('#E5E7EB', '#ebdbb2'), fontSize: '10px', fontFamily: 'ui-monospace, monospace' },
|
|
498
|
+
pointFormat: '<b>${point.y:.4f}</b> ({point.pct}%)'
|
|
499
|
+
},
|
|
500
|
+
plotOptions: {
|
|
501
|
+
pie: {
|
|
502
|
+
innerSize: '50%',
|
|
503
|
+
borderWidth: 0,
|
|
504
|
+
colors: colors,
|
|
505
|
+
dataLabels: {
|
|
506
|
+
enabled: true,
|
|
507
|
+
format: '{point.name}',
|
|
508
|
+
style: { color: chartColor('#6B7280', '#928374'), fontSize: '9px', fontFamily: 'ui-monospace, monospace', fontWeight: 'normal', textOutline: 'none' },
|
|
509
|
+
distance: 10
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
},
|
|
513
|
+
series: [{ name: 'cost', data: modelData }]
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function initCharts() {
|
|
518
|
+
window.__initHighchartsDefaults && window.__initHighchartsDefaults();
|
|
519
|
+
|
|
520
|
+
fetch(chartUrl)
|
|
521
|
+
.then(function(res) { return res.json(); })
|
|
522
|
+
.then(function(data) {
|
|
523
|
+
renderActivityChart(data);
|
|
524
|
+
renderCostChart(data);
|
|
525
|
+
renderTokensChart(data);
|
|
526
|
+
})
|
|
527
|
+
.catch(function(err) { console.log('[RubyLLM::Agents] Chart error:', err); });
|
|
528
|
+
|
|
529
|
+
renderCostByAgentChart();
|
|
530
|
+
renderCostByModelChart();
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Wait for Highcharts to load (handles async fallback CDN)
|
|
534
|
+
function waitForHighcharts(attempts) {
|
|
535
|
+
if (typeof Highcharts !== 'undefined') {
|
|
536
|
+
initCharts();
|
|
537
|
+
} else if (attempts > 0) {
|
|
538
|
+
setTimeout(function() { waitForHighcharts(attempts - 1); }, 100);
|
|
539
|
+
} else {
|
|
540
|
+
console.log('[RubyLLM::Agents] Highcharts not loaded after 5s — charts disabled');
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
document.readyState === 'loading'
|
|
545
|
+
? document.addEventListener('DOMContentLoaded', function() { waitForHighcharts(50); })
|
|
546
|
+
: waitForHighcharts(50);
|
|
547
|
+
})();
|
|
548
|
+
</script>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module RubyLlmAgents
|
|
7
|
+
# Generator for creating a migration to rename an agent in execution records
|
|
8
|
+
#
|
|
9
|
+
# Usage:
|
|
10
|
+
# rails generate ruby_llm_agents:rename_agent OldAgentName NewAgentName
|
|
11
|
+
#
|
|
12
|
+
# This creates a reversible migration that updates the agent_type column
|
|
13
|
+
# in the ruby_llm_agents_executions table.
|
|
14
|
+
#
|
|
15
|
+
class RenameAgentGenerator < ::Rails::Generators::Base
|
|
16
|
+
include ::ActiveRecord::Generators::Migration
|
|
17
|
+
|
|
18
|
+
source_root File.expand_path("templates", __dir__)
|
|
19
|
+
|
|
20
|
+
argument :old_name, type: :string, desc: "The current (old) agent class name"
|
|
21
|
+
argument :new_name, type: :string, desc: "The new agent class name"
|
|
22
|
+
|
|
23
|
+
def validate_names
|
|
24
|
+
if old_name == new_name
|
|
25
|
+
raise Thor::Error, "Old and new agent names must be different"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def create_migration_file
|
|
30
|
+
migration_template(
|
|
31
|
+
"rename_agent_migration.rb.tt",
|
|
32
|
+
File.join(db_migrate_path, "rename_#{old_name.underscore}_to_#{new_name.underscore}.rb")
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def show_message
|
|
37
|
+
say ""
|
|
38
|
+
say "Created migration to rename #{old_name} -> #{new_name}", :green
|
|
39
|
+
say "Run `rails db:migrate` to apply."
|
|
40
|
+
say ""
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def migration_version
|
|
46
|
+
"[#{::ActiveRecord::VERSION::STRING.to_f}]"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def db_migrate_path
|
|
50
|
+
"db/migrate"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Rename<%= old_name.gsub("::", "") %>To<%= new_name.gsub("::", "") %> < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
def up
|
|
5
|
+
execute <<~SQL.squish
|
|
6
|
+
UPDATE ruby_llm_agents_executions
|
|
7
|
+
SET agent_type = '<%= new_name %>'
|
|
8
|
+
WHERE agent_type = '<%= old_name %>'
|
|
9
|
+
SQL
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def down
|
|
13
|
+
execute <<~SQL.squish
|
|
14
|
+
UPDATE ruby_llm_agents_executions
|
|
15
|
+
SET agent_type = '<%= old_name %>'
|
|
16
|
+
WHERE agent_type = '<%= new_name %>'
|
|
17
|
+
SQL
|
|
18
|
+
end
|
|
19
|
+
end
|