Skip to content

Commit 29a3286

Browse files
committed
FEATURE: hashtag and mention autocomplete for first bot message
Also removes controller which is a deprecated pattern
1 parent 9ee82fd commit 29a3286

File tree

3 files changed

+376
-298
lines changed

3 files changed

+376
-298
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,374 @@
1+
import { tracked } from "@glimmer/tracking";
2+
import Component from "@ember/component";
3+
import Controller from "@ember/controller";
4+
import { fn, hash } from "@ember/helper";
5+
import { on } from "@ember/modifier";
6+
import { action } from "@ember/object";
7+
import { getOwner } from "@ember/owner";
8+
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
9+
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
10+
import { service } from "@ember/service";
11+
import { TrackedArray } from "@ember-compat/tracked-built-ins";
12+
import $ from "jquery";
13+
import DButton from "discourse/components/d-button";
14+
import PluginOutlet from "discourse/components/plugin-outlet";
15+
import { popupAjaxError } from "discourse/lib/ajax-error";
16+
import userAutocomplete from "discourse/lib/autocomplete/user";
17+
import { setupHashtagAutocomplete } from "discourse/lib/hashtag-autocomplete";
18+
import UppyUpload from "discourse/lib/uppy/uppy-upload";
19+
import UppyMediaOptimization from "discourse/lib/uppy-media-optimization-plugin";
20+
import userSearch from "discourse/lib/user-search";
21+
import {
22+
destroyUserStatuses,
23+
initUserStatusHtml,
24+
renderUserStatusHtml,
25+
} from "discourse/lib/user-status-on-autocomplete";
26+
import { clipboardHelpers } from "discourse/lib/utilities";
27+
import { i18n } from "discourse-i18n";
28+
import AiPersonaLlmSelector from "discourse/plugins/discourse-ai/discourse/components/ai-persona-llm-selector";
29+
30+
export default class AiBotConversations extends Component {
31+
@service aiBotConversationsHiddenSubmit;
32+
@service currentUser;
33+
@service mediaOptimizationWorker;
34+
@service site;
35+
@service siteSettings;
36+
37+
@tracked uploads = new TrackedArray();
38+
// Don't track this directly - we'll get it from uppyUpload
39+
40+
textarea = null;
41+
uppyUpload = null;
42+
fileInputEl = null;
43+
44+
_handlePaste = (event) => {
45+
if (document.activeElement !== this.textarea) {
46+
return;
47+
}
48+
49+
const { canUpload, canPasteHtml, types } = clipboardHelpers(event, {
50+
siteSettings: this.siteSettings,
51+
canUpload: true,
52+
});
53+
54+
if (!canUpload || canPasteHtml || types.includes("text/plain")) {
55+
return;
56+
}
57+
58+
if (event && event.clipboardData && event.clipboardData.files) {
59+
this.uppyUpload.addFiles([...event.clipboardData.files], {
60+
pasted: true,
61+
});
62+
}
63+
};
64+
65+
init() {
66+
super.init(...arguments);
67+
68+
this.uppyUpload = new UppyUpload(getOwner(this), {
69+
id: "ai-bot-file-uploader",
70+
type: "ai-bot-conversation",
71+
useMultipartUploadsIfAvailable: true,
72+
73+
uppyReady: () => {
74+
if (this.siteSettings.composer_media_optimization_image_enabled) {
75+
this.uppyUpload.uppyWrapper.useUploadPlugin(UppyMediaOptimization, {
76+
optimizeFn: (data, opts) =>
77+
this.mediaOptimizationWorker.optimizeImage(data, opts),
78+
runParallel: !this.site.isMobileDevice,
79+
});
80+
}
81+
82+
this.uppyUpload.uppyWrapper.onPreProcessProgress((file) => {
83+
const inProgressUpload = this.inProgressUploads?.find(
84+
(upl) => upl.id === file.id
85+
);
86+
if (inProgressUpload && !inProgressUpload.processing) {
87+
inProgressUpload.processing = true;
88+
}
89+
});
90+
91+
this.uppyUpload.uppyWrapper.onPreProcessComplete((file) => {
92+
const inProgressUpload = this.inProgressUploads?.find(
93+
(upl) => upl.id === file.id
94+
);
95+
if (inProgressUpload) {
96+
inProgressUpload.processing = false;
97+
}
98+
});
99+
100+
// Setup paste listener for the textarea
101+
this.textarea?.addEventListener("paste", this._handlePaste);
102+
},
103+
104+
uploadDone: (upload) => {
105+
this.uploads.push(upload);
106+
},
107+
108+
// Fix: Don't try to set inProgressUploads directly
109+
onProgressUploadsChanged: () => {
110+
// This is just for UI triggers - we're already tracking inProgressUploads
111+
this.notifyPropertyChange("inProgressUploads");
112+
},
113+
});
114+
}
115+
116+
willDestroy() {
117+
super.willDestroy(...arguments);
118+
this.textarea?.removeEventListener("paste", this._handlePaste);
119+
this.uppyUpload?.teardown();
120+
// needed for safety
121+
if (this.textarea.autocomplete) {
122+
this.textarea.autocomplete("destroy");
123+
}
124+
}
125+
126+
get loading() {
127+
return this.aiBotConversationsHiddenSubmit?.loading;
128+
}
129+
130+
get inProgressUploads() {
131+
return this.uppyUpload?.inProgressUploads || [];
132+
}
133+
134+
get showUploadsContainer() {
135+
return this.uploads?.length > 0 || this.inProgressUploads?.length > 0;
136+
}
137+
138+
@action
139+
setPersonaId(id) {
140+
this.aiBotConversationsHiddenSubmit.personaId = id;
141+
}
142+
143+
@action
144+
setTargetRecipient(username) {
145+
this.aiBotConversationsHiddenSubmit.targetUsername = username;
146+
}
147+
148+
@action
149+
updateInputValue(value) {
150+
this._autoExpandTextarea();
151+
this.aiBotConversationsHiddenSubmit.inputValue =
152+
value.target?.value || value;
153+
}
154+
155+
@action
156+
handleKeyDown(event) {
157+
if (event.target.tagName !== "TEXTAREA") {
158+
return;
159+
}
160+
if (event.key === "Enter" && !event.shiftKey) {
161+
this.prepareAndSubmitToBot();
162+
}
163+
}
164+
165+
@action
166+
setTextArea(element) {
167+
this.textarea = element;
168+
this.setupAutocomplete(element);
169+
}
170+
171+
@action
172+
setupAutocomplete(textarea) {
173+
const $textarea = $(textarea);
174+
this.applyUserAutocomplete($textarea);
175+
this.applyHashtagAutocomplete($textarea);
176+
}
177+
178+
@action
179+
applyUserAutocomplete($textarea) {
180+
if (!this.siteSettings.enable_mentions) {
181+
return;
182+
}
183+
184+
$textarea.autocomplete({
185+
template: userAutocomplete,
186+
dataSource: (term) => {
187+
destroyUserStatuses();
188+
return userSearch({
189+
term,
190+
includeGroups: true,
191+
}).then((result) => {
192+
initUserStatusHtml(getOwner(this), result.users);
193+
return result;
194+
});
195+
},
196+
onRender: (options) => renderUserStatusHtml(options),
197+
key: "@",
198+
width: "100%",
199+
treatAsTextarea: true,
200+
autoSelectFirstSuggestion: true,
201+
transformComplete: (obj) => obj.username || obj.name,
202+
afterComplete: (text) => {
203+
this.textarea.value = text;
204+
this.textarea.focus();
205+
this.updateInputValue({ target: { value: text } });
206+
},
207+
onClose: destroyUserStatuses,
208+
});
209+
}
210+
211+
@action
212+
applyHashtagAutocomplete($textarea) {
213+
// Use the "topic-composer" configuration or create a specific one for AI bot
214+
// You can change this to "chat-composer" if that's more appropriate
215+
const hashtagConfig = this.site.hashtag_configurations["topic-composer"];
216+
217+
setupHashtagAutocomplete(hashtagConfig, $textarea, this.siteSettings, {
218+
treatAsTextarea: true,
219+
afterComplete: (text) => {
220+
this.textarea.value = text;
221+
this.textarea.focus();
222+
this.updateInputValue({ target: { value: text } });
223+
},
224+
});
225+
}
226+
227+
@action
228+
registerFileInput(element) {
229+
if (element) {
230+
this.fileInputEl = element;
231+
if (this.uppyUpload) {
232+
this.uppyUpload.setup(element);
233+
}
234+
}
235+
}
236+
237+
@action
238+
openFileUpload() {
239+
if (this.fileInputEl) {
240+
this.fileInputEl.click();
241+
}
242+
}
243+
244+
@action
245+
removeUpload(upload) {
246+
this.uploads = new TrackedArray(this.uploads.filter((u) => u !== upload));
247+
}
248+
249+
@action
250+
cancelUpload(upload) {
251+
this.uppyUpload.cancelSingleUpload({
252+
fileId: upload.id,
253+
});
254+
}
255+
256+
@action
257+
async prepareAndSubmitToBot() {
258+
// Pass uploads to the service before submitting
259+
this.aiBotConversationsHiddenSubmit.uploads = this.uploads;
260+
try {
261+
await this.aiBotConversationsHiddenSubmit.submitToBot();
262+
this.uploads = new TrackedArray();
263+
} catch (error) {
264+
popupAjaxError(error);
265+
}
266+
}
267+
268+
_autoExpandTextarea() {
269+
this.textarea.style.height = "auto";
270+
this.textarea.style.height = this.textarea.scrollHeight + "px";
271+
272+
// Get the max-height value from CSS (30vh)
273+
const maxHeight = parseInt(getComputedStyle(this.textarea).maxHeight, 10);
274+
275+
// Only enable scrolling if content exceeds max-height
276+
if (this.textarea.scrollHeight > maxHeight) {
277+
this.textarea.style.overflowY = "auto";
278+
} else {
279+
this.textarea.style.overflowY = "hidden";
280+
}
281+
}
282+
283+
<template>
284+
<div class="ai-bot-conversations">
285+
<AiPersonaLlmSelector
286+
@showLabels={{true}}
287+
@setPersonaId={{this.setPersonaId}}
288+
@setTargetRecipient={{this.setTargetRecipient}}
289+
/>
290+
291+
<div class="ai-bot-conversations__content-wrapper">
292+
<div class="ai-bot-conversations__title">
293+
{{i18n "discourse_ai.ai_bot.conversations.header"}}
294+
</div>
295+
<PluginOutlet
296+
@name="ai-bot-conversations-above-input"
297+
@outletArgs={{hash
298+
updateInput=this.updateInputValue
299+
submit=this.prepareAndSubmitToBot
300+
}}
301+
/>
302+
303+
<div class="ai-bot-conversations__input-wrapper">
304+
<DButton
305+
@icon="upload"
306+
@action={{this.openFileUpload}}
307+
@title="discourse_ai.ai_bot.conversations.upload_files"
308+
class="btn btn-transparent ai-bot-upload-btn"
309+
/>
310+
<textarea
311+
{{didInsert this.setTextArea}}
312+
{{on "input" this.updateInputValue}}
313+
{{on "keydown" this.handleKeyDown}}
314+
id="ai-bot-conversations-input"
315+
autofocus="true"
316+
placeholder={{i18n "discourse_ai.ai_bot.conversations.placeholder"}}
317+
minlength="10"
318+
disabled={{this.loading}}
319+
rows="1"
320+
/>
321+
<DButton
322+
@action={{this.prepareAndSubmitToBot}}
323+
@icon="paper-plane"
324+
@isLoading={{this.loading}}
325+
@title="discourse_ai.ai_bot.conversations.header"
326+
class="ai-bot-button btn-transparent ai-conversation-submit"
327+
/>
328+
<input
329+
type="file"
330+
id="ai-bot-file-uploader"
331+
class="hidden-upload-field"
332+
multiple="multiple"
333+
{{didInsert this.registerFileInput}}
334+
/>
335+
</div>
336+
337+
<p class="ai-disclaimer">
338+
{{i18n "discourse_ai.ai_bot.conversations.disclaimer"}}
339+
</p>
340+
341+
{{#if this.showUploadsContainer}}
342+
<div class="ai-bot-conversations__uploads-container">
343+
{{#each this.uploads as |upload|}}
344+
<div class="ai-bot-upload">
345+
<span class="ai-bot-upload__filename">
346+
{{upload.original_filename}}
347+
</span>
348+
<DButton
349+
@icon="xmark"
350+
@action={{fn this.removeUpload upload}}
351+
class="btn-transparent ai-bot-upload__remove"
352+
/>
353+
</div>
354+
{{/each}}
355+
356+
{{#each this.inProgressUploads as |upload|}}
357+
<div class="ai-bot-upload ai-bot-upload--in-progress">
358+
<span class="ai-bot-upload__filename">{{upload.fileName}}</span>
359+
<span class="ai-bot-upload__progress">
360+
{{upload.progress}}%
361+
</span>
362+
<DButton
363+
@icon="xmark"
364+
@action={{fn this.cancelUpload upload}}
365+
class="btn-flat ai-bot-upload__cancel"
366+
/>
367+
</div>
368+
{{/each}}
369+
</div>
370+
{{/if}}
371+
</div>
372+
</div>
373+
</template>
374+
}

0 commit comments

Comments
 (0)