aicli 0.1.1 → 0.2.6
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/CHANGELOG.md +39 -0
- data/README.md +48 -14
- data/lib/aicli/cli.rb +2 -2
- data/lib/aicli/commands/chat.rb +91 -30
- data/lib/aicli/commands/update.rb +65 -3
- data/lib/aicli/helpers/completion.rb +138 -182
- data/lib/aicli/helpers/config.rb +103 -15
- data/lib/aicli/helpers/context.rb +94 -0
- data/lib/aicli/helpers/llm.rb +130 -0
- data/lib/aicli/prompt.rb +43 -37
- data/lib/aicli/version.rb +1 -1
- data/lib/aicli.rb +2 -0
- data/locales/en.yml +10 -0
- data/locales/it.yml +10 -0
- metadata +17 -1
|
@@ -1,219 +1,100 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'json'
|
|
4
|
-
require 'net/http'
|
|
5
|
-
require 'uri'
|
|
6
|
-
require 'thread'
|
|
7
|
-
|
|
8
3
|
module AiCli
|
|
9
4
|
module Helpers
|
|
10
5
|
module Completion
|
|
11
6
|
EXPLAIN_IN_SECOND_REQUEST = true
|
|
12
|
-
SHELL_CODE_EXCLUSIONS = [/```[a-zA-Z]*\n
|
|
7
|
+
SHELL_CODE_EXCLUSIONS = [/```[a-zA-Z]*\n?/i, /```[a-zA-Z]*/i, "\n"].freeze
|
|
13
8
|
|
|
14
9
|
module_function
|
|
15
10
|
|
|
16
|
-
def get_script_and_info(prompt:,
|
|
17
|
-
stream = generate_completion(
|
|
18
|
-
prompt: full_prompt(prompt),
|
|
19
|
-
key: key,
|
|
20
|
-
model: model,
|
|
21
|
-
api_endpoint: api_endpoint
|
|
22
|
-
)
|
|
23
|
-
enumerator = stream.each
|
|
24
|
-
|
|
11
|
+
def get_script_and_info(prompt:, config:)
|
|
25
12
|
{
|
|
26
|
-
read_script:
|
|
27
|
-
read_info:
|
|
13
|
+
read_script: stream_prompt(full_prompt(prompt), config: config, exclusions: SHELL_CODE_EXCLUSIONS),
|
|
14
|
+
read_info: ->(_writer) { '' }
|
|
28
15
|
}
|
|
29
16
|
end
|
|
30
17
|
|
|
31
|
-
def get_explanation(script:,
|
|
32
|
-
|
|
33
|
-
prompt: explanation_prompt(script),
|
|
34
|
-
key: key,
|
|
35
|
-
model: model,
|
|
36
|
-
api_endpoint: api_endpoint
|
|
37
|
-
)
|
|
38
|
-
{ read_explanation: read_data(stream.each) }
|
|
18
|
+
def get_explanation(script:, config:)
|
|
19
|
+
{ read_explanation: stream_prompt(explanation_prompt(script), config: config) }
|
|
39
20
|
end
|
|
40
21
|
|
|
41
|
-
def get_revision(prompt:, code:,
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
22
|
+
def get_revision(prompt:, code:, config:)
|
|
23
|
+
{
|
|
24
|
+
read_script: stream_prompt(
|
|
25
|
+
revision_prompt(prompt, code),
|
|
26
|
+
config: config,
|
|
27
|
+
exclusions: SHELL_CODE_EXCLUSIONS
|
|
28
|
+
)
|
|
29
|
+
}
|
|
49
30
|
end
|
|
50
31
|
|
|
51
|
-
def get_models(
|
|
52
|
-
|
|
53
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
54
|
-
http.use_ssl = uri.scheme == 'https'
|
|
55
|
-
|
|
56
|
-
request = Net::HTTP::Get.new(uri)
|
|
57
|
-
request['Authorization'] = "Bearer #{key}"
|
|
58
|
-
request['Content-Type'] = 'application/json'
|
|
59
|
-
|
|
60
|
-
response = http.request(request)
|
|
61
|
-
raise KnownError, "Failed to list models: #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
62
|
-
|
|
63
|
-
data = JSON.parse(response.body)
|
|
64
|
-
data.fetch('data', []).select { |m| m['object'] == 'model' }
|
|
32
|
+
def get_models(provider)
|
|
33
|
+
Llm.list_chat_models(provider)
|
|
65
34
|
end
|
|
66
35
|
|
|
67
|
-
#
|
|
68
|
-
def
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
else
|
|
72
|
-
[{ 'role' => 'user', 'content' => prompt }]
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
uri = URI.join(ensure_trailing_slash(api_endpoint), 'chat/completions')
|
|
76
|
-
body = {
|
|
77
|
-
model: model || 'gpt-4o-mini',
|
|
78
|
-
messages: messages,
|
|
79
|
-
n: [number, 10].min,
|
|
80
|
-
stream: true
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
queue = Queue.new
|
|
84
|
-
error_box = []
|
|
85
|
-
|
|
86
|
-
Thread.new do
|
|
87
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
88
|
-
http.use_ssl = uri.scheme == 'https'
|
|
89
|
-
http.read_timeout = 120
|
|
90
|
-
|
|
91
|
-
request = Net::HTTP::Post.new(uri)
|
|
92
|
-
request['Authorization'] = "Bearer #{key}"
|
|
93
|
-
request['Content-Type'] = 'application/json'
|
|
94
|
-
request['Accept'] = 'text/event-stream'
|
|
95
|
-
request.body = JSON.generate(body)
|
|
96
|
-
|
|
97
|
-
http.request(request) do |response|
|
|
98
|
-
unless response.is_a?(Net::HTTPSuccess)
|
|
99
|
-
error_box << [:api, response.code.to_i, response.body]
|
|
100
|
-
queue << :done
|
|
101
|
-
next
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
buffer = +''
|
|
105
|
-
response.read_body do |chunk|
|
|
106
|
-
buffer << chunk
|
|
107
|
-
while (eol = buffer.index("\n"))
|
|
108
|
-
line = buffer.slice!(0..eol).strip
|
|
109
|
-
queue << line if line.start_with?('data: ')
|
|
110
|
-
end
|
|
111
|
-
end
|
|
112
|
-
end
|
|
113
|
-
queue << :done
|
|
114
|
-
rescue SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH => e
|
|
115
|
-
error_box << [:network, uri.host, e.message]
|
|
116
|
-
queue << :done
|
|
117
|
-
rescue StandardError => e
|
|
118
|
-
error_box << [:other, e]
|
|
119
|
-
queue << :done
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
Enumerator.new do |yielder|
|
|
123
|
-
loop do
|
|
124
|
-
item = queue.pop
|
|
125
|
-
break if item == :done
|
|
126
|
-
|
|
127
|
-
yielder << item
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
if (err = error_box.first)
|
|
131
|
-
case err[0]
|
|
132
|
-
when :api
|
|
133
|
-
handle_api_error(err[1], err[2])
|
|
134
|
-
when :network
|
|
135
|
-
raise KnownError,
|
|
136
|
-
"Error connecting to #{err[1]}. Are you connected to the internet? (#{err[2]})"
|
|
137
|
-
when :other
|
|
138
|
-
raise err[1]
|
|
139
|
-
end
|
|
140
|
-
end
|
|
36
|
+
# Streams a single-turn completion. Returns a callable that takes a writer.
|
|
37
|
+
def stream_prompt(prompt, config:, exclusions: [])
|
|
38
|
+
lambda do |writer|
|
|
39
|
+
stream_to_writer(prompt, config: config, exclusions: exclusions, writer: writer)
|
|
141
40
|
end
|
|
142
41
|
end
|
|
143
42
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
loop do
|
|
152
|
-
chunk = enumerator.next
|
|
153
|
-
payloads = chunk.to_s.split("\n\n")
|
|
154
|
-
|
|
155
|
-
payloads.each do |payload|
|
|
156
|
-
return data if payload.include?('[DONE]')
|
|
43
|
+
# Multi-turn chat helper used by `aicli chat`.
|
|
44
|
+
def start_chat(config)
|
|
45
|
+
chat = Llm.build_chat(config)
|
|
46
|
+
chat.with_instructions(chat_system_prompt)
|
|
47
|
+
chat
|
|
48
|
+
end
|
|
157
49
|
|
|
158
|
-
|
|
50
|
+
def chat_system_prompt
|
|
51
|
+
<<~PROMPT
|
|
52
|
+
You are aicli, a terminal assistant. The user is asking for shell commands.
|
|
159
53
|
|
|
160
|
-
|
|
54
|
+
Target OS: #{OsDetect.operating_system_name}
|
|
55
|
+
Target shell: #{OsDetect.detect_shell}
|
|
161
56
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
if excluded_prefix.nil? || buffer.match?(excluded_prefix)
|
|
165
|
-
data_start = true
|
|
166
|
-
buffer = +''
|
|
167
|
-
break if excluded_prefix
|
|
168
|
-
end
|
|
169
|
-
end
|
|
57
|
+
Whenever you suggest a command the user can run, put the exact runnable command
|
|
58
|
+
alone inside a markdown fenced code block, for example:
|
|
170
59
|
|
|
171
|
-
|
|
60
|
+
```
|
|
61
|
+
ls -la
|
|
62
|
+
```
|
|
172
63
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
end
|
|
177
|
-
rescue StopIteration
|
|
178
|
-
break
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
data
|
|
182
|
-
end
|
|
64
|
+
Prefer a single command. You may briefly explain outside the fence.
|
|
65
|
+
Do not wrap non-runnable prose in fences.
|
|
66
|
+
PROMPT
|
|
183
67
|
end
|
|
184
68
|
|
|
185
|
-
def
|
|
186
|
-
|
|
187
|
-
delta = JSON.parse(raw.strip)
|
|
188
|
-
delta.dig('choices', 0, 'delta', 'content') || ''
|
|
189
|
-
rescue JSON::ParserError => e
|
|
190
|
-
"Error with JSON.parse and #{payload}.\n#{e}"
|
|
191
|
-
end
|
|
69
|
+
def extract_commands(text)
|
|
70
|
+
return [] if text.nil? || text.empty?
|
|
192
71
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
end
|
|
72
|
+
blocks = text.scan(/```(?:[a-zA-Z0-9_+-]*)\s*\n?(.*?)```/m)
|
|
73
|
+
.flatten
|
|
74
|
+
.map { |block| block.strip.gsub(/\A`+|`+\z/, '') }
|
|
75
|
+
.map(&:strip)
|
|
76
|
+
.reject(&:empty?)
|
|
199
77
|
|
|
200
|
-
if
|
|
201
|
-
|
|
202
|
-
|
|
78
|
+
# Prefer the last fenced command if several were suggested.
|
|
79
|
+
blocks
|
|
80
|
+
end
|
|
203
81
|
|
|
204
|
-
|
|
82
|
+
def stream_chat_message(chat, message, writer:)
|
|
83
|
+
data = +''
|
|
205
84
|
|
|
206
|
-
|
|
85
|
+
begin
|
|
86
|
+
chat.ask(message) do |chunk|
|
|
87
|
+
content = chunk.content.to_s
|
|
88
|
+
next if content.empty?
|
|
207
89
|
|
|
208
|
-
|
|
209
|
-
|
|
90
|
+
data << content
|
|
91
|
+
writer.call(content)
|
|
92
|
+
end
|
|
93
|
+
rescue RubyLLM::Error, RubyLLM::ConfigurationError => e
|
|
94
|
+
handle_ruby_llm_error(e)
|
|
210
95
|
end
|
|
211
96
|
|
|
212
|
-
|
|
213
|
-
Request to OpenAI failed with status #{status}:
|
|
214
|
-
|
|
215
|
-
#{message_string}
|
|
216
|
-
MSG
|
|
97
|
+
data
|
|
217
98
|
end
|
|
218
99
|
|
|
219
100
|
def explanation_prompt(script)
|
|
@@ -267,9 +148,84 @@ module AiCli
|
|
|
267
148
|
PROMPT
|
|
268
149
|
end
|
|
269
150
|
|
|
270
|
-
def
|
|
271
|
-
|
|
151
|
+
def stream_to_writer(prompt, config:, exclusions:, writer:)
|
|
152
|
+
chat = Llm.build_chat(config)
|
|
153
|
+
data = +''
|
|
154
|
+
data_start = exclusions.empty?
|
|
155
|
+
buffer = +''
|
|
156
|
+
excluded_prefix = exclusions.first
|
|
157
|
+
|
|
158
|
+
begin
|
|
159
|
+
chat.ask(prompt) do |chunk|
|
|
160
|
+
content = chunk.content.to_s
|
|
161
|
+
next if content.empty?
|
|
162
|
+
|
|
163
|
+
unless data_start
|
|
164
|
+
buffer << content
|
|
165
|
+
next unless excluded_prefix.nil? || buffer.match?(excluded_prefix)
|
|
166
|
+
|
|
167
|
+
data_start = true
|
|
168
|
+
remainder = StripRegexPatterns.call(buffer, exclusions)
|
|
169
|
+
buffer = +''
|
|
170
|
+
next if remainder.empty?
|
|
171
|
+
|
|
172
|
+
data << remainder
|
|
173
|
+
writer.call(remainder)
|
|
174
|
+
next
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
cleaned = StripRegexPatterns.call(content, exclusions)
|
|
178
|
+
next if cleaned.empty?
|
|
179
|
+
|
|
180
|
+
data << cleaned
|
|
181
|
+
writer.call(cleaned)
|
|
182
|
+
end
|
|
183
|
+
rescue RubyLLM::Error, RubyLLM::ConfigurationError => e
|
|
184
|
+
handle_ruby_llm_error(e)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
data
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def handle_ruby_llm_error(error)
|
|
191
|
+
message = error.message.to_s
|
|
192
|
+
|
|
193
|
+
if error.is_a?(RubyLLM::RateLimitError) || error.is_a?(RubyLLM::PaymentRequiredError) ||
|
|
194
|
+
message.match?(/rate.?limit|quota|billing/i)
|
|
195
|
+
raise KnownError, <<~MSG
|
|
196
|
+
Request to the LLM provider failed (rate limit / quota).
|
|
197
|
+
|
|
198
|
+
Check your plan and billing details for the configured provider, then try again.
|
|
199
|
+
|
|
200
|
+
Full message:
|
|
201
|
+
|
|
202
|
+
#{message}
|
|
203
|
+
MSG
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
if message.match?(/model_not_found|does not exist|do not have access/i)
|
|
207
|
+
raise KnownError, <<~MSG
|
|
208
|
+
The configured model is not available for this API key/provider.
|
|
209
|
+
|
|
210
|
+
Fix with:
|
|
211
|
+
#{Constants::COMMAND_NAME} config
|
|
212
|
+
or:
|
|
213
|
+
#{Constants::COMMAND_NAME} config set MODEL=#{Llm.default_model_for('anthropic')}
|
|
214
|
+
#{Constants::COMMAND_NAME} config set PROVIDER=anthropic
|
|
215
|
+
|
|
216
|
+
Full message:
|
|
217
|
+
|
|
218
|
+
#{message}
|
|
219
|
+
MSG
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
raise KnownError, <<~MSG
|
|
223
|
+
Request to the LLM provider failed:
|
|
224
|
+
|
|
225
|
+
#{message}
|
|
226
|
+
MSG
|
|
272
227
|
end
|
|
228
|
+
private_class_method :stream_to_writer, :handle_ruby_llm_error
|
|
273
229
|
end
|
|
274
230
|
end
|
|
275
231
|
end
|
data/lib/aicli/helpers/config.rb
CHANGED
|
@@ -7,19 +7,23 @@ require 'tty-prompt'
|
|
|
7
7
|
module AiCli
|
|
8
8
|
module Helpers
|
|
9
9
|
module Config
|
|
10
|
-
|
|
10
|
+
HOME_DIR = File.join(Dir.home, '.aicli')
|
|
11
|
+
CONFIG_PATH = File.join(HOME_DIR, 'config')
|
|
12
|
+
LEGACY_FLAT_CONFIG_PATH = File.join(Dir.home, '.aicli')
|
|
11
13
|
LEGACY_CONFIG_PATH = File.join(Dir.home, '.ai-shell')
|
|
12
14
|
|
|
13
15
|
CONFIG_PARSERS = {
|
|
16
|
+
'PROVIDER' => lambda { |provider|
|
|
17
|
+
Llm.normalize_provider(provider)
|
|
18
|
+
},
|
|
14
19
|
'OPENAI_KEY' => lambda { |key|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
key
|
|
20
|
+
key.to_s
|
|
21
|
+
},
|
|
22
|
+
'ANTHROPIC_KEY' => lambda { |key|
|
|
23
|
+
key.to_s
|
|
20
24
|
},
|
|
21
25
|
'MODEL' => lambda { |model|
|
|
22
|
-
model.
|
|
26
|
+
model.to_s
|
|
23
27
|
},
|
|
24
28
|
'SILENT_MODE' => lambda { |mode|
|
|
25
29
|
mode.to_s.downcase == 'true'
|
|
@@ -34,6 +38,32 @@ module AiCli
|
|
|
34
38
|
|
|
35
39
|
module_function
|
|
36
40
|
|
|
41
|
+
def home_dir
|
|
42
|
+
ensure_home!
|
|
43
|
+
HOME_DIR
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def ensure_home!
|
|
47
|
+
if File.file?(LEGACY_FLAT_CONFIG_PATH) && !File.directory?(LEGACY_FLAT_CONFIG_PATH)
|
|
48
|
+
migrate_flat_config!
|
|
49
|
+
else
|
|
50
|
+
FileUtils.mkdir_p(HOME_DIR)
|
|
51
|
+
end
|
|
52
|
+
HOME_DIR
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def migrate_flat_config!
|
|
56
|
+
backup = "#{LEGACY_FLAT_CONFIG_PATH}.migrating.#{Process.pid}"
|
|
57
|
+
File.rename(LEGACY_FLAT_CONFIG_PATH, backup)
|
|
58
|
+
FileUtils.mkdir_p(HOME_DIR)
|
|
59
|
+
FileUtils.mv(backup, CONFIG_PATH)
|
|
60
|
+
rescue StandardError
|
|
61
|
+
FileUtils.mkdir_p(HOME_DIR)
|
|
62
|
+
if File.exist?(backup)
|
|
63
|
+
FileUtils.mv(backup, CONFIG_PATH) unless File.exist?(CONFIG_PATH)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
37
67
|
def get(cli_config = nil)
|
|
38
68
|
config = read_config_file
|
|
39
69
|
parsed = {}
|
|
@@ -43,21 +73,49 @@ module AiCli
|
|
|
43
73
|
parsed[key] = parser.call(value)
|
|
44
74
|
end
|
|
45
75
|
|
|
76
|
+
raw_model = cli_config&.dig('MODEL') || config['MODEL']
|
|
77
|
+
if raw_model.nil? || raw_model.to_s.empty?
|
|
78
|
+
parsed['MODEL'] = Llm.default_model_for(parsed['PROVIDER'])
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
provider, model = Llm.resolve_provider_and_model(parsed)
|
|
82
|
+
parsed['PROVIDER'] = provider
|
|
83
|
+
parsed['MODEL'] = model
|
|
46
84
|
parsed
|
|
47
85
|
end
|
|
48
86
|
|
|
49
87
|
def set(key_values)
|
|
50
88
|
config = read_config_file
|
|
89
|
+
updates = {}
|
|
51
90
|
|
|
52
91
|
key_values.each do |key, value|
|
|
53
92
|
unless CONFIG_PARSERS.key?(key)
|
|
54
93
|
raise KnownError, "#{I18n.t('Invalid config property')}: #{key}"
|
|
55
94
|
end
|
|
56
95
|
|
|
57
|
-
|
|
58
|
-
|
|
96
|
+
updates[key] = CONFIG_PARSERS[key].call(value).to_s
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Keep PROVIDER and MODEL aligned with the RubyLLM registry.
|
|
100
|
+
if updates.key?('MODEL') && !updates['MODEL'].empty?
|
|
101
|
+
model_provider = Llm.provider_for_model(updates['MODEL'])
|
|
102
|
+
if model_provider
|
|
103
|
+
updates['PROVIDER'] = model_provider unless updates.key?('PROVIDER')
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
if updates.key?('PROVIDER')
|
|
108
|
+
provider = Llm.normalize_provider(updates['PROVIDER'])
|
|
109
|
+
updates['PROVIDER'] = provider
|
|
110
|
+
next_model = updates['MODEL'] || config['MODEL']
|
|
111
|
+
known_ids = Completion.get_models(provider).map { |m| m['id'] }
|
|
112
|
+
if next_model.to_s.empty? || !known_ids.include?(next_model)
|
|
113
|
+
updates['MODEL'] = Llm.default_model_for(provider) unless updates.key?('MODEL') && known_ids.include?(updates['MODEL'])
|
|
114
|
+
end
|
|
59
115
|
end
|
|
60
116
|
|
|
117
|
+
updates.each { |key, value| config[key] = value }
|
|
118
|
+
ensure_home!
|
|
61
119
|
File.write(CONFIG_PATH, stringify_ini(config))
|
|
62
120
|
end
|
|
63
121
|
|
|
@@ -72,8 +130,12 @@ module AiCli
|
|
|
72
130
|
loop do
|
|
73
131
|
config = get
|
|
74
132
|
choice = prompt.select("#{I18n.t('Set config')}:") do |menu|
|
|
133
|
+
menu.choice "#{I18n.t('Provider')} (#{display_hint(config, 'PROVIDER')})",
|
|
134
|
+
'PROVIDER'
|
|
75
135
|
menu.choice "#{I18n.t('OpenAI Key')} (#{display_hint(config, 'OPENAI_KEY') { |v| "sk-...#{v[-3..]}" }})",
|
|
76
136
|
'OPENAI_KEY'
|
|
137
|
+
menu.choice "#{I18n.t('Anthropic Key')} (#{display_hint(config, 'ANTHROPIC_KEY') { |v| "...#{v[-3..]}" }})",
|
|
138
|
+
'ANTHROPIC_KEY'
|
|
77
139
|
menu.choice "#{I18n.t('OpenAI API Endpoint')} (#{display_hint(config, 'OPENAI_API_ENDPOINT')})",
|
|
78
140
|
'OPENAI_API_ENDPOINT'
|
|
79
141
|
menu.choice "#{I18n.t('Silent Mode')} (#{display_hint(config, 'SILENT_MODE')})",
|
|
@@ -86,11 +148,27 @@ module AiCli
|
|
|
86
148
|
end
|
|
87
149
|
|
|
88
150
|
case choice
|
|
151
|
+
when 'PROVIDER'
|
|
152
|
+
provider = prompt.select(I18n.t('Pick a provider')) do |menu|
|
|
153
|
+
Llm::PROVIDERS.each { |p| menu.choice p, p }
|
|
154
|
+
end
|
|
155
|
+
updates = [['PROVIDER', provider]]
|
|
156
|
+
current_model = get['MODEL']
|
|
157
|
+
known_ids = Completion.get_models(provider).map { |m| m['id'] }
|
|
158
|
+
unless known_ids.include?(current_model)
|
|
159
|
+
updates << ['MODEL', Llm.default_model_for(provider)]
|
|
160
|
+
end
|
|
161
|
+
set(updates)
|
|
89
162
|
when 'OPENAI_KEY'
|
|
90
163
|
key = prompt.ask(I18n.t('Enter your OpenAI API key')) do |q|
|
|
91
164
|
q.required true
|
|
92
165
|
end
|
|
93
166
|
set([['OPENAI_KEY', key]])
|
|
167
|
+
when 'ANTHROPIC_KEY'
|
|
168
|
+
key = prompt.ask(I18n.t('Enter your Anthropic API key')) do |q|
|
|
169
|
+
q.required true
|
|
170
|
+
end
|
|
171
|
+
set([['ANTHROPIC_KEY', key]])
|
|
94
172
|
when 'OPENAI_API_ENDPOINT'
|
|
95
173
|
api_endpoint = prompt.ask(I18n.t('Enter your OpenAI API Endpoint'))
|
|
96
174
|
set([['OPENAI_API_ENDPOINT', api_endpoint]]) if api_endpoint
|
|
@@ -99,9 +177,17 @@ module AiCli
|
|
|
99
177
|
set([['SILENT_MODE', silent ? 'true' : 'false']])
|
|
100
178
|
when 'MODEL'
|
|
101
179
|
cfg = get
|
|
102
|
-
models = Completion.get_models(cfg['
|
|
103
|
-
|
|
104
|
-
|
|
180
|
+
models = Completion.get_models(cfg['PROVIDER'])
|
|
181
|
+
if models.empty?
|
|
182
|
+
puts pastel.yellow(I18n.t('No models found for this provider.'))
|
|
183
|
+
next
|
|
184
|
+
end
|
|
185
|
+
model = prompt.select(I18n.t('Pick a model.')) do |menu|
|
|
186
|
+
menu.default cfg['MODEL'] if models.any? { |m| m['id'] == cfg['MODEL'] }
|
|
187
|
+
models.each do |m|
|
|
188
|
+
label = m['name'].empty? || m['name'] == m['id'] ? m['id'] : "#{m['name']} (#{m['id']})"
|
|
189
|
+
menu.choice label, m['id']
|
|
190
|
+
end
|
|
105
191
|
end
|
|
106
192
|
set([['MODEL', model]])
|
|
107
193
|
when 'LANGUAGE'
|
|
@@ -121,9 +207,11 @@ module AiCli
|
|
|
121
207
|
end
|
|
122
208
|
|
|
123
209
|
def read_config_file
|
|
124
|
-
|
|
210
|
+
ensure_home!
|
|
211
|
+
|
|
212
|
+
path = if File.file?(CONFIG_PATH)
|
|
125
213
|
CONFIG_PATH
|
|
126
|
-
elsif File.
|
|
214
|
+
elsif File.file?(LEGACY_CONFIG_PATH)
|
|
127
215
|
LEGACY_CONFIG_PATH
|
|
128
216
|
end
|
|
129
217
|
return {} unless path
|
|
@@ -149,7 +237,7 @@ module AiCli
|
|
|
149
237
|
end
|
|
150
238
|
|
|
151
239
|
def display_hint(config, key)
|
|
152
|
-
if config.key?(key) && !config[key].nil?
|
|
240
|
+
if config.key?(key) && !config[key].nil? && !(config[key].respond_to?(:empty?) && config[key].empty?)
|
|
153
241
|
value = config[key]
|
|
154
242
|
block_given? ? yield(value) : value.to_s
|
|
155
243
|
else
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'time'
|
|
6
|
+
|
|
7
|
+
module AiCli
|
|
8
|
+
module Helpers
|
|
9
|
+
module Context
|
|
10
|
+
# Keep the last N user/assistant messages across sessions.
|
|
11
|
+
MAX_MESSAGES = 40
|
|
12
|
+
CONTEXT_FILENAME = 'context'
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def path
|
|
17
|
+
File.join(Config.home_dir, CONTEXT_FILENAME)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def load_messages
|
|
21
|
+
Config.ensure_home!
|
|
22
|
+
return [] unless File.file?(path)
|
|
23
|
+
|
|
24
|
+
data = JSON.parse(File.read(path))
|
|
25
|
+
Array(data['messages'])
|
|
26
|
+
.select { |m| m.is_a?(Hash) && %w[user assistant].include?(m['role'].to_s) }
|
|
27
|
+
.map { |m| { 'role' => m['role'].to_s, 'content' => m['content'].to_s } }
|
|
28
|
+
.reject { |m| m['content'].strip.empty? }
|
|
29
|
+
.last(MAX_MESSAGES)
|
|
30
|
+
rescue StandardError
|
|
31
|
+
[]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def save_messages(messages)
|
|
35
|
+
Config.ensure_home!
|
|
36
|
+
normalized = Array(messages)
|
|
37
|
+
.map { |m| normalize_message(m) }
|
|
38
|
+
.compact
|
|
39
|
+
.last(MAX_MESSAGES)
|
|
40
|
+
|
|
41
|
+
payload = {
|
|
42
|
+
'version' => 1,
|
|
43
|
+
'updated_at' => Time.now.utc.iso8601,
|
|
44
|
+
'messages' => normalized
|
|
45
|
+
}
|
|
46
|
+
File.write(path, "#{JSON.pretty_generate(payload)}\n")
|
|
47
|
+
rescue StandardError
|
|
48
|
+
# Ignore persistence failures; chat should still work.
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def save_from_chat(chat)
|
|
52
|
+
save_messages(chat.messages)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def apply_to_chat(chat)
|
|
56
|
+
load_messages.each do |message|
|
|
57
|
+
chat.add_message(role: message['role'].to_sym, content: message['content'])
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def user_prompts
|
|
62
|
+
load_messages.select { |m| m['role'] == 'user' }.map { |m| m['content'] }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def seed_prompt_history(prompt)
|
|
66
|
+
return unless prompt.respond_to?(:reader)
|
|
67
|
+
|
|
68
|
+
user_prompts.each do |line|
|
|
69
|
+
prompt.reader.add_to_history(line)
|
|
70
|
+
end
|
|
71
|
+
rescue StandardError
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def normalize_message(message)
|
|
76
|
+
if message.respond_to?(:role) && message.respond_to?(:content)
|
|
77
|
+
role = message.role.to_s
|
|
78
|
+
content = message.content.to_s
|
|
79
|
+
elsif message.is_a?(Hash)
|
|
80
|
+
role = (message[:role] || message['role']).to_s
|
|
81
|
+
content = (message[:content] || message['content']).to_s
|
|
82
|
+
else
|
|
83
|
+
return nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
return nil unless %w[user assistant].include?(role)
|
|
87
|
+
return nil if content.strip.empty?
|
|
88
|
+
|
|
89
|
+
{ 'role' => role, 'content' => content }
|
|
90
|
+
end
|
|
91
|
+
private_class_method :normalize_message
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|