ai-chat 0.2.2 → 0.2.4
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 +28 -18
- data/ai-chat.gemspec +2 -1
- data/lib/ai/amazing_print.rb +77 -0
- data/lib/ai/chat.rb +231 -66
- data/lib/ai-chat.rb +7 -0
- metadata +23 -5
- data/lib/ai/response.rb +0 -17
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 12727b49e500d9c3c117b0d8a4df291f0b050eae0b12c90d7cd992ac93881c42
|
4
|
+
data.tar.gz: 2ebf913f409bdfbebcc5cd12d8a4a87346be894564d75a6c7811157b9619ecce
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 3707fdf80a68ce541b0c4fb3ab362f2404e2b0e78a3e7bd86ec140702f4b2939a6c2740b12a58d39212aceb24607c87648948f5d93bb5fcacffbf7200e35e2e0
|
7
|
+
data.tar.gz: 9d3e23578c90251da1fb08351aafe48fa5c7a82ec330644a6cffbe156fd42d58bcfbebd42be93df5a7a680cddfe0855c8dc2770ef3c8a0a70f2c4d4e8fa894f8
|
data/README.md
CHANGED
@@ -82,18 +82,18 @@ pp a.messages
|
|
82
82
|
# => [{:role=>"user", :content=>"If the Ruby community had an official motto, what might it be?"}]
|
83
83
|
|
84
84
|
# Generate the next message using AI
|
85
|
-
a.generate! # => "Matz is nice and so we are nice" (or similar)
|
85
|
+
a.generate! # => { :role => "assistant", :content => "Matz is nice and so we are nice" (or similar) }
|
86
86
|
|
87
87
|
# Your array now includes the assistant's response
|
88
88
|
pp a.messages
|
89
89
|
# => [
|
90
90
|
# {:role=>"user", :content=>"If the Ruby community had an official motto, what might it be?"},
|
91
|
-
# {:role=>"assistant", :content=>"Matz is nice and so we are nice", :response =>
|
91
|
+
# {:role=>"assistant", :content=>"Matz is nice and so we are nice", :response => { id=resp_abc... model=gpt-4.1-nano tokens=12 } }
|
92
92
|
# ]
|
93
93
|
|
94
94
|
# Continue the conversation
|
95
95
|
a.add("What about Rails?")
|
96
|
-
a.generate! # => "Convention over configuration."
|
96
|
+
a.generate! # => { :role => "assistant", :content => "Convention over configuration."}
|
97
97
|
```
|
98
98
|
|
99
99
|
## Understanding the Data Structure
|
@@ -108,7 +108,7 @@ That's it! You're building something like this:
|
|
108
108
|
[
|
109
109
|
{:role => "system", :content => "You are a helpful assistant"},
|
110
110
|
{:role => "user", :content => "Hello!"},
|
111
|
-
{:role => "assistant", :content => "Hi there! How can I help you today?", :response =>
|
111
|
+
{:role => "assistant", :content => "Hi there! How can I help you today?", :response => { id=resp_abc... model=gpt-4.1-nano tokens=12 } }
|
112
112
|
]
|
113
113
|
```
|
114
114
|
|
@@ -135,7 +135,7 @@ pp b.messages
|
|
135
135
|
# ]
|
136
136
|
|
137
137
|
# Generate a response
|
138
|
-
b.generate! # => "Methinks 'tis 'Ruby doth bring joy to all who craft with care'"
|
138
|
+
b.generate! # => { :role => "assistant", :content => "Methinks 'tis 'Ruby doth bring joy to all who craft with care'" }
|
139
139
|
```
|
140
140
|
|
141
141
|
### Convenience Methods
|
@@ -237,7 +237,7 @@ h.messages.last[:content]
|
|
237
237
|
# => "Here's how to boil an egg..."
|
238
238
|
|
239
239
|
# Or use the convenient shortcut
|
240
|
-
h.last
|
240
|
+
h.last[:content]
|
241
241
|
# => "Here's how to boil an egg..."
|
242
242
|
```
|
243
243
|
|
@@ -277,10 +277,11 @@ i.schema = '{"name": "nutrition_values","strict": true,"schema": {"type": "objec
|
|
277
277
|
i.user("1 slice of pizza")
|
278
278
|
|
279
279
|
response = i.generate!
|
280
|
+
data = response[:content]
|
280
281
|
# => {:fat=>15, :protein=>12, :carbs=>35, :total_calories=>285}
|
281
282
|
|
282
283
|
# The response is parsed JSON, not a string!
|
283
|
-
|
284
|
+
data[:total_calories] # => 285
|
284
285
|
```
|
285
286
|
|
286
287
|
### Schema Formats
|
@@ -442,14 +443,14 @@ a = AI::Chat.new
|
|
442
443
|
a.user("What color is the object in this photo?", image: "thing.png")
|
443
444
|
a.generate! # => "Red"
|
444
445
|
a.user("What is the object in the photo?")
|
445
|
-
a.generate! # => "I don't see a photo"
|
446
|
+
a.generate! # => { :content => "I don't see a photo", ... }
|
446
447
|
|
447
448
|
b = AI::Chat.new
|
448
449
|
b.user("What color is the object in this photo?", image: "thing.png")
|
449
450
|
b.generate! # => "Red"
|
450
451
|
b.user("What is the object in the photo?")
|
451
452
|
b.previous_response_id = nil
|
452
|
-
b.generate! # => "An apple"
|
453
|
+
b.generate! # => { :content => "An apple", ... }
|
453
454
|
```
|
454
455
|
|
455
456
|
If you don't set `previous_response_id` to `nil`, the model won't have the old image(s) to work with.
|
@@ -462,7 +463,7 @@ You can enable OpenAI's image generation tool:
|
|
462
463
|
a = AI::Chat.new
|
463
464
|
a.image_generation = true
|
464
465
|
a.user("Draw a picture of a kitten")
|
465
|
-
a.generate! # => "Here is your picture of a kitten:"
|
466
|
+
a.generate! # => { :content => "Here is your picture of a kitten:", ... }
|
466
467
|
```
|
467
468
|
|
468
469
|
By default, images are saved to `./images`. You can configure a different location:
|
@@ -472,7 +473,7 @@ a = AI::Chat.new
|
|
472
473
|
a.image_generation = true
|
473
474
|
a.image_folder = "./my_images"
|
474
475
|
a.user("Draw a picture of a kitten")
|
475
|
-
a.generate! # => "Here is your picture of a kitten:"
|
476
|
+
a.generate! # => { :content => "Here is your picture of a kitten:", ... }
|
476
477
|
```
|
477
478
|
|
478
479
|
Images are saved in timestamped subfolders using ISO 8601 basic format. For example:
|
@@ -510,9 +511,18 @@ a = AI::Chat.new
|
|
510
511
|
a.image_generation = true
|
511
512
|
a.image_folder = "./images"
|
512
513
|
a.user("Draw a picture of a kitten")
|
513
|
-
a.generate! # => "Here is a picture of a kitten:"
|
514
|
+
a.generate! # => { :content => "Here is a picture of a kitten:", ... }
|
514
515
|
a.user("Make it even cuter")
|
515
|
-
a.generate! # => "Here is the kitten, but even cuter:"
|
516
|
+
a.generate! # => { :content => "Here is the kitten, but even cuter:", ... }
|
517
|
+
```
|
518
|
+
|
519
|
+
## Code Interpreter
|
520
|
+
|
521
|
+
```ruby
|
522
|
+
y = AI::Chat.new
|
523
|
+
y.code_interpreter = true
|
524
|
+
y.user("Plot y = 2x*3 when x is -5 to 5.")
|
525
|
+
y.generate! # => {:content => "Here is the graph.", ... }
|
516
526
|
```
|
517
527
|
|
518
528
|
## Building Conversations Without API Calls
|
@@ -577,14 +587,14 @@ pp t.messages.last
|
|
577
587
|
# => {
|
578
588
|
# :role => "assistant",
|
579
589
|
# :content => "Hello! How can I help you today?",
|
580
|
-
# :response =>
|
590
|
+
# :response => { id=resp_abc... model=gpt-4.1-nano tokens=12 }
|
581
591
|
# }
|
582
592
|
|
583
593
|
# Access detailed information
|
584
594
|
response = t.last[:response]
|
585
|
-
response
|
586
|
-
response
|
587
|
-
response
|
595
|
+
response[:id] # => "resp_abc123..."
|
596
|
+
response[:model] # => "gpt-4.1-nano"
|
597
|
+
response[:usage] # => {:prompt_tokens=>5, :completion_tokens=>7, :total_tokens=>12}
|
588
598
|
```
|
589
599
|
|
590
600
|
This information is useful for:
|
@@ -599,7 +609,7 @@ You can also, if you know a response ID, continue an old conversation by setting
|
|
599
609
|
t = AI::Chat.new
|
600
610
|
t.user("Hello!")
|
601
611
|
t.generate!
|
602
|
-
old_id = t.last[:response]
|
612
|
+
old_id = t.last[:response][:id] # => "resp_abc123..."
|
603
613
|
|
604
614
|
# Some time in the future...
|
605
615
|
|
data/ai-chat.gemspec
CHANGED
@@ -2,7 +2,7 @@
|
|
2
2
|
|
3
3
|
Gem::Specification.new do |spec|
|
4
4
|
spec.name = "ai-chat"
|
5
|
-
spec.version = "0.2.
|
5
|
+
spec.version = "0.2.4"
|
6
6
|
spec.authors = ["Raghu Betina"]
|
7
7
|
spec.email = ["raghu@firstdraft.com"]
|
8
8
|
spec.homepage = "https://github.com/firstdraft/ai-chat"
|
@@ -23,6 +23,7 @@ Gem::Specification.new do |spec|
|
|
23
23
|
spec.add_runtime_dependency "marcel", "~> 1.0"
|
24
24
|
spec.add_runtime_dependency "base64", "> 0.1.1"
|
25
25
|
spec.add_runtime_dependency "json", "~> 2.0"
|
26
|
+
spec.add_runtime_dependency "tty-spinner", "~> 0.9.3"
|
26
27
|
|
27
28
|
spec.add_development_dependency "dotenv"
|
28
29
|
spec.add_development_dependency "refinements", "~> 11.1"
|
@@ -0,0 +1,77 @@
|
|
1
|
+
require "amazing_print"
|
2
|
+
# :reek:IrresponsibleModule
|
3
|
+
module AmazingPrint
|
4
|
+
module AI
|
5
|
+
def self.included(base)
|
6
|
+
base.send :alias_method, :cast_without_ai, :cast
|
7
|
+
base.send :alias_method, :cast, :cast_with_ai
|
8
|
+
end
|
9
|
+
|
10
|
+
def cast_with_ai(object, type)
|
11
|
+
case object
|
12
|
+
when ::AI::Chat
|
13
|
+
:ai_object
|
14
|
+
else
|
15
|
+
cast_without_ai(object, type)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
def awesome_ai_object(object)
|
22
|
+
case object
|
23
|
+
when ::AI::Chat
|
24
|
+
format_ai_chat(object)
|
25
|
+
else
|
26
|
+
awesome_object(object)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
# :reek:DuplicateMethodCall
|
31
|
+
# :reek:FeatureEnvy
|
32
|
+
# :reek:NilCheck
|
33
|
+
# :reek:TooManyStatements
|
34
|
+
def format_ai_chat(chat)
|
35
|
+
vars = []
|
36
|
+
|
37
|
+
# Format messages with truncation
|
38
|
+
if chat.instance_variable_defined?(:@messages)
|
39
|
+
messages = chat.instance_variable_get(:@messages).map do |msg|
|
40
|
+
truncated_msg = msg.dup
|
41
|
+
if msg[:content].is_a?(String) && msg[:content].length > 80
|
42
|
+
truncated_msg[:content] = msg[:content][0..77] + "..."
|
43
|
+
end
|
44
|
+
truncated_msg
|
45
|
+
end
|
46
|
+
vars << ["@messages", messages]
|
47
|
+
end
|
48
|
+
|
49
|
+
# Add other variables (except sensitive ones)
|
50
|
+
skip_vars = [:@api_key, :@client, :@messages]
|
51
|
+
chat.instance_variables.sort.each do |var|
|
52
|
+
next if skip_vars.include?(var)
|
53
|
+
value = chat.instance_variable_get(var)
|
54
|
+
vars << [var.to_s, value] unless value.nil?
|
55
|
+
end
|
56
|
+
|
57
|
+
format_object(chat, vars)
|
58
|
+
end
|
59
|
+
|
60
|
+
# :reek:TooManyStatements
|
61
|
+
# :reek:DuplicateMethodCall
|
62
|
+
def format_object(object, vars)
|
63
|
+
data = vars.map do |(name, value)|
|
64
|
+
name = colorize(name, :variable) unless @options[:plain]
|
65
|
+
"#{name}: #{inspector.awesome(value)}"
|
66
|
+
end
|
67
|
+
|
68
|
+
if @options[:multiline]
|
69
|
+
"#<#{object.class}\n#{data.map { |line| " #{line}" }.join("\n")}\n>"
|
70
|
+
else
|
71
|
+
"#<#{object.class} #{data.join(', ')}>"
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
AmazingPrint::Formatter.send(:include, AmazingPrint::AI)
|
data/lib/ai/chat.rb
CHANGED
@@ -7,8 +7,8 @@ require "openai"
|
|
7
7
|
require "pathname"
|
8
8
|
require "stringio"
|
9
9
|
require "fileutils"
|
10
|
-
|
11
|
-
|
10
|
+
require "tty-spinner"
|
11
|
+
require "timeout"
|
12
12
|
|
13
13
|
module AI
|
14
14
|
# :reek:MissingSafeMethod { exclude: [ generate! ] }
|
@@ -18,7 +18,7 @@ module AI
|
|
18
18
|
# :reek:IrresponsibleModule
|
19
19
|
class Chat
|
20
20
|
# :reek:Attribute
|
21
|
-
attr_accessor :
|
21
|
+
attr_accessor :background, :code_interpreter, :image_generation, :image_folder, :messages, :model, :previous_response_id, :web_search
|
22
22
|
attr_reader :reasoning_effort, :client, :schema
|
23
23
|
|
24
24
|
VALID_REASONING_EFFORTS = [:low, :medium, :high].freeze
|
@@ -36,16 +36,16 @@ module AI
|
|
36
36
|
|
37
37
|
# :reek:TooManyStatements
|
38
38
|
# :reek:NilCheck
|
39
|
-
def add(content, role: "user", response: nil, image: nil, images: nil, file: nil, files: nil)
|
39
|
+
def add(content, role: "user", response: nil, status: nil, image: nil, images: nil, file: nil, files: nil)
|
40
40
|
if image.nil? && images.nil? && file.nil? && files.nil?
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
41
|
+
message = {
|
42
|
+
role: role,
|
43
|
+
content: content,
|
44
|
+
response: response
|
45
|
+
}
|
46
|
+
message[:content] = content if content
|
47
|
+
message[:status] = status if status
|
48
|
+
messages.push(message)
|
49
49
|
else
|
50
50
|
text_and_files_array = [
|
51
51
|
{
|
@@ -78,7 +78,8 @@ module AI
|
|
78
78
|
messages.push(
|
79
79
|
{
|
80
80
|
role: role,
|
81
|
-
content: text_and_files_array
|
81
|
+
content: text_and_files_array,
|
82
|
+
status: status
|
82
83
|
}
|
83
84
|
)
|
84
85
|
end
|
@@ -92,48 +93,31 @@ module AI
|
|
92
93
|
add(message, role: "user", image: image, images: images, file: file, files: files)
|
93
94
|
end
|
94
95
|
|
95
|
-
def assistant(message, response: nil)
|
96
|
-
add(message, role: "assistant", response: response)
|
96
|
+
def assistant(message, response: nil, status: nil)
|
97
|
+
add(message, role: "assistant", response: response, status: status)
|
97
98
|
end
|
98
99
|
|
99
100
|
# :reek:NilCheck
|
100
101
|
# :reek:TooManyStatements
|
101
102
|
def generate!
|
102
103
|
response = create_response
|
104
|
+
parse_response(response)
|
103
105
|
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
image_filenames = extract_and_save_images(response)
|
109
|
-
|
110
|
-
chat_response.images = image_filenames
|
111
|
-
|
112
|
-
message = if schema
|
113
|
-
if text_response.nil? || text_response.empty?
|
114
|
-
raise ArgumentError, "No text content in response to parse as JSON for schema: #{schema.inspect}"
|
115
|
-
end
|
116
|
-
JSON.parse(text_response, symbolize_names: true)
|
117
|
-
else
|
118
|
-
text_response
|
119
|
-
end
|
106
|
+
self.previous_response_id = last.dig(:response, :id)
|
107
|
+
last
|
108
|
+
end
|
120
109
|
|
121
|
-
|
122
|
-
|
110
|
+
# :reek:BooleanParameter
|
111
|
+
# :reek:ControlParameter
|
112
|
+
# :reek:DuplicateMethodCall
|
113
|
+
# :reek:TooManyStatements
|
114
|
+
def get_response(wait: false, timeout: 600)
|
115
|
+
response = if wait
|
116
|
+
wait_for_response(timeout)
|
123
117
|
else
|
124
|
-
|
125
|
-
{
|
126
|
-
role: "assistant",
|
127
|
-
content: message,
|
128
|
-
images: image_filenames,
|
129
|
-
response: chat_response
|
130
|
-
}.compact
|
131
|
-
)
|
118
|
+
client.responses.retrieve(previous_response_id)
|
132
119
|
end
|
133
|
-
|
134
|
-
self.previous_response_id = response.id
|
135
|
-
|
136
|
-
message
|
120
|
+
parse_response(response)
|
137
121
|
end
|
138
122
|
|
139
123
|
# :reek:NilCheck
|
@@ -173,6 +157,42 @@ module AI
|
|
173
157
|
"#<#{self.class.name} @messages=#{messages.inspect} @model=#{@model.inspect} @schema=#{@schema.inspect} @reasoning_effort=#{@reasoning_effort.inspect}>"
|
174
158
|
end
|
175
159
|
|
160
|
+
# Support for Ruby's pp (pretty print)
|
161
|
+
# :reek:TooManyStatements
|
162
|
+
# :reek:NilCheck
|
163
|
+
# :reek:FeatureEnvy
|
164
|
+
# :reek:DuplicateMethodCall
|
165
|
+
# :reek:UncommunicativeParameterName
|
166
|
+
def pretty_print(q)
|
167
|
+
q.group(1, "#<#{self.class}", '>') do
|
168
|
+
q.breakable
|
169
|
+
|
170
|
+
# Show messages with truncation
|
171
|
+
q.text "@messages="
|
172
|
+
truncated_messages = @messages.map do |msg|
|
173
|
+
truncated_msg = msg.dup
|
174
|
+
if msg[:content].is_a?(String) && msg[:content].length > 80
|
175
|
+
truncated_msg[:content] = msg[:content][0..77] + "..."
|
176
|
+
end
|
177
|
+
truncated_msg
|
178
|
+
end
|
179
|
+
q.pp truncated_messages
|
180
|
+
|
181
|
+
# Show other instance variables (except sensitive ones)
|
182
|
+
skip_vars = [:@messages, :@api_key, :@client]
|
183
|
+
instance_variables.sort.each do |var|
|
184
|
+
next if skip_vars.include?(var)
|
185
|
+
value = instance_variable_get(var)
|
186
|
+
unless value.nil?
|
187
|
+
q.text ","
|
188
|
+
q.breakable
|
189
|
+
q.text "#{var}="
|
190
|
+
q.pp value
|
191
|
+
end
|
192
|
+
end
|
193
|
+
end
|
194
|
+
end
|
195
|
+
|
176
196
|
private
|
177
197
|
|
178
198
|
class InputClassificationError < StandardError; end
|
@@ -196,6 +216,7 @@ module AI
|
|
196
216
|
model: model
|
197
217
|
}
|
198
218
|
|
219
|
+
parameters[:background] = background if background
|
199
220
|
parameters[:tools] = tools unless tools.empty?
|
200
221
|
parameters[:text] = schema if schema
|
201
222
|
parameters[:reasoning] = {effort: reasoning_effort} if reasoning_effort
|
@@ -207,10 +228,60 @@ module AI
|
|
207
228
|
client.responses.create(**parameters)
|
208
229
|
end
|
209
230
|
|
231
|
+
# :reek:NilCheck
|
232
|
+
# :reek:TooManyStatements
|
233
|
+
def parse_response(response)
|
234
|
+
text_response = response.output_text
|
235
|
+
image_filenames = extract_and_save_images(response)
|
236
|
+
response_id = response.id
|
237
|
+
response_usage = response.usage.to_h.slice(:input_tokens, :output_tokens, :total_tokens)
|
238
|
+
|
239
|
+
chat_response = {
|
240
|
+
id: response_id,
|
241
|
+
model: response.model,
|
242
|
+
usage: response_usage,
|
243
|
+
total_tokens: response_usage[:total_tokens],
|
244
|
+
images: image_filenames
|
245
|
+
}.compact
|
246
|
+
|
247
|
+
response_content = if schema
|
248
|
+
if text_response.nil? || text_response.empty?
|
249
|
+
raise ArgumentError, "No text content in response to parse as JSON for schema: #{schema.inspect}"
|
250
|
+
end
|
251
|
+
JSON.parse(text_response, symbolize_names: true)
|
252
|
+
else
|
253
|
+
text_response
|
254
|
+
end
|
255
|
+
|
256
|
+
existing_message_position = messages.find_index do |message|
|
257
|
+
message.dig(:response, :id) == response_id
|
258
|
+
end
|
259
|
+
|
260
|
+
message = {
|
261
|
+
role: "assistant",
|
262
|
+
content: response_content,
|
263
|
+
response: chat_response,
|
264
|
+
status: response.status
|
265
|
+
}
|
266
|
+
|
267
|
+
message.store(:images, image_filenames) unless image_filenames.empty?
|
268
|
+
|
269
|
+
if existing_message_position
|
270
|
+
messages[existing_message_position] = message
|
271
|
+
else
|
272
|
+
messages.push(message)
|
273
|
+
message
|
274
|
+
end
|
275
|
+
end
|
276
|
+
|
277
|
+
def cancel_request
|
278
|
+
client.responses.cancel(previous_response_id)
|
279
|
+
end
|
280
|
+
|
210
281
|
def prepare_messages_for_api
|
211
282
|
return messages unless previous_response_id
|
212
283
|
|
213
|
-
previous_response_index = messages.find_index { |message| message
|
284
|
+
previous_response_index = messages.find_index { |message| message.dig(:response, :id) == previous_response_id }
|
214
285
|
|
215
286
|
if previous_response_index
|
216
287
|
messages[(previous_response_index + 1)..] || []
|
@@ -354,18 +425,14 @@ module AI
|
|
354
425
|
if image_generation
|
355
426
|
tools_list << {type: "image_generation"}
|
356
427
|
end
|
428
|
+
if code_interpreter
|
429
|
+
tools_list << {type: "code_interpreter", container: {type: "auto"}}
|
430
|
+
end
|
357
431
|
tools_list
|
358
432
|
end
|
359
433
|
|
360
|
-
def extract_text_from_response(response)
|
361
|
-
response.output.flat_map { |output|
|
362
|
-
output.respond_to?(:content) ? output.content : []
|
363
|
-
}.compact.find { |content|
|
364
|
-
content.is_a?(OpenAI::Models::Responses::ResponseOutputText)
|
365
|
-
}&.text
|
366
|
-
end
|
367
|
-
|
368
434
|
# :reek:FeatureEnvy
|
435
|
+
# :reek:UtilityFunction
|
369
436
|
def wrap_schema_if_needed(schema)
|
370
437
|
if schema.key?(:format) || schema.key?("format")
|
371
438
|
schema
|
@@ -400,31 +467,129 @@ module AI
|
|
400
467
|
|
401
468
|
return image_filenames if image_outputs.empty?
|
402
469
|
|
403
|
-
|
404
|
-
timestamp = Time.now.strftime("%Y%m%dT%H%M%S%2N")
|
405
|
-
|
406
|
-
subfolder_name = "#{timestamp}_#{response.id}"
|
407
|
-
subfolder_path = File.join(image_folder || "./images", subfolder_name)
|
408
|
-
FileUtils.mkdir_p(subfolder_path)
|
470
|
+
subfolder_path = create_images_folder(response.id)
|
409
471
|
|
410
472
|
image_outputs.each_with_index do |output, index|
|
411
473
|
next unless output.respond_to?(:result) && output.result
|
412
474
|
|
413
|
-
|
475
|
+
warn_if_file_fails_to_save do
|
414
476
|
image_data = Base64.strict_decode64(output.result)
|
415
477
|
|
416
478
|
filename = "#{(index + 1).to_s.rjust(3, "0")}.png"
|
417
|
-
|
479
|
+
file_path = File.join(subfolder_path, filename)
|
418
480
|
|
419
|
-
File.binwrite(
|
481
|
+
File.binwrite(file_path, image_data)
|
420
482
|
|
421
|
-
image_filenames <<
|
422
|
-
rescue => error
|
423
|
-
warn "Failed to save image: #{error.message}"
|
483
|
+
image_filenames << file_path
|
424
484
|
end
|
425
485
|
end
|
426
486
|
|
427
487
|
image_filenames
|
428
488
|
end
|
489
|
+
|
490
|
+
def create_images_folder(response_id)
|
491
|
+
# ISO 8601 basic format with centisecond precision
|
492
|
+
timestamp = Time.now.strftime("%Y%m%dT%H%M%S%2N")
|
493
|
+
|
494
|
+
subfolder_name = "#{timestamp}_#{response_id}"
|
495
|
+
subfolder_path = File.join(image_folder || "./images", subfolder_name)
|
496
|
+
FileUtils.mkdir_p(subfolder_path)
|
497
|
+
subfolder_path
|
498
|
+
end
|
499
|
+
|
500
|
+
def warn_if_file_fails_to_save
|
501
|
+
begin
|
502
|
+
yield
|
503
|
+
rescue => error
|
504
|
+
warn "Failed to save image: #{error.message}"
|
505
|
+
end
|
506
|
+
end
|
507
|
+
|
508
|
+
# :reek:FeatureEnvy
|
509
|
+
# :reek:ManualDispatch
|
510
|
+
# :reek:NestedIterators
|
511
|
+
# :reek:TooManyStatements
|
512
|
+
def extract_and_save_files(response)
|
513
|
+
filenames = []
|
514
|
+
|
515
|
+
message_outputs = response.output.select do |output|
|
516
|
+
output.respond_to?(:type) && output.type == :message
|
517
|
+
end
|
518
|
+
|
519
|
+
outputs_with_annotations = message_outputs.map do |message|
|
520
|
+
message.content.find do |content|
|
521
|
+
content.respond_to?(:annotations) && content.annotations.length.positive?
|
522
|
+
end
|
523
|
+
end.compact
|
524
|
+
|
525
|
+
return filenames if outputs_with_annotations.empty?
|
526
|
+
|
527
|
+
subfolder_path = create_images_folder(response.id)
|
528
|
+
annotations = outputs_with_annotations.map do |output|
|
529
|
+
output.annotations.find do |annotation|
|
530
|
+
annotation.respond_to?(:filename)
|
531
|
+
end
|
532
|
+
end.compact
|
533
|
+
|
534
|
+
annotations.each do |annotation|
|
535
|
+
container_id = annotation.container_id
|
536
|
+
file_id = annotation.file_id
|
537
|
+
filename = annotation.filename
|
538
|
+
|
539
|
+
warn_if_file_fails_to_save do
|
540
|
+
container_content = client.containers.files.content
|
541
|
+
file_content = container_content.retrieve(file_id, container_id: container_id)
|
542
|
+
file_path = File.join(subfolder_path, filename)
|
543
|
+
File.open(file_path, "wb") do |file|
|
544
|
+
file.write(file_content.read)
|
545
|
+
end
|
546
|
+
filenames << file_path
|
547
|
+
end
|
548
|
+
end
|
549
|
+
|
550
|
+
filenames
|
551
|
+
end
|
552
|
+
|
553
|
+
# This is similar to ActiveJob's :polynomially_longer retry option
|
554
|
+
# :reek:DuplicateMethodCall
|
555
|
+
# :reek:UtilityFunction
|
556
|
+
def calculate_wait(executions)
|
557
|
+
# cap the maximum wait time to ~110 seconds
|
558
|
+
executions = executions.clamp(1..10)
|
559
|
+
jitter = 0.15
|
560
|
+
((executions**2) + (Kernel.rand * (executions**2) * jitter)) + 2
|
561
|
+
end
|
562
|
+
|
563
|
+
def timeout_request(duration)
|
564
|
+
begin
|
565
|
+
Timeout.timeout(duration) do
|
566
|
+
yield
|
567
|
+
end
|
568
|
+
rescue Timeout::Error
|
569
|
+
client.responses.cancel(previous_response_id)
|
570
|
+
end
|
571
|
+
end
|
572
|
+
|
573
|
+
# :reek:DuplicateMethodCall
|
574
|
+
# :reek:TooManyStatements
|
575
|
+
def wait_for_response(timeout)
|
576
|
+
spinner = TTY::Spinner.new("[:spinner] Thinking ...", format: :dots)
|
577
|
+
spinner.auto_spin
|
578
|
+
api_response = client.responses.retrieve(previous_response_id)
|
579
|
+
number_of_times_polled = 0
|
580
|
+
response = timeout_request(timeout) do
|
581
|
+
while api_response.status != :completed
|
582
|
+
some_amount_of_seconds = calculate_wait(number_of_times_polled)
|
583
|
+
sleep some_amount_of_seconds
|
584
|
+
number_of_times_polled += 1
|
585
|
+
api_response = client.responses.retrieve(previous_response_id)
|
586
|
+
end
|
587
|
+
api_response
|
588
|
+
end
|
589
|
+
|
590
|
+
exit_message = response.status == :cancelled ? "request timed out" : "done!"
|
591
|
+
spinner.stop(exit_message)
|
592
|
+
response
|
593
|
+
end
|
429
594
|
end
|
430
595
|
end
|
data/lib/ai-chat.rb
CHANGED
metadata
CHANGED
@@ -1,13 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: ai-chat
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.2.
|
4
|
+
version: 0.2.4
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Raghu Betina
|
8
|
+
autorequire:
|
8
9
|
bindir: bin
|
9
10
|
cert_chain: []
|
10
|
-
date:
|
11
|
+
date: 2025-09-08 00:00:00.000000000 Z
|
11
12
|
dependencies:
|
12
13
|
- !ruby/object:Gem::Dependency
|
13
14
|
name: openai
|
@@ -65,6 +66,20 @@ dependencies:
|
|
65
66
|
- - "~>"
|
66
67
|
- !ruby/object:Gem::Version
|
67
68
|
version: '2.0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: tty-spinner
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - "~>"
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: 0.9.3
|
76
|
+
type: :runtime
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - "~>"
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: 0.9.3
|
68
83
|
- !ruby/object:Gem::Dependency
|
69
84
|
name: dotenv
|
70
85
|
requirement: !ruby/object:Gem::Requirement
|
@@ -93,20 +108,21 @@ dependencies:
|
|
93
108
|
- - "~>"
|
94
109
|
- !ruby/object:Gem::Version
|
95
110
|
version: '11.1'
|
111
|
+
description:
|
96
112
|
email:
|
97
113
|
- raghu@firstdraft.com
|
98
114
|
executables: []
|
99
115
|
extensions: []
|
100
116
|
extra_rdoc_files:
|
101
|
-
- LICENSE
|
102
117
|
- README.md
|
118
|
+
- LICENSE
|
103
119
|
files:
|
104
120
|
- LICENSE
|
105
121
|
- README.md
|
106
122
|
- ai-chat.gemspec
|
107
123
|
- lib/ai-chat.rb
|
124
|
+
- lib/ai/amazing_print.rb
|
108
125
|
- lib/ai/chat.rb
|
109
|
-
- lib/ai/response.rb
|
110
126
|
homepage: https://github.com/firstdraft/ai-chat
|
111
127
|
licenses:
|
112
128
|
- MIT
|
@@ -117,6 +133,7 @@ metadata:
|
|
117
133
|
label: AI Chat
|
118
134
|
rubygems_mfa_required: 'true'
|
119
135
|
source_code_uri: https://github.com/firstdraft/ai-chat
|
136
|
+
post_install_message:
|
120
137
|
rdoc_options: []
|
121
138
|
require_paths:
|
122
139
|
- lib
|
@@ -131,7 +148,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
131
148
|
- !ruby/object:Gem::Version
|
132
149
|
version: '0'
|
133
150
|
requirements: []
|
134
|
-
rubygems_version: 3.
|
151
|
+
rubygems_version: 3.4.6
|
152
|
+
signing_key:
|
135
153
|
specification_version: 4
|
136
154
|
summary: A beginner-friendly Ruby interface for OpenAI's API
|
137
155
|
test_files: []
|
data/lib/ai/response.rb
DELETED
@@ -1,17 +0,0 @@
|
|
1
|
-
module AI
|
2
|
-
# :reek:IrresponsibleModule
|
3
|
-
# :reek:TooManyInstanceVariables
|
4
|
-
class Response
|
5
|
-
attr_reader :id, :model, :usage, :total_tokens
|
6
|
-
# :reek:Attribute
|
7
|
-
attr_accessor :images
|
8
|
-
|
9
|
-
def initialize(response)
|
10
|
-
@id = response.id
|
11
|
-
@model = response.model
|
12
|
-
@usage = response.usage.to_h.slice(:input_tokens, :output_tokens, :total_tokens)
|
13
|
-
@total_tokens = @usage[:total_tokens]
|
14
|
-
@images = []
|
15
|
-
end
|
16
|
-
end
|
17
|
-
end
|