Skip to content
This repository was archived by the owner on Jul 22, 2025. It is now read-only.

Commit 8b1b681

Browse files
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 <[email protected]>
1 parent fc3be6f commit 8b1b681

File tree

11 files changed

+355
-32
lines changed

11 files changed

+355
-32
lines changed

assets/javascripts/discourse/components/ai-bot-header-icon.gjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export default class AiBotHeaderIcon extends Component {
3939
get clickShouldRouteOutOfConversations() {
4040
return (
4141
!this.navigationMenu.isHeaderDropdownMode &&
42-
this.siteSettings.ai_enable_experimental_bot_ux &&
42+
this.siteSettings.ai_bot_enable_dedicated_ux &&
4343
this.sidebarState.currentPanel?.key === AI_CONVERSATIONS_PANEL
4444
);
4545
}
@@ -50,7 +50,7 @@ export default class AiBotHeaderIcon extends Component {
5050
return this.router.transitionTo(`discovery.${defaultHomepage()}`);
5151
}
5252

53-
if (this.siteSettings.ai_enable_experimental_bot_ux) {
53+
if (this.siteSettings.ai_bot_enable_dedicated_ux) {
5454
return this.router.transitionTo("discourse-ai-bot-conversations");
5555
}
5656

assets/javascripts/discourse/controllers/discourse-ai-bot-conversations.js

Lines changed: 133 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,117 @@
1+
import { tracked } from "@glimmer/tracking";
12
import Controller from "@ember/controller";
23
import { action } from "@ember/object";
4+
import { getOwner } from "@ember/owner";
35
import { service } from "@ember/service";
6+
import UppyUpload from "discourse/lib/uppy/uppy-upload";
7+
import UppyMediaOptimization from "discourse/lib/uppy-media-optimization-plugin";
8+
import { clipboardHelpers } from "discourse/lib/utilities";
49

510
export default class DiscourseAiBotConversations extends Controller {
611
@service aiBotConversationsHiddenSubmit;
712
@service currentUser;
13+
@service mediaOptimizationWorker;
14+
@service site;
15+
@service siteSettings;
16+
17+
@tracked uploads = [];
18+
// Don't track this directly - we'll get it from uppyUpload
819

920
textarea = null;
21+
uppyUpload = null;
22+
fileInputEl = null;
23+
24+
_handlePaste = (event) => {
25+
if (document.activeElement !== this.textarea) {
26+
return;
27+
}
28+
29+
const { canUpload, canPasteHtml, types } = clipboardHelpers(event, {
30+
siteSettings: this.siteSettings,
31+
canUpload: true,
32+
});
33+
34+
if (!canUpload || canPasteHtml || types.includes("text/plain")) {
35+
return;
36+
}
1037

38+
if (event && event.clipboardData && event.clipboardData.files) {
39+
this.uppyUpload.addFiles([...event.clipboardData.files], {
40+
pasted: true,
41+
});
42+
}
43+
};
1144
init() {
1245
super.init(...arguments);
46+
47+
this.uploads = [];
48+
49+
this.uppyUpload = new UppyUpload(getOwner(this), {
50+
id: "ai-bot-file-uploader",
51+
type: "ai-bot-conversation",
52+
useMultipartUploadsIfAvailable: true,
53+
54+
uppyReady: () => {
55+
if (this.siteSettings.composer_media_optimization_image_enabled) {
56+
this.uppyUpload.uppyWrapper.useUploadPlugin(UppyMediaOptimization, {
57+
optimizeFn: (data, opts) =>
58+
this.mediaOptimizationWorker.optimizeImage(data, opts),
59+
runParallel: !this.site.isMobileDevice,
60+
});
61+
}
62+
63+
this.uppyUpload.uppyWrapper.onPreProcessProgress((file) => {
64+
const inProgressUpload = this.inProgressUploads?.find(
65+
(upl) => upl.id === file.id
66+
);
67+
if (inProgressUpload && !inProgressUpload.processing) {
68+
inProgressUpload.processing = true;
69+
}
70+
});
71+
72+
this.uppyUpload.uppyWrapper.onPreProcessComplete((file) => {
73+
const inProgressUpload = this.inProgressUploads?.find(
74+
(upl) => upl.id === file.id
75+
);
76+
if (inProgressUpload) {
77+
inProgressUpload.processing = false;
78+
}
79+
});
80+
81+
// Setup paste listener for the textarea
82+
this.textarea?.addEventListener("paste", this._handlePaste);
83+
},
84+
85+
uploadDone: (upload) => {
86+
this.uploads.pushObject(upload);
87+
},
88+
89+
// Fix: Don't try to set inProgressUploads directly
90+
onProgressUploadsChanged: () => {
91+
// This is just for UI triggers - we're already tracking inProgressUploads
92+
this.notifyPropertyChange("inProgressUploads");
93+
},
94+
});
95+
}
96+
97+
willDestroy() {
98+
super.willDestroy(...arguments);
99+
this.textarea?.removeEventListener("paste", this._handlePaste);
100+
this.uppyUpload?.teardown();
13101
}
14102

15103
get loading() {
16104
return this.aiBotConversationsHiddenSubmit?.loading;
17105
}
18106

107+
get inProgressUploads() {
108+
return this.uppyUpload?.inProgressUploads || [];
109+
}
110+
111+
get showUploadsContainer() {
112+
return this.uploads?.length > 0 || this.inProgressUploads?.length > 0;
113+
}
114+
19115
@action
20116
setPersonaId(id) {
21117
this.aiBotConversationsHiddenSubmit.personaId = id;
@@ -36,7 +132,7 @@ export default class DiscourseAiBotConversations extends Controller {
36132
@action
37133
handleKeyDown(event) {
38134
if (event.key === "Enter" && !event.shiftKey) {
39-
this.aiBotConversationsHiddenSubmit.submitToBot();
135+
this.prepareAndSubmitToBot();
40136
}
41137
}
42138

@@ -45,6 +141,42 @@ export default class DiscourseAiBotConversations extends Controller {
45141
this.textarea = element;
46142
}
47143

144+
@action
145+
registerFileInput(element) {
146+
if (element) {
147+
this.fileInputEl = element;
148+
if (this.uppyUpload) {
149+
this.uppyUpload.setup(element);
150+
}
151+
}
152+
}
153+
154+
@action
155+
openFileUpload() {
156+
if (this.fileInputEl) {
157+
this.fileInputEl.click();
158+
}
159+
}
160+
161+
@action
162+
removeUpload(upload) {
163+
this.uploads.removeObject(upload);
164+
}
165+
166+
@action
167+
cancelUpload(upload) {
168+
this.uppyUpload.cancelSingleUpload({
169+
fileId: upload.id,
170+
});
171+
}
172+
173+
@action
174+
prepareAndSubmitToBot() {
175+
// Pass uploads to the service before submitting
176+
this.aiBotConversationsHiddenSubmit.uploads = this.uploads;
177+
this.aiBotConversationsHiddenSubmit.submitToBot();
178+
}
179+
48180
_autoExpandTextarea() {
49181
this.textarea.style.height = "auto";
50182
this.textarea.style.height = this.textarea.scrollHeight + "px";

assets/javascripts/discourse/services/ai-bot-conversations-hidden-submit.js

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import Service, { service } from "@ember/service";
44
import { tracked } from "@ember-compat/tracked-built-ins";
55
import { ajax } from "discourse/lib/ajax";
66
import { popupAjaxError } from "discourse/lib/ajax-error";
7+
import { getUploadMarkdown } from "discourse/lib/uploads";
78
import { i18n } from "discourse-i18n";
89

910
export default class AiBotConversationsHiddenSubmit extends Service {
@@ -17,6 +18,7 @@ export default class AiBotConversationsHiddenSubmit extends Service {
1718

1819
personaId;
1920
targetUsername;
21+
uploads = [];
2022

2123
inputValue = "";
2224

@@ -25,7 +27,7 @@ export default class AiBotConversationsHiddenSubmit extends Service {
2527
this.composer.destroyDraft();
2628
this.composer.close();
2729
next(() => {
28-
document.getElementById("custom-homepage-input").focus();
30+
document.getElementById("ai-bot-conversations-input").focus();
2931
});
3032
}
3133

@@ -41,26 +43,51 @@ export default class AiBotConversationsHiddenSubmit extends Service {
4143
});
4244
}
4345

46+
// Don't submit if there are still uploads in progress
47+
if (document.querySelector(".ai-bot-upload--in-progress")) {
48+
return this.dialog.alert({
49+
message: i18n("discourse_ai.ai_bot.conversations.uploads_in_progress"),
50+
});
51+
}
52+
4453
this.loading = true;
4554
const title = i18n("discourse_ai.ai_bot.default_pm_prefix");
4655

56+
// Prepare the raw content with any uploads appended
57+
let rawContent = this.inputValue;
58+
59+
// Append upload markdown if we have uploads
60+
if (this.uploads && this.uploads.length > 0) {
61+
rawContent += "\n\n";
62+
63+
this.uploads.forEach((upload) => {
64+
const uploadMarkdown = getUploadMarkdown(upload);
65+
rawContent += uploadMarkdown + "\n";
66+
});
67+
}
68+
4769
try {
4870
const response = await ajax("/posts.json", {
4971
method: "POST",
5072
data: {
51-
raw: this.inputValue,
73+
raw: rawContent,
5274
title,
5375
archetype: "private_message",
5476
target_recipients: this.targetUsername,
5577
meta_data: { ai_persona_id: this.personaId },
5678
},
5779
});
5880

81+
// Reset uploads after successful submission
82+
this.uploads = [];
83+
this.inputValue = "";
84+
5985
this.appEvents.trigger("discourse-ai:bot-pm-created", {
6086
id: response.topic_id,
6187
slug: response.topic_slug,
6288
title,
6389
});
90+
6491
this.router.transitionTo(response.post_url);
6592
} catch (e) {
6693
popupAjaxError(e);

assets/javascripts/discourse/templates/discourse-ai-bot-conversations.gjs

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { hash } from "@ember/helper";
1+
import { fn, hash } from "@ember/helper";
22
import { on } from "@ember/modifier";
33
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
44
import RouteTemplate from "ember-route-template";
@@ -17,15 +17,24 @@ export default RouteTemplate(
1717
/>
1818

1919
<div class="ai-bot-conversations__content-wrapper">
20-
<h1>{{i18n "discourse_ai.ai_bot.conversations.header"}}</h1>
20+
<div class="ai-bot-conversations__title">
21+
{{i18n "discourse_ai.ai_bot.conversations.header"}}
22+
</div>
2123
<PluginOutlet
2224
@name="ai-bot-conversations-above-input"
2325
@outletArgs={{hash
2426
updateInput=@controller.updateInputValue
25-
submit=@controller.aiBotConversationsHiddenSubmit.submitToBot
27+
submit=@controller.prepareAndSubmitToBot
2628
}}
2729
/>
30+
2831
<div class="ai-bot-conversations__input-wrapper">
32+
<DButton
33+
@icon="upload"
34+
@action={{@controller.openFileUpload}}
35+
@title="discourse_ai.ai_bot.conversations.upload_files"
36+
class="btn btn-transparent ai-bot-upload-btn"
37+
/>
2938
<textarea
3039
{{didInsert @controller.setTextArea}}
3140
{{on "input" @controller.updateInputValue}}
@@ -38,13 +47,54 @@ export default RouteTemplate(
3847
rows="1"
3948
/>
4049
<DButton
41-
@action={{@controller.aiBotConversationsHiddenSubmit.submitToBot}}
50+
@action={{@controller.prepareAndSubmitToBot}}
4251
@icon="paper-plane"
4352
@isLoading={{@controller.loading}}
4453
@title="discourse_ai.ai_bot.conversations.header"
4554
class="ai-bot-button btn-primary ai-conversation-submit"
4655
/>
56+
<input
57+
type="file"
58+
id="ai-bot-file-uploader"
59+
class="hidden-upload-field"
60+
multiple="multiple"
61+
{{didInsert @controller.registerFileInput}}
62+
/>
63+
64+
{{#if @controller.showUploadsContainer}}
65+
<div class="ai-bot-conversations__uploads-container">
66+
{{#each @controller.uploads as |upload|}}
67+
<div class="ai-bot-upload">
68+
<span class="ai-bot-upload__filename">
69+
{{upload.original_filename}}
70+
</span>
71+
<DButton
72+
@icon="xmark"
73+
@action={{fn @controller.removeUpload upload}}
74+
class="btn-transparent ai-bot-upload__remove"
75+
/>
76+
</div>
77+
{{/each}}
78+
79+
{{#each @controller.inProgressUploads as |upload|}}
80+
<div class="ai-bot-upload ai-bot-upload--in-progress">
81+
<span
82+
class="ai-bot-upload__filename"
83+
>{{upload.fileName}}</span>
84+
<span class="ai-bot-upload__progress">
85+
{{upload.progress}}%
86+
</span>
87+
<DButton
88+
@icon="xmark"
89+
@action={{fn @controller.cancelUpload upload}}
90+
class="btn-flat ai-bot-upload__remove"
91+
/>
92+
</div>
93+
{{/each}}
94+
</div>
95+
{{/if}}
4796
</div>
97+
4898
<p class="ai-disclaimer">
4999
{{i18n "discourse_ai.ai_bot.conversations.disclaimer"}}
50100
</p>

assets/javascripts/initializers/ai-conversations-sidebar.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export default {
1616
initialize() {
1717
withPluginApi((api) => {
1818
const siteSettings = api.container.lookup("service:site-settings");
19-
if (!siteSettings.ai_enable_experimental_bot_ux) {
19+
if (!siteSettings.ai_bot_enable_dedicated_ux) {
2020
return;
2121
}
2222

0 commit comments

Comments
 (0)