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 +4 -4
- data/README.md +99 -4
- data/app/controllers/rails_error_dashboard/errors_controller.rb +41 -0
- data/app/helpers/rails_error_dashboard/application_helper.rb +2 -0
- data/app/views/layouts/rails_error_dashboard.html.erb +307 -0
- data/app/views/rails_error_dashboard/errors/_ai_help_panel.html.erb +36 -0
- data/app/views/rails_error_dashboard/errors/_breadcrumbs_group.html.erb +63 -5
- data/app/views/rails_error_dashboard/errors/_llm_summary.html.erb +97 -0
- data/app/views/rails_error_dashboard/errors/show.html.erb +8 -0
- data/config/routes.rb +1 -0
- data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +27 -0
- data/lib/rails_error_dashboard/configuration.rb +101 -1
- data/lib/rails_error_dashboard/engine.rb +14 -0
- data/lib/rails_error_dashboard/integrations/llm_middleware.rb +276 -0
- data/lib/rails_error_dashboard/integrations/llm_span_processor.rb +181 -0
- data/lib/rails_error_dashboard/integrations/o_tel.rb +45 -0
- data/lib/rails_error_dashboard/services/llm_client.rb +368 -0
- data/lib/rails_error_dashboard/services/llm_cost_estimator.rb +85 -0
- data/lib/rails_error_dashboard/services/llm_summary.rb +91 -0
- data/lib/rails_error_dashboard/services/markdown_error_formatter.rb +87 -0
- data/lib/rails_error_dashboard/subscribers/llm_call_subscriber.rb +150 -0
- data/lib/rails_error_dashboard/value_objects/llm_call_event.rb +92 -0
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +8 -0
- metadata +12 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bfd6bfaddc3831f37ef4162c92037dddfb18129068e1ad279449b4fcdf164514
|
|
4
|
+
data.tar.gz: 031a14fd395298e46d95bcbb182c8333dab85d12c64439e7d840a577a7ced989
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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** · **
|
|
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
|

|
|
37
37
|
|
|
38
|
+
**AI Help** — Optional OpenAI or Anthropic assistance streamed directly inside the error detail page.
|
|
39
|
+
|
|
40
|
+

|
|
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
|

|
|
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
|

|
|
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
|
[](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),
|
|
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
|
|
@@ -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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
|
+
·
|
|
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>
|