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.disclaimer"}}
diff --git a/assets/javascripts/initializers/ai-conversations-sidebar.js b/assets/javascripts/initializers/ai-conversations-sidebar.js index 4040632d..895468cd 100644 --- a/assets/javascripts/initializers/ai-conversations-sidebar.js +++ b/assets/javascripts/initializers/ai-conversations-sidebar.js @@ -16,7 +16,7 @@ export default { initialize() { withPluginApi((api) => { const siteSettings = api.container.lookup("service:site-settings"); - if (!siteSettings.ai_enable_experimental_bot_ux) { + if (!siteSettings.ai_bot_enable_dedicated_ux) { return; } diff --git a/assets/stylesheets/modules/ai-bot-conversations/common.scss b/assets/stylesheets/modules/ai-bot-conversations/common.scss index 213003ba..aafbc603 100644 --- a/assets/stylesheets/modules/ai-bot-conversations/common.scss +++ b/assets/stylesheets/modules/ai-bot-conversations/common.scss @@ -203,47 +203,75 @@ body.has-ai-conversations-sidebar { align-items: center; justify-content: center; flex: 1 1 auto; + gap: 0.5em; .loading-container { display: contents; } + } + + &__title { + font-size: var(--font-up-5); + font-weight: bold; + text-align: center; + margin-bottom: 0.25em; + line-height: var(--line-height-medium); // optical centering for layout balance @media screen and (min-height: 600px) { - h1 { - margin-top: -6em; - } + margin-top: -6em; } } &__input-wrapper { + --input-min-height: 2.5em; display: flex; - align-items: stretch; - gap: 0.5em; + align-items: end; + flex-wrap: wrap; width: 100%; @include viewport.from(sm) { + --input-max-width: 46em; width: 80%; - max-width: 46em; + max-width: var(--input-max-width); } .ai-conversation-submit { - align-self: end; - min-height: 2.5em; - max-height: 2.5em; + margin-left: 0.5em; + height: var(--input-min-height); + } + + .ai-bot-upload-btn { + min-height: var(--input-min-height); + align-self: stretch; + background: transparent; + border: 1px solid var(--primary-low); + border-right: none; + border-radius: var(--d-button-border-radius) 0 0 + var(--d-button-border-radius); + + &:focus-visible { + border-color: var(--tertiary); + + + #ai-bot-conversations-input { + border-left-color: var(--tertiary); + } + } } #ai-bot-conversations-input { - width: 100%; + flex-grow: 1; margin: 0; resize: none; - border-radius: var(--d-button-border-radius); max-height: 30vh; - overflow-y: hidden; + min-height: var(--input-min-height); + border-radius: 0 var(--d-button-border-radius) + var(--d-button-border-radius) 0; + border: 1px solid var(--primary-low); - &:focus { + &:focus-visible { outline: none; - border-color: var(--primary-high); + border-color: var(--tertiary); } } } @@ -252,10 +280,11 @@ body.has-ai-conversations-sidebar { text-align: center; font-size: var(--font-down-1); color: var(--primary-700); + margin-top: 0; @include viewport.from(sm) { width: 80%; - max-width: 46em; + max-width: var(--input-max-width); } } @@ -274,6 +303,32 @@ body.has-ai-conversations-sidebar { .topic-footer-main-buttons { justify-content: flex-end; } + + .ai-bot-conversations__uploads-container { + width: 100%; + display: flex; + flex-wrap: wrap; + gap: 0.5em; + padding-bottom: 1em; + margin-top: 0.5em; + } + + .ai-bot-upload { + display: flex; + align-items: center; + border: 1px solid var(--primary-low); + border-radius: 10em; + padding-left: 0.75em; + color: var(--primary-high); + font-size: var(--font-down-2); + + &:hover, + &:focus-visible { + .d-icon { + color: var(--danger); + } + } + } } @include viewport.until(sm) { diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 137e2515..1c3351c9 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -113,7 +113,7 @@ en: ai_discord_search_mode: "Select the search mode to use for Discord search" ai_discord_search_persona: "The persona to use for Discord search." ai_discord_allowed_guilds: "Discord guilds (servers) where the bot is allowed to search" - ai_enable_experimental_bot_ux: "Enable experimental bot UI that allows for a more dedicated experience" + ai_bot_enable_dedicated_ux: "Allow for full screen bot interface, instead of a PM" reviewables: reasons: diff --git a/config/settings.yml b/config/settings.yml index ae4eb693..8e8b9e68 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -386,7 +386,8 @@ discourse_ai: ai_rag_images_enabled: default: false hidden: true - ai_enable_experimental_bot_ux: - default: false + + ai_bot_enable_dedicated_ux: + default: true client: true diff --git a/db/migrate/20250501002657_renamed_experimental_ai_bot_setting.rb b/db/migrate/20250501002657_renamed_experimental_ai_bot_setting.rb new file mode 100644 index 00000000..b272c3b8 --- /dev/null +++ b/db/migrate/20250501002657_renamed_experimental_ai_bot_setting.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true +class RenamedExperimentalAiBotSetting < ActiveRecord::Migration[7.2] + def up + execute "UPDATE site_settings SET name = 'ai_bot_enable_dedicated_ux' WHERE name = 'ai_enable_experimental_bot_ux'" + end + + def down + execute "UPDATE site_settings SET name = 'ai_enable_experimental_bot_ux' WHERE name = 'ai_bot_enable_dedicated_ux'" + end +end diff --git a/spec/system/ai_bot/ai_bot_helper_spec.rb b/spec/system/ai_bot/ai_bot_helper_spec.rb index d7762d09..b777c45a 100644 --- a/spec/system/ai_bot/ai_bot_helper_spec.rb +++ b/spec/system/ai_bot/ai_bot_helper_spec.rb @@ -19,6 +19,8 @@ RSpec.describe "AI chat channel summarization", type: :system, js: true do end it "shows the AI bot button, which is clickable (even if group is hidden)" do + # test was authored with this in mind + SiteSetting.ai_bot_enable_dedicated_ux = false group.add(user) group.save diff --git a/spec/system/ai_bot/homepage_spec.rb b/spec/system/ai_bot/homepage_spec.rb index 93dfb7ad..c0500c1d 100644 --- a/spec/system/ai_bot/homepage_spec.rb +++ b/spec/system/ai_bot/homepage_spec.rb @@ -97,7 +97,7 @@ RSpec.describe "AI Bot - Homepage", type: :system do pm.custom_fields[DiscourseAi::AiBot::TOPIC_AI_BOT_PM_FIELD] = "t" pm.save! - SiteSetting.ai_enable_experimental_bot_ux = true + SiteSetting.ai_bot_enable_dedicated_ux = true SiteSetting.ai_bot_enabled = true SiteSetting.navigation_menu = "sidebar" Jobs.run_immediately! @@ -109,7 +109,53 @@ RSpec.describe "AI Bot - Homepage", type: :system do before { SiteSetting.glimmer_post_stream_mode = value } context "when glimmer_post_stream_mode=#{value}" do - context "when `ai_enable_experimental_bot_ux` is enabled" do + context "when `ai_bot_enable_dedicated_ux` is enabled" do + it "allows uploading files to a new conversation" do + ai_pm_homepage.visit + expect(ai_pm_homepage).to have_homepage + + file_path_1 = file_from_fixtures("logo.png", "images").path + file_path_2 = file_from_fixtures("logo.jpg", "images").path + + attach_file([file_path_1, file_path_2]) do + find(".ai-bot-upload-btn", visible: true).click + end + + expect(page).to have_css(".ai-bot-upload", count: 2) + + ai_pm_homepage.input.fill_in(with: "Here are two image attachments") + + responses = ["hello user", "topic title"] + DiscourseAi::Completions::Llm.with_prepared_responses(responses) do + ai_pm_homepage.submit + expect(topic_page).to have_content("Here are two image attachments") + expect(page).to have_css(".cooked img", count: 2) + end + end + + it "allows removing an upload before submission" do + ai_pm_homepage.visit + expect(ai_pm_homepage).to have_homepage + + file_path = file_from_fixtures("logo.png", "images").path + attach_file([file_path]) { find(".ai-bot-upload-btn", visible: true).click } + + expect(page).to have_css(".ai-bot-upload", count: 1) + + find(".ai-bot-upload__remove").click + + expect(page).to have_no_css(".ai-bot-upload") + + ai_pm_homepage.input.fill_in(with: "Message without attachments") + + responses = ["hello user", "topic title"] + DiscourseAi::Completions::Llm.with_prepared_responses(responses) do + ai_pm_homepage.submit + expect(topic_page).to have_content("Message without attachments") + expect(page).to have_no_css(".cooked img") + end + end + it "renders landing page on bot click" do visit "/" header.click_bot_button @@ -296,8 +342,8 @@ RSpec.describe "AI Bot - Homepage", type: :system do end end - context "when `ai_enable_experimental_bot_ux` is disabled" do - before { SiteSetting.ai_enable_experimental_bot_ux = false } + context "when `ai_bot_enable_dedicated_ux` is disabled" do + before { SiteSetting.ai_bot_enable_dedicated_ux = false } it "opens composer on bot click" do visit "/"