ruby_llm-agents 3.11.0 → 3.13.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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/ruby_llm/agents/agents_controller.rb +74 -0
  3. data/app/controllers/ruby_llm/agents/analytics_controller.rb +304 -0
  4. data/app/controllers/ruby_llm/agents/executions_controller.rb +5 -0
  5. data/app/controllers/ruby_llm/agents/tenants_controller.rb +74 -2
  6. data/app/models/ruby_llm/agents/agent_override.rb +47 -0
  7. data/app/models/ruby_llm/agents/execution/analytics.rb +37 -16
  8. data/app/models/ruby_llm/agents/execution.rb +51 -1
  9. data/app/services/ruby_llm/agents/agent_registry.rb +8 -1
  10. data/app/views/layouts/ruby_llm/agents/application.html.erb +4 -2
  11. data/app/views/ruby_llm/agents/agents/_config_agent.html.erb +93 -4
  12. data/app/views/ruby_llm/agents/agents/show.html.erb +17 -2
  13. data/app/views/ruby_llm/agents/analytics/index.html.erb +398 -0
  14. data/app/views/ruby_llm/agents/executions/_audio_player.html.erb +1 -1
  15. data/app/views/ruby_llm/agents/executions/_filters.html.erb +12 -8
  16. data/app/views/ruby_llm/agents/executions/show.html.erb +26 -12
  17. data/app/views/ruby_llm/agents/shared/_filter_dropdown.html.erb +46 -7
  18. data/app/views/ruby_llm/agents/shared/_tenant_filter.html.erb +2 -2
  19. data/app/views/ruby_llm/agents/system_config/show.html.erb +6 -2
  20. data/app/views/ruby_llm/agents/tenants/index.html.erb +3 -2
  21. data/app/views/ruby_llm/agents/tenants/show.html.erb +225 -0
  22. data/config/routes.rb +12 -4
  23. data/lib/generators/ruby_llm_agents/templates/create_overrides_migration.rb.tt +28 -0
  24. data/lib/generators/ruby_llm_agents/templates/initializer.rb.tt +27 -1
  25. data/lib/generators/ruby_llm_agents/templates/skills/AGENTS.md.tt +1 -1
  26. data/lib/generators/ruby_llm_agents/templates/skills/TOOLS.md.tt +1 -1
  27. data/lib/generators/ruby_llm_agents/upgrade_generator.rb +14 -0
  28. data/lib/ruby_llm/agents/base_agent.rb +90 -133
  29. data/lib/ruby_llm/agents/core/base.rb +9 -0
  30. data/lib/ruby_llm/agents/core/configuration.rb +93 -7
  31. data/lib/ruby_llm/agents/core/version.rb +1 -1
  32. data/lib/ruby_llm/agents/dsl/base.rb +131 -4
  33. data/lib/ruby_llm/agents/dsl/knowledge.rb +157 -0
  34. data/lib/ruby_llm/agents/dsl.rb +1 -1
  35. data/lib/ruby_llm/agents/image/concerns/image_operation_execution.rb +9 -5
  36. data/lib/ruby_llm/agents/infrastructure/retention_job.rb +118 -0
  37. data/lib/ruby_llm/agents/pipeline/middleware/budget.rb +32 -20
  38. data/lib/ruby_llm/agents/pipeline/middleware/instrumentation.rb +22 -1
  39. data/lib/ruby_llm/agents/pipeline/middleware/reliability.rb +1 -1
  40. data/lib/ruby_llm/agents/rails/engine.rb +20 -4
  41. data/lib/ruby_llm/agents/routing.rb +28 -5
  42. data/lib/ruby_llm/agents/stream_event.rb +2 -10
  43. data/lib/ruby_llm/agents/tool.rb +1 -1
  44. data/lib/ruby_llm/agents.rb +1 -3
  45. data/lib/tasks/ruby_llm_agents.rake +7 -0
  46. metadata +9 -5
  47. data/lib/ruby_llm/agents/agent_tool.rb +0 -143
  48. data/lib/ruby_llm/agents/dsl/agents.rb +0 -141
@@ -0,0 +1,398 @@
1
+ <%
2
+ s = @summary || {}
3
+ total_cost = s[:total_cost] || 0
4
+ analytics_range_presets = [
5
+ {value: "7d", label: "7 Days"},
6
+ {value: "30d", label: "30 Days"},
7
+ {value: "90d", label: "90 Days"}
8
+ ]
9
+ # Preserve filters across range/form changes
10
+ filter_params = {}
11
+ filter_params[:agent] = @filter_agent if @filter_agent
12
+ filter_params[:model] = @filter_model if @filter_model
13
+ filter_params[:filter_tenant] = @filter_tenant if @filter_tenant
14
+ %>
15
+
16
+ <!-- ── header ──────────────── -->
17
+ <div class="flex items-center gap-4 min-w-0 mb-3">
18
+ <h1 class="text-[10px] font-medium text-gray-400 dark:text-gray-500 uppercase tracking-widest font-mono shrink-0">analytics</h1>
19
+ <%= render "ruby_llm/agents/shared/doc_link" %>
20
+ </div>
21
+
22
+ <!-- ── filter bar ──────────────── -->
23
+ <!-- Filter data (consumed by Alpine below) -->
24
+ <script>
25
+ window.__analyticsFilters = {
26
+ agents: <%= @available_agents.map { |a| { value: a, label: a.to_s.split("::").last } }.to_json.html_safe %>,
27
+ models: <%= @available_models.map { |m| { value: m, label: m.to_s.split("/").last } }.to_json.html_safe %>,
28
+ tenants: <%= @available_tenants.map { |tid, label| { value: tid, label: label } }.to_json.html_safe %>,
29
+ selected: {
30
+ agent: <%= @filter_agent.to_s.to_json.html_safe %>,
31
+ model: <%= @filter_model.to_s.to_json.html_safe %>,
32
+ tenant: <%= @filter_tenant.to_s.to_json.html_safe %>
33
+ }
34
+ };
35
+ window.__searchSelect = function(dataKey, paramName, placeholder) {
36
+ var cfg = window.__analyticsFilters;
37
+ return {
38
+ items: cfg[dataKey],
39
+ selected: cfg.selected[paramName] || '',
40
+ paramName: paramName,
41
+ placeholder: placeholder,
42
+ search: '',
43
+ open: false,
44
+ toggle: function() {
45
+ this.open = !this.open;
46
+ if (this.open) {
47
+ this.search = '';
48
+ var self = this;
49
+ this.$nextTick(function() { self.$refs.searchInput && self.$refs.searchInput.focus(); });
50
+ }
51
+ },
52
+ pick: function(value) {
53
+ this.selected = value;
54
+ this.open = false;
55
+ var self = this;
56
+ this.$nextTick(function() { document.getElementById('analytics-filters').submit(); });
57
+ },
58
+ label: function() {
59
+ if (!this.selected) return this.placeholder;
60
+ var s = this.selected;
61
+ var match = this.items.find(function(i) { return i.value === s; });
62
+ return match ? match.label : s;
63
+ },
64
+ filtered: function() {
65
+ var q = this.search.toLowerCase();
66
+ if (!q) return this.items;
67
+ return this.items.filter(function(i) { return i.label.toLowerCase().indexOf(q) >= 0 || i.value.toLowerCase().indexOf(q) >= 0; });
68
+ }
69
+ };
70
+ };
71
+ </script>
72
+
73
+ <%= form_with url: ruby_llm_agents.analytics_path, method: :get, id: "analytics-filters",
74
+ class: "flex flex-wrap items-center gap-2 mb-4 font-mono text-xs", data: { turbo: false } do %>
75
+ <input type="hidden" name="range" value="<%= @selected_range %>">
76
+ <% if @selected_range == "custom" %>
77
+ <input type="hidden" name="from" value="<%= @custom_from %>">
78
+ <input type="hidden" name="to" value="<%= @custom_to %>">
79
+ <% end %>
80
+
81
+ <%# Range selector (inline with filters) %>
82
+ <div class="relative" x-data="{ open: false, showCustom: false }" @click.outside="open = false; showCustom = false">
83
+ <button type="button" @click.stop="open = !open" class="flex items-center gap-1 w-32 px-2 py-1 bg-transparent border border-gray-200 dark:border-gray-800 rounded text-gray-900 dark:text-gray-100 focus:ring-1 focus:ring-gray-400 dark:focus:ring-gray-600">
84
+ <% if @selected_range == "custom" && @custom_from && @custom_to %>
85
+ <span class="flex-1 truncate"><%= @custom_from.strftime("%b %-d") %> – <%= @custom_to.strftime("%b %-d") %></span>
86
+ <% else %>
87
+ <span class="flex-1"><%= range_display_name(@selected_range) %></span>
88
+ <% end %>
89
+ <svg class="w-3 h-3 text-gray-400 flex-shrink-0" 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>
90
+ </button>
91
+ <div x-show="open" x-cloak x-transition class="absolute left-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">
92
+ <% analytics_range_presets.each do |preset| %>
93
+ <%= link_to preset[:label], ruby_llm_agents.analytics_path(filter_params.merge(range: preset[:value])),
94
+ 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'}" %>
95
+ <% end %>
96
+ <div class="border-t border-gray-200 dark:border-gray-700 my-1"></div>
97
+ <button type="button" @click.stop="showCustom = !showCustom" class="w-full text-left px-3 py-1.5 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">Custom range&hellip;</button>
98
+ <div x-show="showCustom" x-cloak class="px-3 py-2 border-t border-gray-200 dark:border-gray-700" x-data="{ cfrom: '<%= @custom_from %>', cto: '<%= @custom_to %>' }">
99
+ <label class="block text-[10px] text-gray-400 dark:text-gray-500 uppercase">From</label>
100
+ <input type="date" x-model="cfrom" max="<%= Date.current %>" 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">
101
+ <label class="block text-[10px] text-gray-400 dark:text-gray-500 uppercase mt-2">To</label>
102
+ <input type="date" x-model="cto" max="<%= Date.current %>" 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">
103
+ <button type="button" @click="window.location = '<%= ruby_llm_agents.analytics_path %>' + '?range=custom&from=' + cfrom + '&to=' + cto + '<%= filter_params.map { |k, v| "&#{k}=#{ERB::Util.url_encode(v)}" }.join %>'"
104
+ class="w-full mt-2 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">Apply</button>
105
+ </div>
106
+ </div>
107
+ </div>
108
+
109
+ <%
110
+ dropdowns = [
111
+ { key: "agents", param: "agent", placeholder: "All agents" },
112
+ { key: "models", param: "model", placeholder: "All models" }
113
+ ]
114
+ dropdowns << { key: "tenants", param: "tenant", placeholder: "All tenants", name: "filter_tenant" } if @available_tenants.any?
115
+ %>
116
+
117
+ <% dropdowns.each do |dd| %>
118
+ <div class="relative w-44" x-data="__searchSelect('<%= dd[:key] %>', '<%= dd[:param] %>', '<%= dd[:placeholder] %>')" @click.outside="open = false">
119
+ <input type="hidden" name="<%= dd[:name] || dd[:param] %>" :value="selected">
120
+ <button type="button" @click.stop="toggle()" class="flex items-center gap-1 w-44 px-2 py-1 bg-transparent border border-gray-200 dark:border-gray-800 rounded text-left text-gray-900 dark:text-gray-100 focus:ring-1 focus:ring-gray-400 dark:focus:ring-gray-600 truncate">
121
+ <span x-text="label()" class="flex-1 truncate"></span>
122
+ <svg class="w-3 h-3 text-gray-400 flex-shrink-0" 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>
123
+ </button>
124
+ <div x-show="open" class="absolute left-0 mt-1 w-56 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded shadow-lg z-50">
125
+ <div class="p-1.5">
126
+ <input type="text" x-model="search" x-ref="searchInput" placeholder="Search..." autocomplete="off"
127
+ class="w-full px-2 py-1 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded text-xs text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-600 focus:outline-none focus:ring-1 focus:ring-gray-400">
128
+ </div>
129
+ <div class="max-h-48 overflow-y-auto py-1">
130
+ <button type="button" @click="pick('')"
131
+ class="block w-full text-left px-3 py-1.5 text-xs hover:bg-gray-100 dark:hover:bg-gray-800"
132
+ :class="selected === '' ? 'text-gray-900 dark:text-gray-100 bg-gray-100 dark:bg-gray-800' : 'text-gray-500 dark:text-gray-400'">
133
+ <%= dd[:placeholder] %>
134
+ </button>
135
+ <template x-for="item in filtered()" :key="item.value">
136
+ <button type="button" @click="pick(item.value)"
137
+ class="block w-full text-left px-3 py-1.5 text-xs truncate hover:bg-gray-100 dark:hover:bg-gray-800"
138
+ :class="selected === item.value ? 'text-gray-900 dark:text-gray-100 bg-gray-100 dark:bg-gray-800' : 'text-gray-500 dark:text-gray-400'"
139
+ x-text="item.label"></button>
140
+ </template>
141
+ <div x-show="filtered().length === 0" class="px-3 py-1.5 text-xs text-gray-400 dark:text-gray-600">no matches</div>
142
+ </div>
143
+ </div>
144
+ </div>
145
+ <% end %>
146
+
147
+ <% if @any_filter %>
148
+ <%= link_to "clear", ruby_llm_agents.analytics_path(range: @selected_range,
149
+ from: @custom_from, to: @custom_to),
150
+ class: "text-gray-400 dark:text-gray-600 hover:text-gray-700 dark:hover:text-gray-300" %>
151
+ <% end %>
152
+ <% end %>
153
+
154
+ <!-- ── summary strip ──────────────── -->
155
+ <div class="grid grid-cols-2 sm:grid-cols-4 gap-4 font-mono text-xs mb-2">
156
+ <div class="space-y-0.5">
157
+ <div class="text-[10px] text-gray-400 dark:text-gray-600 uppercase tracking-wider">total cost</div>
158
+ <div class="text-gray-900 dark:text-gray-200">$<%= number_with_precision(s[:total_cost] || 0, precision: 4) %></div>
159
+ <% if s[:cost_change] && s[:cost_change] != 0 %>
160
+ <div class="<%= s[:cost_change] > 0 ? 'text-red-500' : 'text-green-500' %> text-[10px]">
161
+ <%= s[:cost_change] > 0 ? "+" : "" %><%= s[:cost_change] %>% vs prior
162
+ </div>
163
+ <% elsif s[:prior_cost] %>
164
+ <div class="text-gray-400 dark:text-gray-600 text-[10px]">— vs prior</div>
165
+ <% end %>
166
+ </div>
167
+ <div class="space-y-0.5">
168
+ <div class="text-[10px] text-gray-400 dark:text-gray-600 uppercase tracking-wider">avg cost/run</div>
169
+ <div class="text-gray-900 dark:text-gray-200">$<%= number_with_precision(s[:avg_cost] || 0, precision: 4) %></div>
170
+ <div class="text-gray-400 dark:text-gray-600 text-[10px]"><%= number_with_delimiter(s[:avg_tokens] || 0) %> tok/run</div>
171
+ </div>
172
+ <div class="space-y-0.5">
173
+ <div class="text-[10px] text-gray-400 dark:text-gray-600 uppercase tracking-wider">runs</div>
174
+ <div class="text-gray-900 dark:text-gray-200"><%= number_with_delimiter(s[:total_runs] || 0) %></div>
175
+ <% if s[:runs_change] && s[:runs_change] != 0 %>
176
+ <div class="<%= s[:runs_change] > 0 ? 'text-gray-500' : 'text-gray-500' %> text-[10px]">
177
+ <%= s[:runs_change] > 0 ? "+" : "" %><%= s[:runs_change] %>% vs prior
178
+ </div>
179
+ <% end %>
180
+ </div>
181
+ <div class="space-y-0.5">
182
+ <div class="text-[10px] text-gray-400 dark:text-gray-600 uppercase tracking-wider">tokens</div>
183
+ <div class="text-gray-900 dark:text-gray-200"><%= number_with_delimiter(s[:total_tokens] || 0) %></div>
184
+ </div>
185
+ </div>
186
+
187
+ <!-- ── projection + savings ──────────────── -->
188
+ <div class="font-mono text-xs space-y-1 mb-2">
189
+ <% if @projection %>
190
+ <div class="text-gray-500 dark:text-gray-400">
191
+ Burning <span class="text-gray-800 dark:text-gray-200">$<%= number_with_precision(@projection[:daily_rate], precision: 4) %>/day</span>
192
+ <% if @projection[:days_left] > 0 %>
193
+ &middot; projected <span class="text-gray-800 dark:text-gray-200">$<%= number_with_precision(@projection[:projected_month], precision: 2) %></span> by end of month
194
+ (<%= @projection[:days_left] %> days left)
195
+ <% end %>
196
+ </div>
197
+ <% end %>
198
+
199
+ <% if @savings %>
200
+ <div class="text-gray-500 dark:text-gray-400">
201
+ <span class="text-green-500">Save ~$<%= number_with_precision(@savings[:potential_savings], precision: 2) %></span>
202
+ by moving <span class="text-gray-800 dark:text-gray-200"><%= @savings[:expensive_runs] %></span> runs
203
+ from <span class="text-gray-800 dark:text-gray-200"><%= @savings[:expensive_model].to_s.split("/").last %></span>
204
+ ($<%= number_with_precision(@savings[:expensive_cost_per_run], precision: 4) %>/run)
205
+ to <span class="text-gray-800 dark:text-gray-200"><%= @savings[:cheap_model].to_s.split("/").last %></span>
206
+ ($<%= number_with_precision(@savings[:cheap_cost_per_run], precision: 4) %>/run)
207
+ </div>
208
+ <% end %>
209
+
210
+ <% if @error_total_count > 0 && total_cost > 0 %>
211
+ <div class="text-gray-500 dark:text-gray-400">
212
+ <span class="text-red-500">$<%= number_with_precision(@error_total_cost, precision: 4) %></span>
213
+ wasted on <span class="text-red-500"><%= number_with_delimiter(@error_total_count) %></span> failed runs
214
+ (<%= (@error_total_cost.to_f / total_cost * 100).round(1) %>% of spend)
215
+ </div>
216
+ <% end %>
217
+ </div>
218
+
219
+ <div class="border-t border-gray-200 dark:border-gray-800 mb-2"></div>
220
+
221
+ <!-- ── cost over time (current + prior overlay) ──────────────── -->
222
+ <div class="flex items-center gap-3 mt-4 mb-2">
223
+ <span class="text-[10px] font-medium text-gray-400 dark:text-gray-600 uppercase tracking-widest font-mono">cost over time</span>
224
+ <span class="text-[10px] font-mono text-gray-400 dark:text-gray-600">solid = current &middot; dashed = prior period</span>
225
+ <div class="flex-1 border-t border-gray-200 dark:border-gray-800"></div>
226
+ </div>
227
+ <div id="analytics-cost-chart" style="width: 100%; height: 200px;"></div>
228
+
229
+ <!-- ── model efficiency ──────────────── -->
230
+ <% if @efficiency.present? %>
231
+ <div class="flex items-center gap-3 mt-6 mb-3">
232
+ <span class="text-[10px] font-medium text-gray-400 dark:text-gray-600 uppercase tracking-widest font-mono">model efficiency</span>
233
+ <div class="flex-1 border-t border-gray-200 dark:border-gray-800"></div>
234
+ </div>
235
+
236
+ <div class="flex items-center gap-3 px-2 -mx-2 font-mono text-[10px] text-gray-400 dark:text-gray-600 uppercase tracking-wider mb-1">
237
+ <span class="flex-[2] min-w-0">model</span>
238
+ <span class="w-12 flex-shrink-0 text-right">runs</span>
239
+ <span class="w-20 flex-shrink-0 text-right">cost</span>
240
+ <span class="w-20 flex-shrink-0 text-right">$/1k tok</span>
241
+ <span class="w-20 flex-shrink-0 text-right hidden sm:block">avg dur</span>
242
+ <span class="w-12 flex-shrink-0 text-right">ok%</span>
243
+ <span class="w-10 flex-shrink-0 text-right">%</span>
244
+ </div>
245
+ <div class="font-mono text-xs space-y-px">
246
+ <% @efficiency.each do |model| %>
247
+ <div class="flex items-center gap-3 py-1 px-2 -mx-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800/50">
248
+ <span class="flex-[2] min-w-0 truncate text-gray-900 dark:text-gray-200"><%= model[:model_id].to_s.split("/").last.truncate(28) %></span>
249
+ <span class="w-12 flex-shrink-0 text-right text-gray-500 dark:text-gray-400"><%= number_with_delimiter(model[:executions]) %></span>
250
+ <span class="w-20 flex-shrink-0 text-right text-gray-800 dark:text-gray-200">$<%= number_with_precision(model[:total_cost], precision: 4) %></span>
251
+ <span class="w-20 flex-shrink-0 text-right text-gray-500 dark:text-gray-400">$<%= number_with_precision(model[:cost_per_1k_tokens], precision: 4) %></span>
252
+ <span class="w-20 flex-shrink-0 text-right text-gray-500 dark:text-gray-400 hidden sm:block"><%= format_duration_ms(model[:avg_duration_ms]) %></span>
253
+ <span class="w-12 flex-shrink-0 text-right <%= model[:success_rate] >= 95 ? 'text-green-500' : model[:success_rate] >= 80 ? 'text-yellow-500' : 'text-red-500' %>"><%= model[:success_rate] %>%</span>
254
+ <span class="w-10 flex-shrink-0 text-right text-gray-400 dark:text-gray-600"><%= model[:cost_percentage] %>%</span>
255
+ </div>
256
+ <% end %>
257
+ </div>
258
+ <% end %>
259
+
260
+ <!-- ── error cost breakdown ──────────────── -->
261
+ <% if @error_breakdown.present? %>
262
+ <div class="flex items-center gap-3 mt-6 mb-3">
263
+ <span class="text-[10px] font-medium text-gray-400 dark:text-gray-600 uppercase tracking-widest font-mono">error cost breakdown</span>
264
+ <span class="text-[10px] font-mono text-red-400 dark:text-red-500/70">($<%= number_with_precision(@error_total_cost, precision: 4) %>)</span>
265
+ <div class="flex-1 border-t border-gray-200 dark:border-gray-800"></div>
266
+ </div>
267
+
268
+ <div class="flex items-center gap-3 px-2 -mx-2 font-mono text-[10px] text-gray-400 dark:text-gray-600 uppercase tracking-wider mb-1">
269
+ <span class="flex-[2] min-w-0">error</span>
270
+ <span class="w-28 flex-shrink-0 hidden sm:block">agent</span>
271
+ <span class="w-10 flex-shrink-0 text-right">count</span>
272
+ <span class="w-20 flex-shrink-0 text-right">cost</span>
273
+ <span class="w-24 flex-shrink-0 text-right">last seen</span>
274
+ </div>
275
+ <div class="font-mono text-xs space-y-px">
276
+ <% @error_breakdown.each do |err| %>
277
+ <div class="flex items-center gap-3 py-1 px-2 -mx-2">
278
+ <span class="w-1.5 h-1.5 rounded-full bg-red-500 flex-shrink-0"></span>
279
+ <span class="flex-[2] min-w-0 truncate text-gray-900 dark:text-gray-200"><%= err[:error_class].to_s.split("::").last %></span>
280
+ <span class="w-28 flex-shrink-0 truncate text-gray-500 dark:text-gray-400 hidden sm:block"><%= err[:agent_type].to_s.split("::").last %></span>
281
+ <span class="w-10 flex-shrink-0 text-right text-red-500"><%= err[:count] %></span>
282
+ <span class="w-20 flex-shrink-0 text-right text-gray-800 dark:text-gray-200">$<%= number_with_precision(err[:cost], precision: 4) %></span>
283
+ <span class="w-24 flex-shrink-0 text-right text-gray-400 dark:text-gray-600 whitespace-nowrap"><%= err[:last_seen] ? time_ago_in_words(err[:last_seen]) : "—" %></span>
284
+ </div>
285
+ <% end %>
286
+ </div>
287
+ <% end %>
288
+
289
+ <!-- ── charts ──────────────── -->
290
+ <script>
291
+ (function() {
292
+ var chartUrl = '<%= ruby_llm_agents.analytics_chart_data_path %>';
293
+ var params = new URLSearchParams({
294
+ range: '<%= @selected_range %>'
295
+ });
296
+ <% if @selected_range == "custom" && @custom_from && @custom_to %>
297
+ params.set('from', '<%= @custom_from %>');
298
+ params.set('to', '<%= @custom_to %>');
299
+ <% end %>
300
+ <% if @filter_agent %>params.set('agent', '<%= j @filter_agent %>');<% end %>
301
+ <% if @filter_model %>params.set('model', '<%= j @filter_model %>');<% end %>
302
+ <% if @filter_tenant %>params.set('filter_tenant', '<%= j @filter_tenant %>');<% end %>
303
+ chartUrl += '?' + params.toString();
304
+
305
+ function renderCostChart(data) {
306
+ if (!data.series || data.series.length === 0 || (data.series[0].data.length === 0 && data.series[1].data.length === 0)) {
307
+ document.getElementById('analytics-cost-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') + '">no data for this period</div>';
308
+ return;
309
+ }
310
+
311
+ var series = [];
312
+ var s0 = data.series[0]; // current
313
+ var s1 = data.series[1]; // prior
314
+
315
+ series.push({
316
+ name: s0.name,
317
+ type: s0.type || 'areaspline',
318
+ data: s0.data,
319
+ color: chartColor('#8B5CF6', '#d3869b'),
320
+ lineWidth: 2,
321
+ marker: { enabled: false, states: { hover: { enabled: true, radius: 3 } } },
322
+ fillColor: { linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 },
323
+ stops: [[0, chartColorAlpha('rgba(139, 92, 246, 0.15)', 211, 134, 155, 0.15)], [1, chartColorAlpha('rgba(139, 92, 246, 0)', 211, 134, 155, 0)]] }
324
+ });
325
+
326
+ if (s1 && s1.data && s1.data.length > 0) {
327
+ series.push({
328
+ name: s1.name,
329
+ type: s1.type || 'spline',
330
+ data: s1.data,
331
+ dashStyle: s1.dashStyle || 'Dash',
332
+ color: chartColorAlpha('rgba(107, 114, 128, 0.5)', 146, 131, 116, 0.5),
333
+ lineWidth: 1.5,
334
+ marker: { enabled: false, states: { hover: { enabled: true, radius: 2 } } }
335
+ });
336
+ }
337
+
338
+ Highcharts.chart('analytics-cost-chart', {
339
+ chart: { backgroundColor: 'transparent', spacing: [5, 0, 0, 0] },
340
+ title: { text: null },
341
+ xAxis: {
342
+ type: 'datetime',
343
+ labels: { style: { color: chartColor('#6B7280', '#928374'), fontSize: '9px', fontFamily: 'ui-monospace, monospace' }, format: '{value:%b %d}' },
344
+ lineColor: 'transparent', tickLength: 0, gridLineWidth: 0
345
+ },
346
+ yAxis: {
347
+ title: { text: null }, min: 0,
348
+ labels: { style: { color: chartColor('#6B7280', '#928374'), fontSize: '9px', fontFamily: 'ui-monospace, monospace' }, format: '${value}' },
349
+ gridLineColor: chartColorAlpha('rgba(107, 114, 128, 0.08)', 146, 131, 116, 0.08)
350
+ },
351
+ legend: {
352
+ enabled: true, align: 'left', verticalAlign: 'top', floating: true, x: 0, y: -5,
353
+ itemStyle: { color: chartColor('#6B7280', '#928374'), fontSize: '9px', fontFamily: 'ui-monospace, monospace', fontWeight: 'normal' },
354
+ symbolHeight: 8, symbolWidth: 12, symbolRadius: 2
355
+ },
356
+ credits: { enabled: false },
357
+ tooltip: {
358
+ backgroundColor: chartColorAlpha('rgba(0, 0, 0, 0.85)', 40, 40, 40, 0.95),
359
+ borderColor: 'transparent', borderRadius: 3,
360
+ style: { color: chartColor('#E5E7EB', '#ebdbb2'), fontSize: '10px', fontFamily: 'ui-monospace, monospace' },
361
+ shared: true,
362
+ formatter: function() {
363
+ var html = '<span style="color:' + chartColor('#9CA3AF', '#bdae93') + '">' + Highcharts.dateFormat('%b %d', this.x) + '</span>';
364
+ this.points.forEach(function(p) {
365
+ var style = p.series.options.dashStyle === 'Dash' ? ' (prior)' : '';
366
+ html += '<br/><span style="color:' + p.color + '">' + p.series.name + '</span>: <b>$' + p.y.toFixed(4) + '</b>';
367
+ });
368
+ return html;
369
+ }
370
+ },
371
+ plotOptions: {
372
+ areaspline: { marker: { enabled: false } },
373
+ spline: { marker: { enabled: false } }
374
+ },
375
+ series: series
376
+ });
377
+ }
378
+
379
+ function renderAll() {
380
+ window.__initHighchartsDefaults && window.__initHighchartsDefaults();
381
+ fetch(chartUrl)
382
+ .then(function(r) { return r.json(); })
383
+ .then(renderCostChart)
384
+ .catch(function() {
385
+ document.getElementById('analytics-cost-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') + '">failed to load chart</div>';
386
+ });
387
+ }
388
+
389
+ function waitForHighcharts(attempts) {
390
+ if (typeof Highcharts !== 'undefined') { renderAll(); }
391
+ else if (attempts > 0) { setTimeout(function() { waitForHighcharts(attempts - 1); }, 100); }
392
+ }
393
+
394
+ document.readyState === 'loading'
395
+ ? document.addEventListener('DOMContentLoaded', function() { waitForHighcharts(50); })
396
+ : waitForHighcharts(50);
397
+ })();
398
+ </script>
@@ -1,5 +1,5 @@
1
1
  <%
2
- response = @execution.response || {}
2
+ response = @execution.response_hash
3
3
  audio_src = response["audio_url"] || response[:audio_url] ||
4
4
  response["audio_data_uri"] || response[:audio_data_uri]
5
5
  audio_format = response["format"] || response[:format] ||
@@ -3,14 +3,15 @@
3
3
  selected_agents = params[:agent_types].present? ? (params[:agent_types].is_a?(Array) ? params[:agent_types] : params[:agent_types].split(",")) : []
4
4
  selected_statuses = params[:statuses].present? ? (params[:statuses].is_a?(Array) ? params[:statuses] : params[:statuses].split(",")) : []
5
5
  selected_models = params[:model_ids].present? ? (params[:model_ids].is_a?(Array) ? params[:model_ids] : params[:model_ids].split(",")) : []
6
+ selected_tenants = params[:tenant_ids].present? ? (params[:tenant_ids].is_a?(Array) ? params[:tenant_ids] : params[:tenant_ids].split(",")) : []
6
7
 
7
- has_filters = selected_agents.any? || selected_statuses.any? || params[:days].present? || selected_models.any? || params[:tenant_id].present?
8
+ has_filters = selected_agents.any? || selected_statuses.any? || params[:days].present? || selected_models.any? || selected_tenants.any?
8
9
  active_filter_count = [
9
10
  selected_agents.any? ? 1 : 0,
10
11
  selected_statuses.any? ? 1 : 0,
11
12
  params[:days].present? ? 1 : 0,
12
13
  selected_models.any? ? 1 : 0,
13
- params[:tenant_id].present? ? 1 : 0
14
+ selected_tenants.any? ? 1 : 0
14
15
  ].sum
15
16
 
16
17
  status_options = [
@@ -89,11 +90,14 @@
89
90
  <%# Tenant Filter %>
90
91
  <% if tenant_filter_enabled? && available_tenants.any? %>
91
92
  <div class="md:w-auto">
92
- <%= render "ruby_llm/agents/shared/select_dropdown",
93
- name: "tenant_id",
94
- filter_id: "tenant_id",
95
- options: [{ value: "", label: "All Tenants" }] + available_tenants.map { |t| { value: t, label: t.truncate(15) } },
96
- selected: params[:tenant_id],
93
+ <%= render "ruby_llm/agents/shared/filter_dropdown",
94
+ name: "tenant_ids[]",
95
+ filter_id: "tenant_ids",
96
+ label: "Tenants",
97
+ all_label: "All Tenants",
98
+ options: available_tenants,
99
+ selected: selected_tenants,
100
+ searchable: true,
97
101
  icon: "M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4",
98
102
  width: "w-40",
99
103
  full_width: true %>
@@ -173,7 +177,7 @@
173
177
  <span class="md:hidden">Clear</span>
174
178
  <% end %>
175
179
  <% end %>
176
- <%= link_to ruby_llm_agents.export_executions_path(agent_types: selected_agents.presence, statuses: selected_statuses.presence, days: params[:days].presence, model_ids: selected_models.presence, tenant_id: params[:tenant_id].presence),
180
+ <%= link_to ruby_llm_agents.export_executions_path(agent_types: selected_agents.presence, statuses: selected_statuses.presence, days: params[:days].presence, model_ids: selected_models.presence, tenant_ids: selected_tenants.presence),
177
181
  class: "flex-1 md:flex-initial flex items-center justify-center gap-2 px-3 py-2 md:p-2 text-sm md:text-base text-gray-600 md:text-gray-400 dark:text-gray-300 md:dark:text-gray-400 bg-gray-50 md:bg-transparent dark:bg-gray-700 md:dark:bg-transparent hover:text-gray-600 md:hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600 md:dark:hover:bg-gray-700 rounded-lg transition-colors",
178
182
  title: "Export CSV" do %>
179
183
  <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -57,6 +57,16 @@
57
57
  </div>
58
58
  <% end %>
59
59
 
60
+ <!-- Soft-purge banner -->
61
+ <% if @execution.soft_purged? %>
62
+ <div class="flex items-start gap-2 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded px-3 py-2 mb-2">
63
+ <span class="text-amber-600 dark:text-amber-400 text-xs font-mono leading-5">
64
+ &#9888; Execution details were purged<% if @execution.soft_purged_at %> on <%= @execution.soft_purged_at.strftime("%b %d, %Y") %><% end %>.
65
+ Cost, token, and timing analytics are preserved.
66
+ </span>
67
+ </div>
68
+ <% end %>
69
+
60
70
  <!-- Stats inline row -->
61
71
  <div class="flex flex-wrap items-center gap-x-4 gap-y-1 font-mono text-xs text-gray-400 dark:text-gray-500 mb-2">
62
72
  <span><span class="text-gray-800 dark:text-gray-200"><%= number_to_human_short(@execution.duration_ms || 0) %>ms</span> duration</span>
@@ -70,8 +80,8 @@
70
80
  <!-- ── audio player ──────────────────── -->
71
81
  <% if @execution.agent_type.to_s.match?(/Speaker|Narrator|Transcriber/i) ||
72
82
  @execution.metadata&.dig("audio_duration_seconds").present? ||
73
- @execution.response&.dig("audio_data_uri").present? ||
74
- @execution.response&.dig("audio_url").present? %>
83
+ @execution.response_hash["audio_data_uri"].present? ||
84
+ @execution.response_hash["audio_url"].present? %>
75
85
  <%= render "ruby_llm/agents/executions/audio_player" %>
76
86
  <% end %>
77
87
 
@@ -254,16 +264,18 @@
254
264
  <% end %>
255
265
 
256
266
  <!-- ── parameters ──────────────────── -->
257
- <div class="flex items-center gap-3 mt-6 mb-3">
258
- <span class="text-[10px] font-medium text-gray-400 dark:text-gray-600 uppercase tracking-widest font-mono">parameters</span>
259
- <div class="flex-1 border-t border-gray-200 dark:border-gray-800"></div>
260
- <button
261
- type="button"
262
- data-copy-json="<%= Base64.strict_encode64(JSON.pretty_generate(@execution.parameters || {})) %>"
263
- class="copy-json-btn font-mono text-xs text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
264
- >copy</button>
265
- </div>
266
- <pre class="bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 rounded p-4 text-xs overflow-x-auto font-mono"><%= highlight_json(@execution.parameters || {}) %></pre>
267
+ <% if @execution.detail && @execution.parameters.present? %>
268
+ <div class="flex items-center gap-3 mt-6 mb-3">
269
+ <span class="text-[10px] font-medium text-gray-400 dark:text-gray-600 uppercase tracking-widest font-mono">parameters</span>
270
+ <div class="flex-1 border-t border-gray-200 dark:border-gray-800"></div>
271
+ <button
272
+ type="button"
273
+ data-copy-json="<%= Base64.strict_encode64(JSON.pretty_generate(@execution.parameters)) %>"
274
+ class="copy-json-btn font-mono text-xs text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
275
+ >copy</button>
276
+ </div>
277
+ <pre class="bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 rounded p-4 text-xs overflow-x-auto font-mono"><%= highlight_json(@execution.parameters) %></pre>
278
+ <% end %>
267
279
 
268
280
  <!-- ── response ──────────────────────── -->
269
281
  <% if @execution.response.present? %>
@@ -280,6 +292,7 @@
280
292
  <% end %>
281
293
 
282
294
  <!-- ── tool calls ──────────────────── -->
295
+ <% if @execution.detail %>
283
296
  <% tool_calls = @execution.tool_calls || [] %>
284
297
  <% tool_call_count = tool_calls.size %>
285
298
  <div x-data="{ expanded: <%= tool_call_count <= 3 && tool_call_count > 0 %> }">
@@ -374,6 +387,7 @@
374
387
  <p class="text-xs text-gray-400 dark:text-gray-600 font-mono italic">no tool calls</p>
375
388
  <% end %>
376
389
  </div>
390
+ <% end %>
377
391
 
378
392
  <!-- ── metadata ──────────────────────── -->
379
393
  <% if @execution.metadata.present? && @execution.metadata.any? %>
@@ -10,11 +10,13 @@
10
10
  # { value: "success", label: "Success", color: "bg-green-500" },
11
11
  # { value: "error", label: "Error", color: "bg-red-500" }
12
12
  # ],
13
- # selected: ["success"]
13
+ # selected: ["success"],
14
+ # searchable: true # renders a client-side search input above the options
14
15
 
15
16
  selected = local_assigns[:selected] || []
16
17
  show_all = local_assigns.fetch(:show_all_option, true)
17
18
  all_label = local_assigns[:all_label] || "All"
19
+ searchable = local_assigns.fetch(:searchable, false)
18
20
 
19
21
  current_label = if selected.empty?
20
22
  label
@@ -32,8 +34,31 @@
32
34
 
33
35
  has_selection = selected.any?
34
36
  %>
35
- <div class="relative" x-data="{ open: false }" @click.outside="open = false" data-filter="<%= filter_id %>">
36
- <button type="button" @click="open = !open"
37
+ <%#
38
+ Multi-select behavior:
39
+ - Checkbox click: toggle that option, mark dirty, keep dropdown open.
40
+ - Label click: clear other selections, check only this one, close, submit.
41
+ - Close dropdown (outside click, button toggle, Escape): if dirty, submit.
42
+ This batches checkbox picks into a single request when the user closes
43
+ the dropdown — no reliance on persisting open state across reload.
44
+ %>
45
+ <div class="relative"
46
+ x-data="{
47
+ open: false,
48
+ query: '',
49
+ dirty: false,
50
+ closeAndApply() {
51
+ this.open = false;
52
+ if (this.dirty) {
53
+ this.dirty = false;
54
+ this.$el.closest('form').requestSubmit();
55
+ }
56
+ }
57
+ }"
58
+ @click.outside="closeAndApply()"
59
+ @keydown.escape.window="if (open) closeAndApply()"
60
+ data-filter="<%= filter_id %>">
61
+ <button type="button" @click="open ? closeAndApply() : open = true"
37
62
  class="flex items-center gap-1.5 px-2 py-1 font-mono text-xs rounded
38
63
  hover:bg-gray-100 dark:hover:bg-gray-800/50 transition-colors
39
64
  <%= has_selection ? 'text-gray-900 dark:text-gray-100' : 'text-gray-400 dark:text-gray-500' %>">
@@ -50,7 +75,19 @@
50
75
  x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100"
51
76
  x-transition:leave="transition ease-in duration-75"
52
77
  x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
53
- class="absolute z-20 mt-1 min-w-max bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 max-h-64 overflow-y-auto">
78
+ class="absolute z-20 mt-1 min-w-max w-56 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1">
79
+
80
+ <% if searchable %>
81
+ <div class="px-2 py-1.5 border-b border-gray-100 dark:border-gray-700 sticky top-0 bg-white dark:bg-gray-800">
82
+ <input type="text" x-model="query" @click.stop
83
+ placeholder="Search…"
84
+ x-ref="<%= filter_id %>_search"
85
+ x-effect="if (open) $nextTick(() => $refs['<%= filter_id %>_search']?.focus())"
86
+ class="w-full px-2 py-1 text-xs font-mono rounded border border-gray-200 dark:border-gray-700 bg-transparent text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500">
87
+ </div>
88
+ <% end %>
89
+
90
+ <div class="max-h-56 overflow-y-auto">
54
91
 
55
92
  <% if show_all %>
56
93
  <div class="px-3 py-1.5 border-b border-gray-100 dark:border-gray-700">
@@ -60,7 +97,7 @@
60
97
  <%= selected.empty? ? 'checked' : '' %>
61
98
  @change="
62
99
  $el.closest('[data-filter]').querySelectorAll('.filter-cb').forEach(c => c.checked = false);
63
- $el.closest('form').requestSubmit();
100
+ dirty = true;
64
101
  ">
65
102
  <span class="text-gray-700 dark:text-gray-200"><%= all_label %></span>
66
103
  </label>
@@ -68,13 +105,14 @@
68
105
  <% end %>
69
106
 
70
107
  <% options.each do |option| %>
71
- <div class="flex items-center gap-2 px-3 py-1.5 font-mono text-xs text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700">
108
+ <div class="flex items-center gap-2 px-3 py-1.5 font-mono text-xs text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
109
+ <% if searchable %>x-show="query === '' || '<%= j(option[:label].to_s.downcase) %>'.includes(query.toLowerCase())"<% end %>>
72
110
  <input type="checkbox"
73
111
  name="<%= name %>"
74
112
  value="<%= option[:value] %>"
75
113
  class="filter-cb rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 dark:bg-gray-700"
76
114
  <%= selected.include?(option[:value].to_s) ? 'checked' : '' %>
77
- @change="$el.closest('form').requestSubmit()">
115
+ @change="dirty = true">
78
116
  <span class="flex-1 flex items-center gap-1.5 cursor-pointer"
79
117
  @click.prevent="
80
118
  $el.closest('[data-filter]').querySelectorAll('.filter-cb').forEach(c => c.checked = false);
@@ -89,5 +127,6 @@
89
127
  </span>
90
128
  </div>
91
129
  <% end %>
130
+ </div>
92
131
  </div>
93
132
  </div>
@@ -6,8 +6,8 @@
6
6
  class="bg-transparent border border-gray-200 dark:border-gray-800 rounded px-2 py-0.5 text-xs font-mono text-gray-900 dark:text-gray-100 focus:ring-1 focus:ring-gray-400 dark:focus:ring-gray-600 focus:border-gray-400 dark:focus:border-gray-600">
7
7
  <option value="">all tenants</option>
8
8
  <% available_tenants.each do |tenant| %>
9
- <option value="<%= tenant %>" <%= 'selected' if tenant == current_tenant_id %>>
10
- <%= tenant %>
9
+ <option value="<%= tenant[:value] %>" <%= 'selected' if tenant[:value] == current_tenant_id %>>
10
+ <%= tenant[:label] %>
11
11
  </option>
12
12
  <% end %>
13
13
  </select>