Skip to content

Commit 7bf8660

Browse files
authored
Add pdf upload and processing with OpenAI (#726)
1 parent 06c0c5e commit 7bf8660

File tree

9 files changed

+253
-30
lines changed

9 files changed

+253
-30
lines changed

Gemfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ gem "actioncable-enhanced-postgresql-adapter" # longer paylaods w/ postgresql ac
4949
gem "aws-sdk-s3", require: false
5050
gem "postmark-rails"
5151
gem "ostruct"
52+
gem "pdf-reader", "~> 2.11"
5253

5354
gem "omniauth", "~> 2.1"
5455
gem "omniauth-google-oauth2", "~> 1.1"

Gemfile.lock

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
GEM
22
remote: https://rubygems.org/
33
specs:
4+
Ascii85 (2.0.1)
45
actioncable (8.0.2.1)
56
actionpack (= 8.0.2.1)
67
activesupport (= 8.0.2.1)
@@ -78,6 +79,7 @@ GEM
7879
uri (>= 0.13.1)
7980
addressable (2.8.7)
8081
public_suffix (>= 2.0.2, < 7.0)
82+
afm (1.0.0)
8183
amatch (0.4.2)
8284
mize
8385
tins (~> 1.0)
@@ -175,6 +177,7 @@ GEM
175177
os (>= 0.9, < 2.0)
176178
signet (>= 0.16, < 2.a)
177179
hashdiff (1.2.1)
180+
hashery (2.1.2)
178181
hashie (5.0.0)
179182
i18n (1.14.7)
180183
concurrent-ruby (~> 1.0)
@@ -288,6 +291,12 @@ GEM
288291
parser (3.3.9.0)
289292
ast (~> 2.4.1)
290293
racc
294+
pdf-reader (2.15.0)
295+
Ascii85 (>= 1.0, < 3.0, != 2.0.0)
296+
afm (>= 0.2.1, < 2)
297+
hashery (~> 2.0)
298+
ruby-rc4
299+
ttfunk
291300
pg (1.6.2-aarch64-linux)
292301
pg (1.6.2-aarch64-linux-musl)
293302
pg (1.6.2-arm64-darwin)
@@ -417,6 +426,7 @@ GEM
417426
faraday (>= 1)
418427
faraday-multipart (>= 1)
419428
ruby-progressbar (1.13.0)
429+
ruby-rc4 (0.1.5)
420430
ruby-vips (2.2.5)
421431
ffi (~> 1.12)
422432
logger
@@ -488,6 +498,8 @@ GEM
488498
mize (~> 0.6)
489499
sync
490500
tsort (0.2.0)
501+
ttfunk (1.8.0)
502+
bigdecimal (~> 3.1)
491503
turbo-rails (2.0.17)
492504
actionpack (>= 7.1.0)
493505
railties (>= 7.1.0)
@@ -553,6 +565,7 @@ DEPENDENCIES
553565
omniauth-microsoft_graph (~> 2.0)
554566
omniauth-rails_csrf_protection (~> 1.0.2)
555567
ostruct
568+
pdf-reader (~> 2.11)
556569
pg (~> 1.1)
557570
postmark-rails
558571
puma (>= 6.0)

app/javascript/stimulus/image_upload_controller.js

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,21 +35,55 @@ export default class extends Controller {
3535

3636
const input = this.fileTarget
3737
if (input.files && input.files[0]) {
38+
const file = input.files[0]
3839
const reader = new FileReader()
3940
reader.onload = (e) => {
40-
this.previewTarget.querySelector("img").src = e.target.result
41+
const previewContainer = this.previewTarget.querySelector("[data-role='preview']")
42+
const img = previewContainer.querySelector("img")
43+
const fileIcon = previewContainer.querySelector("[data-role='file-icon']")
44+
45+
if (file.type.startsWith('image/')) {
46+
// Handle image files
47+
img.src = e.target.result
48+
img.style.display = 'block'
49+
if (fileIcon) fileIcon.style.display = 'none'
50+
} else if (file.type === 'application/pdf') {
51+
// Handle PDF files
52+
img.style.display = 'none'
53+
if (fileIcon) {
54+
fileIcon.style.display = 'flex'
55+
} else {
56+
// Create PDF icon if it doesn't exist
57+
const pdfIcon = document.createElement('div')
58+
pdfIcon.setAttribute('data-role', 'file-icon')
59+
pdfIcon.className = 'w-full h-full flex items-center justify-center bg-red-100 dark:bg-red-900'
60+
pdfIcon.innerHTML = `
61+
<svg class="w-8 h-8 text-red-600 dark:text-red-400" fill="currentColor" viewBox="0 0 20 20">
62+
<path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z" clip-rule="evenodd"></path>
63+
</svg>
64+
`
65+
previewContainer.appendChild(pdfIcon)
66+
}
67+
}
68+
4169
this.element.classList.add("show-previews")
4270
this.contentTarget.focus()
4371
window.dispatchEvent(new CustomEvent('main-column-changed'))
4472
}
45-
reader.readAsDataURL(input.files[0])
73+
reader.readAsDataURL(file)
4674
}
4775
}
4876

4977
previewRemove() {
5078
if (!this.hasPreviewTarget) return
5179

52-
this.previewTarget.querySelector("img").src = ''
80+
const previewContainer = this.previewTarget.querySelector("[data-role='preview']")
81+
const img = previewContainer.querySelector("img")
82+
const fileIcon = previewContainer.querySelector("[data-role='file-icon']")
83+
84+
if (img) img.src = ''
85+
if (fileIcon) fileIcon.style.display = 'none'
86+
5387
this.element.classList.remove("show-previews")
5488
if (this.hasContentTarget) this.contentTarget.focus()
5589
window.dispatchEvent(new CustomEvent('main-column-changed'))
@@ -105,8 +139,11 @@ export default class extends Controller {
105139
const blob = item.getAsFile()
106140
if (!blob) return
107141

108-
const dataURL = await this.readPastedBlobAsDataURL(blob)
109-
this.addImageToFileInput(dataURL, blob.type)
142+
// Only handle images and PDFs
143+
if (blob.type.startsWith('image/') || blob.type === 'application/pdf') {
144+
const dataURL = await this.readPastedBlobAsDataURL(blob)
145+
this.addFileToFileInput(dataURL, blob.type, blob.name || "pasted-file")
146+
}
110147
}
111148
}
112149
this.previewUpdate()
@@ -135,13 +172,22 @@ export default class extends Controller {
135172
);
136173
}
137174

138-
addImageToFileInput(dataURL, fileType) {
175+
addFileToFileInput(dataURL, fileType, fileName) {
139176
if (!this.hasFileTarget) return
140177

141178
const fileList = new DataTransfer()
142179
const blob = this.dataURLtoBlob(dataURL, fileType)
180+
181+
// Generate appropriate filename based on file type
182+
let defaultFileName = "pasted-file"
183+
if (fileType.startsWith('image/')) {
184+
defaultFileName = "pasted-image.png"
185+
} else if (fileType === 'application/pdf') {
186+
defaultFileName = "pasted-document.pdf"
187+
}
188+
143189
fileList.items.add(
144-
new File([blob], "pasted-image.png", { type: fileType }),
190+
new File([blob], fileName || defaultFileName, { type: fileType }),
145191
)
146192
this.fileTarget.files = fileList.files
147193
}

app/models/document.rb

Lines changed: 81 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,7 @@ class Document < ApplicationRecord
33
belongs_to :assistant, optional: true
44
belongs_to :message, optional: true
55

6-
has_one_attached :file do |file|
7-
file.variant :small, resize_to_limit: [650, 490], preprocessed: true
8-
file.variant :large, resize_to_limit: [1200, 900], preprocessed: true
9-
end
6+
has_one_attached :file
107

118
enum :purpose, %w[fine-tune fine-tune-results assistants assistants_output].index_by(&:to_sym)
129

@@ -20,11 +17,26 @@ class Document < ApplicationRecord
2017
validate :file_present
2118

2219
def has_image?(variant = nil)
20+
return false unless file.attached? && file.content_type&.start_with?("image/")
21+
2322
if variant.present?
2423
return has_file_variant_processed?(variant)
2524
end
2625

27-
file.attached?
26+
true
27+
end
28+
29+
def image_variant(variant)
30+
return nil unless file.attached? && file.content_type&.start_with?("image/")
31+
32+
case variant.to_sym
33+
when :small
34+
file.variant(resize_to_limit: [650, 490])
35+
when :large
36+
file.variant(resize_to_limit: [1200, 900])
37+
else
38+
file
39+
end
2840
end
2941

3042
def image_url(variant, fallback: nil)
@@ -42,32 +54,80 @@ def image_url(variant, fallback: nil)
4254
end
4355

4456
def has_file_variant_processed?(variant)
45-
r = file.attached? &&
46-
variant.present? &&
47-
(key = file.variant(variant.to_sym).key) &&
48-
ActiveStorage::Blob.service.exist?(key)
57+
return false unless file.attached? && file.content_type&.start_with?("image/")
4958

59+
variant_obj = image_variant(variant)
60+
return false unless variant_obj
61+
62+
r = variant_obj.key && ActiveStorage::Blob.service.exist?(variant_obj.key)
5063
!!r
5164
end
5265

5366
def fully_processed_url(variant)
54-
file.attached? && variant.present? && file.representation(variant.to_sym).processed.url
67+
return nil unless file.attached? && file.content_type&.start_with?("image/")
68+
69+
variant_obj = image_variant(variant)
70+
return nil unless variant_obj
71+
72+
variant_obj.processed.url
5573
end
5674

5775
def redirect_to_processed_path(variant)
58-
return nil unless file.attached? && variant.present?
76+
return nil unless file.attached? && file.content_type&.start_with?("image/") && variant.present?
77+
78+
variant_obj = image_variant(variant)
79+
return nil unless variant_obj
5980

6081
Rails.application.routes.url_helpers.rails_representation_url(
61-
file.representation(variant.to_sym),
82+
variant_obj,
6283
only_path: true
6384
)
6485
end
6586

6687
def file_base64(variant = :large)
6788
return nil if !file.attached?
68-
wait_for_file_variant_to_process!(variant.to_sym)
69-
file_contents = file.variant(variant.to_sym).processed.download
70-
base64 = Base64.strict_encode64(file_contents)
89+
90+
if file.content_type&.start_with?("image/")
91+
variant_obj = image_variant(variant)
92+
return nil unless variant_obj
93+
94+
wait_for_file_variant_to_process!(variant.to_sym)
95+
file_contents = variant_obj.processed.download
96+
else
97+
# For non-image files, just return the raw file content
98+
file_contents = file.download
99+
end
100+
101+
Base64.strict_encode64(file_contents)
102+
end
103+
104+
def has_document_pdf?
105+
file.attached? && file.content_type == "application/pdf"
106+
end
107+
108+
def extract_pdf_text
109+
return nil unless has_document_pdf?
110+
111+
begin
112+
pdf_data = file.download
113+
114+
pdf_reader = PDF::Reader.new(StringIO.new(pdf_data))
115+
116+
text_content = ""
117+
pdf_reader.pages.each_with_index do |page, index|
118+
page_text = page.text
119+
text_content += "--- Page #{index + 1} ---\n" if pdf_reader.pages.count > 1
120+
text_content += page_text + "\n\n"
121+
end
122+
text_content.strip
123+
124+
rescue PDF::Reader::MalformedPDFError => e
125+
Rails.logger.error "PDF file is corrupted or malformed: #{e.message}"
126+
nil
127+
rescue => e
128+
Rails.logger.error "Error processing PDF: #{e.message}"
129+
nil
130+
end
71131
end
72132

73133
private
@@ -93,6 +153,11 @@ def file_present
93153
end
94154

95155
def wait_for_file_variant_to_process!(variant)
96-
file && file.attached? && file.variant(variant.to_sym).processed # this blocks until processing is done
156+
return false unless file&.attached? && file.content_type&.start_with?("image/")
157+
158+
variant_obj = image_variant(variant)
159+
return false unless variant_obj
160+
161+
variant_obj.processed # this blocks until processing is done
97162
end
98163
end

app/models/message/document_image.rb

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,35 @@ def has_document_image?(variant = nil)
1111
documents.present? && documents.first.has_image?(variant)
1212
end
1313

14+
def has_document_pdf?
15+
documents.present? && documents.first.file.attached? && documents.first.file.content_type == "application/pdf"
16+
end
17+
18+
def has_documents?
19+
documents.present? && documents.first.file.attached?
20+
end
21+
1422
def document_image_url(variant, fallback: nil)
1523
return nil unless has_document_image?
1624

1725
documents.last.image_url(variant, fallback: fallback)
1826
end
27+
28+
def document_pdf_url
29+
return nil unless has_document_pdf?
30+
31+
documents.last.file.url
32+
end
33+
34+
def document_filename
35+
return nil unless has_documents?
36+
37+
documents.last.filename
38+
end
39+
40+
def document_content_type
41+
return nil unless has_documents?
42+
43+
documents.last.file.content_type
44+
end
1945
end

app/services/ai_backend/open_ai.rb

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -118,16 +118,33 @@ def system_message(content)
118118
def preceding_conversation_messages
119119
@conversation.messages.for_conversation_version(@message.version).where("messages.index < ?", @message.index).collect do |message|
120120
if @assistant.supports_images? && message.documents.present? && message.role == "user"
121-
122-
content_with_images = [{ type: "text", text: message.content_text }]
123-
content_with_images += message.documents.collect do |document|
124-
{ type: "image_url", image_url: { url: document.image_url(:large) }}
121+
# Handle mixed content (images and PDFs)
122+
content_with_media = [{ type: "text", text: message.content_text }]
123+
124+
message.documents.each do |document|
125+
if document.has_image?
126+
content_with_media << { type: "image_url", image_url: { url: document.image_url(:large) }}
127+
elsif document.has_document_pdf?
128+
# Extract text from PDF and include it in the conversation
129+
pdf_text = document.extract_pdf_text
130+
if pdf_text.present?
131+
content_with_media << {
132+
type: "text",
133+
text: "\n\n[PDF Document: #{document.filename}]\n#{pdf_text}"
134+
}
135+
else
136+
content_with_media << {
137+
type: "text",
138+
text: "\n[PDF Document: #{document.filename} - Unable to extract text from this PDF]"
139+
}
140+
end
141+
end
125142
end
126143

127144
{
128145
role: message.role,
129146
name: message.name_for_api,
130-
content: content_with_images,
147+
content: content_with_media,
131148
}.compact
132149
else
133150
begin

app/views/messages/_main_column.html.erb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,7 @@
266266
<%= form.hidden_field :index, value: @messages&.length %>
267267
<%= form.hidden_field :version, value: @version %>
268268
<%= form.fields_for :documents, @new_message.documents.build do |document_form| %>
269-
<%= document_form.file_field :file, accept: "image/*", class: "hidden", data: { image_upload_target: "file" } %>
269+
<%= document_form.file_field :file, accept: "image/*,.pdf", class: "hidden", data: { image_upload_target: "file" } %>
270270
<% end %>
271271

272272
<%= form.text_area :content_text,

0 commit comments

Comments
 (0)