lato_cms 3.1.4 → 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 +16 -13
- data/app/jobs/lato_cms/generate_media_text_job.rb +26 -0
- data/app/models/lato_cms/media.rb +101 -63
- data/app/models/lato_cms/page_field.rb +1 -0
- data/app/views/lato_cms/media/_form_update.html.erb +20 -38
- data/app/views/lato_cms/media/_translations_field.html.erb +41 -0
- data/app/views/lato_cms/media/index.html.erb +1 -1
- data/config/locales/en.yml +7 -5
- data/config/locales/it.yml +7 -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,9 +10,9 @@ 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
|
-
pagination:
|
|
15
|
+
pagination: 20
|
|
16
16
|
)
|
|
17
17
|
end
|
|
18
18
|
|
|
@@ -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
|
|
@@ -172,6 +198,21 @@ module LatoCms
|
|
|
172
198
|
nil
|
|
173
199
|
end
|
|
174
200
|
|
|
201
|
+
# Large uncropped variant for the admin detail view, where the 200x200
|
|
202
|
+
# square thumbnail is too small to actually judge the image. Falls back to
|
|
203
|
+
# the original blob when the file can't be processed (e.g. SVG).
|
|
204
|
+
def preview_url
|
|
205
|
+
return nil unless image? && file.attached?
|
|
206
|
+
return url unless file.variable?
|
|
207
|
+
|
|
208
|
+
Rails.application.routes.url_helpers.rails_representation_path(
|
|
209
|
+
file.variant(resize_to_limit: [1200, 1200]), only_path: true
|
|
210
|
+
)
|
|
211
|
+
rescue StandardError => e
|
|
212
|
+
Rails.logger.error("LatoCms: Failed to build preview for media #{id}: #{e.message}")
|
|
213
|
+
url
|
|
214
|
+
end
|
|
215
|
+
|
|
175
216
|
# Builds a map of { size_name => variant_url } from a field's `settings.sizes`
|
|
176
217
|
# config. The config is field-owned (different fields can request different
|
|
177
218
|
# crops of the same reused Media); the mechanics live here since Media owns
|
|
@@ -197,6 +238,8 @@ module LatoCms
|
|
|
197
238
|
name: name,
|
|
198
239
|
alt_text: alt_text,
|
|
199
240
|
alt_text_translations: alt_text_translations,
|
|
241
|
+
title: title,
|
|
242
|
+
title_translations: title_translations,
|
|
200
243
|
media_type: media_type,
|
|
201
244
|
filename: filename,
|
|
202
245
|
content_type: file.attached? ? file.content_type : nil,
|
|
@@ -228,41 +271,36 @@ module LatoCms
|
|
|
228
271
|
LatoCms::GenerateVideoPosterJob.perform_later(id)
|
|
229
272
|
end
|
|
230
273
|
|
|
231
|
-
def
|
|
232
|
-
LatoCms::
|
|
274
|
+
def enqueue_text_generation
|
|
275
|
+
LatoCms::GenerateMediaTextJob.perform_later(media_id: id)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def write_translation(attribute, locale, value)
|
|
279
|
+
public_send("#{attribute}_translations=", translations_of(attribute).merge(locale.to_s => value))
|
|
233
280
|
end
|
|
234
281
|
|
|
235
|
-
def
|
|
236
|
-
|
|
237
|
-
content = request_alt_text_completion(locales)
|
|
238
|
-
parse_alt_text_response(content, locales)
|
|
282
|
+
def llm_locales
|
|
283
|
+
LatoCms.config.locales.map(&:to_s)
|
|
239
284
|
end
|
|
240
285
|
|
|
241
286
|
# Sends the image as a base64 data URI rather than a URL: the file may be
|
|
242
287
|
# stored on a service (or behind auth) the LLM can't reach, so this works
|
|
243
288
|
# regardless of storage backend or app visibility settings.
|
|
244
|
-
def
|
|
289
|
+
def request_completion(prompt)
|
|
245
290
|
LatoCms::LlmClient.chat(messages: [{
|
|
246
|
-
role:
|
|
291
|
+
role: "user",
|
|
247
292
|
content: [
|
|
248
|
-
{ type:
|
|
249
|
-
{ type:
|
|
293
|
+
{ type: "text", text: prompt },
|
|
294
|
+
{ type: "image_url", image_url: { url: "data:#{file.content_type};base64,#{Base64.strict_encode64(file.download)}" } }
|
|
250
295
|
]
|
|
251
296
|
}])
|
|
252
297
|
end
|
|
253
298
|
|
|
254
|
-
def
|
|
255
|
-
"Write a concise, descriptive alt text for this image, for the HTML <img alt> attribute " \
|
|
256
|
-
"(accessibility use, not a caption). Respond with a single JSON object only, no markdown, " \
|
|
257
|
-
"no extra text, with exactly these keys: #{locales.join(', ')}. Each value is the alt text " \
|
|
258
|
-
"written in that language."
|
|
259
|
-
end
|
|
260
|
-
|
|
261
|
-
def parse_alt_text_response(content, locales)
|
|
299
|
+
def parse_translations(content)
|
|
262
300
|
return {} if content.blank?
|
|
263
301
|
|
|
264
302
|
parsed = JSON.parse(content[/\{.*\}/m] || content)
|
|
265
|
-
parsed.slice(*
|
|
303
|
+
parsed.slice(*llm_locales).transform_values(&:to_s)
|
|
266
304
|
rescue JSON::ParserError
|
|
267
305
|
{}
|
|
268
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,
|
|
@@ -5,9 +5,24 @@
|
|
|
5
5
|
<%= lato_form_notices class: %w[mb-3] %>
|
|
6
6
|
<%= lato_form_errors media, class: %w[mb-3] %>
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
<%# Preview: images/videos get a large view (the index thumbnail is too small
|
|
9
|
+
to judge a file by), everything else keeps the small type icon. %>
|
|
10
|
+
<div class="mb-3">
|
|
11
|
+
<% if media.image? %>
|
|
12
|
+
<%= link_to media.url, target: '_blank', rel: 'noopener', title: t('lato_cms.media_open_original'),
|
|
13
|
+
class: 'd-block bg-light rounded overflow-hidden' do %>
|
|
14
|
+
<%= image_tag media.preview_url, class: 'd-block mx-auto', style: 'max-width: 100%; max-height: 420px;', alt: media.alt_text %>
|
|
15
|
+
<% end %>
|
|
16
|
+
<% elsif media.video? %>
|
|
17
|
+
<video controls preload="metadata" class="d-block w-100 rounded bg-black" style="max-height: 420px;" src="<%= media.url %>"></video>
|
|
18
|
+
<% end %>
|
|
19
|
+
|
|
20
|
+
<div class="d-flex align-items-center gap-2 mt-2">
|
|
21
|
+
<% unless media.image? || media.video? %>
|
|
22
|
+
<%= lato_cms_media_thumb(media, size: 64) %>
|
|
23
|
+
<% end %>
|
|
24
|
+
<span class="text-muted small"><%= media.filename %></span>
|
|
25
|
+
</div>
|
|
11
26
|
</div>
|
|
12
27
|
|
|
13
28
|
<div class="mb-3">
|
|
@@ -16,42 +31,9 @@
|
|
|
16
31
|
</div>
|
|
17
32
|
|
|
18
33
|
<% if media.image? %>
|
|
19
|
-
|
|
20
|
-
<div class="d-flex align-items-center justify-content-between">
|
|
21
|
-
<label class="form-label mb-0"><%= t('lato_cms.media_alt_text') %></label>
|
|
22
|
-
<% if LatoCms.config.llm_configured? %>
|
|
23
|
-
<%= link_to t('lato_cms.media_alt_text_regenerate'), lato_cms.media_regenerate_alt_text_action_path(media),
|
|
24
|
-
class: 'btn btn-sm btn-outline-secondary',
|
|
25
|
-
data: {
|
|
26
|
-
lato_action_target: 'trigger',
|
|
27
|
-
turbo_method: :post,
|
|
28
|
-
turbo_frame: 'lato_operation',
|
|
29
|
-
turbo_confirm: t('lato_cms.media_alt_text_regenerate_confirm'),
|
|
30
|
-
action_title: t('lato_cms.media_alt_text_regenerate')
|
|
31
|
-
} %>
|
|
32
|
-
<% end %>
|
|
33
|
-
</div>
|
|
34
|
-
|
|
35
|
-
<ul class="nav nav-tabs">
|
|
36
|
-
<% LatoCms.config.locales.each_with_index do |locale, index| %>
|
|
37
|
-
<li class="nav-item">
|
|
38
|
-
<button class="nav-link <%= 'active' if index.zero? %>" data-bs-toggle="tab"
|
|
39
|
-
data-bs-target="#<%= dom_id(media, "alt_text_#{locale}_pane") %>" type="button">
|
|
40
|
-
<%= locale_to_flag(locale) %> <%= locale.to_s.upcase %>
|
|
41
|
-
</button>
|
|
42
|
-
</li>
|
|
43
|
-
<% end %>
|
|
44
|
-
</ul>
|
|
45
|
-
|
|
46
|
-
<div class="tab-content border border-top-0 rounded-bottom p-3">
|
|
47
|
-
<% LatoCms.config.locales.each_with_index do |locale, index| %>
|
|
48
|
-
<div class="tab-pane fade <%= 'show active' if index.zero? %>" id="<%= dom_id(media, "alt_text_#{locale}_pane") %>">
|
|
49
|
-
<%= lato_form_item_input_text form, :"alt_text_#{locale}" %>
|
|
50
|
-
</div>
|
|
51
|
-
<% end %>
|
|
52
|
-
</div>
|
|
53
|
-
</div>
|
|
34
|
+
<%= render "lato_cms/media/translations_field", form: form, media: media, attribute: "alt_text" %>
|
|
54
35
|
<% end %>
|
|
36
|
+
<%= render "lato_cms/media/translations_field", form: form, media: media, attribute: "title" %>
|
|
55
37
|
|
|
56
38
|
<div class="d-flex justify-content-end">
|
|
57
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
|
@@ -99,12 +99,14 @@ en:
|
|
|
99
99
|
media_create_title: Upload media
|
|
100
100
|
media_create_cta: Upload
|
|
101
101
|
media_update_title: Edit media
|
|
102
|
+
media_open_original: Open the original file in a new tab
|
|
102
103
|
media_alt_text: Alt text
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
108
110
|
media_created: Media uploaded successfully
|
|
109
111
|
media_updated: Media updated successfully
|
|
110
112
|
media_deleted: Media deleted successfully
|
data/config/locales/it.yml
CHANGED
|
@@ -99,12 +99,14 @@ it:
|
|
|
99
99
|
media_create_title: Carica media
|
|
100
100
|
media_create_cta: Carica
|
|
101
101
|
media_update_title: Modifica media
|
|
102
|
+
media_open_original: Apri il file originale in una nuova scheda
|
|
102
103
|
media_alt_text: Testo alternativo
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
108
110
|
media_created: Media caricato con successo
|
|
109
111
|
media_updated: Media aggiornato con successo
|
|
110
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
|