Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,28 @@ chat.configure_attributes(
chat.messages = CustomMessage.where(conversation_id: 123)
```

### ActionText support

The gem automatically detects and processes Rails ActionText content, extracting both text and embedded images:

```ruby
# In a Rails app with ActionText
class Message < ApplicationRecord
has_rich_text :content
end

# The gem will automatically extract text and images from ActionText content
chat = AI::Chat.new
chat.messages = Message.where(conversation_id: 123)
```

This works with:
- ActionText content with embedded images
- Images added via drag and drop in Trix editor
- Images added via file uploads in Trix editor

If an ActionText field contains multiple paragraphs of text with images between them, the gem will preserve this structure in the conversation.

## Testing with Real API Calls

While this gem includes specs, they use mocked API responses. To test with real API calls:
Expand Down
2 changes: 2 additions & 0 deletions lib/ai-chat.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@
# Internal gem files
require "ai/chat/version"
require "ai/chat"

# Optional ActionText support will be loaded by AI::Chat if available
22 changes: 20 additions & 2 deletions lib/ai/chat.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,18 @@

module AI
class Chat
attr_accessor :schema, :model
attr_reader :messages, :reasoning_effort, :attribute_mappings
attr_accessor :schema, :model, :messages
attr_reader :reasoning_effort, :attribute_mappings

VALID_REASONING_EFFORTS = [:low, :medium, :high].freeze

# Load ActionText support if available
begin
require 'ai/chat/actiontext_support'
include ActionTextSupport
rescue LoadError
# ActionText support is optional
end

def initialize(api_key: nil)
@api_key = api_key || ENV.fetch("OPENAI_API_KEY")
Expand Down Expand Up @@ -296,6 +304,16 @@ def messages=(new_messages)
if content.is_a?(Array)
# This is already a mixed content array with text and images
user(content)
# Check if content is ActionText::RichText when ActionText is available
elsif defined?(ActionText::RichText) && content.is_a?(ActionText::RichText)
# Process ActionText content if the module is available
if self.respond_to?(:extract_actiontext_content)
content_parts = extract_actiontext_content(content)
user(content_parts)
else
# Fallback to plain text if ActionText support not loaded
user(content.to_plain_text || content.to_s)
end
else
# Extract images using configured attribute names
image = extract_attribute(message, @attribute_mappings[:image])
Expand Down
137 changes: 137 additions & 0 deletions lib/ai/chat/actiontext_support.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# frozen_string_literal: true

module AI
class Chat
# ActionText support for AI::Chat
module ActionTextSupport
# Extracts content from an ActionText object, preserving text and images
# @param rich_text [ActionText::RichText] the rich text object to process
# @return [Array<Hash>] array of content parts in AI::Chat compatible format
def extract_actiontext_content(rich_text)
# Skip if ActionText is not defined in the current environment
return [{text: rich_text.to_s}] unless defined?(ActionText::RichText)

# Get the HTML content
html_content = rich_text.to_s

# Parse the HTML to extract text and image references
content_parts = []

# Try to use Nokogiri if available for robust HTML parsing
if defined?(Nokogiri)
doc = Nokogiri::HTML::DocumentFragment.parse(html_content)
process_with_nokogiri(doc, content_parts)
else
# Fallback to simpler regexp-based parsing
process_with_regexp(html_content, content_parts)
end

# Return original text if no parts were extracted
content_parts.empty? ? [{text: html_content}] : content_parts
end

private

# Process HTML content using Nokogiri
# @param doc [Nokogiri::HTML::DocumentFragment] the parsed HTML document
# @param content_parts [Array<Hash>] the array to add content parts to
def process_with_nokogiri(doc, content_parts)
# Current text buffer
text_buffer = ""

doc.children.each do |node|
if node.name == "action-text-attachment"
# Flush text buffer before processing attachment
unless text_buffer.empty?
content_parts << {text: text_buffer.strip}
text_buffer = ""
end

# Extract image from Rails attachment
sgid = node["sgid"]
if sgid && defined?(GlobalID::Locator)
begin
attachment = GlobalID::Locator.locate_signed(sgid)
if attachment && attachment.respond_to?(:blob)
if defined?(Rails) && Rails.application.respond_to?(:routes)
image_url = Rails.application.routes.url_helpers.rails_blob_path(attachment.blob, only_path: true)
content_parts << {image: image_url}
else
# Fallback to direct URL if available
content_parts << {image: attachment.blob.url} if attachment.blob.respond_to?(:url)
end
end
rescue => e
# Silently continue if attachment can't be loaded
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In process_with_nokogiri, errors in processing attachments are silently ignored. Logging these errors might help with troubleshooting in production.

Suggested change
# Silently continue if attachment can't be loaded
Rails.logger.error("Failed to load attachment: \\#{e.message}") if defined?(Rails) && Rails.respond_to?(:logger)

end
end
elsif node.name == "figure" && node.at_css("img")
# Flush text buffer before processing figure
unless text_buffer.empty?
content_parts << {text: text_buffer.strip}
text_buffer = ""
end

# Extract image from figure tag
img = node.at_css("img")
src = img["src"]
content_parts << {image: src} if src
else
# Extract text, preserving basic formatting
text_buffer += node.text
end
end

# Add any remaining text
content_parts << {text: text_buffer.strip} unless text_buffer.empty?
end

# Process HTML content using regular expressions
# @param html_content [String] the HTML content to process
# @param content_parts [Array<Hash>] the array to add content parts to
def process_with_regexp(html_content, content_parts)
# Split by attachment tags or figure tags
parts = html_content.split(/(<action-text-attachment[^>]+>|<figure>.*?<\/figure>)/)

parts.each do |part|
if part.start_with?("<action-text-attachment")
# Extract image SGID from attachment
sgid_match = part.match(/sgid="([^"]+)"/)

if sgid_match && defined?(GlobalID::Locator)
begin
sgid = sgid_match[1]
attachment = GlobalID::Locator.locate_signed(sgid)
if attachment && attachment.respond_to?(:blob)
if defined?(Rails) && Rails.application.respond_to?(:routes)
image_url = Rails.application.routes.url_helpers.rails_blob_path(attachment.blob, only_path: true)
content_parts << {image: image_url}
else
# Fallback to direct URL if available
content_parts << {image: attachment.blob.url} if attachment.blob.respond_to?(:url)
end
end
rescue => e
# Silently continue if attachment can't be loaded
end
end
elsif part.start_with?("<figure")
# Extract image from figure tag
img_match = part.match(/src="([^"]+)"/)
content_parts << {image: img_match[1]} if img_match
elsif !part.strip.empty?
# Clean up text by removing HTML tags
if defined?(ActionController::Base)
clean_text = ActionController::Base.helpers.strip_tags(part).strip
else
# Simple HTML tag removal
clean_text = part.gsub(/<[^>]+>/, "").strip
end

content_parts << {text: clean_text} unless clean_text.empty?
end
end
end
end
end
end
134 changes: 134 additions & 0 deletions spec/ai/chat/actiontext_support_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# frozen_string_literal: true

require "spec_helper"

RSpec.describe AI::Chat, "actiontext support" do
let(:chat) { build(:chat) }
let(:test_image_path) { File.join(File.dirname(__FILE__), "../../fixtures/test1.jpg") }
let(:test_image_url) { "https://example.com/image.jpg" }

# Skip these tests if ActionText is not available
before(:each) do
skip "ActionText is not available" unless defined?(ActionText::RichText)
end

describe "#extract_actiontext_content" do
# Mock ActionText::RichText class if not available
let(:mock_rich_text) do
if defined?(ActionText::RichText)
# Use actual ActionText if available
ActionText::RichText.new(body: html_content)
else
# Mock it otherwise
Class.new do
attr_reader :body

def initialize(body)
@body = body
end

def to_s
@body
end

def to_plain_text
@body.gsub(/<[^>]+>/, "")
end
end.new(html_content)
end
end

context "with text only" do
let(:html_content) { "<div>This is a test message</div>" }

it "extracts plain text correctly" do
# Skip if module not loaded
skip "ActionTextSupport module not loaded" unless chat.respond_to?(:extract_actiontext_content)

result = chat.extract_actiontext_content(mock_rich_text)
expect(result).to be_an(Array)
expect(result.length).to eq(1)
expect(result[0][:text]).to include("This is a test message")
end
end

context "with text and image" do
let(:html_content) do
<<-HTML
<div>Text before image</div>
<figure>
<img src="#{test_image_url}">
<figcaption>Image caption</figcaption>
</figure>
<div>Text after image</div>
HTML
end

it "extracts text and image correctly" do
# Skip if module not loaded
skip "ActionTextSupport module not loaded" unless chat.respond_to?(:extract_actiontext_content)

result = chat.extract_actiontext_content(mock_rich_text)
expect(result).to be_an(Array)
expect(result.length).to eq(3)
expect(result[0][:text]).to include("Text before image")
expect(result[1][:image]).to eq(test_image_url)
expect(result[2][:text]).to include("Text after image")
end
end

context "with ActionText attachment" do
let(:html_content) do
<<-HTML
<div>Text before attachment</div>
<action-text-attachment sgid="test-sgid"></action-text-attachment>
<div>Text after attachment</div>
HTML
end

it "attempts to process action-text-attachment tags" do
# Skip if module not loaded
skip "ActionTextSupport module not loaded" unless chat.respond_to?(:extract_actiontext_content)

# This test is mainly for code coverage, as we can't fully mock
# the GlobalID::Locator behavior in a simple spec
result = chat.extract_actiontext_content(mock_rich_text)
expect(result).to be_an(Array)
expect(result.length).to be >= 2
expect(result[0][:text]).to include("Text before attachment")
expect(result[-1][:text]).to include("Text after attachment")
end
end
end

describe "#messages=" do
# This test depends on the ActionText module being loaded
it "handles ActionText content" do
skip "ActionTextSupport module not loaded" unless chat.respond_to?(:extract_actiontext_content)

# Mock an ActionText-like object
mock_actiontext = double("ActionText::RichText")
allow(mock_actiontext).to receive(:is_a?).with(ActionText::RichText).and_return(true)
allow(mock_actiontext).to receive(:to_plain_text).and_return("Plain text content")
allow(mock_actiontext).to receive(:to_s).and_return("<div>Rich text content</div>")

# Mock a message with ActionText content
mock_message = double("Message")
allow(mock_message).to receive(:role).and_return("user")
allow(mock_message).to receive(:content).and_return(mock_actiontext)

# Simulate the behavior when extract_actiontext_content exists
allow(chat).to receive(:extract_actiontext_content).and_return([{text: "Parsed content"}])

# Set messages
chat.messages = [mock_message]

# Verify that extract_actiontext_content was called
expect(chat).to have_received(:extract_actiontext_content).with(mock_actiontext)

# Check the message content
expect(chat.messages.length).to eq(1)
expect(chat.messages[0][:role]).to eq("user")
end
end
end
Loading