diff --git a/.gitignore b/.gitignore index 6e62f7f..49f9d5b 100644 --- a/.gitignore +++ b/.gitignore @@ -15,10 +15,6 @@ tmp/ .bundle/ vendor/bundle/ -# Development dependencies cloned locally -amazing_print/ -openai-ruby/ - # Temp demo scripts demo2/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ec45de..e2116a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.5.0] - 2025-12-05 + +### Breaking Changes + +- **Renamed `items` to `get_items`**: The method now clearly indicates it makes an API call. Returns an `AI::Items` wrapper that delegates to the underlying response while providing nice display formatting. + +### Added + +- **Reasoning summaries**: When `reasoning_effort` is set, the API now returns chain-of-thought summaries in `get_items`. These show the model's reasoning process (e.g., "Planning Ruby version search", "Confirming image tool usage"). + +- **Improved console display**: `AI::Chat`, `AI::Message`, and `AI::Items` now display nicely in IRB and Rails console with colorized, formatted output via AmazingPrint. + +- **HTML output for ERB templates**: All display objects have a `to_html` method for rendering in views. Includes dark terminal-style background for readability. + +- **`AI::Message` class**: Messages are now `AI::Message` instances (a Hash subclass) with custom display methods. + +- **`AI::Items` class**: Wraps the conversation items API response with nice display methods while delegating all other methods (like `.data`, `.has_more`, etc.) to the underlying response. + +- **TTY-aware display**: Console output automatically detects TTY and disables colors when output is piped or redirected. + +- **New example**: `examples/16_get_items.rb` demonstrates inspecting conversation items including reasoning, web searches, and image generation. + ## [0.4.0] - 2025-11-25 ### Breaking Changes diff --git a/Gemfile.lock b/Gemfile.lock index 1c56340..081dbff 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - ai-chat (0.4.0) + ai-chat (0.5.0) amazing_print (~> 1.8) base64 (~> 0.1, > 0.1.1) json (~> 2.0) diff --git a/README.md b/README.md index 52611bf..7364c8f 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ The `examples/` directory contains focused examples for specific features: - `13_conversation_features_comprehensive.rb` - Conversation features (auto-creation, continuity, inspection) - `14_schema_generation.rb` - Generate JSON schemas from natural language - `15_proxy.rb` - Proxy support for student accounts +- `16_get_items.rb` - Inspecting conversation items (reasoning, web searches, image generation) Each example is self-contained and can be run individually: ```bash @@ -83,22 +84,44 @@ a = AI::Chat.new a.add("If the Ruby community had an official motto, what might it be?") # See the convo so far - it's just an array of hashes! -pp a.messages -# => [{:role=>"user", :content=>"If the Ruby community had an official motto, what might it be?"}] +a.messages +# => [ +# { +# :role => "user", +# :content => "If the Ruby community had an official motto, what might it be?" +# } +# ] # Generate the next message using AI -a.generate! # => { :role => "assistant", :content => "Matz is nice and so we are nice" (or similar) } +a.generate! +# => { +# :role => "assistant", +# :content => "Matz is nice and so we are nice", +# :response => { ... } +# } # Your array now includes the assistant's response -pp a.messages +a.messages # => [ -# {:role=>"user", :content=>"If the Ruby community had an official motto, what might it be?"}, -# {:role=>"assistant", :content=>"Matz is nice and so we are nice", :response => { id=resp_abc... model=gpt-5.1 tokens=12 } } +# { +# :role => "user", +# :content => "If the Ruby community had an official motto, what might it be?" +# }, +# { +# :role => "assistant", +# :content => "Matz is nice and so we are nice", +# :response => { id: "resp_abc...", model: "gpt-5.1", ... } +# } # ] # Continue the conversation a.add("What about Rails?") -a.generate! # => { :role => "assistant", :content => "Convention over configuration."} +a.generate! +# => { +# :role => "assistant", +# :content => "Convention over configuration.", +# :response => { ... } +# } ``` ## Understanding the Data Structure @@ -111,9 +134,19 @@ That's it! You're building something like this: ```ruby [ - {:role => "system", :content => "You are a helpful assistant"}, - {:role => "user", :content => "Hello!"}, - {:role => "assistant", :content => "Hi there! How can I help you today?", :response => { id=resp_abc... model=gpt-5.1 tokens=12 } } + { + :role => "system", + :content => "You are a helpful assistant" + }, + { + :role => "user", + :content => "Hello!" + }, + { + :role => "assistant", + :content => "Hi there! How can I help you today?", + :response => { id: "resp_abc...", model: "gpt-5.1", ... } + } ] ``` @@ -133,14 +166,25 @@ b.add("You are a helpful assistant that talks like Shakespeare.", role: "system" b.add("If the Ruby community had an official motto, what might it be?") # Check what we've built -pp b.messages +b.messages # => [ -# {:role=>"system", :content=>"You are a helpful assistant that talks like Shakespeare."}, -# {:role=>"user", :content=>"If the Ruby community had an official motto, what might it be?"} +# { +# :role => "system", +# :content => "You are a helpful assistant that talks like Shakespeare." +# }, +# { +# :role => "user", +# :content => "If the Ruby community had an official motto, what might it be?" +# } # ] # Generate a response -b.generate! # => { :role => "assistant", :content => "Methinks 'tis 'Ruby doth bring joy to all who craft with care'" } +b.generate! +# => { +# :role => "assistant", +# :content => "Methinks 'tis 'Ruby doth bring joy to all who craft with care'", +# :response => { ... } +# } ``` ### Convenience Methods @@ -219,11 +263,20 @@ h.user("How do I boil an egg?") h.generate! # See the whole conversation -pp h.messages +h.messages # => [ -# {:role=>"system", :content=>"You are a helpful cooking assistant"}, -# {:role=>"user", :content=>"How do I boil an egg?"}, -# {:role=>"assistant", :content=>"Here's how to boil an egg..."} +# { +# :role => "system", +# :content => "You are a helpful cooking assistant" +# }, +# { +# :role => "user", +# :content => "How do I boil an egg?" +# }, +# { +# :role => "assistant", +# :content => "Here's how to boil an egg..." +# } # ] # Get just the last response @@ -460,7 +513,11 @@ You can enable OpenAI's image generation tool: a = AI::Chat.new a.image_generation = true a.user("Draw a picture of a kitten") -a.generate! # => { :content => "Here is your picture of a kitten:", ... } +a.generate! +# => { +# :content => "Here is your picture of a kitten:", +# :response => { ... } +# } ``` By default, images are saved to `./images`. You can configure a different location: @@ -470,7 +527,11 @@ a = AI::Chat.new a.image_generation = true a.image_folder = "./my_images" a.user("Draw a picture of a kitten") -a.generate! # => { :content => "Here is your picture of a kitten:", ... } +a.generate! +# => { +# :content => "Here is your picture of a kitten:", +# :response => { ... } +# } ``` Images are saved in timestamped subfolders using ISO 8601 basic format. For example: @@ -482,11 +543,19 @@ The folder structure ensures images are organized chronologically and by respons The messages array will now look like this: ```ruby -pp a.messages +a.messages # => [ -# {:role=>"user", :content=>"Draw a picture of a kitten"}, -# {:role=>"assistant", :content=>"Here is your picture of a kitten:", :images => ["./images/20250804T11303912_resp_abc123/001.png"], :response => #} -# ] +# { +# :role => "user", +# :content => "Draw a picture of a kitten" +# }, +# { +# :role => "assistant", +# :content => "Here is your picture of a kitten:", +# :images => [ "./images/20250804T11303912_resp_abc123/001.png" ], +# :response => { ... } +# } +# ] ``` You can access the image filenames in several ways: @@ -508,9 +577,11 @@ a = AI::Chat.new a.image_generation = true a.image_folder = "./images" a.user("Draw a picture of a kitten") -a.generate! # => { :content => "Here is a picture of a kitten:", ... } +a.generate! +# => { :content => "Here is a picture of a kitten:", ... } a.user("Make it even cuter") -a.generate! # => { :content => "Here is the kitten, but even cuter:", ... } +a.generate! +# => { :content => "Here is the kitten, but even cuter:", ... } ``` ## Code Interpreter @@ -519,7 +590,8 @@ a.generate! # => { :content => "Here is the kitten, but even cuter:", ... } y = AI::Chat.new y.code_interpreter = true y.user("Plot y = 2x*3 when x is -5 to 5.") -y.generate! # => {:content => "Here is the graph.", ... } +y.generate! +# => { :content => "Here is the graph.", ... } ``` ## Proxying Through prepend.me @@ -594,11 +666,11 @@ t.user("Hello!") t.generate! # Each assistant message includes a response object -pp t.messages.last +t.messages.last # => { -# :role => "assistant", -# :content => "Hello! How can I help you today?", -# :response => { id=resp_abc... model=gpt-5.1 tokens=12 } +# :role => "assistant", +# :content => "Hello! How can I help you today?", +# :response => { id: "resp_abc...", model: "gpt-5.1", ... } # } # Access detailed information @@ -663,60 +735,113 @@ chat.generate! # Uses the loaded conversation ## Inspecting Conversation Details -The gem provides two methods to inspect what happened during a conversation: - -### `items` - Programmatic Access - -Returns the raw conversation items for programmatic use (displaying in views, filtering, etc.): +The `get_items` method fetches all conversation items (messages, tool calls, reasoning, etc.) from the API for both programmatic use and debugging: ```ruby chat = AI::Chat.new +chat.reasoning_effort = "high" # Enable reasoning summaries chat.web_search = true chat.user("Search for Ruby tutorials") chat.generate! # Get all conversation items (chronological order by default) -page = chat.items +chat.get_items -# Access item data -page.data.each do |item| +# Output in IRB/Rails console: +# ┌────────────────────────────────────────────────────────────────────────────┐ +# │ Conversation: conv_6903c1eea6cc819695af3a1b1ebf9b390c3db5e8ec021c9a │ +# │ Items: 8 │ +# └────────────────────────────────────────────────────────────────────────────┘ +# +# [detailed colorized output of all items including web searches, +# reasoning summaries, tool calls, messages, etc.] + +# Iterate over items programmatically +chat.get_items.data.each do |item| case item.type when :message puts "#{item.role}: #{item.content.first.text}" when :web_search_call - puts "Web search: #{item.action.query}" - puts "Results: #{item.results.length}" + puts "Web search: #{item.action.query}" if item.action.respond_to?(:query) && item.action.query when :reasoning - puts "Reasoning: #{item.summary.first.text}" + # Reasoning summaries show the model's chain-of-thought + if item.summary&.first + puts "Reasoning: #{item.summary.first.text}" + end + when :image_generation_call + puts "Image generated" if item.result end end # For long conversations, you can request reverse chronological order # (useful for pagination to get most recent items first) -recent_items = chat.items(order: :desc) +recent_items = chat.get_items(order: :desc) ``` -### `verbose` - Terminal Output - -Pretty-prints the entire conversation with all details for debugging and learning: - -```ruby -chat.verbose - -# Output: -# ┌────────────────────────────────────────────────────────────────────────────┐ -# │ Conversation: conv_6903c1eea6cc819695af3a1b1ebf9b390c3db5e8ec021c9a │ -# │ Items: 3 │ -# └────────────────────────────────────────────────────────────────────────────┘ -# -# [detailed colorized output of all items including web searches, -# reasoning, tool calls, messages, etc.] -``` +When `reasoning_effort` is set, the API returns reasoning summaries that show the model's chain-of-thought process (e.g., "Planning Ruby version search", "Confirming image tool usage"). Note that not all reasoning items have summaries - some intermediate steps may be empty. This is useful for: - **Learning** how the model uses tools (web search, code interpreter, etc.) - **Debugging** why the model made certain decisions - **Understanding** the full context beyond just the final response +- **Transparency** into the model's reasoning process + +### HTML Output for ERB Templates + +All display objects have a `to_html` method for rendering in ERB templates: + +```erb +<%# Display a chat object %> +<%= @chat.to_html %> + +<%# Display individual messages %> +<% @chat.messages.each do |msg| %> + <%= msg.to_html %> +<% end %> + +<%# Display conversation items (quick debug view) %> +<%= @chat.get_items.to_html %> +``` + +The HTML output includes a dark background to match the terminal aesthetic. + +You can also loop over `get_items.data` to build custom displays showing reasoning steps, tool calls, etc.: + +```erb +<% @chat.get_items.data.each do |item| %> + <% case item.type.to_s %> + <% when "message" %> +
+ <%= item.role.capitalize %>: + <% if item.content&.first %> + <% content = item.content.first %> + <% if content.type.to_s == "input_text" %> + <%= content.text %> + <% elsif content.type.to_s == "output_text" %> + <%= content.text %> + <% end %> + <% end %> +
+ <% when "reasoning" %> + <% if item.summary&.first %> +
+ Reasoning + <%= item.summary.first.text %> +
+ <% end %> + <% when "web_search_call" %> + <% if item.action.respond_to?(:query) && item.action.query %> + + <% end %> + <% when "image_generation_call" %> +
+ Image generated +
+ <% end %> +<% end %> +``` ## Setting messages directly @@ -787,7 +912,7 @@ These are all gem-specific transformations (not just OpenAI pass-through) that c Address Reek warnings (`bundle exec reek`). Currently 29 warnings for code smells like: - `TooManyStatements` in several methods -- `DuplicateMethodCall` in `extract_and_save_files`, `verbose`, etc. +- `DuplicateMethodCall` in `extract_and_save_files`, `get_items`, etc. - `RepeatedConditional` for `proxy` checks - `FeatureEnvy` in `parse_response` and `wait_for_response` diff --git a/ai-chat.gemspec b/ai-chat.gemspec index 6735f0f..b87da67 100644 --- a/ai-chat.gemspec +++ b/ai-chat.gemspec @@ -2,9 +2,9 @@ Gem::Specification.new do |spec| spec.name = "ai-chat" - spec.version = "0.4.0" - spec.authors = ["Raghu Betina"] - spec.email = ["raghu@firstdraft.com"] + spec.version = "0.5.0" + spec.authors = ["Raghu Betina", "Jelani Woods"] + spec.email = ["raghu@firstdraft.com", "jelani@firstdraft.com"] spec.homepage = "https://github.com/firstdraft/ai-chat" spec.summary = "A beginner-friendly Ruby interface for OpenAI's API" spec.license = "MIT" diff --git a/examples/13_conversation_features_comprehensive.rb b/examples/13_conversation_features_comprehensive.rb index a73e60c..4e2b351 100644 --- a/examples/13_conversation_features_comprehensive.rb +++ b/examples/13_conversation_features_comprehensive.rb @@ -3,7 +3,7 @@ # This example demonstrates all conversation-related features: # - Automatic conversation creation # - Conversation continuity across multiple turns -# - Inspecting conversation items (programmatically and verbose) +# - Inspecting conversation items with get_items # - Loading existing conversations require_relative "../lib/ai-chat" @@ -47,10 +47,10 @@ # Feature 3: Programmatic access to items puts "3. Accessing Conversation Items (Programmatically)" puts "-" * 60 -puts "Use chat.items to get conversation data for processing or display." +puts "Use chat.get_items to get conversation data for processing or display." puts -page = chat.items +page = chat.get_items puts "Total items: #{page.data.length}" puts "Item breakdown:" page.data.each_with_index do |item, i| @@ -79,7 +79,9 @@ if web_searches.any? search = web_searches.first puts "Web search found:" - puts " Query: #{search.action.query}" + if search.action.respond_to?(:query) && search.action.query + puts " Query: #{search.action.query}" + end puts " Status: #{search.status}" if search.respond_to?(:results) && search.results puts " Results: #{search.results.length} found" @@ -98,8 +100,8 @@ puts "Items default to chronological order (:asc), but you can request :desc." puts -asc_items = chat.items -desc_items = chat.items(order: :desc) +asc_items = chat.get_items +desc_items = chat.get_items(order: :desc) puts "First item in chronological order:" first = asc_items.data.first @@ -111,12 +113,12 @@ puts "\n(Reverse order is useful for pagination in long conversations)" puts -# Feature 6: Verbose inspection -puts "6. Verbose Inspection (Terminal Output)" +# Feature 6: Formatted inspection +puts "6. Formatted Items Display (Terminal Output)" puts "-" * 60 -puts "Use chat.verbose for a detailed, colorized view of all conversation items." +puts "get_items returns an AI::Items object with nice inspect output." puts -chat.verbose +puts chat.get_items puts # Feature 7: Loading existing conversation diff --git a/examples/15_proxy.rb b/examples/15_proxy.rb index d4f0ae1..cc315f6 100644 --- a/examples/15_proxy.rb +++ b/examples/15_proxy.rb @@ -231,10 +231,10 @@ # Feature 3: Programmatic access to items puts "c. Accessing Conversation Items (Programmatically)" puts "-" * 60 -puts "Use chat.items to get conversation data for processing or display." +puts "Use chat.get_items to get conversation data for processing or display." puts -page = chat.items +page = chat.get_items puts "Total items: #{page.data.length}" puts "Item breakdown:" diff --git a/examples/16_get_items.rb b/examples/16_get_items.rb new file mode 100644 index 0000000..386fcf4 --- /dev/null +++ b/examples/16_get_items.rb @@ -0,0 +1,74 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# This example demonstrates how to use get_items to inspect +# all conversation items including reasoning, web searches, and tool calls. + +require_relative "../lib/ai-chat" +require "dotenv" +Dotenv.load(File.expand_path("../.env", __dir__)) +require "amazing_print" + +chat = AI::Chat.new +chat.reasoning_effort = "high" +chat.web_search = true +chat.image_generation = true + +chat.user("Search for the current stable Ruby version, then generate an image of the Ruby logo with the version number prominently displayed.") + +puts "Generating response with reasoning, web search, and image generation..." +puts +response = chat.generate! + +puts "=== Response ===" +puts response[:content] +puts + +# Fetch all conversation items from the API +items = chat.get_items + +puts "=== Conversation Items ===" +puts "Total items: #{items.data.length}" +puts + +# Iterate through items and display based on type +items.data.each_with_index do |item, index| + puts "--- Item #{index + 1}: #{item.type} ---" + + case item.type + when :message + puts "Role: #{item.role}" + if item.content&.first + content = item.content.first + case content.type + when :input_text + puts "Input: #{content.text}" + when :output_text + text = content.text.to_s + puts "Output: #{text[0..200]}#{"..." if text.length > 200}" + end + end + + when :reasoning + if item.summary&.first + text = item.summary.first.text.to_s + puts "Summary: #{text[0..200]}#{"..." if text.length > 200}" + else + puts "(Reasoning without summary)" + end + + when :web_search_call + puts "Query: #{item.action.query}" if item.action.respond_to?(:query) && item.action.query + puts "Status: #{item.status}" + + when :image_generation_call + puts "Status: #{item.status}" + puts "Result: Image generated" if item.result + end + + puts +end + +# Display the full items object (uses custom inspect) +puts "=== Full Items Display (IRB-style) ===" +puts items.inspect diff --git a/lib/ai-chat.rb b/lib/ai-chat.rb index 6eaaa4e..afe0fa8 100644 --- a/lib/ai-chat.rb +++ b/lib/ai-chat.rb @@ -1,3 +1,14 @@ +module AI + HTML_PRE_STYLE = "background-color: #1e1e1e; color: #d4d4d4; padding: 1em; white-space: pre-wrap; word-wrap: break-word;" + + def self.wrap_html(html) + html = html.gsub("
", "
")
+    html.respond_to?(:html_safe) ? html.html_safe : html
+  end
+end
+
+require_relative "ai/message"
+require_relative "ai/items"
 require_relative "ai/chat"
 
 # Load amazing_print extension if amazing_print is available
diff --git a/lib/ai/amazing_print.rb b/lib/ai/amazing_print.rb
index a584fd1..c243104 100644
--- a/lib/ai/amazing_print.rb
+++ b/lib/ai/amazing_print.rb
@@ -1,4 +1,22 @@
 require "amazing_print"
+
+# Fix AmazingPrint's colorless method to strip HTML tags in addition to ANSI codes.
+# Without this, alignment is broken when html: true because colorless_size
+# doesn't account for  tag lengths.
+# TODO: Remove if https://github.com/amazing-print/amazing_print/pull/146 is merged.
+module AmazingPrint
+  module Formatters
+    class BaseFormatter
+      alias_method :original_colorless, :colorless
+
+      def colorless(string)
+        result = original_colorless(string)
+        result.gsub(/]*>|<\/kbd>/, "")
+      end
+    end
+  end
+end
+
 # :reek:IrresponsibleModule
 module AmazingPrint
   module AI
@@ -27,33 +45,11 @@ def awesome_ai_object(object)
       end
     end
 
-    # :reek:DuplicateMethodCall
     # :reek:FeatureEnvy
-    # :reek:NilCheck
-    # :reek:TooManyStatements
     def format_ai_chat(chat)
-      vars = []
-
-      # Format messages with truncation
-      if chat.instance_variable_defined?(:@messages)
-        messages = chat.instance_variable_get(:@messages).map do |msg|
-          truncated_msg = msg.dup
-          if msg[:content].is_a?(String) && msg[:content].length > 80
-            truncated_msg[:content] = msg[:content][0..77] + "..."
-          end
-          truncated_msg
-        end
-        vars << ["@messages", messages]
+      vars = chat.inspectable_attributes.map do |(name, value)|
+        [name.to_s, value]
       end
-
-      # Add other variables (except sensitive ones)
-      skip_vars = [:@api_key, :@client, :@messages]
-      chat.instance_variables.sort.each do |var|
-        next if skip_vars.include?(var)
-        value = chat.instance_variable_get(var)
-        vars << [var.to_s, value] unless value.nil?
-      end
-
       format_object(chat, vars)
     end
 
@@ -65,10 +61,13 @@ def format_object(object, vars)
         "#{name}: #{inspector.awesome(value)}"
       end
 
+      lt = @options[:html] ? "<" : "<"
+      gt = @options[:html] ? ">" : ">"
+
       if @options[:multiline]
-        "#<#{object.class}\n#{data.map { |line| "  #{line}" }.join("\n")}\n>"
+        "##{lt}#{object.class}\n#{data.map { |line| "  #{line}" }.join("\n")}\n#{gt}"
       else
-        "#<#{object.class} #{data.join(", ")}>"
+        "##{lt}#{object.class} #{data.join(", ")}#{gt}"
       end
     end
   end
diff --git a/lib/ai/chat.rb b/lib/ai/chat.rb
index f1b7e41..3a5b891 100644
--- a/lib/ai/chat.rb
+++ b/lib/ai/chat.rb
@@ -12,7 +12,6 @@
 require "timeout"
 
 require_relative "http"
-include AI::Http
 
 module AI
   # :reek:MissingSafeMethod { exclude: [ generate! ] }
@@ -21,6 +20,8 @@ module AI
   # :reek:InstanceVariableAssumption
   # :reek:IrresponsibleModule
   class Chat
+    include AI::Http
+
     # :reek:Attribute
     attr_accessor :background, :code_interpreter, :conversation_id, :image_generation, :image_folder, :messages, :model, :proxy, :reasoning_effort, :web_search
     attr_reader :client, :last_response_id, :schema, :schema_file
@@ -84,15 +85,12 @@ def self.generate_schema!(description, location: "schema.json", api_key: nil, ap
     # :reek:TooManyStatements
     # :reek:NilCheck
     def add(content, role: "user", response: nil, status: nil, image: nil, images: nil, file: nil, files: nil)
-      if image.nil? && images.nil? && file.nil? && files.nil?
-        message = {
-          role: role,
-          content: content,
-          response: response
-        }
-        message[:content] = content if content
-        message[:status] = status if status
-        messages.push(message)
+      message = if image.nil? && images.nil? && file.nil? && files.nil?
+        msg = Message[role: role]
+        msg[:content] = content if content
+        msg[:response] = response if response
+        msg[:status] = status if status
+        msg
       else
         text_and_files_array = [
           {
@@ -122,14 +120,15 @@ def add(content, role: "user", response: nil, status: nil, image: nil, images: n
           text_and_files_array.push(process_file_input(file))
         end
 
-        messages.push(
-          {
-            role: role,
-            content: text_and_files_array,
-            status: status
-          }
-        )
+        Message[
+          role: role,
+          content: text_and_files_array,
+          status: status
+        ]
       end
+
+      messages.push(message)
+      message
     end
 
     def system(message)
@@ -189,10 +188,10 @@ def last
       messages.last
     end
 
-    def items(order: :asc)
+    def get_items(order: :asc)
       raise "No conversation_id set. Call generate! first to create a conversation." unless conversation_id
 
-      if proxy
+      raw_items = if proxy
         uri = URI(PROXY_URL + "api.openai.com/v1/conversations/#{conversation_id}/items?order=#{order}")
         response_hash = send_request(uri, content_type: "json", method: "get")
 
@@ -215,62 +214,50 @@ def items(order: :asc)
       else
         client.conversations.items.list(conversation_id, order: order)
       end
+
+      Items.new(raw_items, conversation_id: conversation_id)
     end
 
-    def verbose
-      page = items
+    def inspectable_attributes
+      attrs = []
+
+      # 1. Model and reasoning (configuration)
+      attrs << [:@model, @model]
+      attrs << [:@reasoning_effort, @reasoning_effort]
+
+      # 2. Conversation state
+      attrs << [:@conversation_id, @conversation_id]
+      attrs << [:@last_response_id, @last_response_id] if @last_response_id
 
-      box_width = 78
-      inner_width = box_width - 4
+      # 3. Messages (the main content, without response details)
+      display_messages = @messages.map { |msg| msg.except(:response) }
+      attrs << [:@messages, display_messages]
 
-      puts
-      puts "┌#{"─" * (box_width - 2)}┐"
-      puts "│ Conversation: #{conversation_id.ljust(inner_width - 14)} │"
-      puts "│ Items: #{page.data.length.to_s.ljust(inner_width - 7)} │"
-      puts "└#{"─" * (box_width - 2)}┘"
-      puts
+      # 4. Optional features (only if enabled/changed from default)
+      attrs << [:@proxy, @proxy] if @proxy != false
+      attrs << [:@image_generation, @image_generation] if @image_generation != false
+      attrs << [:@image_folder, @image_folder] if @image_folder != "./images"
 
-      ap page.data, limit: 10, indent: 2
+      # 5. Optional state (only if set)
+      attrs << [:@background, @background] if @background
+      attrs << [:@code_interpreter, @code_interpreter] if @code_interpreter
+      attrs << [:@web_search, @web_search] if @web_search
+      attrs << [:@schema, @schema] if @schema
+      attrs << [:@schema_file, @schema_file] if @schema_file
+
+      attrs
     end
 
     def inspect
-      "#<#{self.class.name} @messages=#{messages.inspect} @model=#{@model.inspect} @schema=#{@schema.inspect} @reasoning_effort=#{@reasoning_effort.inspect}>"
+      ai(plain: !$stdout.tty?, multiline: true)
     end
 
-    # Support for Ruby's pp (pretty print)
-    # :reek:TooManyStatements
-    # :reek:NilCheck
-    # :reek:FeatureEnvy
-    # :reek:DuplicateMethodCall
-    # :reek:UncommunicativeParameterName
-    def pretty_print(q)
-      q.group(1, "#<#{self.class}", ">") do
-        q.breakable
-
-        # Show messages with truncation
-        q.text "@messages="
-        truncated_messages = @messages.map do |msg|
-          truncated_msg = msg.dup
-          if msg[:content].is_a?(String) && msg[:content].length > 80
-            truncated_msg[:content] = msg[:content][0..77] + "..."
-          end
-          truncated_msg
-        end
-        q.pp truncated_messages
-
-        # Show other instance variables (except sensitive ones)
-        skip_vars = [:@messages, :@api_key, :@client]
-        instance_variables.sort.each do |var|
-          next if skip_vars.include?(var)
-          value = instance_variable_get(var)
-          unless value.nil?
-            q.text ","
-            q.breakable
-            q.text "#{var}="
-            q.pp value
-          end
-        end
-      end
+    def to_html
+      AI.wrap_html(ai(html: true, multiline: true))
+    end
+
+    def pretty_inspect
+      "#{inspect}\n"
     end
 
     private
@@ -312,7 +299,7 @@ def create_response
       parameters[:background] = background if background
       parameters[:tools] = tools unless tools.empty?
       parameters[:text] = schema if schema
-      parameters[:reasoning] = {effort: reasoning_effort} if reasoning_effort
+      parameters[:reasoning] = {effort: reasoning_effort, summary: "auto"} if reasoning_effort
 
       create_conversation unless conversation_id
       parameters[:conversation] = conversation_id
@@ -387,12 +374,12 @@ def parse_response(response)
         message.dig(:response, :id) == response_id
       end
 
-      message = {
+      message = Message[
         role: "assistant",
         content: response_content,
         response: chat_response,
         status: response_status
-      }
+      ]
 
       message.store(:images, image_filenames) unless image_filenames.empty?
 
@@ -400,8 +387,9 @@ def parse_response(response)
         messages[existing_message_position] = message
       else
         messages.push(message)
-        message
       end
+
+      message
     end
 
     def cancel_request
diff --git a/lib/ai/items.rb b/lib/ai/items.rb
new file mode 100644
index 0000000..fda667d
--- /dev/null
+++ b/lib/ai/items.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+require "delegate"
+
+module AI
+  class Items < SimpleDelegator
+    def initialize(response, conversation_id:)
+      super(response)
+      @conversation_id = conversation_id
+    end
+
+    def to_html
+      AI.wrap_html(build_output(html: true))
+    end
+
+    def inspect
+      build_output(html: false, plain: !$stdout.tty?)
+    end
+
+    def pretty_inspect
+      "#{inspect}\n"
+    end
+
+    def pretty_print(q)
+      q.output << inspect
+    end
+
+    private
+
+    def build_output(html: false, plain: false)
+      box = build_box
+      items_output = data.ai(html: html, plain: plain, limit: 100, indent: 2, index: true)
+
+      if html
+        "
#{box}
\n#{items_output}" + else + "#{box}\n#{items_output}" + end + end + + def build_box + box_width = 78 + inner_width = box_width - 4 + + lines = [] + lines << "┌#{"─" * (box_width - 2)}┐" + lines << "│ Conversation: #{@conversation_id.to_s.ljust(inner_width - 14)} │" + lines << "│ Items: #{data.length.to_s.ljust(inner_width - 7)} │" + lines << "└#{"─" * (box_width - 2)}┘" + + lines.join("\n") + end + end +end diff --git a/lib/ai/message.rb b/lib/ai/message.rb new file mode 100644 index 0000000..b33eade --- /dev/null +++ b/lib/ai/message.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module AI + class Message < Hash + def inspect + ai(plain: !$stdout.tty?, index: false) + end + + def pretty_inspect + "#{inspect}\n" + end + + # IRB's ColorPrinter calls pretty_print and re-colorizes text, + # which escapes our ANSI codes. Write directly to output to bypass. + def pretty_print(q) + q.output << inspect + end + + def to_html + AI.wrap_html(ai(html: true, index: false)) + end + end +end diff --git a/spec/integration/ai_chat_integration_spec.rb b/spec/integration/ai_chat_integration_spec.rb index 451690b..1b123f9 100644 --- a/spec/integration/ai_chat_integration_spec.rb +++ b/spec/integration/ai_chat_integration_spec.rb @@ -204,7 +204,7 @@ chat.user("Say hello") chat.generate! - items = chat.items + items = chat.get_items expect(items).to respond_to(:data) expect(items.data).to be_an(Array) diff --git a/spec/unit/chat_spec.rb b/spec/unit/chat_spec.rb new file mode 100644 index 0000000..c7081dc --- /dev/null +++ b/spec/unit/chat_spec.rb @@ -0,0 +1,191 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe AI::Chat do + let(:chat) { AI::Chat.new } + + describe "#add" do + it "returns the added message, not the messages array" do + result = chat.add("Hello", role: "user") + + expect(result).not_to be_an(Array) + expect(result[:content]).to eq("Hello") + expect(result[:role]).to eq("user") + end + + it "returns an AI::Message instance" do + result = chat.add("Hello", role: "user") + + expect(result).to be_an(AI::Message) + end + + it "still adds the message to the messages array" do + chat.add("Hello", role: "user") + + expect(chat.messages.count).to eq(1) + expect(chat.messages.first[:content]).to eq("Hello") + end + + it "only includes :response key when response is provided" do + result_without_response = chat.add("Hello", role: "user") + result_with_response = chat.add("Hi", role: "assistant", response: {id: "resp_123"}) + + expect(result_without_response).not_to have_key(:response) + expect(result_with_response).to have_key(:response) + expect(result_with_response[:response]).to eq({id: "resp_123"}) + end + + it "only includes :status key when status is provided" do + result_without_status = chat.add("Hello", role: "user") + result_with_status = chat.add("Hi", role: "assistant", status: :completed) + + expect(result_without_status).not_to have_key(:status) + expect(result_with_status).to have_key(:status) + expect(result_with_status[:status]).to eq(:completed) + end + + it "only includes :content key when content is provided" do + result_with_content = chat.add("Hello", role: "user") + result_without_content = chat.add(nil, role: "system") + + expect(result_with_content).to have_key(:content) + expect(result_without_content).not_to have_key(:content) + end + end + + describe "#system" do + it "returns an AI::Message" do + result = chat.system("You are helpful") + + expect(result).to be_an(AI::Message) + expect(result[:role]).to eq("system") + end + end + + describe "#user" do + it "returns an AI::Message" do + result = chat.user("Hello") + + expect(result).to be_an(AI::Message) + expect(result[:role]).to eq("user") + end + end + + describe "#assistant" do + it "returns an AI::Message" do + result = chat.assistant("Hello!") + + expect(result).to be_an(AI::Message) + expect(result[:role]).to eq("assistant") + end + end + + describe "#inspectable_attributes" do + it "excludes :response key from displayed messages" do + chat.add("Hello", role: "user") + chat.add("Hi there!", role: "assistant", response: {id: "resp_123", model: "gpt-4"}) + + attrs = chat.inspectable_attributes + messages_attr = attrs.find { |name, _| name == :@messages } + display_messages = messages_attr[1] + + display_messages.each do |msg| + expect(msg).not_to have_key(:response) + end + end + + it "includes @last_response_id only when set" do + attrs_without = chat.inspectable_attributes + attr_names_without = attrs_without.map(&:first) + + expect(attr_names_without).not_to include(:@last_response_id) + + chat.instance_variable_set(:@last_response_id, "resp_123") + attrs_with = chat.inspectable_attributes + + last_response_attr = attrs_with.find { |name, _| name == :@last_response_id } + expect(last_response_attr).not_to be_nil + expect(last_response_attr[1]).to eq("resp_123") + end + + it "shows @last_response_id after @conversation_id" do + chat.instance_variable_set(:@conversation_id, "conv_123") + chat.instance_variable_set(:@last_response_id, "resp_456") + + attrs = chat.inspectable_attributes + attr_names = attrs.map(&:first) + + conv_index = attr_names.index(:@conversation_id) + resp_index = attr_names.index(:@last_response_id) + + expect(resp_index).to eq(conv_index + 1) + end + + it "excludes optional features when at default values" do + attrs = chat.inspectable_attributes + attr_names = attrs.map(&:first) + + expect(attr_names).not_to include(:@proxy) + expect(attr_names).not_to include(:@image_generation) + expect(attr_names).not_to include(:@image_folder) + end + + it "includes optional features when changed from defaults" do + chat.proxy = true + chat.image_generation = true + chat.image_folder = "./my_images" + + attrs = chat.inspectable_attributes + attr_names = attrs.map(&:first) + + expect(attr_names).to include(:@proxy) + expect(attr_names).to include(:@image_generation) + expect(attr_names).to include(:@image_folder) + end + + it "excludes optional state when not set" do + attrs = chat.inspectable_attributes + attr_names = attrs.map(&:first) + + expect(attr_names).not_to include(:@background) + expect(attr_names).not_to include(:@code_interpreter) + expect(attr_names).not_to include(:@web_search) + expect(attr_names).not_to include(:@schema) + end + + it "includes optional state when set" do + chat.background = true + chat.code_interpreter = true + chat.web_search = true + chat.schema = {name: "test", strict: true, schema: {type: "object", properties: {}, additionalProperties: false}} + + attrs = chat.inspectable_attributes + attr_names = attrs.map(&:first) + + expect(attr_names).to include(:@background) + expect(attr_names).to include(:@code_interpreter) + expect(attr_names).to include(:@web_search) + expect(attr_names).to include(:@schema) + end + end + + describe "#inspect" do + it "returns a String" do + expect(chat.inspect).to be_a(String) + end + end + + describe "#pretty_inspect" do + it "returns a String ending with newline" do + expect(chat.pretty_inspect).to be_a(String) + expect(chat.pretty_inspect).to end_with("\n") + end + end + + describe "#to_html" do + it "returns a String" do + expect(chat.to_html).to be_a(String) + end + end +end diff --git a/spec/unit/items_spec.rb b/spec/unit/items_spec.rb new file mode 100644 index 0000000..c9b5486 --- /dev/null +++ b/spec/unit/items_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe AI::Items do + let(:sample_data) do + [ + {type: :message, role: "user", content: [{type: "input_text", text: "Hello"}]}, + {type: :message, role: "assistant", content: [{type: "output_text", text: "Hi there!"}]} + ] + end + let(:response) { OpenStruct.new(data: sample_data, has_more: false, first_id: "item_1", last_id: "item_2") } + let(:conversation_id) { "conv_abc123" } + let(:items) { AI::Items.new(response, conversation_id: conversation_id) } + + describe "delegation" do + it "delegates #data to the underlying response" do + expect(items.data).to eq(sample_data) + end + + it "delegates pagination fields to the underlying response" do + expect(items.has_more).to eq(false) + expect(items.first_id).to eq("item_1") + expect(items.last_id).to eq("item_2") + end + + it "allows iterating over data" do + results = [] + items.data.each { |item| results << item[:type] } + expect(results).to eq([:message, :message]) + end + end + + describe "#inspect" do + it "returns a String" do + expect(items.inspect).to be_a(String) + end + + it "includes conversation_id in the output" do + expect(items.inspect).to include("conv_abc123") + end + + it "includes item count in the output" do + expect(items.inspect).to include("Items: 2") + end + end + + describe "#pretty_inspect" do + it "returns a String ending with newline" do + expect(items.pretty_inspect).to be_a(String) + expect(items.pretty_inspect).to end_with("\n") + end + end + + describe "#to_html" do + it "returns a String" do + expect(items.to_html).to be_a(String) + end + + it "includes conversation_id in the output" do + expect(items.to_html).to include("conv_abc123") + end + + it "includes the dark background style" do + expect(items.to_html).to include("background-color: #1e1e1e") + end + end +end diff --git a/spec/unit/message_spec.rb b/spec/unit/message_spec.rb new file mode 100644 index 0000000..1cc1f96 --- /dev/null +++ b/spec/unit/message_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe AI::Message do + describe "Hash subclass behavior" do + it "is a subclass of Hash" do + expect(AI::Message.new).to be_a(Hash) + end + + it "can be created with Hash.[] syntax" do + message = AI::Message[role: "user", content: "Hello"] + + expect(message[:role]).to eq("user") + expect(message[:content]).to eq("Hello") + end + + it "supports standard Hash operations" do + message = AI::Message[role: "user"] + message[:content] = "Hello" + + expect(message.keys).to contain_exactly(:role, :content) + expect(message.values).to contain_exactly("user", "Hello") + end + end + + describe "#inspect" do + it "returns a String" do + message = AI::Message[role: "user", content: "Hello"] + + expect(message.inspect).to be_a(String) + end + + it "does not return raw Hash representation" do + message = AI::Message[role: "user", content: "Hello"] + + expect(message.inspect).not_to eq({role: "user", content: "Hello"}.inspect) + end + end + + describe "#pretty_inspect" do + it "returns a String ending with newline" do + message = AI::Message[role: "user", content: "Hello"] + + expect(message.pretty_inspect).to be_a(String) + expect(message.pretty_inspect).to end_with("\n") + end + end + + describe "#to_html" do + it "returns a String" do + message = AI::Message[role: "user", content: "Hello"] + + expect(message.to_html).to be_a(String) + end + end + + describe "#pretty_print" do + it "writes directly to output to bypass IRB colorization" do + message = AI::Message[role: "user", content: "Hello"] + output = StringIO.new + mock_q = double("PrettyPrint", output: output) + + message.pretty_print(mock_q) + + expect(output.string).to eq(message.inspect) + end + end +end