lato_cms 3.1.5 → 3.2.0
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/app/controllers/lato_cms/media_controller.rb +15 -12
- data/app/jobs/lato_cms/generate_media_text_job.rb +26 -0
- data/app/models/lato_cms/media.rb +86 -63
- data/app/models/lato_cms/page_field.rb +1 -0
- data/app/views/lato_cms/media/_form_update.html.erb +2 -35
- data/app/views/lato_cms/media/_translations_field.html.erb +41 -0
- data/config/locales/en.yml +6 -5
- data/config/locales/it.yml +6 -5
- data/config/routes.rb +1 -1
- data/db/migrate/20260907120000_add_title_to_lato_cms_media.rb +6 -0
- data/lib/lato_cms/config.rb +54 -5
- data/lib/lato_cms/version.rb +1 -1
- metadata +4 -2
- data/app/jobs/lato_cms/generate_alt_text_job.rb +0 -21
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0f9ff4673b5fb6fd0cf73a6cb74878abb42d06e060e54df3f21a969e875afc26
|
|
4
|
+
data.tar.gz: 5825b755ceef65a720e190d31373a209a43d7620666117faa74d446df7a7668a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 735617a5f9e02a8f661bb784d4f958bfc7f720837387e6046586738228957d585bdd67ae11481e2613d845aebfc14c7adf8576d94d0f50b98ad113eae4816ecb
|
|
7
|
+
data.tar.gz: 56fb22fef815efe46da9774ecf9795413543b737264d470d384beac492308692e5ddc836c0f98e4ead2848a199c32e48e1568b918a5a7321d71b219f6dfb9934
|
|
@@ -10,7 +10,7 @@ module LatoCms
|
|
|
10
10
|
query_media.order(created_at: :desc),
|
|
11
11
|
columns: %i[name media_type actions],
|
|
12
12
|
sortable_columns: %i[name media_type created_at],
|
|
13
|
-
searchable_columns: %i[name alt_text],
|
|
13
|
+
searchable_columns: %i[name alt_text title],
|
|
14
14
|
default_sort_by: 'created_at|DESC',
|
|
15
15
|
pagination: 20
|
|
16
16
|
)
|
|
@@ -62,27 +62,30 @@ module LatoCms
|
|
|
62
62
|
end
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
-
#
|
|
66
|
-
#
|
|
67
|
-
#
|
|
68
|
-
|
|
65
|
+
# Regenerates one translatable attribute (alt_text or title, see the
|
|
66
|
+
# route constraint) with the LLM. Runs as a Lato::Operation (see
|
|
67
|
+
# GenerateMediaTextJob) rather than inline: an LLM call can take a while,
|
|
68
|
+
# and blocking the request risks timing it out. The admin instead lands on
|
|
69
|
+
# a live progress page.
|
|
70
|
+
def regenerate_text_action
|
|
69
71
|
@media = query_media.find(params[:id])
|
|
72
|
+
attribute = params[:attribute].to_s
|
|
70
73
|
|
|
71
|
-
unless @media.image? && LatoCms.config.
|
|
74
|
+
unless @media.image? && LatoCms.config.llm_generates?(attribute)
|
|
72
75
|
respond_to do |format|
|
|
73
|
-
message = t(
|
|
76
|
+
message = t("lato_cms.media_text_regenerate_unavailable")
|
|
74
77
|
format.html { redirect_to lato_cms.media_update_path(@media), alert: message }
|
|
75
78
|
format.json { render json: { error: message }, status: :unprocessable_entity }
|
|
76
79
|
end
|
|
77
80
|
return
|
|
78
81
|
end
|
|
79
82
|
|
|
80
|
-
operation = Lato::Operation.generate(
|
|
83
|
+
operation = Lato::Operation.generate("LatoCms::GenerateMediaTextJob", { media_id: @media.id, attributes: [attribute] }, @session.user_id)
|
|
81
84
|
|
|
82
85
|
if operation.start
|
|
83
86
|
redirect_to lato.operation_path(operation)
|
|
84
87
|
else
|
|
85
|
-
redirect_to lato_cms.media_update_path(@media), alert: t(
|
|
88
|
+
redirect_to lato_cms.media_update_path(@media), alert: t("lato_cms.media_text_regenerate_failed")
|
|
86
89
|
end
|
|
87
90
|
end
|
|
88
91
|
|
|
@@ -106,7 +109,7 @@ module LatoCms
|
|
|
106
109
|
private
|
|
107
110
|
|
|
108
111
|
def create_params
|
|
109
|
-
params.require(:media).permit(:file, :name, :alt_text)
|
|
112
|
+
params.require(:media).permit(:file, :name, :alt_text, :title)
|
|
110
113
|
end
|
|
111
114
|
|
|
112
115
|
# :file is intentionally not permitted here: the underlying file is
|
|
@@ -114,8 +117,8 @@ module LatoCms
|
|
|
114
117
|
# many pages, so replacing it in place would silently change what renders
|
|
115
118
|
# everywhere it's referenced). A different file means a new Media.
|
|
116
119
|
def update_params
|
|
117
|
-
|
|
118
|
-
params.require(:media).permit(:name, *
|
|
120
|
+
translation_keys = LatoCms::Media::TRANSLATABLE_ATTRIBUTES.product(LatoCms.config.locales).map { |attribute, locale| :"#{attribute}_#{locale}" }
|
|
121
|
+
params.require(:media).permit(:name, *translation_keys)
|
|
119
122
|
end
|
|
120
123
|
end
|
|
121
124
|
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module LatoCms
|
|
2
|
+
# Generates alt text and/or title for an image Media via an OpenAI-compatible
|
|
3
|
+
# LLM, one LLM call per attribute (each has its own prompt, see
|
|
4
|
+
# LatoCms::Config#llm_prompt). `attributes` defaults to every attribute
|
|
5
|
+
# enabled in config (the automatic post-upload run); the manual "Regenerate
|
|
6
|
+
# with AI" buttons pass the single attribute they sit next to.
|
|
7
|
+
#
|
|
8
|
+
# Doubles as a plain background job (post-upload call site, see
|
|
9
|
+
# Media#enqueue_text_generation: fire-and-forget, best effort, never blocks
|
|
10
|
+
# the upload) and as a Lato::Operation-backed job (the admin actions, see
|
|
11
|
+
# MediaController#regenerate_text_action: the admin is watching, so failures
|
|
12
|
+
# should surface instead of being swallowed). Inheriting Lato::ApplicationJob
|
|
13
|
+
# makes both work from the same perform: `operation?` is only true when a
|
|
14
|
+
# `_operation_id` was injected by Lato::Operation#start.
|
|
15
|
+
class GenerateMediaTextJob < Lato::ApplicationJob
|
|
16
|
+
def perform(params = {})
|
|
17
|
+
params = params.with_indifferent_access
|
|
18
|
+
media = LatoCms::Media.find_by(id: params[:media_id])
|
|
19
|
+
return unless media
|
|
20
|
+
|
|
21
|
+
attributes = Array(params[:attributes]).presence || LatoCms.config.llm_media_attributes
|
|
22
|
+
attributes.each { |attribute| media.generate_text!(attribute, raise_on_error: operation?) }
|
|
23
|
+
save_operation_output_message(I18n.t("lato_cms.media_text_regenerated")) if operation?
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -10,14 +10,23 @@ module LatoCms
|
|
|
10
10
|
|
|
11
11
|
MEDIA_TYPES = %w[image video document file].freeze
|
|
12
12
|
|
|
13
|
-
# alt_text
|
|
14
|
-
#
|
|
15
|
-
# table since
|
|
16
|
-
#
|
|
17
|
-
# entry; `alt_text_en`, `
|
|
18
|
-
# are handled dynamically so forms can
|
|
19
|
-
# attribute without predefining a
|
|
20
|
-
|
|
13
|
+
# alt_text and title are each stored as a single JSON-serialized
|
|
14
|
+
# {locale => text} hash in their own text column (kept inline rather than
|
|
15
|
+
# in a translations table since these are Media's only translatable
|
|
16
|
+
# attributes). `alt_text`/`title` themselves are overridden below to
|
|
17
|
+
# read/write the current I18n.locale's entry; `alt_text_en`, `title_it`,
|
|
18
|
+
# etc. (one per configured locale) are handled dynamically so forms can
|
|
19
|
+
# render/submit them like any other attribute without predefining a
|
|
20
|
+
# method per locale.
|
|
21
|
+
TRANSLATABLE_ATTRIBUTES = %w[alt_text title].freeze
|
|
22
|
+
TRANSLATION_ACCESSOR = /\A(?<attribute>#{TRANSLATABLE_ATTRIBUTES.join("|")})_(?<locale>[a-z]{2}(?:_[A-Z]{2})?)(?<setter>=)?\z/
|
|
23
|
+
|
|
24
|
+
TRANSLATABLE_ATTRIBUTES.each do |attribute|
|
|
25
|
+
define_method(attribute) { |locale = I18n.locale| translations_of(attribute)[locale.to_s].presence }
|
|
26
|
+
define_method("#{attribute}=") { |value| write_translation(attribute, I18n.locale, value) }
|
|
27
|
+
define_method("#{attribute}_translations") { translations_of(attribute) }
|
|
28
|
+
define_method("#{attribute}_translations=") { |hash| self[attribute] = hash.stringify_keys.to_json }
|
|
29
|
+
end
|
|
21
30
|
|
|
22
31
|
has_one_attached :file
|
|
23
32
|
has_one_attached :poster_file
|
|
@@ -32,7 +41,7 @@ module LatoCms
|
|
|
32
41
|
before_validation :set_media_type, on: :create
|
|
33
42
|
|
|
34
43
|
after_create_commit :enqueue_poster_generation, if: :video?
|
|
35
|
-
after_create_commit :
|
|
44
|
+
after_create_commit :enqueue_text_generation, if: -> { image? && LatoCms.config.llm_media_attributes.any? }
|
|
36
45
|
|
|
37
46
|
scope :of_type, ->(type) { where(media_type: type) if type.present? }
|
|
38
47
|
|
|
@@ -73,37 +82,33 @@ module LatoCms
|
|
|
73
82
|
file.filename.to_s if file.attached?
|
|
74
83
|
end
|
|
75
84
|
|
|
76
|
-
def
|
|
77
|
-
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
def alt_text=(value)
|
|
81
|
-
self.alt_text_translations = alt_text_translations.merge(I18n.locale.to_s => value)
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
def alt_text_translations
|
|
85
|
-
JSON.parse(self[:alt_text].presence || '{}')
|
|
85
|
+
def translations_of(attribute)
|
|
86
|
+
JSON.parse(self[attribute].presence || "{}")
|
|
86
87
|
rescue JSON::ParserError
|
|
87
88
|
{}
|
|
88
89
|
end
|
|
89
90
|
|
|
90
|
-
def alt_text_translations=(hash)
|
|
91
|
-
self[:alt_text] = hash.stringify_keys.to_json
|
|
92
|
-
end
|
|
93
|
-
|
|
94
91
|
def method_missing(name, *args)
|
|
95
|
-
match =
|
|
92
|
+
match = TRANSLATION_ACCESSOR.match(name.to_s)
|
|
96
93
|
return super unless match
|
|
97
94
|
|
|
98
95
|
if match[:setter]
|
|
99
|
-
|
|
96
|
+
write_translation(match[:attribute], match[:locale], args.first)
|
|
100
97
|
else
|
|
101
|
-
|
|
98
|
+
translations_of(match[:attribute])[match[:locale]].presence
|
|
102
99
|
end
|
|
103
100
|
end
|
|
104
101
|
|
|
105
102
|
def respond_to_missing?(name, include_private = false)
|
|
106
|
-
|
|
103
|
+
TRANSLATION_ACCESSOR.match?(name.to_s) || super
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Frontend URLs of the pages using this media through any field: the
|
|
107
|
+
# absolute URLs handed to the LLM as `{urls}` prompt context, so the model
|
|
108
|
+
# can see where the image is shown. Pages without a frontend URL are left
|
|
109
|
+
# out. Empty right after upload, since nothing references a new media yet.
|
|
110
|
+
def usage_urls
|
|
111
|
+
LatoCms::Page.where(id: page_fields.select(:page_id)).where.not(frontend_url: [nil, ""]).distinct.order(:frontend_url).pluck(:frontend_url)
|
|
107
112
|
end
|
|
108
113
|
|
|
109
114
|
def url
|
|
@@ -133,29 +138,50 @@ module LatoCms
|
|
|
133
138
|
Rails.logger.warn("LatoCms: failed to generate video poster for media #{id}: #{e.message}")
|
|
134
139
|
end
|
|
135
140
|
|
|
136
|
-
# Asks the configured OpenAI-compatible LLM for
|
|
137
|
-
# configured locale and merges it in (existing
|
|
138
|
-
# the LLM didn't return
|
|
139
|
-
# individually). No-op unless
|
|
140
|
-
# LatoCms::Config#
|
|
141
|
+
# Asks the configured OpenAI-compatible LLM for `attribute` (alt_text or
|
|
142
|
+
# title) in every configured locale and merges it in (existing
|
|
143
|
+
# translations for locales the LLM didn't return are kept, the rest are
|
|
144
|
+
# replaced individually). No-op unless the LLM is configured and enabled
|
|
145
|
+
# for that attribute (see LatoCms::Config#llm_generates?) and this media
|
|
146
|
+
# is an image, since the LLM has to look at the file.
|
|
141
147
|
#
|
|
142
148
|
# Best effort by default (`raise_on_error: false`): any failure is logged
|
|
143
149
|
# and swallowed, since the automatic post-upload call site (see
|
|
144
|
-
#
|
|
145
|
-
# manual "Regenerate with AI"
|
|
146
|
-
# is actively watching, so
|
|
150
|
+
# GenerateMediaTextJob) must never break the upload over a flaky LLM. The
|
|
151
|
+
# manual "Regenerate with AI" actions run as a Lato::Operation the admin
|
|
152
|
+
# is actively watching, so they pass `raise_on_error: true` to have
|
|
147
153
|
# failures surface there instead of disappearing silently.
|
|
148
|
-
def
|
|
149
|
-
|
|
154
|
+
def generate_text!(attribute, raise_on_error: false)
|
|
155
|
+
attribute = attribute.to_s
|
|
156
|
+
raise ArgumentError, "unknown translatable attribute: #{attribute}" unless TRANSLATABLE_ATTRIBUTES.include?(attribute)
|
|
157
|
+
return unless image? && file.attached? && LatoCms.config.llm_generates?(attribute)
|
|
158
|
+
|
|
159
|
+
begin
|
|
160
|
+
translations = parse_translations(request_completion(llm_prompt(attribute)))
|
|
161
|
+
raise "The LLM returned no usable #{attribute.humanize.downcase}" if translations.blank?
|
|
162
|
+
|
|
163
|
+
public_send("#{attribute}_translations=", translations_of(attribute).merge(translations))
|
|
164
|
+
save!
|
|
165
|
+
rescue StandardError => e
|
|
166
|
+
Rails.logger.warn("LatoCms: failed to generate #{attribute} for media #{id}: #{e.message}")
|
|
167
|
+
raise if raise_on_error
|
|
168
|
+
end
|
|
169
|
+
end
|
|
150
170
|
|
|
151
|
-
|
|
152
|
-
|
|
171
|
+
# Full prompt sent for `attribute`: the custom-or-default task prompt (see
|
|
172
|
+
# LatoCms::Config#llm_prompt) with {languages}/{urls} substituted, plus the
|
|
173
|
+
# engine's fixed response-format contract, appended here rather than left
|
|
174
|
+
# to the prompt so parsing never depends on how a custom prompt is worded.
|
|
175
|
+
def llm_prompt(attribute)
|
|
176
|
+
variables = {
|
|
177
|
+
"languages" => llm_locales.join(", "),
|
|
178
|
+
"urls" => usage_urls.presence&.join(", ") || "none"
|
|
179
|
+
}
|
|
180
|
+
placeholders = /\{(#{LatoCms::Config::LLM_PROMPT_VARIABLES.join("|")})\}/
|
|
181
|
+
task = LatoCms.config.llm_prompt(attribute).gsub(placeholders) { variables[Regexp.last_match(1)] }
|
|
153
182
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
rescue StandardError => e
|
|
157
|
-
Rails.logger.warn("LatoCms: failed to generate alt text for media #{id}: #{e.message}")
|
|
158
|
-
raise if raise_on_error
|
|
183
|
+
"#{task}\n\nRespond with a single JSON object only, no markdown, no extra text, with exactly these " \
|
|
184
|
+
"keys: #{llm_locales.join(", ")}. Each value is the text written in that language."
|
|
159
185
|
end
|
|
160
186
|
|
|
161
187
|
# Fixed small variant used across admin UI (Media index, picker grid, field
|
|
@@ -212,6 +238,8 @@ module LatoCms
|
|
|
212
238
|
name: name,
|
|
213
239
|
alt_text: alt_text,
|
|
214
240
|
alt_text_translations: alt_text_translations,
|
|
241
|
+
title: title,
|
|
242
|
+
title_translations: title_translations,
|
|
215
243
|
media_type: media_type,
|
|
216
244
|
filename: filename,
|
|
217
245
|
content_type: file.attached? ? file.content_type : nil,
|
|
@@ -243,41 +271,36 @@ module LatoCms
|
|
|
243
271
|
LatoCms::GenerateVideoPosterJob.perform_later(id)
|
|
244
272
|
end
|
|
245
273
|
|
|
246
|
-
def
|
|
247
|
-
LatoCms::
|
|
274
|
+
def enqueue_text_generation
|
|
275
|
+
LatoCms::GenerateMediaTextJob.perform_later(media_id: id)
|
|
248
276
|
end
|
|
249
277
|
|
|
250
|
-
def
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
278
|
+
def write_translation(attribute, locale, value)
|
|
279
|
+
public_send("#{attribute}_translations=", translations_of(attribute).merge(locale.to_s => value))
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def llm_locales
|
|
283
|
+
LatoCms.config.locales.map(&:to_s)
|
|
254
284
|
end
|
|
255
285
|
|
|
256
286
|
# Sends the image as a base64 data URI rather than a URL: the file may be
|
|
257
287
|
# stored on a service (or behind auth) the LLM can't reach, so this works
|
|
258
288
|
# regardless of storage backend or app visibility settings.
|
|
259
|
-
def
|
|
289
|
+
def request_completion(prompt)
|
|
260
290
|
LatoCms::LlmClient.chat(messages: [{
|
|
261
|
-
role:
|
|
291
|
+
role: "user",
|
|
262
292
|
content: [
|
|
263
|
-
{ type:
|
|
264
|
-
{ type:
|
|
293
|
+
{ type: "text", text: prompt },
|
|
294
|
+
{ type: "image_url", image_url: { url: "data:#{file.content_type};base64,#{Base64.strict_encode64(file.download)}" } }
|
|
265
295
|
]
|
|
266
296
|
}])
|
|
267
297
|
end
|
|
268
298
|
|
|
269
|
-
def
|
|
270
|
-
"Write a concise, descriptive alt text for this image, for the HTML <img alt> attribute " \
|
|
271
|
-
"(accessibility use, not a caption). Respond with a single JSON object only, no markdown, " \
|
|
272
|
-
"no extra text, with exactly these keys: #{locales.join(', ')}. Each value is the alt text " \
|
|
273
|
-
"written in that language."
|
|
274
|
-
end
|
|
275
|
-
|
|
276
|
-
def parse_alt_text_response(content, locales)
|
|
299
|
+
def parse_translations(content)
|
|
277
300
|
return {} if content.blank?
|
|
278
301
|
|
|
279
302
|
parsed = JSON.parse(content[/\{.*\}/m] || content)
|
|
280
|
-
parsed.slice(*
|
|
303
|
+
parsed.slice(*llm_locales).transform_values(&:to_s)
|
|
281
304
|
rescue JSON::ParserError
|
|
282
305
|
{}
|
|
283
306
|
end
|
|
@@ -141,6 +141,7 @@ module LatoCms
|
|
|
141
141
|
media_id: m.id,
|
|
142
142
|
name: m.name,
|
|
143
143
|
alt_text: m.alt_text(page.locale),
|
|
144
|
+
title: m.title(page.locale),
|
|
144
145
|
filename: m.filename,
|
|
145
146
|
content_type: m.file.attached? ? m.file.content_type : nil,
|
|
146
147
|
byte_size: m.file.attached? ? m.file.byte_size : nil,
|
|
@@ -31,42 +31,9 @@
|
|
|
31
31
|
</div>
|
|
32
32
|
|
|
33
33
|
<% if media.image? %>
|
|
34
|
-
|
|
35
|
-
<div class="d-flex align-items-center justify-content-between">
|
|
36
|
-
<label class="form-label mb-0"><%= t('lato_cms.media_alt_text') %></label>
|
|
37
|
-
<% if LatoCms.config.llm_configured? %>
|
|
38
|
-
<%= link_to t('lato_cms.media_alt_text_regenerate'), lato_cms.media_regenerate_alt_text_action_path(media),
|
|
39
|
-
class: 'btn btn-sm btn-outline-secondary',
|
|
40
|
-
data: {
|
|
41
|
-
lato_action_target: 'trigger',
|
|
42
|
-
turbo_method: :post,
|
|
43
|
-
turbo_frame: 'lato_operation',
|
|
44
|
-
turbo_confirm: t('lato_cms.media_alt_text_regenerate_confirm'),
|
|
45
|
-
action_title: t('lato_cms.media_alt_text_regenerate')
|
|
46
|
-
} %>
|
|
47
|
-
<% end %>
|
|
48
|
-
</div>
|
|
49
|
-
|
|
50
|
-
<ul class="nav nav-tabs">
|
|
51
|
-
<% LatoCms.config.locales.each_with_index do |locale, index| %>
|
|
52
|
-
<li class="nav-item">
|
|
53
|
-
<button class="nav-link <%= 'active' if index.zero? %>" data-bs-toggle="tab"
|
|
54
|
-
data-bs-target="#<%= dom_id(media, "alt_text_#{locale}_pane") %>" type="button">
|
|
55
|
-
<%= locale_to_flag(locale) %> <%= locale.to_s.upcase %>
|
|
56
|
-
</button>
|
|
57
|
-
</li>
|
|
58
|
-
<% end %>
|
|
59
|
-
</ul>
|
|
60
|
-
|
|
61
|
-
<div class="tab-content border border-top-0 rounded-bottom p-3">
|
|
62
|
-
<% LatoCms.config.locales.each_with_index do |locale, index| %>
|
|
63
|
-
<div class="tab-pane fade <%= 'show active' if index.zero? %>" id="<%= dom_id(media, "alt_text_#{locale}_pane") %>">
|
|
64
|
-
<%= lato_form_item_input_text form, :"alt_text_#{locale}" %>
|
|
65
|
-
</div>
|
|
66
|
-
<% end %>
|
|
67
|
-
</div>
|
|
68
|
-
</div>
|
|
34
|
+
<%= render "lato_cms/media/translations_field", form: form, media: media, attribute: "alt_text" %>
|
|
69
35
|
<% end %>
|
|
36
|
+
<%= render "lato_cms/media/translations_field", form: form, media: media, attribute: "title" %>
|
|
70
37
|
|
|
71
38
|
<div class="d-flex justify-content-end">
|
|
72
39
|
<%= lato_form_submit form, t('lato_cms.cta_update'), class: %w[btn-success] %>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<%# One tab per configured locale for a translatable Media attribute
|
|
2
|
+
(alt_text/title), with its own "Regenerate with AI" button when the LLM
|
|
3
|
+
is configured and enabled for that attribute. Images only: the LLM has
|
|
4
|
+
to look at the file to describe it. %>
|
|
5
|
+
<% label = t("lato_cms.media_#{attribute}") %>
|
|
6
|
+
|
|
7
|
+
<div class="mb-3">
|
|
8
|
+
<div class="d-flex align-items-center justify-content-between">
|
|
9
|
+
<label class="form-label mb-0"><%= label %></label>
|
|
10
|
+
<% if media.image? && LatoCms.config.llm_generates?(attribute) %>
|
|
11
|
+
<%= link_to t("lato_cms.media_text_regenerate"), lato_cms.media_regenerate_text_action_path(media, attribute: attribute),
|
|
12
|
+
class: "btn btn-sm btn-outline-secondary",
|
|
13
|
+
data: {
|
|
14
|
+
lato_action_target: "trigger",
|
|
15
|
+
turbo_method: :post,
|
|
16
|
+
turbo_frame: "lato_operation",
|
|
17
|
+
turbo_confirm: t("lato_cms.media_text_regenerate_confirm", field: label.downcase),
|
|
18
|
+
action_title: "#{t('lato_cms.media_text_regenerate')} - #{label}"
|
|
19
|
+
} %>
|
|
20
|
+
<% end %>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<ul class="nav nav-tabs">
|
|
24
|
+
<% LatoCms.config.locales.each_with_index do |locale, index| %>
|
|
25
|
+
<li class="nav-item">
|
|
26
|
+
<button class="nav-link <%= 'active' if index.zero? %>" data-bs-toggle="tab"
|
|
27
|
+
data-bs-target="#<%= dom_id(media, "#{attribute}_#{locale}_pane") %>" type="button">
|
|
28
|
+
<%= locale_to_flag(locale) %> <%= locale.to_s.upcase %>
|
|
29
|
+
</button>
|
|
30
|
+
</li>
|
|
31
|
+
<% end %>
|
|
32
|
+
</ul>
|
|
33
|
+
|
|
34
|
+
<div class="tab-content border border-top-0 rounded-bottom p-3">
|
|
35
|
+
<% LatoCms.config.locales.each_with_index do |locale, index| %>
|
|
36
|
+
<div class="tab-pane fade <%= 'show active' if index.zero? %>" id="<%= dom_id(media, "#{attribute}_#{locale}_pane") %>">
|
|
37
|
+
<%= lato_form_item_input_text form, :"#{attribute}_#{locale}" %>
|
|
38
|
+
</div>
|
|
39
|
+
<% end %>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
data/config/locales/en.yml
CHANGED
|
@@ -101,11 +101,12 @@ en:
|
|
|
101
101
|
media_update_title: Edit media
|
|
102
102
|
media_open_original: Open the original file in a new tab
|
|
103
103
|
media_alt_text: Alt text
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
104
|
+
media_title: Title
|
|
105
|
+
media_text_regenerate: Regenerate with AI
|
|
106
|
+
media_text_regenerate_confirm: "This will overwrite the current %{field} in every language with a new AI-generated one. Continue?"
|
|
107
|
+
media_text_regenerated: Text regenerated
|
|
108
|
+
media_text_regenerate_unavailable: AI generation is only available for images, and only when an LLM is configured and enabled for this field
|
|
109
|
+
media_text_regenerate_failed: Failed to start AI generation
|
|
109
110
|
media_created: Media uploaded successfully
|
|
110
111
|
media_updated: Media updated successfully
|
|
111
112
|
media_deleted: Media deleted successfully
|
data/config/locales/it.yml
CHANGED
|
@@ -101,11 +101,12 @@ it:
|
|
|
101
101
|
media_update_title: Modifica media
|
|
102
102
|
media_open_original: Apri il file originale in una nuova scheda
|
|
103
103
|
media_alt_text: Testo alternativo
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
104
|
+
media_title: Titolo
|
|
105
|
+
media_text_regenerate: Rigenera con AI
|
|
106
|
+
media_text_regenerate_confirm: "Il %{field} attuale in ogni lingua verrà sostituito con uno generato dall'AI. Continuare?"
|
|
107
|
+
media_text_regenerated: Testo rigenerato
|
|
108
|
+
media_text_regenerate_unavailable: La generazione AI è disponibile solo per le immagini e solo se un LLM è configurato e abilitato per questo campo
|
|
109
|
+
media_text_regenerate_failed: Impossibile avviare la generazione AI
|
|
109
110
|
media_created: Media caricato con successo
|
|
110
111
|
media_updated: Media aggiornato con successo
|
|
111
112
|
media_deleted: Media eliminato con successo
|
data/config/routes.rb
CHANGED
|
@@ -32,7 +32,7 @@ LatoCms::Engine.routes.draw do
|
|
|
32
32
|
post 'create', to: 'media#create_action', as: :media_create_action
|
|
33
33
|
get ':id/update', to: 'media#update', as: :media_update
|
|
34
34
|
patch ':id/update', to: 'media#update_action', as: :media_update_action
|
|
35
|
-
post ':id/regenerate
|
|
35
|
+
post ':id/regenerate/:attribute', to: 'media#regenerate_text_action', as: :media_regenerate_text_action, constraints: { attribute: /alt_text|title/ }
|
|
36
36
|
delete ':id', to: 'media#destroy_action', as: :media_destroy_action
|
|
37
37
|
end
|
|
38
38
|
end
|
data/lib/lato_cms/config.rb
CHANGED
|
@@ -3,11 +3,32 @@ module LatoCms
|
|
|
3
3
|
# This class contains the default configuration of the engine.
|
|
4
4
|
##
|
|
5
5
|
class Config
|
|
6
|
-
attr_accessor :locales, :templates_path, :admin_roles, :llm_api_url, :llm_model, :llm_api_key
|
|
6
|
+
attr_accessor :locales, :templates_path, :admin_roles, :llm_api_url, :llm_model, :llm_api_key,
|
|
7
|
+
:llm_generate_alt_text, :llm_generate_title, :llm_alt_text_prompt, :llm_title_prompt
|
|
8
|
+
|
|
9
|
+
# Placeholders substituted at request time in both default and custom LLM
|
|
10
|
+
# prompts (see LatoCms::Media#llm_prompt):
|
|
11
|
+
# - {languages}: comma-separated LatoCms.config.locales
|
|
12
|
+
# - {urls}: comma-separated frontend URLs of the pages using the media, so
|
|
13
|
+
# the model can look at where the image is shown ("none" when it isn't
|
|
14
|
+
# used anywhere yet, e.g. right after upload)
|
|
15
|
+
LLM_PROMPT_VARIABLES = %w[languages urls].freeze
|
|
16
|
+
|
|
17
|
+
# Task prompts used when the host app sets no custom one. The response
|
|
18
|
+
# format contract (a JSON object keyed by locale) is appended by the
|
|
19
|
+
# engine to every prompt, custom ones included, so it's not part of these.
|
|
20
|
+
DEFAULT_LLM_PROMPTS = {
|
|
21
|
+
alt_text: "Write a concise, descriptive alt text for this image, for the HTML <img alt> attribute " \
|
|
22
|
+
"(accessibility use, not a caption). The website is published in these languages: {languages}. " \
|
|
23
|
+
"The image is used on these pages, use them as context for what it depicts and why: {urls}.",
|
|
24
|
+
title: "Write a short, human-readable title for this image, for the HTML <img title> attribute " \
|
|
25
|
+
"(a few words, no trailing period, not a full description). The website is published in these " \
|
|
26
|
+
"languages: {languages}. The image is used on these pages, use them as context: {urls}."
|
|
27
|
+
}.freeze
|
|
7
28
|
|
|
8
29
|
def initialize
|
|
9
30
|
@locales = [:en]
|
|
10
|
-
@templates_path =
|
|
31
|
+
@templates_path = "config/lato_cms"
|
|
11
32
|
|
|
12
33
|
# Admin roles exposed on Lato::User#lato_cms_admin_role and rendered
|
|
13
34
|
# as a select by lato_users. Ordered map of role key => integer value;
|
|
@@ -16,16 +37,44 @@ module LatoCms
|
|
|
16
37
|
# (create, update, delete) and translation links.
|
|
17
38
|
@admin_roles = { none: 0, operator: 1, admin: 2 }
|
|
18
39
|
|
|
19
|
-
# Optional OpenAI-compatible endpoint used to auto-generate alt text
|
|
20
|
-
# for uploaded images (see LatoCms::Media#
|
|
40
|
+
# Optional OpenAI-compatible endpoint used to auto-generate alt text and
|
|
41
|
+
# title for uploaded images (see LatoCms::Media#generate_text!). All
|
|
21
42
|
# three must be set for the feature to activate; unset by default.
|
|
22
43
|
@llm_api_url = nil
|
|
23
44
|
@llm_model = nil
|
|
24
45
|
@llm_api_key = nil
|
|
46
|
+
|
|
47
|
+
# Which Media attributes the LLM generates (post-upload and via the
|
|
48
|
+
# "Regenerate with AI" buttons). Both on once an LLM is configured;
|
|
49
|
+
# switch one off to skip it, each costs one LLM call per upload.
|
|
50
|
+
@llm_generate_alt_text = true
|
|
51
|
+
@llm_generate_title = true
|
|
52
|
+
|
|
53
|
+
# Custom task prompts, nil falls back to DEFAULT_LLM_PROMPTS. May embed
|
|
54
|
+
# LLM_PROMPT_VARIABLES placeholders.
|
|
55
|
+
@llm_alt_text_prompt = nil
|
|
56
|
+
@llm_title_prompt = nil
|
|
25
57
|
end
|
|
26
58
|
|
|
27
59
|
def llm_configured?
|
|
28
60
|
llm_api_url.present? && llm_model.present? && llm_api_key.present?
|
|
29
61
|
end
|
|
62
|
+
|
|
63
|
+
# Media attributes the LLM generates, as configured. Empty unless an LLM
|
|
64
|
+
# is configured, so callers need a single check.
|
|
65
|
+
def llm_media_attributes
|
|
66
|
+
return [] unless llm_configured?
|
|
67
|
+
|
|
68
|
+
{ alt_text: llm_generate_alt_text, title: llm_generate_title }.select { |_attribute, enabled| enabled }.keys
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def llm_generates?(attribute)
|
|
72
|
+
llm_media_attributes.include?(attribute.to_sym)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def llm_prompt(attribute)
|
|
76
|
+
attribute = attribute.to_sym
|
|
77
|
+
public_send("llm_#{attribute}_prompt").presence || DEFAULT_LLM_PROMPTS.fetch(attribute)
|
|
78
|
+
end
|
|
30
79
|
end
|
|
31
|
-
end
|
|
80
|
+
end
|
data/lib/lato_cms/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: lato_cms
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.
|
|
4
|
+
version: 3.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Gregorio Galante
|
|
@@ -128,7 +128,7 @@ files:
|
|
|
128
128
|
- app/helpers/lato_cms/pages_helper.rb
|
|
129
129
|
- app/jobs/lato_cms/application_job.rb
|
|
130
130
|
- app/jobs/lato_cms/export_sitemap_csv_job.rb
|
|
131
|
-
- app/jobs/lato_cms/
|
|
131
|
+
- app/jobs/lato_cms/generate_media_text_job.rb
|
|
132
132
|
- app/jobs/lato_cms/generate_video_poster_job.rb
|
|
133
133
|
- app/jobs/lato_cms/translate_component_fields_job.rb
|
|
134
134
|
- app/mailers/lato_cms/application_mailer.rb
|
|
@@ -140,6 +140,7 @@ files:
|
|
|
140
140
|
- app/models/lato_cms/template_manager.rb
|
|
141
141
|
- app/views/lato_cms/media/_form_create.html.erb
|
|
142
142
|
- app/views/lato_cms/media/_form_update.html.erb
|
|
143
|
+
- app/views/lato_cms/media/_translations_field.html.erb
|
|
143
144
|
- app/views/lato_cms/media/create.html.erb
|
|
144
145
|
- app/views/lato_cms/media/index.html.erb
|
|
145
146
|
- app/views/lato_cms/media/picker_action.html.erb
|
|
@@ -185,6 +186,7 @@ files:
|
|
|
185
186
|
- db/migrate/20260728175413_create_lato_cms_media.rb
|
|
186
187
|
- db/migrate/20260728175414_create_lato_cms_page_field_media.rb
|
|
187
188
|
- db/migrate/20260729120000_change_lato_cms_media_alt_text_to_translations.rb
|
|
189
|
+
- db/migrate/20260907120000_add_title_to_lato_cms_media.rb
|
|
188
190
|
- lib/lato_cms.rb
|
|
189
191
|
- lib/lato_cms/config.rb
|
|
190
192
|
- lib/lato_cms/engine.rb
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
module LatoCms
|
|
2
|
-
# Generates alt text for an image Media via an OpenAI-compatible LLM.
|
|
3
|
-
# Doubles as a plain background job (automatic post-upload call site, see
|
|
4
|
-
# Media#enqueue_alt_text_generation: fire-and-forget, best effort, never
|
|
5
|
-
# blocks the upload) and as a Lato::Operation-backed job (the "Regenerate
|
|
6
|
-
# with AI" admin action, see MediaController#regenerate_alt_text_action:
|
|
7
|
-
# the admin is watching, so failures should surface instead of being
|
|
8
|
-
# swallowed). Inheriting Lato::ApplicationJob makes both work from the
|
|
9
|
-
# same perform: `operation?` is only true when a `_operation_id` was
|
|
10
|
-
# injected by Lato::Operation#start.
|
|
11
|
-
class GenerateAltTextJob < Lato::ApplicationJob
|
|
12
|
-
def perform(params = {})
|
|
13
|
-
params = params.with_indifferent_access
|
|
14
|
-
media = LatoCms::Media.find_by(id: params[:media_id])
|
|
15
|
-
return unless media
|
|
16
|
-
|
|
17
|
-
media.generate_alt_text!(raise_on_error: operation?)
|
|
18
|
-
save_operation_output_message(I18n.t('lato_cms.media_alt_text_regenerated')) if operation?
|
|
19
|
-
end
|
|
20
|
-
end
|
|
21
|
-
end
|