Skip to content

Commit 7316058

Browse files
authored
FEATURE: hashtag and mention autocomplete for first bot message (#1342)
Also removes controller which is a deprecated pattern * some comment improvements * remove uneeded code
1 parent 9ee82fd commit 7316058

File tree

3 files changed

+374
-300
lines changed

3 files changed

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

0 commit comments

Comments
 (0)