From 8b1b6811f4d8fac9af52ae5228a6d101820e7e01 Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 1 May 2025 12:21:07 +1000 Subject: [PATCH] FEATURE: add support for uploads when starting a convo (#1301) This commit introduces file upload capabilities to the AI Bot conversations interface and improves the overall dedicated UX experience. It also changes the experimental setting to a more permanent one. ## Key changes: - **File upload support**: - Integrates UppyUpload for handling file uploads in conversations - Adds UI for uploading, displaying, and managing attachments - Supports drag & drop, clipboard paste, and manual file selection - Shows upload progress indicators for in-progress uploads - Appends uploaded file markdown to message content - **Renamed setting**: - Changed `ai_enable_experimental_bot_ux` to `ai_bot_enable_dedicated_ux` - Updated setting description to be clearer - Changed default value to `true` as this is now a stable feature - Added migration to handle the setting name change in database - **UI improvements**: - Enhanced input area with better focus states - Improved layout and styling for conversations page - Added visual feedback for upload states - Better error handling for uploads in progress - **Code organization**: - Refactored message submission logic to handle attachments - Updated DOM element IDs for consistency - Fixed focus management after submission - **Added tests**: - Tests for file upload functionality - Tests for removing uploads before submission - Updated existing tests to work with the renamed setting --------- Co-authored-by: awesomerobot --- .../components/ai-bot-header-icon.gjs | 4 +- .../discourse-ai-bot-conversations.js | 134 +++++++++++++++++- .../ai-bot-conversations-hidden-submit.js | 31 +++- .../discourse-ai-bot-conversations.gjs | 58 +++++++- .../initializers/ai-conversations-sidebar.js | 2 +- .../modules/ai-bot-conversations/common.scss | 85 +++++++++-- config/locales/server.en.yml | 2 +- config/settings.yml | 5 +- ...657_renamed_experimental_ai_bot_setting.rb | 10 ++ spec/system/ai_bot/ai_bot_helper_spec.rb | 2 + spec/system/ai_bot/homepage_spec.rb | 54 ++++++- 11 files changed, 355 insertions(+), 32 deletions(-) create mode 100644 db/migrate/20250501002657_renamed_experimental_ai_bot_setting.rb diff --git a/assets/javascripts/discourse/components/ai-bot-header-icon.gjs b/assets/javascripts/discourse/components/ai-bot-header-icon.gjs index 6b6103da..8821e91a 100644 --- a/assets/javascripts/discourse/components/ai-bot-header-icon.gjs +++ b/assets/javascripts/discourse/components/ai-bot-header-icon.gjs @@ -39,7 +39,7 @@ export default class AiBotHeaderIcon extends Component { get clickShouldRouteOutOfConversations() { return ( !this.navigationMenu.isHeaderDropdownMode && - this.siteSettings.ai_enable_experimental_bot_ux && + this.siteSettings.ai_bot_enable_dedicated_ux && this.sidebarState.currentPanel?.key === AI_CONVERSATIONS_PANEL ); } @@ -50,7 +50,7 @@ export default class AiBotHeaderIcon extends Component { return this.router.transitionTo(`discovery.${defaultHomepage()}`); } - if (this.siteSettings.ai_enable_experimental_bot_ux) { + if (this.siteSettings.ai_bot_enable_dedicated_ux) { return this.router.transitionTo("discourse-ai-bot-conversations"); } diff --git a/assets/javascripts/discourse/controllers/discourse-ai-bot-conversations.js b/assets/javascripts/discourse/controllers/discourse-ai-bot-conversations.js index 8b61a5ef..37d4226d 100644 --- a/assets/javascripts/discourse/controllers/discourse-ai-bot-conversations.js +++ b/assets/javascripts/discourse/controllers/discourse-ai-bot-conversations.js @@ -1,21 +1,117 @@ +import { tracked } from "@glimmer/tracking"; import Controller from "@ember/controller"; import { action } from "@ember/object"; +import { getOwner } from "@ember/owner"; import { service } from "@ember/service"; +import UppyUpload from "discourse/lib/uppy/uppy-upload"; +import UppyMediaOptimization from "discourse/lib/uppy-media-optimization-plugin"; +import { clipboardHelpers } from "discourse/lib/utilities"; export default class DiscourseAiBotConversations extends Controller { @service aiBotConversationsHiddenSubmit; @service currentUser; + @service mediaOptimizationWorker; + @service site; + @service siteSettings; + + @tracked uploads = []; + // 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.uploads = []; + + 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.pushObject(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(); } 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; @@ -36,7 +132,7 @@ export default class DiscourseAiBotConversations extends Controller { @action handleKeyDown(event) { if (event.key === "Enter" && !event.shiftKey) { - this.aiBotConversationsHiddenSubmit.submitToBot(); + this.prepareAndSubmitToBot(); } } @@ -45,6 +141,42 @@ export default class DiscourseAiBotConversations extends Controller { this.textarea = element; } + @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.removeObject(upload); + } + + @action + cancelUpload(upload) { + this.uppyUpload.cancelSingleUpload({ + fileId: upload.id, + }); + } + + @action + prepareAndSubmitToBot() { + // Pass uploads to the service before submitting + this.aiBotConversationsHiddenSubmit.uploads = this.uploads; + this.aiBotConversationsHiddenSubmit.submitToBot(); + } + _autoExpandTextarea() { this.textarea.style.height = "auto"; this.textarea.style.height = this.textarea.scrollHeight + "px"; diff --git a/assets/javascripts/discourse/services/ai-bot-conversations-hidden-submit.js b/assets/javascripts/discourse/services/ai-bot-conversations-hidden-submit.js index 7ded2bfd..4eb6fa90 100644 --- a/assets/javascripts/discourse/services/ai-bot-conversations-hidden-submit.js +++ b/assets/javascripts/discourse/services/ai-bot-conversations-hidden-submit.js @@ -4,6 +4,7 @@ import Service, { service } from "@ember/service"; import { tracked } from "@ember-compat/tracked-built-ins"; import { ajax } from "discourse/lib/ajax"; import { popupAjaxError } from "discourse/lib/ajax-error"; +import { getUploadMarkdown } from "discourse/lib/uploads"; import { i18n } from "discourse-i18n"; export default class AiBotConversationsHiddenSubmit extends Service { @@ -17,6 +18,7 @@ export default class AiBotConversationsHiddenSubmit extends Service { personaId; targetUsername; + uploads = []; inputValue = ""; @@ -25,7 +27,7 @@ export default class AiBotConversationsHiddenSubmit extends Service { this.composer.destroyDraft(); this.composer.close(); next(() => { - document.getElementById("custom-homepage-input").focus(); + document.getElementById("ai-bot-conversations-input").focus(); }); } @@ -41,14 +43,34 @@ export default class AiBotConversationsHiddenSubmit extends Service { }); } + // Don't submit if there are still uploads in progress + if (document.querySelector(".ai-bot-upload--in-progress")) { + return this.dialog.alert({ + message: i18n("discourse_ai.ai_bot.conversations.uploads_in_progress"), + }); + } + this.loading = true; const title = i18n("discourse_ai.ai_bot.default_pm_prefix"); + // Prepare the raw content with any uploads appended + let rawContent = this.inputValue; + + // Append upload markdown if we have uploads + if (this.uploads && this.uploads.length > 0) { + rawContent += "\n\n"; + + this.uploads.forEach((upload) => { + const uploadMarkdown = getUploadMarkdown(upload); + rawContent += uploadMarkdown + "\n"; + }); + } + try { const response = await ajax("/posts.json", { method: "POST", data: { - raw: this.inputValue, + raw: rawContent, title, archetype: "private_message", target_recipients: this.targetUsername, @@ -56,11 +78,16 @@ export default class AiBotConversationsHiddenSubmit extends Service { }, }); + // Reset uploads after successful submission + this.uploads = []; + this.inputValue = ""; + this.appEvents.trigger("discourse-ai:bot-pm-created", { id: response.topic_id, slug: response.topic_slug, title, }); + this.router.transitionTo(response.post_url); } catch (e) { popupAjaxError(e); diff --git a/assets/javascripts/discourse/templates/discourse-ai-bot-conversations.gjs b/assets/javascripts/discourse/templates/discourse-ai-bot-conversations.gjs index 3027a780..e8e8ee6e 100644 --- a/assets/javascripts/discourse/templates/discourse-ai-bot-conversations.gjs +++ b/assets/javascripts/discourse/templates/discourse-ai-bot-conversations.gjs @@ -1,4 +1,4 @@ -import { hash } from "@ember/helper"; +import { fn, hash } from "@ember/helper"; import { on } from "@ember/modifier"; import didInsert from "@ember/render-modifiers/modifiers/did-insert"; import RouteTemplate from "ember-route-template"; @@ -17,15 +17,24 @@ export default RouteTemplate( />
-

{{i18n "discourse_ai.ai_bot.conversations.header"}}

+
+ {{i18n "discourse_ai.ai_bot.conversations.header"}} +
+
+