commitgpt 0.2.0 → 0.3.3
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 +208 -70
- data/bin/aicm +1 -1
- data/commitgpt.gemspec +36 -0
- data/lib/commitgpt/cli.rb +54 -9
- data/lib/commitgpt/commit_ai.rb +403 -69
- data/lib/commitgpt/config_manager.rb +164 -0
- data/lib/commitgpt/provider_presets.rb +19 -0
- data/lib/commitgpt/setup_wizard.rb +317 -0
- data/lib/commitgpt/string.rb +4 -0
- data/lib/commitgpt/version.rb +1 -1
- data/lib/commitgpt.rb +2 -2
- metadata +24 -13
- data/.rspec +0 -3
- data/.rubocop.yml +0 -19
- data/CHANGELOG.md +0 -5
- data/CODE_OF_CONDUCT.md +0 -84
- data/Gemfile +0 -19
- data/Gemfile.lock +0 -73
- data/LICENSE.txt +0 -21
- data/Rakefile +0 -12
data/lib/commitgpt/commit_ai.rb
CHANGED
|
@@ -1,18 +1,35 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
5
|
-
require
|
|
6
|
-
|
|
3
|
+
require 'httparty'
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'uri'
|
|
6
|
+
require 'json'
|
|
7
|
+
require 'io/console'
|
|
8
|
+
require 'tty-prompt'
|
|
9
|
+
require_relative 'string'
|
|
10
|
+
require_relative 'config_manager'
|
|
7
11
|
|
|
8
12
|
# CommitGpt based on GPT-3
|
|
9
13
|
module CommitGpt
|
|
10
14
|
# Commit AI roboter based on GPT-3
|
|
11
15
|
class CommitAi
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
+
attr_reader :api_key, :base_url, :model, :diff_len
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
provider_config = ConfigManager.get_active_provider_config
|
|
20
|
+
|
|
21
|
+
if provider_config
|
|
22
|
+
@api_key = provider_config['api_key']
|
|
23
|
+
@base_url = provider_config['base_url']
|
|
24
|
+
@model = provider_config['model']
|
|
25
|
+
@diff_len = provider_config['diff_len'] || 32_768
|
|
26
|
+
else
|
|
27
|
+
@api_key = nil
|
|
28
|
+
@base_url = nil
|
|
29
|
+
@model = nil
|
|
30
|
+
@diff_len = 32_768
|
|
31
|
+
end
|
|
32
|
+
end
|
|
16
33
|
|
|
17
34
|
def aicm(verbose: false)
|
|
18
35
|
exit(1) unless welcome
|
|
@@ -22,139 +39,456 @@ module CommitGpt
|
|
|
22
39
|
puts diff
|
|
23
40
|
puts "\n"
|
|
24
41
|
end
|
|
25
|
-
|
|
26
|
-
|
|
42
|
+
|
|
43
|
+
loop do
|
|
44
|
+
ai_commit_message = message(diff) || exit(1)
|
|
45
|
+
action = confirm_commit(ai_commit_message)
|
|
46
|
+
|
|
47
|
+
case action
|
|
48
|
+
when :commit
|
|
49
|
+
commit_command = "git commit -m \"#{ai_commit_message}\""
|
|
50
|
+
puts "\n▲ Executing: #{commit_command}".yellow
|
|
51
|
+
system(commit_command)
|
|
52
|
+
puts "\n\n"
|
|
53
|
+
puts `git log -1`
|
|
54
|
+
break
|
|
55
|
+
when :regenerate
|
|
56
|
+
puts "\n"
|
|
57
|
+
next
|
|
58
|
+
when :edit
|
|
59
|
+
prompt = TTY::Prompt.new
|
|
60
|
+
new_message = prompt.ask('Enter your commit message:')
|
|
61
|
+
if new_message && !new_message.strip.empty?
|
|
62
|
+
commit_command = "git commit -m \"#{new_message}\""
|
|
63
|
+
system(commit_command)
|
|
64
|
+
puts "\n"
|
|
65
|
+
puts `git log -1`
|
|
66
|
+
else
|
|
67
|
+
puts '▲ Commit aborted (empty message).'.red
|
|
68
|
+
end
|
|
69
|
+
break
|
|
70
|
+
when :exit
|
|
71
|
+
puts '▲ Exit without commit.'.yellow
|
|
72
|
+
break
|
|
73
|
+
end
|
|
74
|
+
end
|
|
27
75
|
end
|
|
28
76
|
|
|
29
77
|
def list_models
|
|
30
78
|
headers = {
|
|
31
|
-
|
|
32
|
-
|
|
79
|
+
'Content-Type' => 'application/json',
|
|
80
|
+
'User-Agent' => "Ruby/#{RUBY_VERSION}"
|
|
33
81
|
}
|
|
34
|
-
headers[
|
|
82
|
+
headers['Authorization'] = "Bearer #{@api_key}" if @api_key
|
|
35
83
|
|
|
36
84
|
begin
|
|
37
|
-
response = HTTParty.get("#{
|
|
38
|
-
models = response[
|
|
39
|
-
models.each { |m| puts m[
|
|
85
|
+
response = HTTParty.get("#{@base_url}/models", headers: headers)
|
|
86
|
+
models = response['data'] || []
|
|
87
|
+
models.each { |m| puts m['id'] }
|
|
40
88
|
rescue StandardError => e
|
|
41
89
|
puts "▲ Failed to list models: #{e.message}".red
|
|
42
90
|
end
|
|
43
91
|
end
|
|
44
92
|
|
|
45
|
-
|
|
93
|
+
def list_models
|
|
94
|
+
headers = {
|
|
95
|
+
'Content-Type' => 'application/json',
|
|
96
|
+
'User-Agent' => "Ruby/#{RUBY_VERSION}"
|
|
97
|
+
}
|
|
98
|
+
headers['Authorization'] = "Bearer #{AICM_KEY}" if AICM_KEY
|
|
46
99
|
|
|
47
|
-
|
|
48
|
-
|
|
100
|
+
begin
|
|
101
|
+
response = HTTParty.get("#{AICM_LINK}/models", headers: headers)
|
|
102
|
+
models = response['data'] || []
|
|
103
|
+
models.each { |m| puts m['id'] }
|
|
104
|
+
rescue StandardError => e
|
|
105
|
+
puts "▲ Failed to list models: #{e.message}".red
|
|
106
|
+
end
|
|
107
|
+
end
|
|
49
108
|
|
|
50
|
-
|
|
51
|
-
use_commit_message = $stdin.getch.downcase until use_commit_message =~ /\A[yn]\z/i
|
|
109
|
+
private
|
|
52
110
|
|
|
53
|
-
|
|
111
|
+
def confirm_commit(_message)
|
|
112
|
+
prompt = TTY::Prompt.new
|
|
54
113
|
|
|
55
|
-
|
|
114
|
+
begin
|
|
115
|
+
prompt.select('Action:') do |menu|
|
|
116
|
+
menu.choice 'Commit', :commit
|
|
117
|
+
menu.choice 'Regenerate', :regenerate
|
|
118
|
+
menu.choice 'Edit', :edit
|
|
119
|
+
menu.choice 'Exit without commit', :exit
|
|
120
|
+
end
|
|
121
|
+
rescue TTY::Reader::InputInterrupt, Interrupt
|
|
122
|
+
:exit
|
|
123
|
+
end
|
|
56
124
|
end
|
|
57
125
|
|
|
58
126
|
def message(diff = nil)
|
|
59
|
-
|
|
60
|
-
ai_commit_message = generate_commit(diff)
|
|
61
|
-
return nil if ai_commit_message.nil?
|
|
62
|
-
|
|
63
|
-
puts "#{"▲ Commit message: ".green}git commit -am \"#{ai_commit_message}\"\n\n"
|
|
64
|
-
ai_commit_message
|
|
127
|
+
generate_commit(diff)
|
|
65
128
|
end
|
|
66
129
|
|
|
67
130
|
def git_diff
|
|
68
|
-
|
|
131
|
+
exclusions = '":(exclude)Gemfile.lock" ":(exclude)package-lock.json" ":(exclude)yarn.lock" ":(exclude)pnpm-lock.yaml"'
|
|
132
|
+
diff_cached = `git diff --cached . #{exclusions}`.chomp
|
|
133
|
+
diff_unstaged = `git diff . #{exclusions}`.chomp
|
|
69
134
|
|
|
70
|
-
if
|
|
71
|
-
|
|
72
|
-
|
|
135
|
+
if !diff_unstaged.empty?
|
|
136
|
+
if diff_cached.empty?
|
|
137
|
+
# Scenario: Only unstaged changes
|
|
138
|
+
choice = prompt_no_staged_changes
|
|
139
|
+
case choice
|
|
140
|
+
when :add_all
|
|
141
|
+
puts '▲ Running git add .'.yellow
|
|
142
|
+
system('git add .')
|
|
143
|
+
diff_cached = `git diff --cached . #{exclusions}`.chomp
|
|
144
|
+
if diff_cached.empty?
|
|
145
|
+
puts '▲ Still no changes to commit.'.red
|
|
146
|
+
return nil
|
|
147
|
+
end
|
|
148
|
+
when :exit
|
|
149
|
+
return nil
|
|
150
|
+
end
|
|
151
|
+
else
|
|
152
|
+
# Scenario: Mixed state (some staged, some not)
|
|
153
|
+
puts '▲ You have both staged and unstaged changes:'.yellow
|
|
154
|
+
|
|
155
|
+
staged_files = `git diff --cached --name-status . #{exclusions}`.chomp
|
|
156
|
+
unstaged_files = `git diff --name-status . #{exclusions}`.chomp
|
|
157
|
+
|
|
158
|
+
puts "\n #{'Staged changes:'.green}"
|
|
159
|
+
puts staged_files.gsub(/^/, ' ')
|
|
160
|
+
|
|
161
|
+
puts "\n #{'Unstaged changes:'.red}"
|
|
162
|
+
puts unstaged_files.gsub(/^/, ' ')
|
|
163
|
+
puts ''
|
|
164
|
+
|
|
165
|
+
prompt = TTY::Prompt.new
|
|
166
|
+
choice = prompt.select('How to proceed?') do |menu|
|
|
167
|
+
menu.choice 'Include unstaged changes (git add .)', :add_all
|
|
168
|
+
menu.choice 'Use staged changes only', :staged_only
|
|
169
|
+
menu.choice 'Exit', :exit
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
case choice
|
|
173
|
+
when :add_all
|
|
174
|
+
puts '▲ Running git add .'.yellow
|
|
175
|
+
system('git add .')
|
|
176
|
+
diff_cached = `git diff --cached . #{exclusions}`.chomp
|
|
177
|
+
when :exit
|
|
178
|
+
return nil
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
elsif diff_cached.empty?
|
|
182
|
+
# Scenario: No changes at all (staged or unstaged)
|
|
183
|
+
# Check if there are ANY unstaged files (maybe untracked?)
|
|
184
|
+
# git status --porcelain includes untracked files
|
|
185
|
+
git_status = `git status --porcelain`.chomp
|
|
186
|
+
if git_status.empty?
|
|
187
|
+
puts '▲ No changes to commit. Working tree clean.'.yellow
|
|
188
|
+
return nil
|
|
189
|
+
else
|
|
190
|
+
# Only untracked files? Or ignored files?
|
|
191
|
+
# If diff_unstaged is empty but git status is not, it usually means untracked files.
|
|
192
|
+
# Let's offer to add them too.
|
|
193
|
+
choice = prompt_no_staged_changes
|
|
194
|
+
case choice
|
|
195
|
+
when :add_all
|
|
196
|
+
puts '▲ Running git add .'.yellow
|
|
197
|
+
system('git add .')
|
|
198
|
+
diff_cached = `git diff --cached . #{exclusions}`.chomp
|
|
199
|
+
when :exit
|
|
200
|
+
return nil
|
|
201
|
+
end
|
|
202
|
+
end
|
|
73
203
|
end
|
|
74
204
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
205
|
+
diff = diff_cached
|
|
206
|
+
|
|
207
|
+
if diff.length > @diff_len
|
|
208
|
+
choice = prompt_diff_handling(diff.length, @diff_len)
|
|
209
|
+
case choice
|
|
210
|
+
when :truncate
|
|
211
|
+
puts "▲ Truncating diff to #{@diff_len} chars...".yellow
|
|
212
|
+
diff = diff[0...@diff_len]
|
|
213
|
+
when :unlimited
|
|
214
|
+
puts "▲ Using full diff (#{diff.length} chars)...".yellow
|
|
215
|
+
when :exit
|
|
216
|
+
return nil
|
|
217
|
+
end
|
|
78
218
|
end
|
|
79
219
|
|
|
80
220
|
diff
|
|
81
221
|
end
|
|
82
222
|
|
|
223
|
+
def prompt_no_staged_changes
|
|
224
|
+
puts '▲ No staged changes found (but unstaged/untracked files exist).'.yellow
|
|
225
|
+
prompt = TTY::Prompt.new
|
|
226
|
+
begin
|
|
227
|
+
prompt.select('Choose an option:') do |menu|
|
|
228
|
+
menu.choice "Run 'git add .' to stage all changes", :add_all
|
|
229
|
+
menu.choice 'Exit (stage files manually)', :exit
|
|
230
|
+
end
|
|
231
|
+
rescue TTY::Reader::InputInterrupt, Interrupt
|
|
232
|
+
:exit
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def prompt_diff_handling(current_len, max_len)
|
|
237
|
+
puts "▲ The diff is too large (#{current_len} chars, max #{max_len}).".yellow
|
|
238
|
+
prompt = TTY::Prompt.new
|
|
239
|
+
begin
|
|
240
|
+
prompt.select('Choose an option:') do |menu|
|
|
241
|
+
menu.choice "Use first #{max_len} characters to generate commit message", :truncate
|
|
242
|
+
menu.choice 'Use unlimited characters (may fail or be slow)', :unlimited
|
|
243
|
+
menu.choice 'Exit', :exit
|
|
244
|
+
end
|
|
245
|
+
rescue TTY::Reader::InputInterrupt, Interrupt
|
|
246
|
+
:exit
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
83
250
|
def welcome
|
|
84
251
|
puts "\n▲ Welcome to AI Commits!".green
|
|
85
252
|
|
|
86
|
-
|
|
87
|
-
|
|
253
|
+
# Check if config exists
|
|
254
|
+
unless ConfigManager.config_exists?
|
|
255
|
+
puts '▲ Configuration not found. Generating default config...'.yellow
|
|
256
|
+
ConfigManager.generate_default_configs
|
|
257
|
+
puts "▲ Please run 'aicm setup' to configure your provider.".red
|
|
258
|
+
return false
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Check if active provider is configured
|
|
262
|
+
if @api_key.nil? || @api_key.empty?
|
|
263
|
+
puts "▲ No active provider configured. Please run 'aicm setup'.".red
|
|
264
|
+
return false
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
if @model.nil? || @model.empty?
|
|
268
|
+
puts "▲ No model selected. Please run 'aicm setup'.".red
|
|
88
269
|
return false
|
|
89
270
|
end
|
|
90
271
|
|
|
91
272
|
begin
|
|
92
273
|
`git rev-parse --is-inside-work-tree`
|
|
93
274
|
rescue StandardError
|
|
94
|
-
puts
|
|
275
|
+
puts '▲ This is not a git repository'.red
|
|
95
276
|
return false
|
|
96
277
|
end
|
|
97
278
|
|
|
98
279
|
true
|
|
99
280
|
end
|
|
100
281
|
|
|
101
|
-
def generate_commit(diff =
|
|
282
|
+
def generate_commit(diff = '')
|
|
102
283
|
messages = [
|
|
103
284
|
{
|
|
104
|
-
role:
|
|
105
|
-
content:
|
|
285
|
+
role: 'system',
|
|
286
|
+
content: 'Generate a concise git commit message title in present tense that precisely describes the key changes in the following code diff. Focus on what was changed, not just file names. Provide only the title, no description or body. ' \
|
|
106
287
|
"Message language: English. Rules:\n" \
|
|
107
288
|
"- Commit message must be a maximum of 100 characters.\n" \
|
|
108
289
|
"- Exclude anything unnecessary such as translation. Your entire response will be passed directly into git commit.\n" \
|
|
109
290
|
"- IMPORTANT: Do not include any explanations, introductions, or additional text. Do not wrap the commit message in quotes or any other formatting. The commit message must not exceed 100 characters. Respond with ONLY the commit message text. \n" \
|
|
110
291
|
"- Be specific: include concrete details (package names, versions, functionality) rather than generic statements. \n" \
|
|
111
|
-
|
|
292
|
+
'- Return ONLY the commit message, nothing else.'
|
|
112
293
|
},
|
|
113
294
|
{
|
|
114
|
-
role:
|
|
295
|
+
role: 'user',
|
|
115
296
|
content: "Generate a commit message for the following git diff:\n\n#{diff}"
|
|
116
297
|
}
|
|
117
298
|
]
|
|
118
299
|
|
|
300
|
+
# Check config for disable_reasoning support (default true if not set)
|
|
301
|
+
provider_config = ConfigManager.get_active_provider_config
|
|
302
|
+
can_disable_reasoning = provider_config.key?('can_disable_reasoning') ? provider_config['can_disable_reasoning'] : true
|
|
303
|
+
# Get configured max_tokens or default to 2000
|
|
304
|
+
configured_max_tokens = provider_config['max_tokens'] || 2000
|
|
305
|
+
|
|
119
306
|
payload = {
|
|
120
|
-
model:
|
|
307
|
+
model: @model,
|
|
121
308
|
messages: messages,
|
|
122
|
-
temperature: 0.
|
|
123
|
-
|
|
124
|
-
disable_reasoning: true
|
|
309
|
+
temperature: 0.5,
|
|
310
|
+
stream: true
|
|
125
311
|
}
|
|
126
312
|
|
|
313
|
+
if can_disable_reasoning
|
|
314
|
+
payload[:disable_reasoning] = true
|
|
315
|
+
payload[:max_tokens] = 300
|
|
316
|
+
else
|
|
317
|
+
payload[:max_tokens] = configured_max_tokens
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Initial UI feedback (only on first try)
|
|
321
|
+
puts '....... Generating your AI commit message ......'.gray unless defined?(@is_retrying) && @is_retrying
|
|
322
|
+
|
|
323
|
+
full_content = ''
|
|
324
|
+
full_reasoning = ''
|
|
325
|
+
printed_reasoning = false
|
|
326
|
+
printed_content_prefix = false
|
|
327
|
+
stop_stream = false
|
|
328
|
+
|
|
329
|
+
uri = URI("#{@base_url}/chat/completions")
|
|
330
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
331
|
+
http.use_ssl = (uri.scheme == 'https')
|
|
332
|
+
http.read_timeout = 120
|
|
333
|
+
|
|
334
|
+
request = Net::HTTP::Post.new(uri)
|
|
335
|
+
request['Content-Type'] = 'application/json'
|
|
336
|
+
request['Authorization'] = "Bearer #{@api_key}" if @api_key
|
|
337
|
+
request.body = payload.to_json
|
|
338
|
+
|
|
127
339
|
begin
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
340
|
+
http.request(request) do |response|
|
|
341
|
+
if response.code != '200'
|
|
342
|
+
# Parse error body
|
|
343
|
+
error_body = response.read_body
|
|
344
|
+
result = begin
|
|
345
|
+
JSON.parse(error_body)
|
|
346
|
+
rescue StandardError
|
|
347
|
+
nil
|
|
348
|
+
end
|
|
133
349
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
350
|
+
error_msg = if result
|
|
351
|
+
result.dig('error', 'message') || result['error'] || result['message']
|
|
352
|
+
else
|
|
353
|
+
error_body
|
|
354
|
+
end
|
|
137
355
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
end
|
|
356
|
+
if error_msg.nil? || error_msg.to_s.strip.empty?
|
|
357
|
+
error_msg = "HTTP #{response.code}"
|
|
358
|
+
error_msg += " Raw: #{error_body}" unless error_body.to_s.strip.empty?
|
|
359
|
+
end
|
|
143
360
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
361
|
+
if can_disable_reasoning && (error_msg =~ /parameter|reasoning|unsupported/i || response.code == '400')
|
|
362
|
+
puts "▲ Provider does not support 'disable_reasoning'. Updating config and retrying...".yellow
|
|
363
|
+
ConfigManager.update_provider(provider_config['name'], { 'can_disable_reasoning' => false })
|
|
364
|
+
@is_retrying = true
|
|
365
|
+
return generate_commit(diff)
|
|
366
|
+
else
|
|
367
|
+
puts "▲ API Error: #{error_msg}".red
|
|
368
|
+
return nil
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# Process Streaming Response
|
|
373
|
+
buffer = ''
|
|
374
|
+
response.read_body do |chunk|
|
|
375
|
+
break if stop_stream
|
|
376
|
+
|
|
377
|
+
buffer += chunk
|
|
378
|
+
while (line_end = buffer.index("\n"))
|
|
379
|
+
line = buffer.slice!(0, line_end + 1).strip
|
|
380
|
+
next if line.empty?
|
|
381
|
+
next unless line.start_with?('data: ')
|
|
382
|
+
|
|
383
|
+
data_str = line[6..]
|
|
384
|
+
next if data_str == '[DONE]'
|
|
385
|
+
|
|
386
|
+
begin
|
|
387
|
+
data = JSON.parse(data_str)
|
|
388
|
+
delta = data.dig('choices', 0, 'delta')
|
|
389
|
+
next unless delta
|
|
390
|
+
|
|
391
|
+
# Handle Reasoning
|
|
392
|
+
reasoning_chunk = delta['reasoning_content'] || delta['reasoning']
|
|
393
|
+
if reasoning_chunk && !reasoning_chunk.empty?
|
|
394
|
+
puts "\nThinking...".gray unless printed_reasoning
|
|
395
|
+
print reasoning_chunk.gray
|
|
396
|
+
full_reasoning += reasoning_chunk
|
|
397
|
+
printed_reasoning = true
|
|
398
|
+
$stdout.flush
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# Handle Content
|
|
402
|
+
content_chunk = delta['content']
|
|
403
|
+
if content_chunk && !content_chunk.empty?
|
|
404
|
+
if printed_reasoning && !printed_content_prefix
|
|
405
|
+
puts '' # Newline after reasoning block
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
unless printed_content_prefix
|
|
409
|
+
print "\n▲ Commit message: git commit -am \"".green
|
|
410
|
+
printed_content_prefix = true
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# Prevent infinite loops/repetitive garbage
|
|
414
|
+
if full_content.length + content_chunk.length > 300
|
|
415
|
+
stop_stream = true
|
|
416
|
+
break
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
print content_chunk.green
|
|
420
|
+
full_content += content_chunk
|
|
421
|
+
$stdout.flush
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# Handle Usage (some providers send usage at the end)
|
|
425
|
+
@last_usage = data['usage'] if data['usage']
|
|
426
|
+
rescue JSON::ParserError
|
|
427
|
+
# Partial JSON, wait for more data
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
end
|
|
151
431
|
end
|
|
152
432
|
rescue StandardError => e
|
|
153
433
|
puts "▲ Error: #{e.message}".red
|
|
154
434
|
return nil
|
|
155
435
|
end
|
|
156
436
|
|
|
157
|
-
|
|
437
|
+
# Close the quote
|
|
438
|
+
puts '"'.green if printed_content_prefix
|
|
439
|
+
|
|
440
|
+
# Post-processing Logic (Retry if empty content)
|
|
441
|
+
if (full_content.nil? || full_content.strip.empty?) && (full_reasoning && !full_reasoning.strip.empty?)
|
|
442
|
+
if can_disable_reasoning
|
|
443
|
+
puts "\n▲ Model returned reasoning despite 'disable_reasoning: true'. Updating config and retrying...".yellow
|
|
444
|
+
ConfigManager.update_provider(provider_config['name'], { 'can_disable_reasoning' => false })
|
|
445
|
+
@is_retrying = true
|
|
446
|
+
return generate_commit(diff)
|
|
447
|
+
else
|
|
448
|
+
puts "\n▲ Model output truncated (Reasoning consumed all #{configured_max_tokens} tokens).".red
|
|
449
|
+
prompt = TTY::Prompt.new
|
|
450
|
+
choice = prompt.select('Choose an action:') do |menu|
|
|
451
|
+
menu.choice "Double max_tokens to #{configured_max_tokens * 2}", :double
|
|
452
|
+
menu.choice 'Set custom max_tokens...', :custom
|
|
453
|
+
menu.choice 'Abort', :abort
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
new_max = case choice
|
|
457
|
+
when :double
|
|
458
|
+
configured_max_tokens * 2
|
|
459
|
+
when :custom
|
|
460
|
+
prompt.ask('Enter new max_tokens:', convert: :int)
|
|
461
|
+
when :abort
|
|
462
|
+
return nil
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
if new_max
|
|
466
|
+
puts "▲ Updating max_tokens to #{new_max} and retrying...".yellow
|
|
467
|
+
ConfigManager.update_provider(provider_config['name'], { 'max_tokens' => new_max })
|
|
468
|
+
@is_retrying = true
|
|
469
|
+
return generate_commit(diff)
|
|
470
|
+
end
|
|
471
|
+
return nil
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
if full_content.empty? && full_reasoning.empty?
|
|
476
|
+
puts '▲ No response from AI.'.red
|
|
477
|
+
return nil
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# Print usage info if available (saved from stream or approximated)
|
|
481
|
+
if defined?(@last_usage) && @last_usage
|
|
482
|
+
puts "\n...... Tokens: #{@last_usage['total_tokens']} (Prompt: #{@last_usage['prompt_tokens']}, Completion: #{@last_usage['completion_tokens']})\n\n".gray
|
|
483
|
+
@last_usage = nil
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# Reset retrying flag
|
|
487
|
+
@is_retrying = false
|
|
488
|
+
|
|
489
|
+
# Take only the first non-empty line to avoid repetition or multi-line garbage
|
|
490
|
+
first_line = full_content.split("\n").map(&:strip).reject(&:empty?).first
|
|
491
|
+
first_line&.gsub(/\A["']|["']\z/, '') || ''
|
|
158
492
|
end
|
|
159
493
|
end
|
|
160
494
|
end
|