From 29a3286b858018fa428d592dbf15ee2773f91f04 Mon Sep 17 00:00:00 2001 From: Sam Saffron Date: Fri, 16 May 2025 09:43:19 +1000 Subject: [PATCH 1/3] FEATURE: hashtag and mention autocomplete for first bot message Also removes controller which is a deprecated pattern --- .../components/ai-bot-conversations.gjs | 374 ++++++++++++++++++ .../discourse-ai-bot-conversations.js | 200 ---------- .../discourse-ai-bot-conversations.gjs | 100 +---- 3 files changed, 376 insertions(+), 298 deletions(-) create mode 100644 assets/javascripts/discourse/components/ai-bot-conversations.gjs delete mode 100644 assets/javascripts/discourse/controllers/discourse-ai-bot-conversations.js diff --git a/assets/javascripts/discourse/components/ai-bot-conversations.gjs b/assets/javascripts/discourse/components/ai-bot-conversations.gjs new file mode 100644 index 000000000..9ab1bfebe --- /dev/null +++ b/assets/javascripts/discourse/components/ai-bot-conversations.gjs @@ -0,0 +1,374 @@ +import { tracked } from "@glimmer/tracking"; +import Component from "@ember/component"; +import Controller from "@ember/controller"; +import { fn, hash } from "@ember/helper"; +import { on } from "@ember/modifier"; +import { action } from "@ember/object"; +import { getOwner } from "@ember/owner"; +import didInsert from "@ember/render-modifiers/modifiers/did-insert"; +import willDestroy from "@ember/render-modifiers/modifiers/will-destroy"; +import { service } from "@ember/service"; +import { TrackedArray } from "@ember-compat/tracked-built-ins"; +import $ from "jquery"; +import DButton from "discourse/components/d-button"; +import PluginOutlet from "discourse/components/plugin-outlet"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import userAutocomplete from "discourse/lib/autocomplete/user"; +import { setupHashtagAutocomplete } from "discourse/lib/hashtag-autocomplete"; +import UppyUpload from "discourse/lib/uppy/uppy-upload"; +import UppyMediaOptimization from "discourse/lib/uppy-media-optimization-plugin"; +import userSearch from "discourse/lib/user-search"; +import { + destroyUserStatuses, + initUserStatusHtml, + renderUserStatusHtml, +} from "discourse/lib/user-status-on-autocomplete"; +import { clipboardHelpers } from "discourse/lib/utilities"; +import { i18n } from "discourse-i18n"; +import AiPersonaLlmSelector from "discourse/plugins/discourse-ai/discourse/components/ai-persona-llm-selector"; + +export default class AiBotConversations extends Component { + @service aiBotConversationsHiddenSubmit; + @service currentUser; + @service mediaOptimizationWorker; + @service site; + @service siteSettings; + + @tracked uploads = new TrackedArray(); + // Don't track this directly - we'll get it from uppyUpload + + textarea = null; + uppyUpload = null; + fileInputEl = null; + + _handlePaste = (event) => { + if (document.activeElement !== this.textarea) { + return; + } + + const { canUpload, canPasteHtml, types } = clipboardHelpers(event, { + siteSettings: this.siteSettings, + canUpload: true, + }); + + if (!canUpload || canPasteHtml || types.includes("text/plain")) { + return; + } + + if (event && event.clipboardData && event.clipboardData.files) { + this.uppyUpload.addFiles([...event.clipboardData.files], { + pasted: true, + }); + } + }; + + init() { + super.init(...arguments); + + this.uppyUpload = new UppyUpload(getOwner(this), { + id: "ai-bot-file-uploader", + type: "ai-bot-conversation", + useMultipartUploadsIfAvailable: true, + + uppyReady: () => { + if (this.siteSettings.composer_media_optimization_image_enabled) { + this.uppyUpload.uppyWrapper.useUploadPlugin(UppyMediaOptimization, { + optimizeFn: (data, opts) => + this.mediaOptimizationWorker.optimizeImage(data, opts), + runParallel: !this.site.isMobileDevice, + }); + } + + this.uppyUpload.uppyWrapper.onPreProcessProgress((file) => { + const inProgressUpload = this.inProgressUploads?.find( + (upl) => upl.id === file.id + ); + if (inProgressUpload && !inProgressUpload.processing) { + inProgressUpload.processing = true; + } + }); + + this.uppyUpload.uppyWrapper.onPreProcessComplete((file) => { + const inProgressUpload = this.inProgressUploads?.find( + (upl) => upl.id === file.id + ); + if (inProgressUpload) { + inProgressUpload.processing = false; + } + }); + + // Setup paste listener for the textarea + this.textarea?.addEventListener("paste", this._handlePaste); + }, + + uploadDone: (upload) => { + this.uploads.push(upload); + }, + + // Fix: Don't try to set inProgressUploads directly + onProgressUploadsChanged: () => { + // This is just for UI triggers - we're already tracking inProgressUploads + this.notifyPropertyChange("inProgressUploads"); + }, + }); + } + + willDestroy() { + super.willDestroy(...arguments); + this.textarea?.removeEventListener("paste", this._handlePaste); + this.uppyUpload?.teardown(); + // needed for safety + if (this.textarea.autocomplete) { + this.textarea.autocomplete("destroy"); + } + } + + get loading() { + return this.aiBotConversationsHiddenSubmit?.loading; + } + + get inProgressUploads() { + return this.uppyUpload?.inProgressUploads || []; + } + + get showUploadsContainer() { + return this.uploads?.length > 0 || this.inProgressUploads?.length > 0; + } + + @action + setPersonaId(id) { + this.aiBotConversationsHiddenSubmit.personaId = id; + } + + @action + setTargetRecipient(username) { + this.aiBotConversationsHiddenSubmit.targetUsername = username; + } + + @action + updateInputValue(value) { + this._autoExpandTextarea(); + this.aiBotConversationsHiddenSubmit.inputValue = + value.target?.value || value; + } + + @action + handleKeyDown(event) { + if (event.target.tagName !== "TEXTAREA") { + return; + } + if (event.key === "Enter" && !event.shiftKey) { + this.prepareAndSubmitToBot(); + } + } + + @action + setTextArea(element) { + this.textarea = element; + this.setupAutocomplete(element); + } + + @action + setupAutocomplete(textarea) { + const $textarea = $(textarea); + this.applyUserAutocomplete($textarea); + this.applyHashtagAutocomplete($textarea); + } + + @action + applyUserAutocomplete($textarea) { + if (!this.siteSettings.enable_mentions) { + return; + } + + $textarea.autocomplete({ + template: userAutocomplete, + dataSource: (term) => { + destroyUserStatuses(); + return userSearch({ + term, + includeGroups: true, + }).then((result) => { + initUserStatusHtml(getOwner(this), result.users); + return result; + }); + }, + onRender: (options) => renderUserStatusHtml(options), + key: "@", + width: "100%", + treatAsTextarea: true, + autoSelectFirstSuggestion: true, + transformComplete: (obj) => obj.username || obj.name, + afterComplete: (text) => { + this.textarea.value = text; + this.textarea.focus(); + this.updateInputValue({ target: { value: text } }); + }, + onClose: destroyUserStatuses, + }); + } + + @action + applyHashtagAutocomplete($textarea) { + // Use the "topic-composer" configuration or create a specific one for AI bot + // You can change this to "chat-composer" if that's more appropriate + const hashtagConfig = this.site.hashtag_configurations["topic-composer"]; + + setupHashtagAutocomplete(hashtagConfig, $textarea, this.siteSettings, { + treatAsTextarea: true, + afterComplete: (text) => { + this.textarea.value = text; + this.textarea.focus(); + this.updateInputValue({ target: { value: text } }); + }, + }); + } + + @action + registerFileInput(element) { + if (element) { + this.fileInputEl = element; + if (this.uppyUpload) { + this.uppyUpload.setup(element); + } + } + } + + @action + openFileUpload() { + if (this.fileInputEl) { + this.fileInputEl.click(); + } + } + + @action + removeUpload(upload) { + this.uploads = new TrackedArray(this.uploads.filter((u) => u !== upload)); + } + + @action + cancelUpload(upload) { + this.uppyUpload.cancelSingleUpload({ + fileId: upload.id, + }); + } + + @action + async prepareAndSubmitToBot() { + // Pass uploads to the service before submitting + this.aiBotConversationsHiddenSubmit.uploads = this.uploads; + try { + await this.aiBotConversationsHiddenSubmit.submitToBot(); + this.uploads = new TrackedArray(); + } catch (error) { + popupAjaxError(error); + } + } + + _autoExpandTextarea() { + this.textarea.style.height = "auto"; + this.textarea.style.height = this.textarea.scrollHeight + "px"; + + // Get the max-height value from CSS (30vh) + const maxHeight = parseInt(getComputedStyle(this.textarea).maxHeight, 10); + + // Only enable scrolling if content exceeds max-height + if (this.textarea.scrollHeight > maxHeight) { + this.textarea.style.overflowY = "auto"; + } else { + this.textarea.style.overflowY = "hidden"; + } + } + +