bugsage 0.2.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 +7 -0
- data/ARCHITECTURE.md +442 -0
- data/CHANGELOG.md +28 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/CONTRIBUTING.md +301 -0
- data/LICENSE.txt +21 -0
- data/README.md +344 -0
- data/ROADMAP.md +217 -0
- data/SECURITY.md +83 -0
- data/docs/AI.md +167 -0
- data/docs/Configuration.md +168 -0
- data/docs/GettingStarted.md +146 -0
- data/docs/RELEASE_CHECKLIST.md +346 -0
- data/docs/Troubleshooting.md +181 -0
- data/docs/images/BadRequest.png +0 -0
- data/docs/images/BadRequest2.png +0 -0
- data/docs/images/BugSage_Logo.png +0 -0
- data/docs/images/BugSage_Social_Preview.png +0 -0
- data/docs/images/NoMethodError.png +0 -0
- data/docs/images/NoMethodError2.png +0 -0
- data/docs/images/README.md +38 -0
- data/docs/releases/README.md +21 -0
- data/docs/releases/v0.2.0.md +40 -0
- data/exe/bugsage +7 -0
- data/lib/bugsage/ai_analyzer.rb +145 -0
- data/lib/bugsage/ai_chat.rb +388 -0
- data/lib/bugsage/ai_context.rb +69 -0
- data/lib/bugsage/ai_panel.rb +708 -0
- data/lib/bugsage/ai_support.rb +36 -0
- data/lib/bugsage/auto_configurator.rb +97 -0
- data/lib/bugsage/cli.rb +59 -0
- data/lib/bugsage/code_context.rb +81 -0
- data/lib/bugsage/code_patch.rb +147 -0
- data/lib/bugsage/configuration.rb +149 -0
- data/lib/bugsage/console_context.rb +51 -0
- data/lib/bugsage/cursor_client.rb +165 -0
- data/lib/bugsage/dashboard.rb +627 -0
- data/lib/bugsage/editor_links.rb +34 -0
- data/lib/bugsage/error_page.rb +298 -0
- data/lib/bugsage/exception_handler.rb +66 -0
- data/lib/bugsage/exception_support.rb +95 -0
- data/lib/bugsage/exceptions_app.rb +31 -0
- data/lib/bugsage/fix_applicator.rb +107 -0
- data/lib/bugsage/formatter.rb +37 -0
- data/lib/bugsage/http_error_capture.rb +104 -0
- data/lib/bugsage/http_response_error.rb +14 -0
- data/lib/bugsage/inline_console.rb +226 -0
- data/lib/bugsage/installation.rb +94 -0
- data/lib/bugsage/installer.rb +66 -0
- data/lib/bugsage/json_endpoint.rb +34 -0
- data/lib/bugsage/locales/en.yml +439 -0
- data/lib/bugsage/middleware.rb +152 -0
- data/lib/bugsage/openai_client.rb +103 -0
- data/lib/bugsage/page_actions.rb +255 -0
- data/lib/bugsage/railtie.rb +49 -0
- data/lib/bugsage/request_context.rb +56 -0
- data/lib/bugsage/rule.rb +271 -0
- data/lib/bugsage/session_clear.rb +35 -0
- data/lib/bugsage/store.rb +60 -0
- data/lib/bugsage/suggestion.rb +58 -0
- data/lib/bugsage/trace_cleaner.rb +24 -0
- data/lib/bugsage/translations.rb +85 -0
- data/lib/bugsage/version.rb +5 -0
- data/lib/bugsage.rb +65 -0
- data/lib/generators/bugsage/install/install_generator.rb +21 -0
- data/lib/generators/bugsage/install/templates/bugsage.rb +22 -0
- data/scripts/publish-github-release.sh +38 -0
- data/sig/bugsage.rbs +4 -0
- metadata +157 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Image assets
|
|
2
|
+
|
|
3
|
+
## Logo & social
|
|
4
|
+
|
|
5
|
+
| File | Shows |
|
|
6
|
+
|------|--------|
|
|
7
|
+
| `BugSage_Logo.png` | Project logo / wordmark banner (magnifying-glass bug + BugSage) |
|
|
8
|
+
| `BugSage_Social_Preview.png` | GitHub / social Open Graph image — logo, tagline, feature grid, dashboard preview |
|
|
9
|
+
|
|
10
|
+
### Set the GitHub Social Preview
|
|
11
|
+
|
|
12
|
+
GitHub does not auto-detect this file. After committing:
|
|
13
|
+
|
|
14
|
+
1. Open [Repository Settings → General → Social preview](https://github.com/MONARCHKOLI/bugsage/settings)
|
|
15
|
+
2. Upload `docs/images/BugSage_Social_Preview.png`
|
|
16
|
+
3. Save
|
|
17
|
+
|
|
18
|
+
GitHub recommends about **1280×640** px (minimum ~640×320). This asset also works when sharing the repo on X, LinkedIn, and Slack.
|
|
19
|
+
|
|
20
|
+
## Screenshots
|
|
21
|
+
|
|
22
|
+
PNG captures used by the root [README.md](../README.md) **Screenshots** section. Most were taken from [`examples/sample_app`](../examples/sample_app).
|
|
23
|
+
|
|
24
|
+
| File | Shows |
|
|
25
|
+
|------|--------|
|
|
26
|
+
| `NoMethodError2.png` | Exception page for `GET /boom` |
|
|
27
|
+
| `NoMethodError.png` | Dashboard detail + AI Suggestions for `NoMethodError` |
|
|
28
|
+
| `BadRequest.png` | Dashboard detail for captured `HTTP 400 Response` |
|
|
29
|
+
| `BadRequest2.png` | Raw JSON body still returned by `GET /bad_request` |
|
|
30
|
+
|
|
31
|
+
## Still useful to add
|
|
32
|
+
|
|
33
|
+
| File | Suggested capture |
|
|
34
|
+
|------|-------------------|
|
|
35
|
+
| `cli-usage.png` | Terminal output for `bugsage version` / `bugsage install` |
|
|
36
|
+
| `ai-suggestions-chat.png` | Optional: Quick Fix result + follow-up chat with code patch |
|
|
37
|
+
|
|
38
|
+
Tips: crop to the relevant UI, avoid secrets, keep width readable on GitHub (~1200–1600px).
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# BugSage release notes
|
|
2
|
+
|
|
3
|
+
Human-readable GitHub Release bodies live here. Create one file per version:
|
|
4
|
+
|
|
5
|
+
```text
|
|
6
|
+
docs/releases/vX.Y.Z.md
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Publish with:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
gh release create vX.Y.Z \
|
|
13
|
+
--title "BugSage vX.Y.Z" \
|
|
14
|
+
--notes-file docs/releases/vX.Y.Z.md
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
See [RELEASE_CHECKLIST.md](../RELEASE_CHECKLIST.md) § GitHub Release.
|
|
18
|
+
|
|
19
|
+
## Releases
|
|
20
|
+
|
|
21
|
+
- [v0.2.0](v0.2.0.md)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# BugSage v0.2.0
|
|
2
|
+
|
|
3
|
+
**Release date:** 2026-07-13
|
|
4
|
+
|
|
5
|
+
## What's New
|
|
6
|
+
|
|
7
|
+
- AI-powered Rails exception analysis (OpenAI / Cursor on demand, Quick Fix + chat, surgical code patches)
|
|
8
|
+
- Dashboard improvements (`/bugsage` session UI, fix actions, clear session)
|
|
9
|
+
- Better configuration (`config.bugsage`, env-based enablement, safer environment defaults)
|
|
10
|
+
- Zero-config Rails install (`AutoConfigurator`, `bugsage install`, optional generator)
|
|
11
|
+
- Inline Rails console on the error page and dashboard
|
|
12
|
+
- Improved documentation (README, Getting Started, Configuration, AI, Troubleshooting, screenshots)
|
|
13
|
+
- GitHub Actions CI (RSpec, RuboCop, bundler-audit, gem build)
|
|
14
|
+
- Security policy (`SECURITY.md`)
|
|
15
|
+
- Architecture documentation (`ARCHITECTURE.md`) and roadmap (`ROADMAP.md`)
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
# Gemfile
|
|
21
|
+
gem "bugsage", "~> 0.2.0"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
bundle install
|
|
26
|
+
bin/rails server
|
|
27
|
+
# Trigger an error in development, then open http://localhost:3000/bugsage
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Optional AI keys:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
export OPENAI_API_KEY=sk-...
|
|
34
|
+
# or
|
|
35
|
+
export CURSOR_API_KEY=crsr_...
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Full changelog
|
|
39
|
+
|
|
40
|
+
See [CHANGELOG.md — 0.2.0](../../CHANGELOG.md#020---2026-07-13).
|
data/exe/bugsage
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Bugsage
|
|
6
|
+
class AiAnalyzer
|
|
7
|
+
SYSTEM_PROMPT = <<~PROMPT
|
|
8
|
+
You are BugSage, a Ruby on Rails debugging assistant.
|
|
9
|
+
Refine the provided rule-based analysis using the exception details and request context.
|
|
10
|
+
Respond with JSON only using this schema:
|
|
11
|
+
{
|
|
12
|
+
"root_cause": "string",
|
|
13
|
+
"fixes": ["string", "string"],
|
|
14
|
+
"confidence": 0,
|
|
15
|
+
"notes": "string",
|
|
16
|
+
"code_patch": {
|
|
17
|
+
"action": "replace_lines | delete_lines | insert_before | no_change",
|
|
18
|
+
"start_line": 0,
|
|
19
|
+
"end_line": 0,
|
|
20
|
+
"replacement": "string"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
Keep fixes concrete and actionable. Confidence must be an integer from 0 to 100.
|
|
24
|
+
|
|
25
|
+
You receive numbered source code from the real file. Rules for code_patch:
|
|
26
|
+
- Read the FULL source context before suggesting changes.
|
|
27
|
+
- Use absolute line numbers from the numbered source (the line marked with >>).
|
|
28
|
+
- NEVER duplicate code that already exists in the file.
|
|
29
|
+
- Change ONLY the lines required to fix the error.
|
|
30
|
+
- To remove a line (e.g. stray raise, debug code), use delete_lines with empty replacement.
|
|
31
|
+
- To replace lines, use replace_lines with start_line/end_line covering only affected lines.
|
|
32
|
+
- If the file already contains the correct fix, use no_change.
|
|
33
|
+
- Do not repeat existing assignments, finds, or renders.
|
|
34
|
+
- replacement must be only the new Ruby code for the affected lines, without markdown fences.
|
|
35
|
+
PROMPT
|
|
36
|
+
|
|
37
|
+
def self.enhance(suggestion, exception, context = {}, config: Bugsage.configuration, client: nil)
|
|
38
|
+
new(config: config, client: client).enhance(suggestion, exception, context)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def initialize(config: Bugsage.configuration, client: nil)
|
|
42
|
+
@config = config
|
|
43
|
+
@client = client
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Returns [suggestion, ai_error_message]
|
|
47
|
+
def enhance(suggestion, exception, context)
|
|
48
|
+
return [suggestion, nil] unless @config.ai_configured?
|
|
49
|
+
|
|
50
|
+
response = client.complete(
|
|
51
|
+
system_prompt: SYSTEM_PROMPT,
|
|
52
|
+
user_prompt: build_prompt(suggestion, exception, context)
|
|
53
|
+
)
|
|
54
|
+
[merge_suggestion(suggestion, parse_response(response, suggestion: suggestion)), nil]
|
|
55
|
+
rescue StandardError => e
|
|
56
|
+
log_failure(e)
|
|
57
|
+
[suggestion, e.message]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def client
|
|
63
|
+
@client ||= @config.ai_client || build_client
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def build_client
|
|
67
|
+
AiSupport.build_client(@config)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def build_prompt(suggestion, exception, context)
|
|
71
|
+
backtrace = TraceCleaner.clean(exception.backtrace).first(8).join("\n")
|
|
72
|
+
request_context = context.empty? ? "none" : JSON.pretty_generate(context)
|
|
73
|
+
file_path, line_number = CodeContext.extract_location(suggestion.location)
|
|
74
|
+
source_context = CodeContext.numbered_source(file_path, line_number) if file_path && line_number
|
|
75
|
+
source_block = source_context ? source_context[:source] : "unavailable"
|
|
76
|
+
|
|
77
|
+
<<~PROMPT
|
|
78
|
+
Exception class: #{exception.class.name}
|
|
79
|
+
Exception message: #{exception.message}
|
|
80
|
+
Location: #{suggestion.location}
|
|
81
|
+
Error line: #{line_number}
|
|
82
|
+
Backtrace:
|
|
83
|
+
#{backtrace}
|
|
84
|
+
|
|
85
|
+
Request context:
|
|
86
|
+
#{request_context}
|
|
87
|
+
|
|
88
|
+
Numbered source code:
|
|
89
|
+
#{source_block}
|
|
90
|
+
|
|
91
|
+
Rule-based analysis:
|
|
92
|
+
Issue: #{suggestion.issue}
|
|
93
|
+
Root cause: #{suggestion.root_cause}
|
|
94
|
+
Fixes: #{suggestion.fixes.join("; ")}
|
|
95
|
+
Confidence: #{suggestion.confidence}
|
|
96
|
+
PROMPT
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def parse_response(response, suggestion:)
|
|
100
|
+
payload = JSON.parse(extract_json(response))
|
|
101
|
+
_file_path, line_number = CodeContext.extract_location(suggestion.location)
|
|
102
|
+
patch = CodePatch.from_ai(payload, error_line: line_number || 1)
|
|
103
|
+
|
|
104
|
+
{
|
|
105
|
+
root_cause: payload["root_cause"].to_s.strip,
|
|
106
|
+
fixes: Array(payload["fixes"]).map { |fix| fix.to_s.strip }.reject(&:empty?),
|
|
107
|
+
confidence: normalize_confidence(payload["confidence"]),
|
|
108
|
+
notes: payload["notes"].to_s.strip,
|
|
109
|
+
code_patch: patch&.to_h
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def merge_suggestion(suggestion, ai_result)
|
|
114
|
+
return suggestion if ai_result[:fixes].empty? && ai_result[:root_cause].empty? && ai_result[:code_patch].nil?
|
|
115
|
+
|
|
116
|
+
suggestion.with_ai_enhancement(
|
|
117
|
+
root_cause: ai_result[:root_cause].empty? ? suggestion.root_cause : ai_result[:root_cause],
|
|
118
|
+
fixes: merge_fixes(suggestion.fixes, ai_result[:fixes]),
|
|
119
|
+
confidence: ai_result[:confidence] || suggestion.confidence,
|
|
120
|
+
ai_notes: ai_result[:notes].empty? ? nil : ai_result[:notes],
|
|
121
|
+
code_patch: ai_result[:code_patch]
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def merge_fixes(rule_fixes, ai_fixes)
|
|
126
|
+
combined = ai_fixes + rule_fixes
|
|
127
|
+
combined.uniq(&:downcase)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def normalize_confidence(value)
|
|
131
|
+
number = Integer(value)
|
|
132
|
+
number.clamp(0, 100)
|
|
133
|
+
rescue ArgumentError, TypeError
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def extract_json(response)
|
|
138
|
+
AiSupport.extract_json(response)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def log_failure(error)
|
|
142
|
+
AiSupport.log_failure("AI enhancement failed", error)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Bugsage
|
|
6
|
+
class AiChat
|
|
7
|
+
extend JsonEndpoint
|
|
8
|
+
|
|
9
|
+
ENDPOINT = "/bugsage/ai-chat"
|
|
10
|
+
|
|
11
|
+
SYSTEM_PROMPT = <<~PROMPT
|
|
12
|
+
You are BugSage, a Ruby on Rails debugging assistant chatting with a developer
|
|
13
|
+
about a specific caught exception.
|
|
14
|
+
|
|
15
|
+
Respond with JSON only using this schema:
|
|
16
|
+
{
|
|
17
|
+
"reply": "conversational answer shown in chat",
|
|
18
|
+
"code_patch": null | {
|
|
19
|
+
"action": "replace_lines | delete_lines | insert_before | no_change",
|
|
20
|
+
"start_line": 0,
|
|
21
|
+
"end_line": 0,
|
|
22
|
+
"replacement": "string"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
Rules:
|
|
27
|
+
- reply must explain your answer clearly and concisely.
|
|
28
|
+
- Include code_patch ONLY when the developer asks for a code change, asks to
|
|
29
|
+
revise the current suggested fix, or agrees to an alternative approach
|
|
30
|
+
(for example commenting out a line instead of deleting it).
|
|
31
|
+
- When they want to comment out a line, use replace_lines with the commented
|
|
32
|
+
Ruby code in replacement (e.g. "# raise "debug""), not delete_lines.
|
|
33
|
+
- Use absolute line numbers from the numbered source (the line marked with >>).
|
|
34
|
+
- NEVER duplicate code that already exists in the file.
|
|
35
|
+
- Change ONLY the lines required for the requested fix.
|
|
36
|
+
- Set code_patch to null when answering questions without changing the fix.
|
|
37
|
+
- replacement must be only the new Ruby code for affected lines, without markdown fences.
|
|
38
|
+
PROMPT
|
|
39
|
+
|
|
40
|
+
def self.handle_request(env)
|
|
41
|
+
return not_found unless Bugsage.configuration.ai_configured?
|
|
42
|
+
|
|
43
|
+
payload = parse_request_body(env)
|
|
44
|
+
context = AiPanel.load_context(payload)
|
|
45
|
+
return json_response(error_response(Bugsage.t("errors.ai_context_not_available"))) unless context
|
|
46
|
+
|
|
47
|
+
message = payload["message"].to_s.strip
|
|
48
|
+
return json_response(error_response(Bugsage.t("errors.enter_message"))) if message.empty?
|
|
49
|
+
|
|
50
|
+
history = normalize_history(payload["history"])
|
|
51
|
+
result = chat(context, message, history)
|
|
52
|
+
|
|
53
|
+
updated_history = history + [
|
|
54
|
+
{ "role" => "user", "content" => message },
|
|
55
|
+
{ "role" => "assistant", "content" => result[:reply] }
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
persist_chat_patch!(context, result[:code_patch], payload["index"]) if result[:code_patch]
|
|
59
|
+
|
|
60
|
+
json_response(
|
|
61
|
+
ok: true,
|
|
62
|
+
reply: result[:reply],
|
|
63
|
+
history: updated_history,
|
|
64
|
+
code_patch: result[:code_patch],
|
|
65
|
+
code_fix: result[:code_fix]
|
|
66
|
+
)
|
|
67
|
+
rescue StandardError => e
|
|
68
|
+
log_failure(e)
|
|
69
|
+
json_response(error_response(e.message))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.chat(context, message, history)
|
|
73
|
+
client = build_client
|
|
74
|
+
user_prompt = build_chat_prompt(context, message, history)
|
|
75
|
+
response = client.complete(system_prompt: SYSTEM_PROMPT, user_prompt: user_prompt)
|
|
76
|
+
parse_chat_response(response, context[:suggestion])
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def self.build_chat_prompt(context, message, history)
|
|
80
|
+
context_block = build_context_block(context)
|
|
81
|
+
conversation = history.map do |entry|
|
|
82
|
+
"#{entry["role"].capitalize}: #{entry["content"]}"
|
|
83
|
+
end.join("\n\n")
|
|
84
|
+
|
|
85
|
+
parts = [context_block]
|
|
86
|
+
parts << "Conversation so far:\n#{conversation}" unless conversation.empty?
|
|
87
|
+
parts << "Developer: #{message}"
|
|
88
|
+
parts.join("\n\n")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def self.parse_chat_response(response, suggestion)
|
|
92
|
+
payload = JSON.parse(extract_json(response))
|
|
93
|
+
_file_path, line_number = CodeContext.extract_location(suggestion.location)
|
|
94
|
+
code_patch = parse_code_patch(payload, line_number)
|
|
95
|
+
|
|
96
|
+
reply = payload["reply"].to_s.strip
|
|
97
|
+
reply = payload["notes"].to_s.strip if reply.empty?
|
|
98
|
+
|
|
99
|
+
{
|
|
100
|
+
reply: reply.empty? ? Bugsage.t("errors.default_chat_reply") : reply,
|
|
101
|
+
code_patch: code_patch,
|
|
102
|
+
code_fix: CodePatch.preview_for(code_patch)
|
|
103
|
+
}
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def self.parse_code_patch(payload, line_number)
|
|
107
|
+
patch_data = payload["code_patch"]
|
|
108
|
+
return nil if patch_data.nil? || (patch_data.respond_to?(:empty?) && patch_data.empty?)
|
|
109
|
+
|
|
110
|
+
patch = CodePatch.from_ai(payload, error_line: line_number || 1)
|
|
111
|
+
patch&.to_h
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def self.persist_chat_patch!(context, code_patch, index)
|
|
115
|
+
return unless code_patch.is_a?(Hash) && !code_patch.empty?
|
|
116
|
+
|
|
117
|
+
suggestion = context[:suggestion]
|
|
118
|
+
updated = suggestion.with_ai_enhancement(
|
|
119
|
+
root_cause: suggestion.root_cause,
|
|
120
|
+
fixes: suggestion.fixes,
|
|
121
|
+
confidence: suggestion.confidence,
|
|
122
|
+
ai_notes: suggestion.ai_notes,
|
|
123
|
+
code_patch: code_patch
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
if index
|
|
127
|
+
Store.update_at(index.to_i, updated)
|
|
128
|
+
elsif Store.all.any?
|
|
129
|
+
Store.update_at(0, updated)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def self.build_context_block(context, source_context = nil)
|
|
134
|
+
suggestion = context[:suggestion]
|
|
135
|
+
exception = context[:exception]
|
|
136
|
+
file_path, line_number = CodeContext.extract_location(suggestion.location)
|
|
137
|
+
source_context ||= CodeContext.numbered_source(file_path, line_number) if file_path && line_number
|
|
138
|
+
source_block = source_context ? source_context[:source] : "unavailable"
|
|
139
|
+
patch_json = suggestion.code_patch ? JSON.generate(suggestion.code_patch) : "none"
|
|
140
|
+
|
|
141
|
+
<<~PROMPT
|
|
142
|
+
Exception: #{exception.class.name}
|
|
143
|
+
Message: #{exception.message}
|
|
144
|
+
Location: #{suggestion.location}
|
|
145
|
+
Root cause: #{suggestion.root_cause}
|
|
146
|
+
Suggested fixes: #{suggestion.fixes.join("; ")}
|
|
147
|
+
AI notes: #{suggestion.ai_notes}
|
|
148
|
+
Current code_patch JSON: #{patch_json}
|
|
149
|
+
Current patch preview: #{suggestion.code_fix || "none yet"}
|
|
150
|
+
|
|
151
|
+
Numbered source:
|
|
152
|
+
#{source_block}
|
|
153
|
+
PROMPT
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def self.extract_json(response)
|
|
157
|
+
AiSupport.extract_json(response)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def self.build_client
|
|
161
|
+
AiSupport.build_client(Bugsage.configuration)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def self.normalize_history(history)
|
|
165
|
+
Array(history).filter_map do |entry|
|
|
166
|
+
role = entry["role"].to_s
|
|
167
|
+
content = entry["content"].to_s.strip
|
|
168
|
+
next if content.empty?
|
|
169
|
+
next unless %w[user assistant].include?(role)
|
|
170
|
+
|
|
171
|
+
{ "role" => role, "content" => content }
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def self.render_widget(suffix:, bug_index: nil)
|
|
176
|
+
index_attr = bug_index.nil? ? "" : %( data-bug-index="#{bug_index}")
|
|
177
|
+
|
|
178
|
+
<<~HTML
|
|
179
|
+
<div class="ai-loading-overlay hidden" id="bugsage-ai-loading#{suffix}" aria-hidden="true">
|
|
180
|
+
<div class="ai-loading-card">
|
|
181
|
+
<div class="ai-spinner" aria-hidden="true"></div>
|
|
182
|
+
<p class="ai-loading-title">#{CodeContext.escape_html(Bugsage.t("ui.ai_chat.working_title"))}</p>
|
|
183
|
+
<p class="ai-loading-step" id="bugsage-ai-loading-step#{suffix}">#{CodeContext.escape_html(Bugsage.t("ui.ai_chat.reading_source"))}</p>
|
|
184
|
+
<div class="ai-loading-bar"><span class="ai-loading-bar-fill"></span></div>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
|
|
188
|
+
<div class="ai-chat-panel hidden" id="bugsage-ai-chat#{suffix}"#{index_attr}>
|
|
189
|
+
<div class="ai-chat-header">
|
|
190
|
+
<span>#{CodeContext.escape_html(Bugsage.t("ui.ai_chat.chat_header"))}</span>
|
|
191
|
+
<button type="button" class="ai-chat-close" aria-label="#{CodeContext.escape_html(Bugsage.t("ui.ai_chat.close_chat_aria"))}">×</button>
|
|
192
|
+
</div>
|
|
193
|
+
<div class="ai-chat-messages" id="bugsage-ai-chat-messages#{suffix}" aria-live="polite"></div>
|
|
194
|
+
<form class="ai-chat-form bugsage-ai-chat-form" data-output-target="#bugsage-ai-chat-messages#{suffix}">
|
|
195
|
+
<input
|
|
196
|
+
class="ai-chat-input"
|
|
197
|
+
type="text"
|
|
198
|
+
name="message"
|
|
199
|
+
autocomplete="off"
|
|
200
|
+
placeholder="#{CodeContext.escape_html(Bugsage.t("ui.ai_chat.input_placeholder"))}"
|
|
201
|
+
/>
|
|
202
|
+
<button type="submit" class="ai-chat-send">#{CodeContext.escape_html(Bugsage.t("ui.ai_chat.send"))}</button>
|
|
203
|
+
</form>
|
|
204
|
+
</div>
|
|
205
|
+
HTML
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def self.styles
|
|
209
|
+
<<~CSS
|
|
210
|
+
.ai-panel.is-loading {
|
|
211
|
+
overflow: hidden;
|
|
212
|
+
}
|
|
213
|
+
.ai-panel-header-actions {
|
|
214
|
+
display: inline-flex;
|
|
215
|
+
align-items: center;
|
|
216
|
+
gap: 8px;
|
|
217
|
+
}
|
|
218
|
+
.ai-chat-toggle {
|
|
219
|
+
background: rgba(137, 180, 250, 0.15);
|
|
220
|
+
color: #89b4fa;
|
|
221
|
+
border: 1px solid rgba(137, 180, 250, 0.35);
|
|
222
|
+
border-radius: 999px;
|
|
223
|
+
width: 36px;
|
|
224
|
+
height: 36px;
|
|
225
|
+
font-size: 16px;
|
|
226
|
+
cursor: pointer;
|
|
227
|
+
}
|
|
228
|
+
.ai-chat-toggle:hover {
|
|
229
|
+
background: rgba(137, 180, 250, 0.25);
|
|
230
|
+
}
|
|
231
|
+
.ai-loading-overlay {
|
|
232
|
+
position: absolute;
|
|
233
|
+
inset: 0;
|
|
234
|
+
background: rgba(30, 30, 46, 0.82);
|
|
235
|
+
border-radius: 8px;
|
|
236
|
+
display: flex;
|
|
237
|
+
align-items: center;
|
|
238
|
+
justify-content: center;
|
|
239
|
+
z-index: 2;
|
|
240
|
+
}
|
|
241
|
+
.ai-loading-overlay.hidden {
|
|
242
|
+
display: none;
|
|
243
|
+
}
|
|
244
|
+
.ai-loading-card {
|
|
245
|
+
text-align: center;
|
|
246
|
+
padding: 20px;
|
|
247
|
+
width: min(100%, 280px);
|
|
248
|
+
}
|
|
249
|
+
.ai-spinner {
|
|
250
|
+
width: 42px;
|
|
251
|
+
height: 42px;
|
|
252
|
+
border: 3px solid rgba(137, 180, 250, 0.2);
|
|
253
|
+
border-top-color: #89b4fa;
|
|
254
|
+
border-radius: 50%;
|
|
255
|
+
margin: 0 auto 14px;
|
|
256
|
+
animation: bugsage-spin 0.9s linear infinite;
|
|
257
|
+
}
|
|
258
|
+
.ai-loading-title {
|
|
259
|
+
color: #cdd6f4;
|
|
260
|
+
font-size: 14px;
|
|
261
|
+
font-weight: bold;
|
|
262
|
+
margin: 0 0 6px 0;
|
|
263
|
+
}
|
|
264
|
+
.ai-loading-step {
|
|
265
|
+
color: #f9e2af;
|
|
266
|
+
font-size: 12px;
|
|
267
|
+
margin: 0 0 12px 0;
|
|
268
|
+
min-height: 16px;
|
|
269
|
+
}
|
|
270
|
+
.ai-loading-bar {
|
|
271
|
+
height: 4px;
|
|
272
|
+
background: rgba(137, 180, 250, 0.15);
|
|
273
|
+
border-radius: 999px;
|
|
274
|
+
overflow: hidden;
|
|
275
|
+
}
|
|
276
|
+
.ai-loading-bar-fill {
|
|
277
|
+
display: block;
|
|
278
|
+
height: 100%;
|
|
279
|
+
width: 35%;
|
|
280
|
+
background: linear-gradient(90deg, #89b4fa, #a6e3a1);
|
|
281
|
+
border-radius: 999px;
|
|
282
|
+
animation: bugsage-progress 1.4s ease-in-out infinite;
|
|
283
|
+
}
|
|
284
|
+
@keyframes bugsage-spin {
|
|
285
|
+
to { transform: rotate(360deg); }
|
|
286
|
+
}
|
|
287
|
+
@keyframes bugsage-progress {
|
|
288
|
+
0% { transform: translateX(-120%); }
|
|
289
|
+
100% { transform: translateX(320%); }
|
|
290
|
+
}
|
|
291
|
+
.ai-chat-panel {
|
|
292
|
+
margin-top: 14px;
|
|
293
|
+
background: #1e1e2e;
|
|
294
|
+
border: 1px solid #45475a;
|
|
295
|
+
border-radius: 8px;
|
|
296
|
+
overflow: hidden;
|
|
297
|
+
}
|
|
298
|
+
.ai-chat-panel.hidden {
|
|
299
|
+
display: none;
|
|
300
|
+
}
|
|
301
|
+
.ai-chat-header {
|
|
302
|
+
display: flex;
|
|
303
|
+
align-items: center;
|
|
304
|
+
justify-content: space-between;
|
|
305
|
+
padding: 10px 12px;
|
|
306
|
+
background: #313244;
|
|
307
|
+
color: #cdd6f4;
|
|
308
|
+
font-size: 13px;
|
|
309
|
+
font-weight: bold;
|
|
310
|
+
}
|
|
311
|
+
.ai-chat-close {
|
|
312
|
+
background: transparent;
|
|
313
|
+
border: 0;
|
|
314
|
+
color: #a6adc8;
|
|
315
|
+
font-size: 20px;
|
|
316
|
+
cursor: pointer;
|
|
317
|
+
line-height: 1;
|
|
318
|
+
}
|
|
319
|
+
.ai-chat-messages {
|
|
320
|
+
max-height: 220px;
|
|
321
|
+
overflow-y: auto;
|
|
322
|
+
padding: 12px;
|
|
323
|
+
display: flex;
|
|
324
|
+
flex-direction: column;
|
|
325
|
+
gap: 10px;
|
|
326
|
+
}
|
|
327
|
+
.ai-chat-bubble {
|
|
328
|
+
max-width: 92%;
|
|
329
|
+
padding: 10px 12px;
|
|
330
|
+
border-radius: 10px;
|
|
331
|
+
font-size: 13px;
|
|
332
|
+
line-height: 1.45;
|
|
333
|
+
white-space: pre-wrap;
|
|
334
|
+
}
|
|
335
|
+
.ai-chat-bubble.user {
|
|
336
|
+
align-self: flex-end;
|
|
337
|
+
background: rgba(137, 180, 250, 0.18);
|
|
338
|
+
color: #cdd6f4;
|
|
339
|
+
}
|
|
340
|
+
.ai-chat-bubble.assistant {
|
|
341
|
+
align-self: flex-start;
|
|
342
|
+
background: rgba(166, 227, 161, 0.12);
|
|
343
|
+
color: #cdd6f4;
|
|
344
|
+
}
|
|
345
|
+
.ai-chat-bubble.loading {
|
|
346
|
+
align-self: flex-start;
|
|
347
|
+
color: #f9e2af;
|
|
348
|
+
font-style: italic;
|
|
349
|
+
}
|
|
350
|
+
.ai-chat-form {
|
|
351
|
+
display: flex;
|
|
352
|
+
gap: 8px;
|
|
353
|
+
padding: 12px;
|
|
354
|
+
border-top: 1px solid #45475a;
|
|
355
|
+
}
|
|
356
|
+
.ai-chat-input {
|
|
357
|
+
flex: 1;
|
|
358
|
+
background: #313244;
|
|
359
|
+
color: #cdd6f4;
|
|
360
|
+
border: 1px solid #45475a;
|
|
361
|
+
border-radius: 6px;
|
|
362
|
+
padding: 10px 12px;
|
|
363
|
+
font-family: inherit;
|
|
364
|
+
font-size: 13px;
|
|
365
|
+
}
|
|
366
|
+
.ai-chat-send {
|
|
367
|
+
background: #89b4fa;
|
|
368
|
+
color: #1e1e2e;
|
|
369
|
+
border: 0;
|
|
370
|
+
border-radius: 6px;
|
|
371
|
+
padding: 10px 14px;
|
|
372
|
+
font-family: inherit;
|
|
373
|
+
font-size: 13px;
|
|
374
|
+
font-weight: bold;
|
|
375
|
+
cursor: pointer;
|
|
376
|
+
}
|
|
377
|
+
.ai-chat-send:disabled {
|
|
378
|
+
opacity: 0.5;
|
|
379
|
+
cursor: not-allowed;
|
|
380
|
+
}
|
|
381
|
+
CSS
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def self.log_failure(error)
|
|
385
|
+
AiSupport.log_failure("AI chat failed", error)
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
end
|