rails_error_dashboard 0.6.4 → 0.7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3d2151cd1323d1261e27376d1d7d8eb9f47fcb6ec171fcc6c3dbab36fcf52f6a
4
- data.tar.gz: 68367fa080c8532c9e69f3aecf13eef84ef8638ac91f83a586cce56341f38b1c
3
+ metadata.gz: bfd6bfaddc3831f37ef4162c92037dddfb18129068e1ad279449b4fcdf164514
4
+ data.tar.gz: 031a14fd395298e46d95bcbb182c8333dab85d12c64439e7d840a577a7ced989
5
5
  SHA512:
6
- metadata.gz: fd3167ec31230e80ca89eb723b78f4d97f073a2c68efa4fbc9fe83e85bfa6b1361fdc43f1f822fb1eadd9d00254d12f105e863d09825843bdeb14386a1cef95a
7
- data.tar.gz: da46299157c3cc47a45e55d006ff6657f4d9728832fefc0c051b8e94c1c919be6320b22663742a94ebfd1f8df8cd93e19ac27e9d8e0f45e578a6a2f6d047712e
6
+ metadata.gz: dae9aaa0bebaf0daf71bd5b3e64590b19b5e48d5a032273304639f49535bc17d5eaa2991a460d9e97403cebcc4dc1f0e70baca2c5291c4c4191d1a5ae092bbf3
7
+ data.tar.gz: 6bc6d8a6fcf5cb0c262688102ec57cd79d24a8d87f3bed2417ef29d9b63102bc67937784217fbf4a54822b06b1691c333b9210f6bc77c1be49e6055a54559708
data/README.md CHANGED
@@ -13,7 +13,7 @@
13
13
  gem 'rails_error_dashboard'
14
14
  ```
15
15
 
16
- **5-minute setup** · **Works out-of-the-box** · **100% Rails + Postgres** · **No vendor lock-in**
16
+ **5-minute setup** · **Works out-of-the-box** · **Postgres, MySQL/Trilogy, SQLite** · **No vendor lock-in**
17
17
 
18
18
  [Full Documentation](https://anjanj.github.io/rails_error_dashboard/) · [Live Demo](https://rails-error-dashboard.anjan.dev) · [RubyGems](https://rubygems.org/gems/rails_error_dashboard)
19
19
 
@@ -35,6 +35,18 @@ gem 'rails_error_dashboard'
35
35
 
36
36
  ![Error Detail](docs/images/error-detail.png)
37
37
 
38
+ **AI Help** — Optional OpenAI or Anthropic assistance streamed directly inside the error detail page.
39
+
40
+ ![AI Help](docs/images/ai-help.png)
41
+
42
+ ---
43
+
44
+ ## From the Community
45
+
46
+ > All three [self-hosted alternatives] had an issue with error backtrace when using Turbo — RED did fix it… solid_errors and Faultline are not very active projects, RED is very active and @AnjanJ is very responsive in fixing issues. So, RED was my final choice.
47
+ >
48
+ > — **Gael Marziou** ([@gmarziou](https://github.com/gmarziou)) · [read the full discussion](https://github.com/AnjanJ/rails_error_dashboard/discussions/116)
49
+
38
50
  ---
39
51
 
40
52
  ## Who This Is For
@@ -124,7 +136,7 @@ Requires breadcrumbs to be enabled.
124
136
 
125
137
  ![Job Health](docs/images/job-health.png)
126
138
 
127
- **Database Health** — PgHero-style live PostgreSQL stats (table sizes, unused indexes, dead tuples, vacuum timestamps) plus historical connection pool data from error snapshots.
139
+ **Database Health** — PgHero-style live PostgreSQL stats (table sizes, unused indexes, dead tuples, vacuum timestamps) plus historical connection pool data from error snapshots. PostgreSQL-only for the system-table views; MySQL and SQLite show connection pool stats and hide the rest.
128
140
 
129
141
  ![Database Health](docs/images/database-health.png)
130
142
 
@@ -147,6 +159,79 @@ config.enable_activestorage_tracking = true # requires enable_breadcrumbs = tru
147
159
  [Complete documentation →](docs/FEATURES.md#job-health-page)
148
160
  </details>
149
161
 
162
+ <details>
163
+ <summary><strong>LLM Observability — Calls, Tokens, Cost, Tool Use</strong></summary>
164
+
165
+ Capture every LLM call your app makes — model, latency, token counts, estimated USD cost, and tool-use requests — as breadcrumbs on the error that follows. When a request crashes, you see the chat completion that preceded it: which model was called, how long it took, what it cost, and which tools it asked to invoke.
166
+
167
+ - Three capture paths — pick whichever matches your stack
168
+ - Cost estimated from a built-in pricing table (Claude 4.x, GPT-4o/o1, Gemini 2.5) — override per-model via `config.llm_pricing_overrides`
169
+ - Tool-call requests summarized inline; tool *execution* spans captured separately via the OTel path
170
+ - Content capture (prompts/completions) **OFF by default** — only token counts and metadata are recorded
171
+ - Same host-app safety guarantees as the rest of the gem — never raises, never blocks the request, every callback rescue-wrapped
172
+
173
+ ```ruby
174
+ config.enable_breadcrumbs = true # required — LLM crumbs ride the breadcrumb pipeline
175
+ config.enable_llm_observability = true
176
+ # Optional — override the built-in pricing table for your account
177
+ # config.llm_pricing_overrides = { "claude-sonnet-4-6" => { input: 3.0, output: 15.0 } }
178
+ ```
179
+
180
+ **Path A — `ruby-openai` (Faraday middleware)**
181
+
182
+ ```ruby
183
+ # Gemfile already has: gem "ruby-openai"
184
+ client = OpenAI::Client.new do |f|
185
+ f.use RailsErrorDashboard::Integrations::LlmMiddleware
186
+ end
187
+ ```
188
+
189
+ **Path B — `ruby_llm` (OpenTelemetry)**
190
+
191
+ `ruby_llm` doesn't expose a Faraday hook, but the thoughtbot OTel instrumentation gem emits GenAI-semconv spans that our SpanProcessor picks up automatically.
192
+
193
+ ```ruby
194
+ # Gemfile
195
+ gem "ruby_llm"
196
+ gem "opentelemetry-sdk"
197
+ gem "opentelemetry-instrumentation-ruby_llm"
198
+
199
+ # config/initializers/opentelemetry.rb
200
+ OpenTelemetry::SDK.configure do |c|
201
+ c.use "OpenTelemetry::Instrumentation::RubyLLM"
202
+ end
203
+ ```
204
+
205
+ The dashboard's `LlmSpanProcessor` registers itself with `OpenTelemetry.tracer_provider` during engine boot — no extra wiring.
206
+
207
+ **Path C — anything else (Anthropic official SDK, Net::HTTP, gRPC, Ollama, …)**
208
+
209
+ The official `anthropic` gem uses `Net::HTTP` directly (no Faraday hook), and many local-inference setups don't run OTel. Wrap any LLM call in `ActiveSupport::Notifications.instrument` — pass a mutable Hash so token counts can be filled in *after* the call:
210
+
211
+ ```ruby
212
+ payload = { provider: "anthropic", model: "claude-sonnet-4-6" }
213
+
214
+ ActiveSupport::Notifications.instrument("red.llm_call", payload) do
215
+ response = Anthropic::Client.new.messages.create(
216
+ model: "claude-sonnet-4-6",
217
+ messages: [ { role: "user", content: "hi" } ]
218
+ )
219
+ payload[:input_tokens] = response.usage.input_tokens
220
+ payload[:output_tokens] = response.usage.output_tokens
221
+ end
222
+
223
+ # Tool execution — captured as its own llm_tool breadcrumb
224
+ ActiveSupport::Notifications.instrument("red.llm_tool_call",
225
+ tool_name: "search_database",
226
+ tool_arguments: { query: "..." }
227
+ ) do
228
+ # run the tool
229
+ end
230
+ ```
231
+
232
+ Payload contract matches the `LlmCallEvent` value object — see [`docs/LLM_OBSERVABILITY.md`](docs/LLM_OBSERVABILITY.md) for the full field list.
233
+ </details>
234
+
150
235
  <details>
151
236
  <summary><strong>Issue Tracking — GitHub, GitLab, Codeberg</strong></summary>
152
237
 
@@ -234,10 +319,20 @@ config.enable_source_code_integration = true # required for source code viewer
234
319
  </details>
235
320
 
236
321
  <details>
237
- <summary><strong>Error Replay — Copy as cURL / RSpec / LLM Markdown</strong></summary>
322
+ <summary><strong>AI Help + Error Replay — Ask, Copy as cURL / RSpec / LLM Markdown</strong></summary>
238
323
 
239
324
  Replay failing requests with one click. Copy the request as a cURL command, generate an RSpec test, or **copy all error details as clean Markdown** for pasting into an LLM session. The LLM export includes app backtrace, cause chain, local/instance variables, breadcrumbs, environment, system health, and related errors — with framework frames filtered and sensitive data preserved as `[FILTERED]`.
240
325
 
326
+ When an LLM provider is configured, the error detail page also shows an **AI Help** drawer. Users can ask follow-up questions about the current error and receive streamed Markdown answers from OpenAI or Anthropic without leaving the dashboard.
327
+
328
+ ```ruby
329
+ config.llm_provider = :openai # or :anthropic
330
+ config.llm_api_key = -> { Rails.application.credentials.dig(:openai, :api_key) }
331
+ config.llm_model = "gpt-5"
332
+ ```
333
+
334
+ > **Privacy:** AI Help sends the error's details (backtrace, context, and your question) to the configured provider (OpenAI or Anthropic). Keep `config.filter_sensitive_data = true` (the default) so sensitive values are redacted as `[FILTERED]` before they leave your app.
335
+
241
336
  [Complete documentation →](docs/FEATURES.md#error-details-page)
242
337
  </details>
243
338
 
@@ -527,7 +622,7 @@ Built with [Rails](https://rubyonrails.org/) · Custom design tokens with [Boots
527
622
 
528
623
  [![Contributors](https://contrib.rocks/image?repo=AnjanJ/rails_error_dashboard)](https://github.com/AnjanJ/rails_error_dashboard/graphs/contributors)
529
624
 
530
- Special thanks to [@bonniesimon](https://github.com/bonniesimon), [@gundestrup](https://github.com/gundestrup), [@midwire](https://github.com/midwire), [@RafaelTurtle](https://github.com/RafaelTurtle), [@j4rs](https://github.com/j4rs), and [@gmarziou](https://github.com/gmarziou). See [CONTRIBUTORS.md](CONTRIBUTORS.md) for the full list.
625
+ Special thanks to [@bonniesimon](https://github.com/bonniesimon), [@gundestrup](https://github.com/gundestrup), [@midwire](https://github.com/midwire), [@RafaelTurtle](https://github.com/RafaelTurtle), [@j4rs](https://github.com/j4rs), [@gmarziou](https://github.com/gmarziou), and [@antarr](https://github.com/antarr). See [CONTRIBUTORS.md](CONTRIBUTORS.md) for the full list.
531
626
 
532
627
  ---
533
628
 
@@ -173,6 +173,43 @@ module RailsErrorDashboard
173
173
  redirect_to error_path(params[:id], anchor: "issue-tracking", **app_context_params)
174
174
  end
175
175
 
176
+ def ai_help
177
+ unless RailsErrorDashboard.configuration.llm_configured?
178
+ render json: { error: "AI Help is not configured" }, status: :not_found
179
+ return
180
+ end
181
+
182
+ question = params[:question].to_s.strip
183
+ if question.blank?
184
+ render json: { error: "Question cannot be blank" }, status: :unprocessable_entity
185
+ return
186
+ end
187
+
188
+ if question.length > 4000
189
+ render json: { error: "Question is too long. Keep it under 4,000 characters." }, status: :unprocessable_entity
190
+ return
191
+ end
192
+
193
+ error = ErrorLog.includes(:comments, :parent_cascade_patterns, :child_cascade_patterns).find(params[:id])
194
+ related_errors = error.related_errors(limit: 5, application_id: @current_application_id)
195
+ context = Services::MarkdownErrorFormatter.call(error, related_errors: related_errors)
196
+
197
+ response.headers["Content-Type"] = "text/event-stream"
198
+ response.headers["Cache-Control"] = "no-cache"
199
+ response.headers["X-Accel-Buffering"] = "no"
200
+
201
+ self.response_body = Enumerator.new do |stream|
202
+ begin
203
+ result = Services::LlmClient.stream(error: error, question: question, context: context) do |text|
204
+ stream << sse_event("chunk", text: text)
205
+ end
206
+ stream << sse_event("done", result)
207
+ rescue Services::LlmClient::ConfigurationError, Services::LlmClient::RequestError => e
208
+ stream << sse_event("error", error: e.message)
209
+ end
210
+ end
211
+ end
212
+
176
213
  def analytics
177
214
  days = days_param(default: 30)
178
215
  @days = days
@@ -609,6 +646,10 @@ module RailsErrorDashboard
609
646
  }
610
647
  end
611
648
 
649
+ def sse_event(event, payload)
650
+ "event: #{event}\ndata: #{payload.to_json}\n\n"
651
+ end
652
+
612
653
  def filter_params
613
654
  params.permit(*FILTERABLE_PARAMS).to_h.symbolize_keys
614
655
  end
@@ -263,6 +263,8 @@ module RailsErrorDashboard
263
263
  when "mailer" then "secondary"
264
264
  when "custom" then "dark"
265
265
  when "deprecation" then "danger"
266
+ when "llm" then "info"
267
+ when "llm_tool" then "warning"
266
268
  else "light"
267
269
  end
268
270
  end
@@ -521,6 +521,48 @@ dd { color: var(--text-primary); }
521
521
  .offcanvas-body { flex-grow: 1; padding: var(--space-4); overflow-y: auto; }
522
522
  .offcanvas-backdrop { position: fixed; top: 0; left: 0; z-index: 1040; width: 100vw; height: 100vh; background: rgba(0,0,0,0.5); }
523
523
 
524
+ .red-ai-help-backdrop { position: fixed; inset: 0; z-index: 1050; background: rgba(15, 23, 42, 0.42); }
525
+ .red-ai-help-panel {
526
+ position: fixed; top: 0; right: 0; bottom: 0; z-index: 1055;
527
+ display: flex; flex-direction: column; width: min(460px, 100vw);
528
+ background: var(--surface-primary); border-left: 1px solid var(--border-primary);
529
+ box-shadow: var(--shadow-lg); transform: translateX(100%);
530
+ transition: transform var(--transition-normal); visibility: hidden;
531
+ }
532
+ .red-ai-help-panel.is-open { transform: translateX(0); visibility: visible; }
533
+ .red-ai-help-header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); padding: var(--space-5); border-bottom: 1px solid var(--border-primary); }
534
+ .red-ai-help-title { display: flex; align-items: center; gap: 8px; font-size: 15px; font-weight: 700; color: var(--text-primary); }
535
+ .red-ai-help-title i { color: var(--accent); }
536
+ .red-ai-help-subtitle { margin-top: 3px; font-size: 12px; color: var(--text-tertiary); }
537
+ .red-ai-help-messages { flex: 1; display: flex; flex-direction: column; gap: var(--space-3); padding: var(--space-4); overflow-y: auto; }
538
+ .red-ai-help-empty { padding: var(--space-4); border: 1px dashed var(--border-primary); border-radius: var(--radius-md); color: var(--text-secondary); font-size: 13px; }
539
+ .red-ai-help-message { border: 1px solid var(--border-primary); border-radius: var(--radius-md); padding: var(--space-3); font-size: 13px; line-height: 1.55; white-space: pre-wrap; overflow-wrap: anywhere; }
540
+ .red-ai-help-message.user { align-self: flex-end; max-width: 88%; background: var(--accent-subtle); border-color: color-mix(in oklch, var(--accent) 35%, var(--border-primary)); color: var(--text-primary); }
541
+ .red-ai-help-message.assistant { background: var(--surface-base); color: var(--text-primary); white-space: normal; }
542
+ .red-ai-help-message.assistant > *:first-child { margin-top: 0; }
543
+ .red-ai-help-message.assistant > *:last-child { margin-bottom: 0; }
544
+ .red-ai-help-message.assistant p { margin: 0 0 var(--space-2); }
545
+ .red-ai-help-message.assistant h1,
546
+ .red-ai-help-message.assistant h2,
547
+ .red-ai-help-message.assistant h3 { margin: var(--space-3) 0 var(--space-2); font-size: 14px; line-height: 1.35; }
548
+ .red-ai-help-message.assistant ul,
549
+ .red-ai-help-message.assistant ol { margin: 0 0 var(--space-2) var(--space-5); padding: 0; }
550
+ .red-ai-help-message.assistant li { margin: 2px 0; }
551
+ .red-ai-help-message.assistant pre { margin: var(--space-2) 0; padding: var(--space-3); max-width: 100%; overflow-x: auto; background: var(--surface-secondary); border: 1px solid var(--border-primary); }
552
+ .red-ai-help-message.assistant pre code { color: var(--text-primary); white-space: pre; }
553
+ .red-ai-help-message.assistant code { font-size: 12px; overflow-wrap: anywhere; }
554
+ .red-ai-help-message.assistant blockquote { margin: var(--space-2) 0; padding-left: var(--space-3); border-left: 3px solid var(--border-primary); color: var(--text-secondary); }
555
+ .red-ai-help-message.assistant a { overflow-wrap: anywhere; }
556
+ .red-ai-help-message.error { background: var(--status-critical-bg); border-color: var(--status-critical); color: var(--status-critical); }
557
+ .red-ai-help-form { display: flex; flex-direction: column; gap: var(--space-2); padding: var(--space-4); border-top: 1px solid var(--border-primary); background: var(--surface-primary); }
558
+ .red-ai-help-form textarea { width: 100%; resize: vertical; min-height: 92px; max-height: 220px; padding: var(--space-3); border: 1px solid var(--border-primary); border-radius: var(--radius-md); background: var(--surface-base); color: var(--text-primary); font: inherit; outline: none; }
559
+ .red-ai-help-form textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-subtle); }
560
+ .red-ai-help-footer { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); }
561
+ .red-ai-help-status { color: var(--text-tertiary); font-size: 12px; min-height: 18px; }
562
+ @media (max-width: 575.98px) {
563
+ .red-ai-help-panel { width: 100vw; }
564
+ }
565
+
524
566
  /* Nav */
525
567
  .nav { display: flex; flex-direction: column; list-style: none; padding: 0; margin: 0; }
526
568
  .nav-item { }
@@ -1611,6 +1653,252 @@ document.addEventListener('DOMContentLoaded', function() {
1611
1653
  return null;
1612
1654
  }
1613
1655
 
1656
+ function setAiHelpOpen(open) {
1657
+ var panel = document.getElementById('red-ai-help-panel');
1658
+ var backdrop = document.getElementById('red-ai-help-backdrop');
1659
+ if (!panel || !backdrop) return;
1660
+
1661
+ panel.classList.toggle('is-open', open);
1662
+ panel.setAttribute('aria-hidden', open ? 'false' : 'true');
1663
+ backdrop.hidden = !open;
1664
+
1665
+ if (open) {
1666
+ var input = document.getElementById('red-ai-help-question');
1667
+ if (input) setTimeout(function() { input.focus(); }, 150);
1668
+ }
1669
+ }
1670
+
1671
+ function appendAiHelpMessage(role, text) {
1672
+ var messages = document.getElementById('red-ai-help-messages');
1673
+ if (!messages) return null;
1674
+
1675
+ var empty = messages.querySelector('.red-ai-help-empty');
1676
+ if (empty) empty.remove();
1677
+
1678
+ var message = document.createElement('div');
1679
+ message.className = 'red-ai-help-message ' + role;
1680
+ if (role === 'assistant') {
1681
+ message.dataset.markdown = text || '';
1682
+ renderAiHelpMarkdown(message);
1683
+ } else {
1684
+ message.textContent = text;
1685
+ }
1686
+ messages.appendChild(message);
1687
+ messages.scrollTop = messages.scrollHeight;
1688
+ return message;
1689
+ }
1690
+
1691
+ function escapeAiHelpHtml(text) {
1692
+ return String(text || '').replace(/[&<>"']/g, function(ch) {
1693
+ return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ch];
1694
+ });
1695
+ }
1696
+
1697
+ function renderAiHelpInlineMarkdown(text) {
1698
+ var escaped = escapeAiHelpHtml(text);
1699
+ escaped = escaped.replace(/`([^`]+)`/g, '<code>$1</code>');
1700
+ escaped = escaped.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
1701
+ escaped = escaped.replace(/\*([^*\n]+)\*/g, '<em>$1</em>');
1702
+ return escaped.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, function(_, label, url) {
1703
+ return '<a href="' + url + '" target="_blank" rel="noopener">' + label + '</a>';
1704
+ });
1705
+ }
1706
+
1707
+ function renderAiHelpMarkdown(message) {
1708
+ var markdown = message.dataset.markdown || '';
1709
+ var lines = markdown.replace(/\r\n/g, '\n').split('\n');
1710
+ var html = [];
1711
+ var paragraph = [];
1712
+ var listType = null;
1713
+ var inCode = false;
1714
+ var codeLines = [];
1715
+
1716
+ function flushParagraph() {
1717
+ if (!paragraph.length) return;
1718
+ html.push('<p>' + renderAiHelpInlineMarkdown(paragraph.join(' ')) + '</p>');
1719
+ paragraph = [];
1720
+ }
1721
+
1722
+ function flushList() {
1723
+ if (!listType) return;
1724
+ html.push('</' + listType + '>');
1725
+ listType = null;
1726
+ }
1727
+
1728
+ lines.forEach(function(line) {
1729
+ var codeFence = line.match(/^```/);
1730
+ if (codeFence) {
1731
+ if (inCode) {
1732
+ html.push('<pre><code>' + escapeAiHelpHtml(codeLines.join('\n')) + '</code></pre>');
1733
+ codeLines = [];
1734
+ inCode = false;
1735
+ } else {
1736
+ flushParagraph();
1737
+ flushList();
1738
+ inCode = true;
1739
+ }
1740
+ return;
1741
+ }
1742
+
1743
+ if (inCode) {
1744
+ codeLines.push(line);
1745
+ return;
1746
+ }
1747
+
1748
+ if (/^\s*$/.test(line)) {
1749
+ flushParagraph();
1750
+ flushList();
1751
+ return;
1752
+ }
1753
+
1754
+ var heading = line.match(/^(#{1,3})\s+(.+)$/);
1755
+ if (heading) {
1756
+ flushParagraph();
1757
+ flushList();
1758
+ html.push('<h' + heading[1].length + '>' + renderAiHelpInlineMarkdown(heading[2]) + '</h' + heading[1].length + '>');
1759
+ return;
1760
+ }
1761
+
1762
+ var unordered = line.match(/^\s*[-*]\s+(.+)$/);
1763
+ var ordered = line.match(/^\s*\d+\.\s+(.+)$/);
1764
+ if (unordered || ordered) {
1765
+ flushParagraph();
1766
+ var desiredList = unordered ? 'ul' : 'ol';
1767
+ if (listType !== desiredList) {
1768
+ flushList();
1769
+ html.push('<' + desiredList + '>');
1770
+ listType = desiredList;
1771
+ }
1772
+ html.push('<li>' + renderAiHelpInlineMarkdown((unordered || ordered)[1]) + '</li>');
1773
+ return;
1774
+ }
1775
+
1776
+ var quote = line.match(/^>\s?(.+)$/);
1777
+ if (quote) {
1778
+ flushParagraph();
1779
+ flushList();
1780
+ html.push('<blockquote>' + renderAiHelpInlineMarkdown(quote[1]) + '</blockquote>');
1781
+ return;
1782
+ }
1783
+
1784
+ paragraph.push(line.trim());
1785
+ });
1786
+
1787
+ if (inCode) {
1788
+ html.push('<pre><code>' + escapeAiHelpHtml(codeLines.join('\n')) + '</code></pre>');
1789
+ }
1790
+ flushParagraph();
1791
+ flushList();
1792
+
1793
+ message.innerHTML = html.join('') || '<p></p>';
1794
+ }
1795
+
1796
+ function submitAiHelpQuestion(form) {
1797
+ var input = document.getElementById('red-ai-help-question');
1798
+ var status = document.getElementById('red-ai-help-status');
1799
+ var button = form.querySelector('button[type="submit"]');
1800
+ var question = input ? input.value.trim() : '';
1801
+ if (!question) {
1802
+ if (status) status.textContent = 'Enter a question first.';
1803
+ return;
1804
+ }
1805
+
1806
+ appendAiHelpMessage('user', question);
1807
+ if (input) input.value = '';
1808
+ if (status) status.textContent = 'Streaming...';
1809
+ if (button) button.disabled = true;
1810
+
1811
+ var tokenMeta = document.querySelector('meta[name="csrf-token"]');
1812
+ fetch(form.dataset.aiHelpUrl, {
1813
+ method: 'POST',
1814
+ headers: {
1815
+ 'Content-Type': 'application/json',
1816
+ 'Accept': 'application/json',
1817
+ 'X-CSRF-Token': tokenMeta ? tokenMeta.content : ''
1818
+ },
1819
+ body: JSON.stringify({ question: question })
1820
+ }).then(function(response) {
1821
+ if (!response.ok) {
1822
+ return response.json().then(function(data) {
1823
+ throw new Error(data.error || 'AI Help request failed.');
1824
+ });
1825
+ }
1826
+ if (!response.body || !window.TextDecoder) {
1827
+ throw new Error('Streaming is not supported by this browser.');
1828
+ }
1829
+
1830
+ var assistantMessage = appendAiHelpMessage('assistant', '');
1831
+ var reader = response.body.getReader();
1832
+ var decoder = new TextDecoder();
1833
+ var buffer = '';
1834
+ var streamError = null;
1835
+
1836
+ function processEvent(rawEvent) {
1837
+ var eventName = 'message';
1838
+ var dataLines = [];
1839
+
1840
+ rawEvent.split('\n').forEach(function(line) {
1841
+ if (!line || line.charAt(0) === ':') return;
1842
+ var separator = line.indexOf(':');
1843
+ var field = separator === -1 ? line : line.slice(0, separator);
1844
+ var value = separator === -1 ? '' : line.slice(separator + 1).replace(/^ /, '');
1845
+ if (field === 'event') eventName = value;
1846
+ if (field === 'data') dataLines.push(value);
1847
+ });
1848
+
1849
+ if (!dataLines.length) return;
1850
+ var data = JSON.parse(dataLines.join('\n'));
1851
+
1852
+ if (eventName === 'chunk') {
1853
+ assistantMessage.dataset.markdown = (assistantMessage.dataset.markdown || '') + (data.text || '');
1854
+ renderAiHelpMarkdown(assistantMessage);
1855
+ var messages = document.getElementById('red-ai-help-messages');
1856
+ if (messages) messages.scrollTop = messages.scrollHeight;
1857
+ } else if (eventName === 'done') {
1858
+ if (status) status.textContent = data.provider && data.model ? data.provider + ' - ' + data.model : '';
1859
+ } else if (eventName === 'error') {
1860
+ streamError = data.error || 'AI Help request failed.';
1861
+ }
1862
+ }
1863
+
1864
+ function processBuffer(finalChunk) {
1865
+ var separator;
1866
+ while ((separator = buffer.indexOf('\n\n')) !== -1) {
1867
+ processEvent(buffer.slice(0, separator));
1868
+ buffer = buffer.slice(separator + 2);
1869
+ }
1870
+ if (finalChunk && buffer.trim()) {
1871
+ processEvent(buffer);
1872
+ buffer = '';
1873
+ }
1874
+ }
1875
+
1876
+ function readNext() {
1877
+ return reader.read().then(function(result) {
1878
+ if (result.done) {
1879
+ buffer += decoder.decode();
1880
+ processBuffer(true);
1881
+ if (streamError) throw new Error(streamError);
1882
+ return;
1883
+ }
1884
+
1885
+ buffer += decoder.decode(result.value, { stream: true }).replace(/\r\n/g, '\n');
1886
+ processBuffer(false);
1887
+ if (streamError) throw new Error(streamError);
1888
+ return readNext();
1889
+ });
1890
+ }
1891
+
1892
+ return readNext();
1893
+ }).catch(function(error) {
1894
+ appendAiHelpMessage('error', error.message);
1895
+ if (status) status.textContent = 'Request failed.';
1896
+ }).finally(function() {
1897
+ if (button) button.disabled = false;
1898
+ if (input) input.focus();
1899
+ });
1900
+ }
1901
+
1614
1902
  document.addEventListener('click', function(e) {
1615
1903
  // Row navigation: <tr data-red-row-href="/path"> — clicking anywhere on the row
1616
1904
  // navigates, except when clicking inside a checkbox cell or interactive elements.
@@ -1656,6 +1944,12 @@ document.addEventListener('DOMContentLoaded', function() {
1656
1944
  window.downloadErrorJSON({ currentTarget: actionEl });
1657
1945
  }
1658
1946
  break;
1947
+ case 'open-ai-help':
1948
+ setAiHelpOpen(true);
1949
+ break;
1950
+ case 'close-ai-help':
1951
+ setAiHelpOpen(false);
1952
+ break;
1659
1953
  case 'switch-tab':
1660
1954
  if (typeof window.switchTab === 'function' && actionEl.dataset.redTab) {
1661
1955
  window.switchTab(actionEl.dataset.redTab);
@@ -1700,6 +1994,19 @@ document.addEventListener('DOMContentLoaded', function() {
1700
1994
  break;
1701
1995
  }
1702
1996
  });
1997
+
1998
+ document.addEventListener('submit', function(e) {
1999
+ if (e.target && e.target.id === 'red-ai-help-form') {
2000
+ e.preventDefault();
2001
+ submitAiHelpQuestion(e.target);
2002
+ }
2003
+ });
2004
+
2005
+ document.addEventListener('keydown', function(e) {
2006
+ if (e.key === 'Escape') {
2007
+ setAiHelpOpen(false);
2008
+ }
2009
+ });
1703
2010
  });
1704
2011
  </script>
1705
2012
 
@@ -0,0 +1,36 @@
1
+ <div id="red-ai-help-backdrop" class="red-ai-help-backdrop" data-red-action="close-ai-help" hidden></div>
2
+
3
+ <aside id="red-ai-help-panel" class="red-ai-help-panel" aria-labelledby="red-ai-help-title" aria-hidden="true">
4
+ <div class="red-ai-help-header">
5
+ <div>
6
+ <div id="red-ai-help-title" class="red-ai-help-title">
7
+ <i class="bi bi-stars"></i>
8
+ AI Help
9
+ </div>
10
+ <div class="red-ai-help-subtitle">
11
+ <%= RailsErrorDashboard.configuration.effective_llm_provider.to_s.titleize %>
12
+ &middot;
13
+ <%= RailsErrorDashboard.configuration.effective_llm_model %>
14
+ </div>
15
+ </div>
16
+ <button type="button" class="btn-close" data-red-action="close-ai-help" aria-label="Close AI Help"></button>
17
+ </div>
18
+
19
+ <div id="red-ai-help-messages" class="red-ai-help-messages" aria-live="polite">
20
+ <div class="red-ai-help-empty">
21
+ Ask about root cause, reproduction steps, likely fixes, or what to inspect next.
22
+ </div>
23
+ </div>
24
+
25
+ <form id="red-ai-help-form" class="red-ai-help-form" data-ai-help-url="<%= ai_help_error_path(error, **app_context) %>">
26
+ <label for="red-ai-help-question">Question</label>
27
+ <textarea id="red-ai-help-question" name="question" rows="4" maxlength="4000" placeholder="What is the likely root cause?"></textarea>
28
+ <div class="red-ai-help-footer">
29
+ <span id="red-ai-help-status" class="red-ai-help-status"></span>
30
+ <button type="submit" class="btn btn-primary">
31
+ <i class="bi bi-send"></i>
32
+ Ask
33
+ </button>
34
+ </div>
35
+ </form>
36
+ </aside>