mirror of
https://github.com/discourse/discourse-ai.git
synced 2025-06-28 18:42:16 +00:00
FEATURE: Allow for persona & llm selection in bot conversations page (#1276)
This commit is contained in:
parent
18dda31412
commit
b7b9179bc8
@ -0,0 +1,202 @@
|
|||||||
|
import Component from "@glimmer/component";
|
||||||
|
import { tracked } from "@glimmer/tracking";
|
||||||
|
import { hash } from "@ember/helper";
|
||||||
|
import { next } from "@ember/runloop";
|
||||||
|
import { service } from "@ember/service";
|
||||||
|
import { i18n } from "discourse-i18n";
|
||||||
|
import DropdownSelectBox from "select-kit/components/dropdown-select-box";
|
||||||
|
|
||||||
|
const PERSONA_SELECTOR_KEY = "ai_persona_selector_id";
|
||||||
|
const LLM_SELECTOR_KEY = "ai_llm_selector_id";
|
||||||
|
|
||||||
|
export default class AiPersonaLlmSelector extends Component {
|
||||||
|
@service currentUser;
|
||||||
|
@service keyValueStore;
|
||||||
|
|
||||||
|
@tracked llm;
|
||||||
|
@tracked allowLLMSelector = true;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super(...arguments);
|
||||||
|
|
||||||
|
if (this.botOptions?.length) {
|
||||||
|
this.#loadStoredPersona();
|
||||||
|
this.#loadStoredLlm();
|
||||||
|
|
||||||
|
next(() => {
|
||||||
|
this.resetTargetRecipients();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get composer() {
|
||||||
|
return this.args?.outletArgs?.model;
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasLlmSelector() {
|
||||||
|
return this.currentUser.ai_enabled_chat_bots.any((bot) => !bot.is_persona);
|
||||||
|
}
|
||||||
|
|
||||||
|
get botOptions() {
|
||||||
|
if (!this.currentUser.ai_enabled_personas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let enabledPersonas = this.currentUser.ai_enabled_personas;
|
||||||
|
|
||||||
|
if (!this.hasLlmSelector) {
|
||||||
|
enabledPersonas = enabledPersonas.filter((persona) => persona.username);
|
||||||
|
}
|
||||||
|
|
||||||
|
return enabledPersonas.map((persona) => {
|
||||||
|
return {
|
||||||
|
id: persona.id,
|
||||||
|
name: persona.name,
|
||||||
|
description: persona.description,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get filterable() {
|
||||||
|
return this.botOptions.length > 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
get value() {
|
||||||
|
return this._value;
|
||||||
|
}
|
||||||
|
|
||||||
|
set value(newValue) {
|
||||||
|
this._value = newValue;
|
||||||
|
this.keyValueStore.setItem(PERSONA_SELECTOR_KEY, newValue);
|
||||||
|
this.args.setPersonaId(newValue);
|
||||||
|
this.setAllowLLMSelector();
|
||||||
|
this.resetTargetRecipients();
|
||||||
|
}
|
||||||
|
|
||||||
|
setAllowLLMSelector() {
|
||||||
|
if (!this.hasLlmSelector) {
|
||||||
|
this.allowLLMSelector = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const persona = this.currentUser.ai_enabled_personas.find(
|
||||||
|
(innerPersona) => innerPersona.id === this._value
|
||||||
|
);
|
||||||
|
|
||||||
|
this.allowLLMSelector = !persona?.force_default_llm;
|
||||||
|
}
|
||||||
|
|
||||||
|
get currentLlm() {
|
||||||
|
return this.llm;
|
||||||
|
}
|
||||||
|
|
||||||
|
set currentLlm(newValue) {
|
||||||
|
this.llm = newValue;
|
||||||
|
this.keyValueStore.setItem(LLM_SELECTOR_KEY, newValue);
|
||||||
|
|
||||||
|
this.resetTargetRecipients();
|
||||||
|
}
|
||||||
|
|
||||||
|
resetTargetRecipients() {
|
||||||
|
if (this.allowLLMSelector) {
|
||||||
|
const botUsername = this.currentUser.ai_enabled_chat_bots.find(
|
||||||
|
(bot) => bot.id === this.llm
|
||||||
|
).username;
|
||||||
|
this.args.setTargetRecipient(botUsername);
|
||||||
|
} else {
|
||||||
|
const persona = this.currentUser.ai_enabled_personas.find(
|
||||||
|
(innerPersona) => innerPersona.id === this._value
|
||||||
|
);
|
||||||
|
this.args.setTargetRecipient(persona.username || "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get llmOptions() {
|
||||||
|
const availableBots = this.currentUser.ai_enabled_chat_bots
|
||||||
|
.filter((bot) => !bot.is_persona)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return availableBots
|
||||||
|
.map((bot) => {
|
||||||
|
return {
|
||||||
|
id: bot.id,
|
||||||
|
name: bot.display_name,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
get showLLMSelector() {
|
||||||
|
return this.allowLLMSelector && this.llmOptions.length > 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#loadStoredPersona() {
|
||||||
|
let personaId = this.keyValueStore.getItem(PERSONA_SELECTOR_KEY);
|
||||||
|
|
||||||
|
this._value = this.botOptions[0].id;
|
||||||
|
if (personaId) {
|
||||||
|
personaId = parseInt(personaId, 10);
|
||||||
|
if (this.botOptions.any((bot) => bot.id === personaId)) {
|
||||||
|
this._value = personaId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.args.setPersonaId(this._value);
|
||||||
|
}
|
||||||
|
|
||||||
|
#loadStoredLlm() {
|
||||||
|
this.setAllowLLMSelector();
|
||||||
|
|
||||||
|
if (this.hasLlmSelector) {
|
||||||
|
let llm = this.keyValueStore.getItem(LLM_SELECTOR_KEY);
|
||||||
|
|
||||||
|
const llmOption =
|
||||||
|
this.llmOptions.find((innerLlmOption) => innerLlmOption.id === llm) ||
|
||||||
|
this.llmOptions[0];
|
||||||
|
|
||||||
|
if (llmOption) {
|
||||||
|
llm = llmOption.id;
|
||||||
|
} else {
|
||||||
|
llm = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (llm) {
|
||||||
|
next(() => {
|
||||||
|
this.currentLlm = llm;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="persona-llm-selector">
|
||||||
|
<div class="persona-llm-selector__selection-wrapper gpt-persona">
|
||||||
|
{{#if @showLabels}}
|
||||||
|
<label>{{i18n "discourse_ai.ai_bot.persona"}}</label>
|
||||||
|
{{/if}}
|
||||||
|
<DropdownSelectBox
|
||||||
|
class="persona-llm-selector__persona-dropdown"
|
||||||
|
@value={{this.value}}
|
||||||
|
@content={{this.botOptions}}
|
||||||
|
@options={{hash
|
||||||
|
icon=(if @showLabels "angle-down" "robot")
|
||||||
|
filterable=this.filterable
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{{#if this.showLLMSelector}}
|
||||||
|
<div class="persona-llm-selector__selection-wrapper llm-selector">
|
||||||
|
{{#if @showLabels}}
|
||||||
|
<label>{{i18n "discourse_ai.ai_bot.llm"}}</label>
|
||||||
|
{{/if}}
|
||||||
|
<DropdownSelectBox
|
||||||
|
class="persona-llm-selector__llm-dropdown"
|
||||||
|
@value={{this.currentLlm}}
|
||||||
|
@content={{this.llmOptions}}
|
||||||
|
@options={{hash icon=(if @showLabels "angle-down" "globe")}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
}
|
@ -1,10 +1,7 @@
|
|||||||
import Component from "@glimmer/component";
|
import Component from "@glimmer/component";
|
||||||
import { tracked } from "@glimmer/tracking";
|
import { action } from "@ember/object";
|
||||||
import { hash } from "@ember/helper";
|
|
||||||
import { next } from "@ember/runloop";
|
|
||||||
import { service } from "@ember/service";
|
import { service } from "@ember/service";
|
||||||
import KeyValueStore from "discourse/lib/key-value-store";
|
import AiPersonaLlmSelector from "discourse/plugins/discourse-ai/discourse/components/ai-persona-llm-selector";
|
||||||
import DropdownSelectBox from "select-kit/components/dropdown-select-box";
|
|
||||||
|
|
||||||
function isBotMessage(composer, currentUser) {
|
function isBotMessage(composer, currentUser) {
|
||||||
if (
|
if (
|
||||||
@ -30,175 +27,21 @@ export default class BotSelector extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@service currentUser;
|
@service currentUser;
|
||||||
@service siteSettings;
|
|
||||||
|
|
||||||
@tracked llm;
|
@action
|
||||||
@tracked allowLLMSelector = true;
|
setPersonaIdOnComposer(id) {
|
||||||
|
this.args.outletArgs.model.metaData = { ai_persona_id: id };
|
||||||
STORE_NAMESPACE = "discourse_ai_persona_selector_";
|
|
||||||
LLM_STORE_NAMESPACE = "discourse_ai_llm_selector_";
|
|
||||||
|
|
||||||
preferredPersonaStore = new KeyValueStore(this.STORE_NAMESPACE);
|
|
||||||
preferredLlmStore = new KeyValueStore(this.LLM_STORE_NAMESPACE);
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super(...arguments);
|
|
||||||
|
|
||||||
if (this.botOptions && this.botOptions.length && this.composer) {
|
|
||||||
let personaId = this.preferredPersonaStore.getObject("id");
|
|
||||||
|
|
||||||
this._value = this.botOptions[0].id;
|
|
||||||
if (personaId) {
|
|
||||||
personaId = parseInt(personaId, 10);
|
|
||||||
if (this.botOptions.any((bot) => bot.id === personaId)) {
|
|
||||||
this._value = personaId;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.composer.metaData = { ai_persona_id: this._value };
|
@action
|
||||||
this.setAllowLLMSelector();
|
setTargetRecipientsOnComposer(username) {
|
||||||
|
this.args.outletArgs.model.set("targetRecipients", username);
|
||||||
if (this.hasLlmSelector) {
|
|
||||||
let llm = this.preferredLlmStore.getObject("id");
|
|
||||||
|
|
||||||
const llmOption =
|
|
||||||
this.llmOptions.find((innerLlmOption) => innerLlmOption.id === llm) ||
|
|
||||||
this.llmOptions[0];
|
|
||||||
|
|
||||||
if (llmOption) {
|
|
||||||
llm = llmOption.id;
|
|
||||||
} else {
|
|
||||||
llm = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (llm) {
|
|
||||||
next(() => {
|
|
||||||
this.currentLlm = llm;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
next(() => {
|
|
||||||
this.resetTargetRecipients();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get composer() {
|
|
||||||
return this.args?.outletArgs?.model;
|
|
||||||
}
|
|
||||||
|
|
||||||
get hasLlmSelector() {
|
|
||||||
return this.currentUser.ai_enabled_chat_bots.any((bot) => !bot.is_persona);
|
|
||||||
}
|
|
||||||
|
|
||||||
get botOptions() {
|
|
||||||
if (this.currentUser.ai_enabled_personas) {
|
|
||||||
let enabledPersonas = this.currentUser.ai_enabled_personas;
|
|
||||||
|
|
||||||
if (!this.hasLlmSelector) {
|
|
||||||
enabledPersonas = enabledPersonas.filter((persona) => persona.username);
|
|
||||||
}
|
|
||||||
|
|
||||||
return enabledPersonas.map((persona) => {
|
|
||||||
return {
|
|
||||||
id: persona.id,
|
|
||||||
name: persona.name,
|
|
||||||
description: persona.description,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get filterable() {
|
|
||||||
return this.botOptions.length > 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
get value() {
|
|
||||||
return this._value;
|
|
||||||
}
|
|
||||||
|
|
||||||
set value(newValue) {
|
|
||||||
this._value = newValue;
|
|
||||||
this.preferredPersonaStore.setObject({ key: "id", value: newValue });
|
|
||||||
this.composer.metaData = { ai_persona_id: newValue };
|
|
||||||
this.setAllowLLMSelector();
|
|
||||||
this.resetTargetRecipients();
|
|
||||||
}
|
|
||||||
|
|
||||||
setAllowLLMSelector() {
|
|
||||||
if (!this.hasLlmSelector) {
|
|
||||||
this.allowLLMSelector = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const persona = this.currentUser.ai_enabled_personas.find(
|
|
||||||
(innerPersona) => innerPersona.id === this._value
|
|
||||||
);
|
|
||||||
|
|
||||||
this.allowLLMSelector = !persona?.force_default_llm;
|
|
||||||
}
|
|
||||||
|
|
||||||
get currentLlm() {
|
|
||||||
return this.llm;
|
|
||||||
}
|
|
||||||
|
|
||||||
set currentLlm(newValue) {
|
|
||||||
this.llm = newValue;
|
|
||||||
this.preferredLlmStore.setObject({ key: "id", value: newValue });
|
|
||||||
|
|
||||||
this.resetTargetRecipients();
|
|
||||||
}
|
|
||||||
|
|
||||||
resetTargetRecipients() {
|
|
||||||
if (this.allowLLMSelector) {
|
|
||||||
const botUsername = this.currentUser.ai_enabled_chat_bots.find(
|
|
||||||
(bot) => bot.id === this.llm
|
|
||||||
).username;
|
|
||||||
this.composer.set("targetRecipients", botUsername);
|
|
||||||
} else {
|
|
||||||
const persona = this.currentUser.ai_enabled_personas.find(
|
|
||||||
(innerPersona) => innerPersona.id === this._value
|
|
||||||
);
|
|
||||||
this.composer.set("targetRecipients", persona.username || "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get llmOptions() {
|
|
||||||
const availableBots = this.currentUser.ai_enabled_chat_bots
|
|
||||||
.filter((bot) => !bot.is_persona)
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
return availableBots
|
|
||||||
.map((bot) => {
|
|
||||||
return {
|
|
||||||
id: bot.id,
|
|
||||||
name: bot.display_name,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="persona-llm-selector">
|
<AiPersonaLlmSelector
|
||||||
<div class="gpt-persona">
|
@setPersonaId={{this.setPersonaIdOnComposer}}
|
||||||
<DropdownSelectBox
|
@setTargetRecipient={{this.setTargetRecipientsOnComposer}}
|
||||||
class="persona-llm-selector__persona-dropdown"
|
|
||||||
@value={{this.value}}
|
|
||||||
@content={{this.botOptions}}
|
|
||||||
@options={{hash icon="robot" filterable=this.filterable}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
{{#if this.allowLLMSelector}}
|
|
||||||
<div class="llm-selector">
|
|
||||||
<DropdownSelectBox
|
|
||||||
class="persona-llm-selector__llm-dropdown"
|
|
||||||
@value={{this.currentLlm}}
|
|
||||||
@content={{this.llmOptions}}
|
|
||||||
@options={{hash icon="globe"}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{{/if}}
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
}
|
}
|
||||||
|
@ -1,48 +1,29 @@
|
|||||||
import Controller from "@ember/controller";
|
import Controller from "@ember/controller";
|
||||||
import { action } from "@ember/object";
|
import { action } from "@ember/object";
|
||||||
import { service } from "@ember/service";
|
import { service } from "@ember/service";
|
||||||
import { tracked } from "@ember-compat/tracked-built-ins";
|
|
||||||
|
|
||||||
export default class DiscourseAiBotConversations extends Controller {
|
export default class DiscourseAiBotConversations extends Controller {
|
||||||
@service aiBotConversationsHiddenSubmit;
|
@service aiBotConversationsHiddenSubmit;
|
||||||
@service currentUser;
|
@service currentUser;
|
||||||
|
|
||||||
@tracked selectedPersona = this.personaOptions[0].username;
|
|
||||||
|
|
||||||
textarea = null;
|
textarea = null;
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
super.init(...arguments);
|
super.init(...arguments);
|
||||||
this.selectedPersonaChanged(this.selectedPersona);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get personaOptions() {
|
get loading() {
|
||||||
if (this.currentUser.ai_enabled_personas) {
|
return this.aiBotConversationsHiddenSubmit?.loading;
|
||||||
return this.currentUser.ai_enabled_personas
|
|
||||||
.filter((persona) => persona.username)
|
|
||||||
.map((persona) => {
|
|
||||||
return {
|
|
||||||
id: persona.id,
|
|
||||||
username: persona.username,
|
|
||||||
name: persona.name,
|
|
||||||
description: persona.description,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get displayPersonaSelector() {
|
|
||||||
return this.personaOptions.length > 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
get filterable() {
|
|
||||||
return this.personaOptions.length > 4;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
selectedPersonaChanged(username) {
|
setPersonaId(id) {
|
||||||
this.selectedPersona = username;
|
this.aiBotConversationsHiddenSubmit.personaId = id;
|
||||||
this.aiBotConversationsHiddenSubmit.personaUsername = username;
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
setTargetRecipient(username) {
|
||||||
|
this.aiBotConversationsHiddenSubmit.targetUsername = username;
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
|
@ -1,16 +1,22 @@
|
|||||||
import { action } from "@ember/object";
|
import { action } from "@ember/object";
|
||||||
import { next } from "@ember/runloop";
|
import { next } from "@ember/runloop";
|
||||||
import Service, { service } from "@ember/service";
|
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 { popupAjaxError } from "discourse/lib/ajax-error";
|
||||||
import { i18n } from "discourse-i18n";
|
import { i18n } from "discourse-i18n";
|
||||||
import { composeAiBotMessage } from "../lib/ai-bot-helper";
|
|
||||||
|
|
||||||
export default class AiBotConversationsHiddenSubmit extends Service {
|
export default class AiBotConversationsHiddenSubmit extends Service {
|
||||||
@service composer;
|
|
||||||
@service aiConversationsSidebarManager;
|
@service aiConversationsSidebarManager;
|
||||||
|
@service appEvents;
|
||||||
|
@service composer;
|
||||||
@service dialog;
|
@service dialog;
|
||||||
|
@service router;
|
||||||
|
|
||||||
personaUsername;
|
@tracked loading = false;
|
||||||
|
|
||||||
|
personaId;
|
||||||
|
targetUsername;
|
||||||
|
|
||||||
inputValue = "";
|
inputValue = "";
|
||||||
|
|
||||||
@ -25,9 +31,6 @@ export default class AiBotConversationsHiddenSubmit extends Service {
|
|||||||
|
|
||||||
@action
|
@action
|
||||||
async submitToBot() {
|
async submitToBot() {
|
||||||
this.composer.destroyDraft();
|
|
||||||
this.composer.close();
|
|
||||||
|
|
||||||
if (this.inputValue.length < 10) {
|
if (this.inputValue.length < 10) {
|
||||||
return this.dialog.alert({
|
return this.dialog.alert({
|
||||||
message: i18n(
|
message: i18n(
|
||||||
@ -38,25 +41,31 @@ export default class AiBotConversationsHiddenSubmit extends Service {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// we are intentionally passing null as the targetBot to allow for the
|
this.loading = true;
|
||||||
// function to select the first available bot. This will be refactored in the
|
const title = i18n("discourse_ai.ai_bot.default_pm_prefix");
|
||||||
// future to allow for selecting a specific bot.
|
|
||||||
await composeAiBotMessage(null, this.composer, {
|
|
||||||
skipFocus: true,
|
|
||||||
topicBody: this.inputValue,
|
|
||||||
personaUsername: this.personaUsername,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.composer.save();
|
const response = await ajax("/posts.json", {
|
||||||
this.aiConversationsSidebarManager.newTopicForceSidebar = true;
|
method: "POST",
|
||||||
if (this.inputValue.length > 10) {
|
data: {
|
||||||
// prevents submitting same message again when returning home
|
raw: this.inputValue,
|
||||||
// but avoids deleting too-short message on submit
|
title,
|
||||||
this.inputValue = "";
|
archetype: "private_message",
|
||||||
}
|
target_recipients: this.targetUsername,
|
||||||
|
meta_data: { ai_persona_id: this.personaId },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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) {
|
} catch (e) {
|
||||||
popupAjaxError(e);
|
popupAjaxError(e);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,29 +1,22 @@
|
|||||||
import { 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";
|
||||||
|
import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
|
||||||
import DButton from "discourse/components/d-button";
|
import DButton from "discourse/components/d-button";
|
||||||
import { i18n } from "discourse-i18n";
|
import { i18n } from "discourse-i18n";
|
||||||
import DropdownSelectBox from "select-kit/components/dropdown-select-box";
|
import AiPersonaLlmSelector from "discourse/plugins/discourse-ai/discourse/components/ai-persona-llm-selector";
|
||||||
|
|
||||||
export default RouteTemplate(
|
export default RouteTemplate(
|
||||||
<template>
|
<template>
|
||||||
<div class="ai-bot-conversations">
|
<div class="ai-bot-conversations">
|
||||||
{{#if @controller.displayPersonaSelector}}
|
<AiPersonaLlmSelector
|
||||||
<div class="ai-bot-conversations__persona-selector">
|
@showLabels={{true}}
|
||||||
<DropdownSelectBox
|
@setPersonaId={{@controller.setPersonaId}}
|
||||||
class="persona-llm-selector__persona-dropdown"
|
@setTargetRecipient={{@controller.setTargetRecipient}}
|
||||||
@value={{@controller.selectedPersona}}
|
|
||||||
@valueProperty="username"
|
|
||||||
@content={{@controller.personaOptions}}
|
|
||||||
@options={{hash icon="angle-down"}}
|
|
||||||
@onChange={{@controller.selectedPersonaChanged}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
{{/if}}
|
|
||||||
|
|
||||||
<div class="ai-bot-conversations__content-wrapper">
|
<div class="ai-bot-conversations__content-wrapper">
|
||||||
|
<ConditionalLoadingSpinner @condition={{@controller.loading}}>
|
||||||
<h1>{{i18n "discourse_ai.ai_bot.conversations.header"}}</h1>
|
<h1>{{i18n "discourse_ai.ai_bot.conversations.header"}}</h1>
|
||||||
<div class="ai-bot-conversations__input-wrapper">
|
<div class="ai-bot-conversations__input-wrapper">
|
||||||
<textarea
|
<textarea
|
||||||
@ -31,7 +24,9 @@ export default RouteTemplate(
|
|||||||
{{on "input" @controller.updateInputValue}}
|
{{on "input" @controller.updateInputValue}}
|
||||||
{{on "keydown" @controller.handleKeyDown}}
|
{{on "keydown" @controller.handleKeyDown}}
|
||||||
id="ai-bot-conversations-input"
|
id="ai-bot-conversations-input"
|
||||||
placeholder={{i18n "discourse_ai.ai_bot.conversations.placeholder"}}
|
placeholder={{i18n
|
||||||
|
"discourse_ai.ai_bot.conversations.placeholder"
|
||||||
|
}}
|
||||||
minlength="10"
|
minlength="10"
|
||||||
rows="1"
|
rows="1"
|
||||||
/>
|
/>
|
||||||
@ -45,6 +40,7 @@ export default RouteTemplate(
|
|||||||
<p class="ai-disclaimer">
|
<p class="ai-disclaimer">
|
||||||
{{i18n "discourse_ai.ai_bot.conversations.disclaimer"}}
|
{{i18n "discourse_ai.ai_bot.conversations.disclaimer"}}
|
||||||
</p>
|
</p>
|
||||||
|
</ConditionalLoadingSpinner>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -53,6 +53,10 @@ export default {
|
|||||||
this.topic = topic;
|
this.topic = topic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get key() {
|
||||||
|
return this.topic.id;
|
||||||
|
}
|
||||||
|
|
||||||
get name() {
|
get name() {
|
||||||
return this.topic.title;
|
return this.topic.title;
|
||||||
}
|
}
|
||||||
@ -90,13 +94,21 @@ export default {
|
|||||||
super(...arguments);
|
super(...arguments);
|
||||||
this.fetchMessages();
|
this.fetchMessages();
|
||||||
|
|
||||||
appEvents.on("topic:created", this, "addNewMessageToSidebar");
|
appEvents.on(
|
||||||
|
"discourse-ai:bot-pm-created",
|
||||||
|
this,
|
||||||
|
"addNewPMToSidebar"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bind
|
@bind
|
||||||
willDestroy() {
|
willDestroy() {
|
||||||
this.removeScrollListener();
|
this.removeScrollListener();
|
||||||
appEvents.on("topic:created", this, "addNewMessageToSidebar");
|
appEvents.off(
|
||||||
|
"discourse-ai:bot-pm-created",
|
||||||
|
this,
|
||||||
|
"addNewPMToSidebar"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
get name() {
|
get name() {
|
||||||
@ -115,8 +127,8 @@ export default {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
addNewMessageToSidebar(topic) {
|
addNewPMToSidebar(topic) {
|
||||||
this.addNewMessage(topic);
|
this.links = [new AiConversationLink(topic), ...this.links];
|
||||||
this.watchForTitleUpdate(topic);
|
this.watchForTitleUpdate(topic);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -201,28 +213,25 @@ export default {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
addNewMessage(newTopic) {
|
|
||||||
this.links = [new AiConversationLink(newTopic), ...this.links];
|
|
||||||
}
|
|
||||||
|
|
||||||
watchForTitleUpdate(topic) {
|
watchForTitleUpdate(topic) {
|
||||||
const channel = `/discourse-ai/ai-bot/topic/${topic.topic_id}`;
|
const channel = `/discourse-ai/ai-bot/topic/${topic.id}`;
|
||||||
const topicId = topic.topic_id;
|
|
||||||
const callback = this.updateTopicTitle.bind(this);
|
const callback = this.updateTopicTitle.bind(this);
|
||||||
messageBus.subscribe(channel, ({ title }) => {
|
messageBus.subscribe(channel, ({ title }) => {
|
||||||
callback(topicId, title);
|
callback(topic, title);
|
||||||
messageBus.unsubscribe(channel);
|
messageBus.unsubscribe(channel);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateTopicTitle(topicId, title) {
|
updateTopicTitle(topic, title) {
|
||||||
// update the topic title in the sidebar, instead of the default title
|
// update the data
|
||||||
const text = document.querySelector(
|
topic.title = title;
|
||||||
`.sidebar-section-link-wrapper .ai-conversation-${topicId} .sidebar-section-link-content-text`
|
|
||||||
|
// force Glimmer to re-render that one link
|
||||||
|
this.links = this.links.map((link) =>
|
||||||
|
link.topic.id === topic.id
|
||||||
|
? new AiConversationLink(topic)
|
||||||
|
: link
|
||||||
);
|
);
|
||||||
if (text) {
|
|
||||||
text.innerText = title;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@ -240,9 +249,10 @@ export default {
|
|||||||
if (
|
if (
|
||||||
topic?.archetype === "private_message" &&
|
topic?.archetype === "private_message" &&
|
||||||
topic.user_id === currentUser.id &&
|
topic.user_id === currentUser.id &&
|
||||||
|
(topic.is_bot_pm ||
|
||||||
topic.postStream.posts.some((post) =>
|
topic.postStream.posts.some((post) =>
|
||||||
isPostFromAiBot(post, currentUser)
|
isPostFromAiBot(post, currentUser)
|
||||||
)
|
))
|
||||||
) {
|
) {
|
||||||
return aiConversationsSidebarManager.forceCustomSidebar();
|
return aiConversationsSidebarManager.forceCustomSidebar();
|
||||||
}
|
}
|
||||||
|
@ -153,26 +153,41 @@ body.has-ai-conversations-sidebar {
|
|||||||
.ai-bot-conversations {
|
.ai-bot-conversations {
|
||||||
height: calc(100dvh - var(--header-offset) - 1.25em);
|
height: calc(100dvh - var(--header-offset) - 1.25em);
|
||||||
|
|
||||||
&__persona-selector {
|
.persona-llm-selector {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
gap: 0.5em;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
|
|
||||||
|
&__selection-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: var(--font-down-1);
|
||||||
|
font-weight: 300;
|
||||||
|
margin-left: 1em;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__persona-selector .btn {
|
.btn {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__persona-selector .btn:hover,
|
.btn:hover,
|
||||||
&__persona-selector .btn:focus {
|
.btn:focus {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
&__persona-selector .btn:hover .d-icon,
|
.btn:hover .d-icon,
|
||||||
&__persona-selector .btn:focus .d-icon {
|
.btn:focus .d-icon {
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&__content-wrapper {
|
&__content-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -185,6 +200,10 @@ body.has-ai-conversations-sidebar {
|
|||||||
@include viewport.until(sm) {
|
@include viewport.until(sm) {
|
||||||
height: calc(75% - 1.25em - var(--header-offset) - 2em);
|
height: calc(75% - 1.25em - var(--header-offset) - 2em);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.loading-container {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__input-wrapper {
|
&__input-wrapper {
|
||||||
|
@ -664,6 +664,8 @@ en:
|
|||||||
click_to_run_label: "Run Artifact"
|
click_to_run_label: "Run Artifact"
|
||||||
|
|
||||||
ai_bot:
|
ai_bot:
|
||||||
|
persona: "Persona"
|
||||||
|
llm: "Model"
|
||||||
pm_warning: "AI chatbot messages are monitored regularly by moderators."
|
pm_warning: "AI chatbot messages are monitored regularly by moderators."
|
||||||
cancel_streaming: "Stop reply"
|
cancel_streaming: "Stop reply"
|
||||||
default_pm_prefix: "[Untitled AI bot PM]"
|
default_pm_prefix: "[Untitled AI bot PM]"
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
module DiscourseAi
|
module DiscourseAi
|
||||||
module AiBot
|
module AiBot
|
||||||
USER_AGENT = "Discourse AI Bot 1.0 (https://www.discourse.org)"
|
USER_AGENT = "Discourse AI Bot 1.0 (https://www.discourse.org)"
|
||||||
|
TOPIC_AI_BOT_PM_FIELD = "is_ai_bot_pm"
|
||||||
|
|
||||||
class EntryPoint
|
class EntryPoint
|
||||||
Bot = Struct.new(:id, :name, :llm)
|
Bot = Struct.new(:id, :name, :llm)
|
||||||
@ -64,6 +65,34 @@ module DiscourseAi
|
|||||||
end
|
end
|
||||||
|
|
||||||
def inject_into(plugin)
|
def inject_into(plugin)
|
||||||
|
plugin.register_topic_custom_field_type(TOPIC_AI_BOT_PM_FIELD, :string)
|
||||||
|
|
||||||
|
plugin.on(:topic_created) do |topic|
|
||||||
|
next if !topic.private_message?
|
||||||
|
creator = topic.user
|
||||||
|
|
||||||
|
# Only process if creator is not a bot or system user
|
||||||
|
next if DiscourseAi::AiBot::Playground.is_bot_user_id?(creator.id)
|
||||||
|
|
||||||
|
# Get all bot user IDs defined by the discourse-ai plugin
|
||||||
|
bot_ids = DiscourseAi::AiBot::EntryPoint.all_bot_ids
|
||||||
|
|
||||||
|
# Check if the only recipients are bots
|
||||||
|
recipients = topic.topic_allowed_users.pluck(:user_id)
|
||||||
|
|
||||||
|
# Remove creator from recipients for checking
|
||||||
|
recipients -= [creator.id]
|
||||||
|
|
||||||
|
# If all remaining recipients are AI bots and there's exactly one recipient
|
||||||
|
if recipients.length == 1 && (recipients - bot_ids).empty?
|
||||||
|
# The only recipient is an AI bot - add the custom field to the topic
|
||||||
|
topic.custom_fields[TOPIC_AI_BOT_PM_FIELD] = true
|
||||||
|
|
||||||
|
# Save the custom fields
|
||||||
|
topic.save_custom_fields
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
plugin.register_modifier(:chat_allowed_bot_user_ids) do |user_ids, guardian|
|
plugin.register_modifier(:chat_allowed_bot_user_ids) do |user_ids, guardian|
|
||||||
if guardian.user
|
if guardian.user
|
||||||
allowed_chat =
|
allowed_chat =
|
||||||
@ -102,6 +131,14 @@ module DiscourseAi
|
|||||||
Rails.root.join("plugins", "discourse-ai", "db", "fixtures", "ai_bot"),
|
Rails.root.join("plugins", "discourse-ai", "db", "fixtures", "ai_bot"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
plugin.add_to_serializer(
|
||||||
|
:topic_view,
|
||||||
|
:is_bot_pm,
|
||||||
|
include_condition: -> do
|
||||||
|
object.personal_message && object.topic.custom_fields[TOPIC_AI_BOT_PM_FIELD]
|
||||||
|
end,
|
||||||
|
) { true }
|
||||||
|
|
||||||
plugin.add_to_serializer(
|
plugin.add_to_serializer(
|
||||||
:current_user,
|
:current_user,
|
||||||
:ai_enabled_personas,
|
:ai_enabled_personas,
|
||||||
|
@ -116,11 +116,7 @@ module DiscourseAi
|
|||||||
base_query
|
base_query
|
||||||
.joins(:user)
|
.joins(:user)
|
||||||
.joins("LEFT JOIN llm_models ON llm_models.name = language_model")
|
.joins("LEFT JOIN llm_models ON llm_models.name = language_model")
|
||||||
.group(
|
.group(:user_id, "users.username", "users.uploaded_avatar_id")
|
||||||
:user_id,
|
|
||||||
"users.username",
|
|
||||||
"users.uploaded_avatar_id",
|
|
||||||
)
|
|
||||||
.order("usage_count DESC")
|
.order("usage_count DESC")
|
||||||
.limit(USER_LIMIT)
|
.limit(USER_LIMIT)
|
||||||
.select(
|
.select(
|
||||||
@ -140,9 +136,7 @@ module DiscourseAi
|
|||||||
def feature_breakdown
|
def feature_breakdown
|
||||||
base_query
|
base_query
|
||||||
.joins("LEFT JOIN llm_models ON llm_models.name = language_model")
|
.joins("LEFT JOIN llm_models ON llm_models.name = language_model")
|
||||||
.group(
|
.group(:feature_name)
|
||||||
:feature_name
|
|
||||||
)
|
|
||||||
.order("usage_count DESC")
|
.order("usage_count DESC")
|
||||||
.select(
|
.select(
|
||||||
"case when coalesce(feature_name, '') = '' then '#{UNKNOWN_FEATURE}' else feature_name end as feature_name",
|
"case when coalesce(feature_name, '') = '' then '#{UNKNOWN_FEATURE}' else feature_name end as feature_name",
|
||||||
|
@ -35,6 +35,20 @@ RSpec.describe DiscourseAi::AiBot::EntryPoint do
|
|||||||
expect(serializer[:current_user][:can_debug_ai_bot_conversations]).to eq(true)
|
expect(serializer[:current_user][:can_debug_ai_bot_conversations]).to eq(true)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "adding TOPIC_AI_BOT_PM_FIELD to topic custom fields" do
|
||||||
|
it "is added when user PMs a single bot" do
|
||||||
|
topic = PostCreator.create!(admin, post_args).topic
|
||||||
|
expect(topic.reload.custom_fields[DiscourseAi::AiBot::TOPIC_AI_BOT_PM_FIELD]).to eq("t")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "is not added when user PMs a bot and another user" do
|
||||||
|
user = Fabricate(:user)
|
||||||
|
post_args[:target_usernames] = [gpt_bot.username, user.username].join(",")
|
||||||
|
topic = PostCreator.create!(admin, post_args).topic
|
||||||
|
expect(topic.reload.custom_fields[DiscourseAi::AiBot::TOPIC_AI_BOT_PM_FIELD]).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
it "adds information about forcing default llm to current_user_serializer" do
|
it "adds information about forcing default llm to current_user_serializer" do
|
||||||
Group.refresh_automatic_groups!
|
Group.refresh_automatic_groups!
|
||||||
|
|
||||||
|
@ -18,10 +18,20 @@ RSpec.describe "AI Bot - Homepage", type: :system do
|
|||||||
provider: "anthropic",
|
provider: "anthropic",
|
||||||
url: "https://api.anthropic.com/v1/messages",
|
url: "https://api.anthropic.com/v1/messages",
|
||||||
name: "claude-2",
|
name: "claude-2",
|
||||||
|
display_name: "Claude 2",
|
||||||
|
)
|
||||||
|
end
|
||||||
|
fab!(:claude_2_dup) do
|
||||||
|
Fabricate(
|
||||||
|
:llm_model,
|
||||||
|
provider: "anthropic",
|
||||||
|
url: "https://api.anthropic.com/v1/messages",
|
||||||
|
name: "claude-2",
|
||||||
|
display_name: "Duplicate",
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
fab!(:bot_user) do
|
fab!(:bot_user) do
|
||||||
toggle_enabled_bots(bots: [claude_2])
|
toggle_enabled_bots(bots: [claude_2, claude_2_dup])
|
||||||
SiteSetting.ai_bot_enabled = true
|
SiteSetting.ai_bot_enabled = true
|
||||||
claude_2.reload.user
|
claude_2.reload.user
|
||||||
end
|
end
|
||||||
@ -216,6 +226,18 @@ RSpec.describe "AI Bot - Homepage", type: :system do
|
|||||||
expect(ai_pm_homepage).to have_homepage
|
expect(ai_pm_homepage).to have_homepage
|
||||||
expect(sidebar).to have_no_section_link(pm.title)
|
expect(sidebar).to have_no_section_link(pm.title)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it "Allows choosing persona and LLM" do
|
||||||
|
ai_pm_homepage.visit
|
||||||
|
|
||||||
|
ai_pm_homepage.persona_selector.expand
|
||||||
|
ai_pm_homepage.persona_selector.select_row_by_name(persona.name)
|
||||||
|
ai_pm_homepage.persona_selector.collapse
|
||||||
|
|
||||||
|
ai_pm_homepage.llm_selector.expand
|
||||||
|
ai_pm_homepage.llm_selector.select_row_by_name(claude_2_dup.display_name)
|
||||||
|
ai_pm_homepage.llm_selector.collapse
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context "when `ai_enable_experimental_bot_ux` is disabled" do
|
context "when `ai_enable_experimental_bot_ux` is disabled" do
|
||||||
|
@ -5,6 +5,10 @@ module PageObjects
|
|||||||
class AiPmHomepage < PageObjects::Components::Base
|
class AiPmHomepage < PageObjects::Components::Base
|
||||||
HOMEPAGE_WRAPPER_CLASS = ".ai-bot-conversations__content-wrapper"
|
HOMEPAGE_WRAPPER_CLASS = ".ai-bot-conversations__content-wrapper"
|
||||||
|
|
||||||
|
def visit
|
||||||
|
page.visit("/discourse-ai/ai-bot/conversations")
|
||||||
|
end
|
||||||
|
|
||||||
def input
|
def input
|
||||||
page.find("#ai-bot-conversations-input")
|
page.find("#ai-bot-conversations-input")
|
||||||
end
|
end
|
||||||
@ -27,6 +31,14 @@ module PageObjects
|
|||||||
def has_no_homepage?
|
def has_no_homepage?
|
||||||
page.has_no_css?(HOMEPAGE_WRAPPER_CLASS)
|
page.has_no_css?(HOMEPAGE_WRAPPER_CLASS)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def persona_selector
|
||||||
|
PageObjects::Components::SelectKit.new(".persona-llm-selector__persona-dropdown")
|
||||||
|
end
|
||||||
|
|
||||||
|
def llm_selector
|
||||||
|
PageObjects::Components::SelectKit.new(".persona-llm-selector__llm-dropdown")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
Loading…
x
Reference in New Issue
Block a user