250 lines
7.7 KiB
JavaScript
250 lines
7.7 KiB
JavaScript
import { ajax } from "discourse/lib/ajax";
|
|
import { extractError, popupAjaxError } from "discourse/lib/ajax-error";
|
|
import { apiInitializer } from "discourse/lib/api";
|
|
import {
|
|
getUploadMarkdown,
|
|
IMAGE_MARKDOWN_REGEX,
|
|
isImage,
|
|
} from "discourse/lib/uploads";
|
|
import I18n from "discourse-i18n";
|
|
|
|
export default apiInitializer("1.25.0", (api) => {
|
|
const buttonAttrs = {
|
|
label: I18n.t("discourse_ai.ai_helper.image_caption.button_label"),
|
|
icon: "discourse-sparkles",
|
|
class: "generate-caption",
|
|
};
|
|
const settings = api.container.lookup("service:site-settings");
|
|
const currentUser = api.getCurrentUser();
|
|
|
|
if (
|
|
!settings.ai_helper_enabled_features.includes("image_caption") ||
|
|
!currentUser?.can_use_assistant
|
|
) {
|
|
return;
|
|
}
|
|
|
|
api.addSaveableUserOptionField("auto_image_caption");
|
|
|
|
api.addComposerImageWrapperButton(
|
|
buttonAttrs.label,
|
|
buttonAttrs.class,
|
|
buttonAttrs.icon,
|
|
(event) => {
|
|
const imageCaptionPopup = api.container.lookup(
|
|
"service:imageCaptionPopup"
|
|
);
|
|
|
|
imageCaptionPopup.popupTrigger = event.target;
|
|
|
|
if (
|
|
imageCaptionPopup.popupTrigger.classList.contains("generate-caption")
|
|
) {
|
|
const buttonWrapper = event.target.closest(".button-wrapper");
|
|
const imageIndex = parseInt(
|
|
buttonWrapper.getAttribute("data-image-index"),
|
|
10
|
|
);
|
|
const imageSrc = event.target
|
|
.closest(".image-wrapper")
|
|
.querySelector("img")
|
|
.getAttribute("src");
|
|
|
|
imageCaptionPopup.toggleLoadingState(true);
|
|
|
|
const site = api.container.lookup("site:main");
|
|
if (!site.mobileView) {
|
|
imageCaptionPopup.showPopup = !imageCaptionPopup.showPopup;
|
|
}
|
|
|
|
imageCaptionPopup._request = ajax(
|
|
`/discourse-ai/ai-helper/caption_image`,
|
|
{
|
|
method: "POST",
|
|
data: {
|
|
image_url: imageSrc,
|
|
image_url_type: "long_url",
|
|
},
|
|
}
|
|
);
|
|
|
|
imageCaptionPopup._request
|
|
.then(({ caption }) => {
|
|
imageCaptionPopup.imageSrc = imageSrc;
|
|
imageCaptionPopup.imageIndex = imageIndex;
|
|
imageCaptionPopup.newCaption = caption;
|
|
|
|
if (site.mobileView) {
|
|
// Auto-saves caption on mobile view
|
|
imageCaptionPopup.updateCaption();
|
|
}
|
|
})
|
|
.catch(popupAjaxError)
|
|
.finally(() => {
|
|
imageCaptionPopup.toggleLoadingState(false);
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
// Checks if image is small (≤ 0.1 MP)
|
|
function isSmallImage(width, height) {
|
|
const megapixels = (width * height) / 1000000;
|
|
return megapixels <= 0.1;
|
|
}
|
|
|
|
function needsImprovedCaption(caption) {
|
|
return caption.length < 20 || caption.split(" ").length === 1;
|
|
}
|
|
|
|
function getUploadUrlFromMarkdown(markdown) {
|
|
const regex = /\(upload:\/\/([^)]+)\)/;
|
|
const match = markdown.match(regex);
|
|
return match ? `upload://${match[1]}` : null;
|
|
}
|
|
|
|
async function fetchImageCaption(imageUrl, urlType) {
|
|
try {
|
|
const response = await ajax(`/discourse-ai/ai-helper/caption_image`, {
|
|
method: "POST",
|
|
data: {
|
|
image_url: imageUrl,
|
|
image_url_type: urlType,
|
|
},
|
|
});
|
|
return response.caption;
|
|
} catch (error) {
|
|
toasts.error({
|
|
class: "ai-image-caption-error-toast",
|
|
duration: 3000,
|
|
data: {
|
|
message: extractError(error),
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
const autoCaptionAllowedGroups =
|
|
settings?.ai_auto_image_caption_allowed_groups
|
|
.split("|")
|
|
.map((id) => parseInt(id, 10));
|
|
const currentUserGroups = currentUser.groups.map((g) => g.id);
|
|
|
|
if (
|
|
!currentUserGroups.some((groupId) =>
|
|
autoCaptionAllowedGroups.includes(groupId)
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const toasts = api.container.lookup("service:toasts");
|
|
// Automatically caption uploaded images
|
|
api.addComposerUploadMarkdownResolver(async (upload) => {
|
|
const autoCaptionEnabled = currentUser.get(
|
|
"user_option.auto_image_caption"
|
|
);
|
|
|
|
if (
|
|
!autoCaptionEnabled ||
|
|
!isImage(upload.url) ||
|
|
!needsImprovedCaption(upload.original_filename) ||
|
|
isSmallImage(upload.width, upload.height)
|
|
) {
|
|
return getUploadMarkdown(upload);
|
|
}
|
|
|
|
const caption = await fetchImageCaption(upload.url, "long_url");
|
|
if (!caption) {
|
|
return getUploadMarkdown(upload);
|
|
}
|
|
return `![${caption}|${upload.thumbnail_width}x${upload.thumbnail_height}](${upload.short_url})`;
|
|
});
|
|
|
|
// Conditionally show dialog to auto image caption
|
|
api.composerBeforeSave(() => {
|
|
return new Promise((resolve, reject) => {
|
|
const dialog = api.container.lookup("service:dialog");
|
|
const composer = api.container.lookup("service:composer");
|
|
const localePrefix =
|
|
"discourse_ai.ai_helper.image_caption.automatic_caption_dialog";
|
|
const autoCaptionEnabled = currentUser.get(
|
|
"user_option.auto_image_caption"
|
|
);
|
|
|
|
const imageUploads = composer.model.reply.match(IMAGE_MARKDOWN_REGEX);
|
|
const hasImageUploads = imageUploads?.length > 0;
|
|
|
|
if (!hasImageUploads) {
|
|
resolve();
|
|
}
|
|
|
|
const imagesToCaption = imageUploads.filter((image) => {
|
|
const caption = image
|
|
.substring(image.indexOf("[") + 1, image.indexOf("]"))
|
|
.split("|")[0];
|
|
// We don't check if the image is small to show the prompt here
|
|
// because the width/height are the thumbnail sizes so the mp count
|
|
// is incorrect. It doesn't matter because the auto caption won't
|
|
// happen anyways if its small because that uses the actual upload dimensions
|
|
return needsImprovedCaption(caption);
|
|
});
|
|
|
|
const needsBetterCaptions = imagesToCaption?.length > 0;
|
|
|
|
const keyValueStore = api.container.lookup("service:key-value-store");
|
|
const imageCaptionPopup = api.container.lookup(
|
|
"service:imageCaptionPopup"
|
|
);
|
|
const autoCaptionPromptKey = "ai-auto-caption-seen";
|
|
const seenAutoCaptionPrompt = keyValueStore.getItem(autoCaptionPromptKey);
|
|
|
|
if (autoCaptionEnabled || !needsBetterCaptions || seenAutoCaptionPrompt) {
|
|
return resolve();
|
|
}
|
|
|
|
keyValueStore.setItem(autoCaptionPromptKey, true);
|
|
|
|
dialog.confirm({
|
|
message: I18n.t(`${localePrefix}.prompt`),
|
|
confirmButtonLabel: `${localePrefix}.confirm`,
|
|
cancelButtonLabel: `${localePrefix}.cancel`,
|
|
class: "ai-image-caption-prompt-dialog",
|
|
|
|
didConfirm: async () => {
|
|
try {
|
|
currentUser.set("user_option.auto_image_caption", true);
|
|
await currentUser.save(["auto_image_caption"]);
|
|
|
|
imagesToCaption.forEach(async (imageMarkdown) => {
|
|
const uploadUrl = getUploadUrlFromMarkdown(imageMarkdown);
|
|
imageCaptionPopup.showAutoCaptionLoader = true;
|
|
const caption = await fetchImageCaption(uploadUrl, "short_url");
|
|
|
|
// Find and replace the caption in the reply
|
|
const regex = new RegExp(
|
|
`(!\\[)[^|]+(\\|[^\\]]+\\]\\(${uploadUrl}\\))`
|
|
);
|
|
const newReply = composer.model.reply.replace(
|
|
regex,
|
|
`$1${caption}$2`
|
|
);
|
|
composer.model.set("reply", newReply);
|
|
imageCaptionPopup.showAutoCaptionLoader = false;
|
|
resolve();
|
|
});
|
|
} catch (error) {
|
|
// Reject the promise if an error occurs
|
|
// Show an error saying unable to generate captions
|
|
reject(error);
|
|
}
|
|
},
|
|
didCancel: () => {
|
|
// Don't enable auto captions and continue with the save
|
|
resolve();
|
|
},
|
|
});
|
|
});
|
|
});
|
|
});
|