FEATURE: Allow for persona & llm selection in bot conversations page (#1276)

This commit is contained in:
Mark VanLandingham 2025-04-24 11:17:24 -05:00 committed by GitHub
parent 18dda31412
commit b7b9179bc8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 439 additions and 298 deletions

View File

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

View File

@ -1,10 +1,7 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { hash } from "@ember/helper";
import { next } from "@ember/runloop";
import { action } from "@ember/object";
import { service } from "@ember/service";
import KeyValueStore from "discourse/lib/key-value-store";
import DropdownSelectBox from "select-kit/components/dropdown-select-box";
import AiPersonaLlmSelector from "discourse/plugins/discourse-ai/discourse/components/ai-persona-llm-selector";
function isBotMessage(composer, currentUser) {
if (
@ -30,175 +27,21 @@ export default class BotSelector extends Component {
}
@service currentUser;
@service siteSettings;
@tracked llm;
@tracked allowLLMSelector = true;
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 };
this.setAllowLLMSelector();
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();
});
}
@action
setPersonaIdOnComposer(id) {
this.args.outletArgs.model.metaData = { ai_persona_id: id };
}
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));
@action
setTargetRecipientsOnComposer(username) {
this.args.outletArgs.model.set("targetRecipients", username);
}
<template>
<div class="persona-llm-selector">
<div class="gpt-persona">
<DropdownSelectBox
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>
<AiPersonaLlmSelector
@setPersonaId={{this.setPersonaIdOnComposer}}
@setTargetRecipient={{this.setTargetRecipientsOnComposer}}
/>
</template>
}

View File

@ -1,48 +1,29 @@
import Controller from "@ember/controller";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { tracked } from "@ember-compat/tracked-built-ins";
export default class DiscourseAiBotConversations extends Controller {
@service aiBotConversationsHiddenSubmit;
@service currentUser;
@tracked selectedPersona = this.personaOptions[0].username;
textarea = null;
init() {
super.init(...arguments);
this.selectedPersonaChanged(this.selectedPersona);
}
get personaOptions() {
if (this.currentUser.ai_enabled_personas) {
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;
get loading() {
return this.aiBotConversationsHiddenSubmit?.loading;
}
@action
selectedPersonaChanged(username) {
this.selectedPersona = username;
this.aiBotConversationsHiddenSubmit.personaUsername = username;
setPersonaId(id) {
this.aiBotConversationsHiddenSubmit.personaId = id;
}
@action
setTargetRecipient(username) {
this.aiBotConversationsHiddenSubmit.targetUsername = username;
}
@action

View File

@ -1,16 +1,22 @@
import { action } from "@ember/object";
import { next } from "@ember/runloop";
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 { i18n } from "discourse-i18n";
import { composeAiBotMessage } from "../lib/ai-bot-helper";
export default class AiBotConversationsHiddenSubmit extends Service {
@service composer;
@service aiConversationsSidebarManager;
@service appEvents;
@service composer;
@service dialog;
@service router;
personaUsername;
@tracked loading = false;
personaId;
targetUsername;
inputValue = "";
@ -25,9 +31,6 @@ export default class AiBotConversationsHiddenSubmit extends Service {
@action
async submitToBot() {
this.composer.destroyDraft();
this.composer.close();
if (this.inputValue.length < 10) {
return this.dialog.alert({
message: i18n(
@ -38,25 +41,31 @@ export default class AiBotConversationsHiddenSubmit extends Service {
});
}
// we are intentionally passing null as the targetBot to allow for the
// function to select the first available bot. This will be refactored in the
// future to allow for selecting a specific bot.
await composeAiBotMessage(null, this.composer, {
skipFocus: true,
topicBody: this.inputValue,
personaUsername: this.personaUsername,
});
this.loading = true;
const title = i18n("discourse_ai.ai_bot.default_pm_prefix");
try {
await this.composer.save();
this.aiConversationsSidebarManager.newTopicForceSidebar = true;
if (this.inputValue.length > 10) {
// prevents submitting same message again when returning home
// but avoids deleting too-short message on submit
this.inputValue = "";
}
const response = await ajax("/posts.json", {
method: "POST",
data: {
raw: this.inputValue,
title,
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) {
popupAjaxError(e);
} finally {
this.loading = false;
}
}
}

View File

@ -1,50 +1,46 @@
import { hash } from "@ember/helper";
import { on } from "@ember/modifier";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import RouteTemplate from "ember-route-template";
import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
import DButton from "discourse/components/d-button";
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(
<template>
<div class="ai-bot-conversations">
{{#if @controller.displayPersonaSelector}}
<div class="ai-bot-conversations__persona-selector">
<DropdownSelectBox
class="persona-llm-selector__persona-dropdown"
@value={{@controller.selectedPersona}}
@valueProperty="username"
@content={{@controller.personaOptions}}
@options={{hash icon="angle-down"}}
@onChange={{@controller.selectedPersonaChanged}}
/>
</div>
{{/if}}
<AiPersonaLlmSelector
@showLabels={{true}}
@setPersonaId={{@controller.setPersonaId}}
@setTargetRecipient={{@controller.setTargetRecipient}}
/>
<div class="ai-bot-conversations__content-wrapper">
<h1>{{i18n "discourse_ai.ai_bot.conversations.header"}}</h1>
<div class="ai-bot-conversations__input-wrapper">
<textarea
{{didInsert @controller.setTextArea}}
{{on "input" @controller.updateInputValue}}
{{on "keydown" @controller.handleKeyDown}}
id="ai-bot-conversations-input"
placeholder={{i18n "discourse_ai.ai_bot.conversations.placeholder"}}
minlength="10"
rows="1"
/>
<DButton
@action={{@controller.aiBotConversationsHiddenSubmit.submitToBot}}
@icon="paper-plane"
@title="discourse_ai.ai_bot.conversations.header"
class="ai-bot-button btn-primary ai-conversation-submit"
/>
</div>
<p class="ai-disclaimer">
{{i18n "discourse_ai.ai_bot.conversations.disclaimer"}}
</p>
<ConditionalLoadingSpinner @condition={{@controller.loading}}>
<h1>{{i18n "discourse_ai.ai_bot.conversations.header"}}</h1>
<div class="ai-bot-conversations__input-wrapper">
<textarea
{{didInsert @controller.setTextArea}}
{{on "input" @controller.updateInputValue}}
{{on "keydown" @controller.handleKeyDown}}
id="ai-bot-conversations-input"
placeholder={{i18n
"discourse_ai.ai_bot.conversations.placeholder"
}}
minlength="10"
rows="1"
/>
<DButton
@action={{@controller.aiBotConversationsHiddenSubmit.submitToBot}}
@icon="paper-plane"
@title="discourse_ai.ai_bot.conversations.header"
class="ai-bot-button btn-primary ai-conversation-submit"
/>
</div>
<p class="ai-disclaimer">
{{i18n "discourse_ai.ai_bot.conversations.disclaimer"}}
</p>
</ConditionalLoadingSpinner>
</div>
</div>
</template>

View File

@ -53,6 +53,10 @@ export default {
this.topic = topic;
}
get key() {
return this.topic.id;
}
get name() {
return this.topic.title;
}
@ -90,13 +94,21 @@ export default {
super(...arguments);
this.fetchMessages();
appEvents.on("topic:created", this, "addNewMessageToSidebar");
appEvents.on(
"discourse-ai:bot-pm-created",
this,
"addNewPMToSidebar"
);
}
@bind
willDestroy() {
this.removeScrollListener();
appEvents.on("topic:created", this, "addNewMessageToSidebar");
appEvents.off(
"discourse-ai:bot-pm-created",
this,
"addNewPMToSidebar"
);
}
get name() {
@ -115,8 +127,8 @@ export default {
);
}
addNewMessageToSidebar(topic) {
this.addNewMessage(topic);
addNewPMToSidebar(topic) {
this.links = [new AiConversationLink(topic), ...this.links];
this.watchForTitleUpdate(topic);
}
@ -201,28 +213,25 @@ export default {
);
}
addNewMessage(newTopic) {
this.links = [new AiConversationLink(newTopic), ...this.links];
}
watchForTitleUpdate(topic) {
const channel = `/discourse-ai/ai-bot/topic/${topic.topic_id}`;
const topicId = topic.topic_id;
const channel = `/discourse-ai/ai-bot/topic/${topic.id}`;
const callback = this.updateTopicTitle.bind(this);
messageBus.subscribe(channel, ({ title }) => {
callback(topicId, title);
callback(topic, title);
messageBus.unsubscribe(channel);
});
}
updateTopicTitle(topicId, title) {
// update the topic title in the sidebar, instead of the default title
const text = document.querySelector(
`.sidebar-section-link-wrapper .ai-conversation-${topicId} .sidebar-section-link-content-text`
updateTopicTitle(topic, title) {
// update the data
topic.title = title;
// 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 (
topic?.archetype === "private_message" &&
topic.user_id === currentUser.id &&
topic.postStream.posts.some((post) =>
isPostFromAiBot(post, currentUser)
)
(topic.is_bot_pm ||
topic.postStream.posts.some((post) =>
isPostFromAiBot(post, currentUser)
))
) {
return aiConversationsSidebarManager.forceCustomSidebar();
}

View File

@ -153,25 +153,40 @@ body.has-ai-conversations-sidebar {
.ai-bot-conversations {
height: calc(100dvh - var(--header-offset) - 1.25em);
&__persona-selector {
.persona-llm-selector {
display: flex;
gap: 0.5em;
justify-content: flex-start;
}
&__persona-selector .btn {
background-color: transparent;
font-weight: bold;
}
&__selection-wrapper {
display: flex;
flex-direction: column;
&__persona-selector .btn:hover,
&__persona-selector .btn:focus {
background-color: transparent;
color: var(--primary);
}
label {
font-size: var(--font-down-1);
font-weight: 300;
margin-left: 1em;
margin-bottom: 0;
}
}
&__persona-selector .btn:hover .d-icon,
&__persona-selector .btn:focus .d-icon {
color: var(--primary);
.btn {
display: flex;
justify-content: flex-start;
background-color: transparent;
font-weight: bold;
}
.btn:hover,
.btn:focus {
background-color: transparent;
color: var(--primary);
}
.btn:hover .d-icon,
.btn:focus .d-icon {
color: var(--primary);
}
}
&__content-wrapper {
@ -185,6 +200,10 @@ body.has-ai-conversations-sidebar {
@include viewport.until(sm) {
height: calc(75% - 1.25em - var(--header-offset) - 2em);
}
.loading-container {
display: contents;
}
}
&__input-wrapper {

View File

@ -664,6 +664,8 @@ en:
click_to_run_label: "Run Artifact"
ai_bot:
persona: "Persona"
llm: "Model"
pm_warning: "AI chatbot messages are monitored regularly by moderators."
cancel_streaming: "Stop reply"
default_pm_prefix: "[Untitled AI bot PM]"

View File

@ -3,6 +3,7 @@
module DiscourseAi
module AiBot
USER_AGENT = "Discourse AI Bot 1.0 (https://www.discourse.org)"
TOPIC_AI_BOT_PM_FIELD = "is_ai_bot_pm"
class EntryPoint
Bot = Struct.new(:id, :name, :llm)
@ -64,6 +65,34 @@ module DiscourseAi
end
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|
if guardian.user
allowed_chat =
@ -102,6 +131,14 @@ module DiscourseAi
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(
:current_user,
:ai_enabled_personas,

View File

@ -116,11 +116,7 @@ module DiscourseAi
base_query
.joins(:user)
.joins("LEFT JOIN llm_models ON llm_models.name = language_model")
.group(
:user_id,
"users.username",
"users.uploaded_avatar_id",
)
.group(:user_id, "users.username", "users.uploaded_avatar_id")
.order("usage_count DESC")
.limit(USER_LIMIT)
.select(
@ -140,9 +136,7 @@ module DiscourseAi
def feature_breakdown
base_query
.joins("LEFT JOIN llm_models ON llm_models.name = language_model")
.group(
:feature_name
)
.group(:feature_name)
.order("usage_count DESC")
.select(
"case when coalesce(feature_name, '') = '' then '#{UNKNOWN_FEATURE}' else feature_name end as feature_name",

View File

@ -35,6 +35,20 @@ RSpec.describe DiscourseAi::AiBot::EntryPoint do
expect(serializer[:current_user][:can_debug_ai_bot_conversations]).to eq(true)
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
Group.refresh_automatic_groups!

View File

@ -18,10 +18,20 @@ RSpec.describe "AI Bot - Homepage", type: :system do
provider: "anthropic",
url: "https://api.anthropic.com/v1/messages",
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
fab!(:bot_user) do
toggle_enabled_bots(bots: [claude_2])
toggle_enabled_bots(bots: [claude_2, claude_2_dup])
SiteSetting.ai_bot_enabled = true
claude_2.reload.user
end
@ -216,6 +226,18 @@ RSpec.describe "AI Bot - Homepage", type: :system do
expect(ai_pm_homepage).to have_homepage
expect(sidebar).to have_no_section_link(pm.title)
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
context "when `ai_enable_experimental_bot_ux` is disabled" do

View File

@ -5,6 +5,10 @@ module PageObjects
class AiPmHomepage < PageObjects::Components::Base
HOMEPAGE_WRAPPER_CLASS = ".ai-bot-conversations__content-wrapper"
def visit
page.visit("/discourse-ai/ai-bot/conversations")
end
def input
page.find("#ai-bot-conversations-input")
end
@ -27,6 +31,14 @@ module PageObjects
def has_no_homepage?
page.has_no_css?(HOMEPAGE_WRAPPER_CLASS)
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