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 <kris.aubuchon@discourse.org>
This commit is contained in:
Sam 2025-05-01 12:21:07 +10:00 committed by GitHub
parent fc3be6f0ce
commit 8b1b6811f4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 355 additions and 32 deletions

View File

@ -39,7 +39,7 @@ export default class AiBotHeaderIcon extends Component {
get clickShouldRouteOutOfConversations() { get clickShouldRouteOutOfConversations() {
return ( return (
!this.navigationMenu.isHeaderDropdownMode && !this.navigationMenu.isHeaderDropdownMode &&
this.siteSettings.ai_enable_experimental_bot_ux && this.siteSettings.ai_bot_enable_dedicated_ux &&
this.sidebarState.currentPanel?.key === AI_CONVERSATIONS_PANEL this.sidebarState.currentPanel?.key === AI_CONVERSATIONS_PANEL
); );
} }
@ -50,7 +50,7 @@ export default class AiBotHeaderIcon extends Component {
return this.router.transitionTo(`discovery.${defaultHomepage()}`); 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"); return this.router.transitionTo("discourse-ai-bot-conversations");
} }

View File

@ -1,21 +1,117 @@
import { tracked } from "@glimmer/tracking";
import Controller from "@ember/controller"; import Controller from "@ember/controller";
import { action } from "@ember/object"; import { action } from "@ember/object";
import { getOwner } from "@ember/owner";
import { service } from "@ember/service"; 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 { export default class DiscourseAiBotConversations extends Controller {
@service aiBotConversationsHiddenSubmit; @service aiBotConversationsHiddenSubmit;
@service currentUser; @service currentUser;
@service mediaOptimizationWorker;
@service site;
@service siteSettings;
@tracked uploads = [];
// Don't track this directly - we'll get it from uppyUpload
textarea = null; 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() { init() {
super.init(...arguments); 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() { get loading() {
return this.aiBotConversationsHiddenSubmit?.loading; return this.aiBotConversationsHiddenSubmit?.loading;
} }
get inProgressUploads() {
return this.uppyUpload?.inProgressUploads || [];
}
get showUploadsContainer() {
return this.uploads?.length > 0 || this.inProgressUploads?.length > 0;
}
@action @action
setPersonaId(id) { setPersonaId(id) {
this.aiBotConversationsHiddenSubmit.personaId = id; this.aiBotConversationsHiddenSubmit.personaId = id;
@ -36,7 +132,7 @@ export default class DiscourseAiBotConversations extends Controller {
@action @action
handleKeyDown(event) { handleKeyDown(event) {
if (event.key === "Enter" && !event.shiftKey) { if (event.key === "Enter" && !event.shiftKey) {
this.aiBotConversationsHiddenSubmit.submitToBot(); this.prepareAndSubmitToBot();
} }
} }
@ -45,6 +141,42 @@ export default class DiscourseAiBotConversations extends Controller {
this.textarea = element; 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() { _autoExpandTextarea() {
this.textarea.style.height = "auto"; this.textarea.style.height = "auto";
this.textarea.style.height = this.textarea.scrollHeight + "px"; this.textarea.style.height = this.textarea.scrollHeight + "px";

View File

@ -4,6 +4,7 @@ import Service, { service } from "@ember/service";
import { tracked } from "@ember-compat/tracked-built-ins"; import { tracked } from "@ember-compat/tracked-built-ins";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error"; import { popupAjaxError } from "discourse/lib/ajax-error";
import { getUploadMarkdown } from "discourse/lib/uploads";
import { i18n } from "discourse-i18n"; import { i18n } from "discourse-i18n";
export default class AiBotConversationsHiddenSubmit extends Service { export default class AiBotConversationsHiddenSubmit extends Service {
@ -17,6 +18,7 @@ export default class AiBotConversationsHiddenSubmit extends Service {
personaId; personaId;
targetUsername; targetUsername;
uploads = [];
inputValue = ""; inputValue = "";
@ -25,7 +27,7 @@ export default class AiBotConversationsHiddenSubmit extends Service {
this.composer.destroyDraft(); this.composer.destroyDraft();
this.composer.close(); this.composer.close();
next(() => { 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; this.loading = true;
const title = i18n("discourse_ai.ai_bot.default_pm_prefix"); 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 { try {
const response = await ajax("/posts.json", { const response = await ajax("/posts.json", {
method: "POST", method: "POST",
data: { data: {
raw: this.inputValue, raw: rawContent,
title, title,
archetype: "private_message", archetype: "private_message",
target_recipients: this.targetUsername, 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", { this.appEvents.trigger("discourse-ai:bot-pm-created", {
id: response.topic_id, id: response.topic_id,
slug: response.topic_slug, slug: response.topic_slug,
title, title,
}); });
this.router.transitionTo(response.post_url); this.router.transitionTo(response.post_url);
} catch (e) { } catch (e) {
popupAjaxError(e); popupAjaxError(e);

View File

@ -1,4 +1,4 @@
import { hash } from "@ember/helper"; import { fn, hash } from "@ember/helper";
import { on } from "@ember/modifier"; import { on } from "@ember/modifier";
import didInsert from "@ember/render-modifiers/modifiers/did-insert"; import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import RouteTemplate from "ember-route-template"; import RouteTemplate from "ember-route-template";
@ -17,15 +17,24 @@ export default RouteTemplate(
/> />
<div class="ai-bot-conversations__content-wrapper"> <div class="ai-bot-conversations__content-wrapper">
<h1>{{i18n "discourse_ai.ai_bot.conversations.header"}}</h1> <div class="ai-bot-conversations__title">
{{i18n "discourse_ai.ai_bot.conversations.header"}}
</div>
<PluginOutlet <PluginOutlet
@name="ai-bot-conversations-above-input" @name="ai-bot-conversations-above-input"
@outletArgs={{hash @outletArgs={{hash
updateInput=@controller.updateInputValue updateInput=@controller.updateInputValue
submit=@controller.aiBotConversationsHiddenSubmit.submitToBot submit=@controller.prepareAndSubmitToBot
}} }}
/> />
<div class="ai-bot-conversations__input-wrapper"> <div class="ai-bot-conversations__input-wrapper">
<DButton
@icon="upload"
@action={{@controller.openFileUpload}}
@title="discourse_ai.ai_bot.conversations.upload_files"
class="btn btn-transparent ai-bot-upload-btn"
/>
<textarea <textarea
{{didInsert @controller.setTextArea}} {{didInsert @controller.setTextArea}}
{{on "input" @controller.updateInputValue}} {{on "input" @controller.updateInputValue}}
@ -38,13 +47,54 @@ export default RouteTemplate(
rows="1" rows="1"
/> />
<DButton <DButton
@action={{@controller.aiBotConversationsHiddenSubmit.submitToBot}} @action={{@controller.prepareAndSubmitToBot}}
@icon="paper-plane" @icon="paper-plane"
@isLoading={{@controller.loading}} @isLoading={{@controller.loading}}
@title="discourse_ai.ai_bot.conversations.header" @title="discourse_ai.ai_bot.conversations.header"
class="ai-bot-button btn-primary ai-conversation-submit" class="ai-bot-button btn-primary ai-conversation-submit"
/> />
<input
type="file"
id="ai-bot-file-uploader"
class="hidden-upload-field"
multiple="multiple"
{{didInsert @controller.registerFileInput}}
/>
{{#if @controller.showUploadsContainer}}
<div class="ai-bot-conversations__uploads-container">
{{#each @controller.uploads as |upload|}}
<div class="ai-bot-upload">
<span class="ai-bot-upload__filename">
{{upload.original_filename}}
</span>
<DButton
@icon="xmark"
@action={{fn @controller.removeUpload upload}}
class="btn-transparent ai-bot-upload__remove"
/>
</div>
{{/each}}
{{#each @controller.inProgressUploads as |upload|}}
<div class="ai-bot-upload ai-bot-upload--in-progress">
<span
class="ai-bot-upload__filename"
>{{upload.fileName}}</span>
<span class="ai-bot-upload__progress">
{{upload.progress}}%
</span>
<DButton
@icon="xmark"
@action={{fn @controller.cancelUpload upload}}
class="btn-flat ai-bot-upload__remove"
/>
</div>
{{/each}}
</div>
{{/if}}
</div> </div>
<p class="ai-disclaimer"> <p class="ai-disclaimer">
{{i18n "discourse_ai.ai_bot.conversations.disclaimer"}} {{i18n "discourse_ai.ai_bot.conversations.disclaimer"}}
</p> </p>

View File

@ -16,7 +16,7 @@ export default {
initialize() { initialize() {
withPluginApi((api) => { withPluginApi((api) => {
const siteSettings = api.container.lookup("service:site-settings"); const siteSettings = api.container.lookup("service:site-settings");
if (!siteSettings.ai_enable_experimental_bot_ux) { if (!siteSettings.ai_bot_enable_dedicated_ux) {
return; return;
} }

View File

@ -203,47 +203,75 @@ body.has-ai-conversations-sidebar {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex: 1 1 auto; flex: 1 1 auto;
gap: 0.5em;
.loading-container { .loading-container {
display: contents; 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 // optical centering for layout balance
@media screen and (min-height: 600px) { @media screen and (min-height: 600px) {
h1 { margin-top: -6em;
margin-top: -6em;
}
} }
} }
&__input-wrapper { &__input-wrapper {
--input-min-height: 2.5em;
display: flex; display: flex;
align-items: stretch; align-items: end;
gap: 0.5em; flex-wrap: wrap;
width: 100%; width: 100%;
@include viewport.from(sm) { @include viewport.from(sm) {
--input-max-width: 46em;
width: 80%; width: 80%;
max-width: 46em; max-width: var(--input-max-width);
} }
.ai-conversation-submit { .ai-conversation-submit {
align-self: end; margin-left: 0.5em;
min-height: 2.5em; height: var(--input-min-height);
max-height: 2.5em; }
.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 { #ai-bot-conversations-input {
width: 100%; flex-grow: 1;
margin: 0; margin: 0;
resize: none; resize: none;
border-radius: var(--d-button-border-radius);
max-height: 30vh; 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; outline: none;
border-color: var(--primary-high); border-color: var(--tertiary);
} }
} }
} }
@ -252,10 +280,11 @@ body.has-ai-conversations-sidebar {
text-align: center; text-align: center;
font-size: var(--font-down-1); font-size: var(--font-down-1);
color: var(--primary-700); color: var(--primary-700);
margin-top: 0;
@include viewport.from(sm) { @include viewport.from(sm) {
width: 80%; width: 80%;
max-width: 46em; max-width: var(--input-max-width);
} }
} }
@ -274,6 +303,32 @@ body.has-ai-conversations-sidebar {
.topic-footer-main-buttons { .topic-footer-main-buttons {
justify-content: flex-end; 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) { @include viewport.until(sm) {

View File

@ -113,7 +113,7 @@ en:
ai_discord_search_mode: "Select the search mode to use for Discord search" 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_search_persona: "The persona to use for Discord search."
ai_discord_allowed_guilds: "Discord guilds (servers) where the bot is allowed to 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: reviewables:
reasons: reasons:

View File

@ -386,7 +386,8 @@ discourse_ai:
ai_rag_images_enabled: ai_rag_images_enabled:
default: false default: false
hidden: true hidden: true
ai_enable_experimental_bot_ux:
default: false ai_bot_enable_dedicated_ux:
default: true
client: true client: true

View File

@ -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

View File

@ -19,6 +19,8 @@ RSpec.describe "AI chat channel summarization", type: :system, js: true do
end end
it "shows the AI bot button, which is clickable (even if group is hidden)" do 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.add(user)
group.save group.save

View File

@ -97,7 +97,7 @@ RSpec.describe "AI Bot - Homepage", type: :system do
pm.custom_fields[DiscourseAi::AiBot::TOPIC_AI_BOT_PM_FIELD] = "t" pm.custom_fields[DiscourseAi::AiBot::TOPIC_AI_BOT_PM_FIELD] = "t"
pm.save! pm.save!
SiteSetting.ai_enable_experimental_bot_ux = true SiteSetting.ai_bot_enable_dedicated_ux = true
SiteSetting.ai_bot_enabled = true SiteSetting.ai_bot_enabled = true
SiteSetting.navigation_menu = "sidebar" SiteSetting.navigation_menu = "sidebar"
Jobs.run_immediately! Jobs.run_immediately!
@ -109,7 +109,53 @@ RSpec.describe "AI Bot - Homepage", type: :system do
before { SiteSetting.glimmer_post_stream_mode = value } before { SiteSetting.glimmer_post_stream_mode = value }
context "when glimmer_post_stream_mode=#{value}" do 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 it "renders landing page on bot click" do
visit "/" visit "/"
header.click_bot_button header.click_bot_button
@ -296,8 +342,8 @@ RSpec.describe "AI Bot - Homepage", type: :system do
end end
end end
context "when `ai_enable_experimental_bot_ux` is disabled" do context "when `ai_bot_enable_dedicated_ux` is disabled" do
before { SiteSetting.ai_enable_experimental_bot_ux = false } before { SiteSetting.ai_bot_enable_dedicated_ux = false }
it "opens composer on bot click" do it "opens composer on bot click" do
visit "/" visit "/"