Compare commits

...
Author SHA1 Message Date
Keegan George a4194d3fb2 FIX: AI preferences tab button not appearing unless Helper enabled (#1452)
This update fixes an issue where the AI user preferences tab was not appearing unless `SiteSetting.ai_helper_enabled` was `true`. This is because we previously checked for it's presence when user preferences only had a single setting related to Helper. However, since then, we've also added search discoveries setting there too. As such, we don't want it to depend on Helper. We also sneak in this update a modernization of converting the preferences template from `.hbs` to `.gjs`.
2025-06-20 10:12:08 -07:00
Sam eab6dd3f8e DEV: re-implement bulk sentiment classifier (#1449)
New implementation uses core concurrent job queue, it is more
robust and predictable than the one shipped in Concurrent.

Additionally:

- Trickles through updates during bulk classification
- Reports errors if we fail during a bulk classification

* push concurrency down to 40. 100 feels quite high.
2025-06-20 16:06:03 +10:00
Keegan GeorgeandJoffrey JAFFEUX baaa3d199a FIX: streaming related specs (#1448)
## 🔍 Overview
This update fixes an issue where message bus streaming related specs
were not working correctly. To do so we pass the `last_id` when
subscribing to `MessageBus` which allows us to unskip those broken
tests.

---------

Co-authored-by: Joffrey JAFFEUX <[email protected]>
2025-06-19 07:41:18 -07:00
Joffrey JAFFEUXandKeegan George 6a33e5154d DEV: makes ai menu helper a standalone menu (#1434)
The current menu was rendering inside the post text toolbar (on desktop). This is not ideal as the post text toolbar rendering is conditioned on the presence of text selection, when you click a button on the toolbar, by design of the web browsers you will lose your text selection, making all of this super tricky.

This commit makes desktop and mobile behave in the same way by rendering their own menu and capturing the quote state when we render the post text selection toolbar, this allows us to reason a much simpler way about the AI helper.

This commit also removes what appears to be an unused file and corrects which was seemingly copy/paste mistakes.

⚠️ Technical note, this commit is correcting the message bus subscription which amongst other things allows to write specs which are not flaky. However due to the current implementation we have a channel per post, which means we need to serialize on last message bus id per post. 

We have two possible solutions here:
- subscribe at the topic level
- refactor the code to be able to use `MessageBus.last_ids` to be able to grab multiple posts at once instead of having to call `MessageBus.last_id` and done one Redis call per post

---------

Co-authored-by: Keegan George <[email protected]>
2025-06-19 11:56:00 +02:00
Sam 37dbd48513 FIX: implement max_output tokens (anthropic/openai/bedrock/gemini/open router) (#1447)
* FIX: implement max_output tokens (anthropic/openai/bedrock/gemini/open router)

Previously this feature existed but was not implemented
Also updates a bunch of models to in our preset to point to latest

* implementing in base is safer, simpler and easier to manage

* anthropic 3.5 is getting older, lets use 4.0 here and fix spec
2025-06-19 16:00:11 +10:00
Natalie Tay 3e87e92631 DEV: Remove 'experimental' from translation features (#1439)
* DEV: Remove 'experimental' from translation features

* include compat

* include compat
2025-06-19 12:23:56 +08:00
Mark VanLandingham cd14b0c0be FIX: Bring back empty state message when appropriate (#1446)
The Today section was added always, but a side-effect was that we hid the empty state component. This commit brings back the empty state
2025-06-18 17:34:08 -05:00
Keegan George cea8fd423e FIX: unable to scroll AI bot persona selector (#1445)
This update fixes a UX issue on the AI bot conversations page where the persona selector dropdown doesn't scroll when there are many items and the viewport is small.

No tests as it's tricky to test scrolling.
2025-06-18 12:20:50 -07:00
Natalie Tay d7a2af5505 DEV: Prevent multiple translation per post (#1443)
We're seeing an aggressive number of translations being enqueued for a single post and locale. Historically, we trigger translation on `cooked` not `raw`, but that has changed a while back.

```
# from AiApiAuditLog, the same post is getting translated to the same locale within a few secs of each other
zh_CN - 2025-06-17 13:02:31 UTC
zh_CN - 2025-06-17 13:02:34 UTC
zh_CN - 2025-06-17 13:02:35 UTC
zh_CN - 2025-06-17 13:02:36 UTC
zh_CN - 2025-06-17 13:02:38 UTC
zh_CN - 2025-06-17 13:02:39 UTC
zh_CN - 2025-06-17 13:02:40 UTC
zh_CN - 2025-06-17 13:02:40 UTC
zh_CN - 2025-06-17 13:02:43 UTC
zh_CN - 2025-06-17 13:02:44 UTC
```

This PR prevents this from happening.
2025-06-18 13:24:02 +08:00
Discourse Translator Bot 6dbe19a772 Update translations (#1441) 2025-06-17 23:07:50 +02:00
Keegan George 62d746662a FIX: Cleanup properties on closing DiffModal (#1442)
This update ensures that we reset the tracked properties when closing the DiffModal so that the state doesn't leak when triggering the AI suggestions again. We also reset before suggesting new changes, thus if regeneration is called there shouldn't be any leaks either.

No tests in this PR as tests currently not working great due to streaming/animation issues. Will do a broader PR following up with various specs to improve test coverage here.
2025-06-17 13:57:46 -07:00
Rafael dos Santos Silva 9dccc1eb93 FEATURE: Add Qwen3 tokenizer and update Gemma to version 3 (#1440) 2025-06-17 10:25:03 -03:00
Natalie Tay df925f8304 DEV: Move examples out of prompt (#1438)
* DEV: Move examples out of prompt
2025-06-17 16:12:52 +08:00
147 changed files with 3138022 additions and 839426 deletions
+1
View File
@@ -1,3 +1,4 @@
< 3.5.0.beta7-dev: cd14b0c0bee0cf63c59b64b6f7213e31a37f11a7
< 3.5.0.beta6-dev: 3e74eea1e5e3143888d67a8d8a11206df214dc24
< 3.5.0.beta3-dev: 09a68414804a1447f52e5d60691ba59742cda9ec
< 3.5.0.beta2-dev: de8624416a15b3d8e7ad350b083cc1420451ccec
+2 -1
View File
@@ -2,6 +2,7 @@
module Jobs
class DetectTranslatePost < ::Jobs::Base
cluster_concurrency 1
sidekiq_options retry: false
def execute(args)
@@ -35,7 +36,7 @@ module Jobs
end
end
locales = SiteSetting.experimental_content_localization_supported_locales.split("|")
locales = SiteSetting.content_localization_supported_locales.split("|")
return if locales.blank?
locales.each do |locale|
+2 -1
View File
@@ -2,6 +2,7 @@
module Jobs
class DetectTranslateTopic < ::Jobs::Base
cluster_concurrency 1
sidekiq_options retry: false
def execute(args)
@@ -33,7 +34,7 @@ module Jobs
end
end
locales = SiteSetting.experimental_content_localization_supported_locales.split("|")
locales = SiteSetting.content_localization_supported_locales.split("|")
return if locales.blank?
locales.each do |locale|
+1 -1
View File
@@ -11,7 +11,7 @@ module Jobs
return if !SiteSetting.discourse_ai_enabled
return if !SiteSetting.ai_translation_enabled
locales = SiteSetting.experimental_content_localization_supported_locales.split("|")
locales = SiteSetting.content_localization_supported_locales.split("|")
return if locales.blank?
cat_id = args[:from_category_id] || Category.order(:id).first&.id
+1 -1
View File
@@ -11,7 +11,7 @@ module Jobs
return if !SiteSetting.discourse_ai_enabled
return if !SiteSetting.ai_translation_enabled
locales = SiteSetting.experimental_content_localization_supported_locales.split("|")
locales = SiteSetting.content_localization_supported_locales.split("|")
return if locales.blank?
limit = args[:limit] || BATCH_SIZE
+1 -1
View File
@@ -11,7 +11,7 @@ module Jobs
return if !SiteSetting.discourse_ai_enabled
return if !SiteSetting.ai_translation_enabled
locales = SiteSetting.experimental_content_localization_supported_locales.split("|")
locales = SiteSetting.content_localization_supported_locales.split("|")
return if locales.blank?
limit = args[:limit] || BATCH_SIZE
@@ -8,7 +8,7 @@ module Jobs
def execute(args)
return if !SiteSetting.discourse_ai_enabled
return if !SiteSetting.ai_translation_enabled
return if SiteSetting.experimental_content_localization_supported_locales.blank?
return if SiteSetting.content_localization_supported_locales.blank?
Jobs.enqueue(:localize_categories)
end
@@ -9,7 +9,7 @@ module Jobs
return if !SiteSetting.discourse_ai_enabled
return if !SiteSetting.ai_translation_enabled
return if SiteSetting.experimental_content_localization_supported_locales.blank?
return if SiteSetting.content_localization_supported_locales.blank?
return if SiteSetting.ai_translation_backfill_rate == 0
Jobs.enqueue(:localize_posts, limit: SiteSetting.ai_translation_backfill_rate)
@@ -9,7 +9,7 @@ module Jobs
return if !SiteSetting.discourse_ai_enabled
return if !SiteSetting.ai_translation_enabled
return if SiteSetting.experimental_content_localization_supported_locales.blank?
return if SiteSetting.content_localization_supported_locales.blank?
return if SiteSetting.ai_translation_backfill_rate == 0
Jobs.enqueue(:localize_topics, limit: SiteSetting.ai_translation_backfill_rate)
+1
View File
@@ -24,6 +24,7 @@ class EmbeddingDefinition < ActiveRecord::Base
DiscourseAi::Tokenizer::MultilingualE5LargeTokenizer,
DiscourseAi::Tokenizer::OpenAiTokenizer,
DiscourseAi::Tokenizer::MixtralTokenizer,
DiscourseAi::Tokenizer::QwenTokenizer,
].map(&:name)
end
@@ -32,7 +32,7 @@ export default class AiLlmsListEditor extends Component {
key = `${llm.provider}-${llm.name}`;
} else {
// case of preset
key = llm.id.replace(/\./g, "-");
key = llm.id.replace(/[.:\/]/g, "-");
}
key = `discourse_ai.llms.model_description.${key}`;
@@ -14,7 +14,6 @@ import concatClass from "discourse/helpers/concat-class";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { bind } from "discourse/lib/decorators";
import { withPluginApi } from "discourse/lib/plugin-api";
import { sanitize } from "discourse/lib/text";
import { clipboardCopy } from "discourse/lib/utilities";
import { i18n } from "discourse-i18n";
@@ -44,6 +43,9 @@ export default class AiPostHelperMenu extends Component {
@tracked lastSelectedOption = null;
@tracked isSavingFootnote = false;
@tracked supportsAddFootnote = this.args.data.supportsFastEdit;
@tracked
channel =
`/discourse-ai/ai-helper/stream_suggestion/${this.args.data.quoteState.postId}`;
@tracked
smoothStreamer = new SmoothStreamer(
@@ -78,23 +80,6 @@ export default class AiPostHelperMenu extends Component {
@tracked _activeAiRequest = null;
constructor() {
super(...arguments);
withPluginApi((api) => {
api.registerValueTransformer(
"post-text-selection-prevent-close",
({ value }) => {
if (this.menuState === this.MENU_STATES.result) {
return true;
}
return value;
}
);
});
}
get footnoteDisabled() {
return this.streaming || !this.supportsAddFootnote;
}
@@ -167,16 +152,17 @@ export default class AiPostHelperMenu extends Component {
@bind
subscribe() {
const channel = `/discourse-ai/ai-helper/stream_suggestion/${this.args.data.quoteState.postId}`;
this.messageBus.subscribe(channel, this._updateResult);
this.messageBus.subscribe(
this.channel,
(data) => this._updateResult(data),
this.args.data.post
.discourse_ai_helper_stream_suggestion_last_message_bus_id
);
}
@bind
unsubscribe() {
this.messageBus.unsubscribe(
"/discourse-ai/ai-helper/stream_suggestion/*",
this._updateResult
);
this.messageBus.unsubscribe(this.channel, this._updateResult);
}
@bind
@@ -239,7 +225,7 @@ export default class AiPostHelperMenu extends Component {
data: {
location: "post",
mode: option.name,
text: this.args.data.selectedText,
text: this.args.data.quoteState.buffer,
post_id: this.args.data.quoteState.postId,
custom_prompt: this.customPromptValue,
client_id: this.messageBus.clientId,
@@ -292,12 +278,10 @@ export default class AiPostHelperMenu extends Component {
@action
closeMenu() {
if (this.site.mobileView) {
return this.args.close();
}
const menu = this.menu.getByIdentifier("post-text-selection-toolbar");
return menu?.close();
// reset state and close
this.suggestion = "";
this.customPromptValue = "";
return this.args.close();
}
@action
@@ -317,9 +301,9 @@ export default class AiPostHelperMenu extends Component {
const credits = i18n(
"discourse_ai.ai_helper.post_options_menu.footnote_credits"
);
const withFootnote = `${this.args.data.selectedText} ^[${sanitizedSuggestion} (${credits})]`;
const withFootnote = `${this.args.data.quoteState.buffer} ^[${sanitizedSuggestion} (${credits})]`;
const newRaw = result.raw.replace(
this.args.data.selectedText,
this.args.data.quoteState.buffer,
withFootnote
);
@@ -338,7 +322,7 @@ export default class AiPostHelperMenu extends Component {
(and this.site.mobileView (eq this.menuState this.MENU_STATES.options))
}}
<div class="ai-post-helper-menu__selected-text">
{{@data.selectedText}}
{{@data.quoteState.buffer}}
</div>
{{/if}}
@@ -85,14 +85,18 @@ export default class ModalDiffModal extends Component {
@bind
subscribe() {
this.messageBus.subscribe(CHANNEL, this.updateResult);
this.messageBus.subscribe(
CHANNEL,
this.updateResult,
this.currentUser
?.discourse_ai_helper_stream_composer_suggestion_last_message_bus_id
);
}
@bind
cleanup() {
// stop all callbacks so it does not end up streaming pointlessly
this.smoothStreamer.resetStreaming();
this.diffStreamer.reset();
this.#resetState();
this.messageBus.unsubscribe(CHANNEL, this.updateResult);
}
@@ -114,8 +118,7 @@ export default class ModalDiffModal extends Component {
@action
async suggestChanges() {
this.smoothStreamer.resetStreaming();
this.diffStreamer.reset();
this.#resetState();
try {
this.loading = true;
@@ -159,11 +162,25 @@ export default class ModalDiffModal extends Component {
}
}
@action
cleanupAndClose() {
this.#resetState();
this.loading = false;
this.args.closeModal();
}
#resetState() {
this.suggestion = "";
this.finalResult = "";
this.smoothStreamer.resetStreaming();
this.diffStreamer.reset();
}
<template>
<DModal
class="composer-ai-helper-modal"
@title={{i18n "discourse_ai.ai_helper.context_menu.changes"}}
@closeModal={{@closeModal}}
@closeModal={{this.cleanupAndClose}}
>
<:body>
<div
@@ -218,7 +235,7 @@ export default class ModalDiffModal extends Component {
</DButton>
<DButton
class="btn-flat discard"
@action={{@closeModal}}
@action={{this.cleanupAndClose}}
@label="discourse_ai.ai_helper.context_menu.discard"
/>
<DButton
@@ -3,8 +3,7 @@ import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { service } from "@ember/service";
import DButton from "discourse/components/d-button";
import virtualElementFromTextRange from "discourse/lib/virtual-element-from-text-range";
import eq from "truth-helpers/helpers/eq";
import { selectedRange } from "discourse/lib/utilities";
import AiPostHelperMenu from "../../components/ai-post-helper-menu";
import { showPostAIHelper } from "../../lib/show-ai-helper";
@@ -13,27 +12,27 @@ export default class AiPostHelperTrigger extends Component {
return showPostAIHelper(outletArgs, helper);
}
@service site;
@service menu;
@tracked menuState = this.MENU_STATES.triggers;
@tracked showMainButtons = true;
@tracked showAiButtons = true;
@tracked postHighlighted = false;
@tracked currentMenu = this.menu.getByIdentifier(
"post-text-selection-toolbar"
);
MENU_STATES = {
triggers: "TRIGGERS",
options: "OPTIONS",
// capture the state at the moment the toolbar is rendered
// so we ensure change of state (selection change for example)
// is not impacting the menu data
menuData = {
...this.args.outletArgs.data,
quoteState: {
buffer: this.args.outletArgs.data.quoteState.buffer,
opts: this.args.outletArgs.data.quoteState.opts,
postId: this.args.outletArgs.data.quoteState.postId,
},
post: this.args.outletArgs.post,
selectedRange: selectedRange(),
};
willDestroy() {
super.willDestroy(...arguments);
this.removeHighlightedText();
}
highlightSelectedText() {
const postId = this.args.outletArgs.data.quoteState.postId;
const postElement = document.querySelector(
@@ -44,14 +43,7 @@ export default class AiPostHelperTrigger extends Component {
return;
}
this.selectedText = this.args.outletArgs.data.quoteState.buffer;
const selection = window.getSelection();
if (!selection.rangeCount) {
return;
}
const range = selection.getRangeAt(0);
const range = this.menuData.selectedRange;
// Split start/end text nodes at their range boundary
if (
@@ -97,11 +89,10 @@ export default class AiPostHelperTrigger extends Component {
// Replace textNode with highlighted clone
const clone = textNode.cloneNode(true);
highlight.appendChild(clone);
textNode.parentNode.replaceChild(highlight, textNode);
}
selection.removeAllRanges();
window.getSelection().removeAllRanges();
this.postHighlighted = true;
}
@@ -110,16 +101,7 @@ export default class AiPostHelperTrigger extends Component {
return;
}
const postId = this.args.outletArgs.data.quoteState.postId;
const postElement = document.querySelector(
`article[data-post-id='${postId}'] .cooked`
);
if (!postElement) {
return;
}
const highlightedSpans = postElement.querySelectorAll(
const highlightedSpans = document.querySelectorAll(
"span.ai-helper-highlighted-selection"
);
@@ -133,65 +115,40 @@ export default class AiPostHelperTrigger extends Component {
@action
async showAiPostHelperMenu() {
this.highlightSelectedText();
if (this.site.mobileView) {
this.currentMenu.close();
await this.currentMenu.close();
await this.menu.show(virtualElementFromTextRange(), {
identifier: "ai-post-helper-menu",
component: AiPostHelperMenu,
inline: true,
interactive: true,
placement: this.shouldRenderUnder ? "bottom-start" : "top-start",
fallbackPlacements: this.shouldRenderUnder
? ["bottom-end", "top-start"]
: ["bottom-start"],
trapTab: false,
closeOnScroll: false,
modalForMobile: true,
data: this.menuData,
});
}
this.showMainButtons = false;
this.menuState = this.MENU_STATES.options;
}
get menuData() {
// Streamline of data model to be passed to the component when
// instantiated as a DMenu or a simple component in the template
return {
...this.args.outletArgs.data,
quoteState: {
buffer: this.args.outletArgs.data.quoteState.buffer,
opts: this.args.outletArgs.data.quoteState.opts,
postId: this.args.outletArgs.data.quoteState.postId,
await this.menu.show(this.currentMenu.trigger, {
identifier: "ai-post-helper-menu",
component: AiPostHelperMenu,
interactive: true,
trapTab: false,
closeOnScroll: false,
modalForMobile: true,
data: this.menuData,
placement: "top-start",
fallbackPlacements: ["bottom-start"],
updateOnScroll: false,
onClose: () => {
this.removeHighlightedText();
},
post: this.args.outletArgs.post,
selectedText: this.selectedText,
};
});
await this.currentMenu.destroy();
this.highlightSelectedText();
}
<template>
{{#if this.showMainButtons}}
{{yield}}
{{/if}}
{{yield}}
{{#if this.showAiButtons}}
<div class="ai-post-helper">
{{#if (eq this.menuState this.MENU_STATES.triggers)}}
<DButton
@icon="discourse-sparkles"
@title="discourse_ai.ai_helper.post_options_menu.title"
@label="discourse_ai.ai_helper.post_options_menu.trigger"
@action={{this.showAiPostHelperMenu}}
class="btn-flat ai-post-helper__trigger"
/>
{{else if (eq this.menuState this.MENU_STATES.options)}}
<AiPostHelperMenu @data={{this.menuData}} />
{{/if}}
</div>
{{/if}}
<div class="ai-post-helper">
<DButton
@icon="discourse-sparkles"
@title="discourse_ai.ai_helper.post_options_menu.title"
@label="discourse_ai.ai_helper.post_options_menu.trigger"
@action={{this.showAiPostHelperMenu}}
class="btn-flat ai-post-helper__trigger"
/>
</div>
</template>
}
@@ -3,27 +3,9 @@ import { LinkTo } from "@ember/routing";
import dIcon from "discourse/helpers/d-icon";
import { i18n } from "discourse-i18n";
function showAiPreferences(user, settings) {
// Since we only have one AI related user setting we don't show
// AI preferences if these conditions aren't met.
// If we add more user settings in the future we can move this
// logic to the the specific settings and conditionally show it in the template.
const aiHelperEnabledFeatures =
settings.ai_helper_enabled_features.split("|");
return (
user?.user_allowed_ai_auto_image_captions &&
aiHelperEnabledFeatures.includes("image_caption") &&
settings.ai_helper_enabled
);
}
export default class AutoImageCaptionSetting extends Component {
static shouldRender(outletArgs, helper) {
return (
helper.siteSettings.discourse_ai_enabled &&
showAiPreferences(outletArgs.model, helper.siteSettings)
);
return helper.siteSettings.discourse_ai_enabled;
}
<template>
@@ -1,45 +0,0 @@
class VirtualElementFromCaretCoords {
constructor(caretCoords, offset = [0, 0]) {
this.caretCoords = caretCoords;
this.offset = offset;
this.updateRect();
}
updateRect() {
const [xOffset, yOffset] = this.offset;
this.rect = {
top: this.caretCoords.y + yOffset,
right: this.caretCoords.x,
bottom: this.caretCoords.y + yOffset,
left: this.caretCoords.x + xOffset,
width: 0,
height: 0,
x: this.caretCoords.x,
y: this.caretCoords.y,
toJSON() {
return this;
},
};
return this.rect;
}
getBoundingClientRect() {
return this.rect;
}
getClientRects() {
return [this.rect];
}
get clientWidth() {
return this.rect.width;
}
get clientHeight() {
return this.rect.height;
}
}
export default function virtualElementFromCaretCoords(caretCoords, offset) {
return new VirtualElementFromCaretCoords(caretCoords, offset);
}
@@ -84,6 +84,13 @@ export default class AiConversationsSidebarManager extends Service {
);
}
addEmptyStateClass() {
document.body.classList.toggle(
"has-empty-ai-conversations-sidebar",
!this.topics.length
);
}
forceCustomSidebar() {
document.body.classList.add("has-ai-conversations-sidebar");
if (!this.sidebarState.isForcingSidebar) {
@@ -100,6 +107,7 @@ export default class AiConversationsSidebarManager extends Service {
// don't render sidebar multiple times
if (this._didInit) {
this.addEmptyStateClass();
return true;
}
@@ -107,6 +115,7 @@ export default class AiConversationsSidebarManager extends Service {
this.fetchMessages().then(() => {
this.sidebarState.setPanel(AI_CONVERSATIONS_PANEL);
this.addEmptyStateClass();
});
return true;
@@ -140,6 +149,7 @@ export default class AiConversationsSidebarManager extends Service {
stopForcingCustomSidebar() {
document.body.classList.remove("has-ai-conversations-sidebar");
document.body.classList.remove("has-empty-ai-conversations-sidebar");
const isAdmin = this.sidebarState.currentPanel?.key === ADMIN_PANEL;
if (this.sidebarState.isForcingSidebar && !isAdmin) {
@@ -0,0 +1,40 @@
import { get } from "@ember/helper";
import RouteTemplate from "ember-route-template";
import { eq } from "truth-helpers";
import PreferenceCheckbox from "discourse/components/preference-checkbox";
import SaveControls from "discourse/components/save-controls";
import { i18n } from "discourse-i18n";
export default RouteTemplate(
<template>
<div class="ai-user-preferences">
<legend class="control-label">{{i18n "discourse_ai.title"}}</legend>
{{#each @controller.booleanSettings as |setting|}}
{{#if setting.isIncluded}}
<div class="control-group ai-setting">
<PreferenceCheckbox
@labelKey={{setting.label}}
@checked={{get @controller.model.user_option setting.key}}
data-setting-name={{setting.settingName}}
class="pref-{{setting.settingName}}"
/>
</div>
{{/if}}
{{/each}}
{{#if (eq @controller.userSettingAttributes.length 0)}}
{{i18n "discourse_ai.user_preferences.empty"}}
{{/if}}
{{#unless (eq @controller.userSettingAttributes.length 0)}}
<SaveControls
@id="user_ai_preference_save"
@model={{@controller.model}}
@action={{@controller.save}}
@saved={{@controller.saved}}
/>
{{/unless}}
</div>
</template>
);
@@ -1,29 +0,0 @@
<div class="ai-user-preferences">
<legend class="control-label">{{i18n "discourse_ai.title"}}</legend>
{{#each this.booleanSettings as |setting|}}
{{#if setting.isIncluded}}
<div class="control-group ai-setting">
<PreferenceCheckbox
@labelKey={{setting.label}}
@checked={{get this.model.user_option setting.key}}
data-setting-name={{setting.settingName}}
class="pref-{{setting.settingName}}"
/>
</div>
{{/if}}
{{/each}}
{{#if (eq this.userSettingAttributes.length 0)}}
{{i18n "discourse_ai.user_preferences.empty"}}
{{/if}}
{{#unless (eq this.userSettingAttributes.length 0)}}
<SaveControls
@id="user_ai_preference_save"
@model={{this.model}}
@action={{this.save}}
@saved={{this.saved}}
/>
{{/unless}}
</div>
@@ -23,8 +23,20 @@ body.has-ai-conversations-sidebar {
}
}
// we always have the "today" section rendered at the top of the sidebar but hide it when empty
.sidebar-section[data-section-name="today"]:has(.ai-bot-sidebar-empty-state) {
// When the sidebar is empty, we want to hide the "Today" header
// and only show the empty state component
&.has-empty-ai-conversations-sidebar
.sidebar-section[data-section-name="today"]
.sidebar-section-header-wrapper {
display: none;
}
// When the sidebar isn't empty, BUT "today" is empty, hide
// the entire section
&:not(.has-empty-ai-conversations-sidebar)
.sidebar-section[data-section-name="today"]:has(
.ai-bot-sidebar-empty-state
) {
display: none;
}
@@ -125,6 +137,13 @@ body.has-ai-conversations-sidebar {
gap: 0.5em;
justify-content: flex-start;
.select-kit-body {
.select-kit-collection {
max-height: 50vh;
overflow-y: auto;
}
}
&__selection-wrapper {
display: flex;
flex-direction: column;
+2 -1
View File
@@ -300,6 +300,8 @@ ar:
open_modal: "تعديل"
modal:
key_title: "المفتاح"
filters:
reset: "إعادة التعيين"
rag:
options:
rag_chunk_tokens: "تحميل أجزاء الرموز"
@@ -478,7 +480,6 @@ ar:
trigger: "اسأل الذكاء الاصطناعي"
loading: "الذكاء الاصطناعي قيد إنشاء المحتوى"
cancel: "إلغاء"
regen: "إعادة المحاولة"
confirm: "تأكيد"
discard: "تجاهل"
changes: "التعديلات المقترحة"
+2 -1
View File
@@ -60,6 +60,8 @@ be:
delete: "выдаляць"
response_format:
open_modal: "рэдагаваць"
filters:
reset: "скінуць"
rag:
uploads:
title: "Загрузкі"
@@ -93,7 +95,6 @@ be:
ai_helper:
context_menu:
cancel: "адмяніць"
regen: "Паспрабаваць яшчэ раз"
post_options_menu:
close: "зачыніць"
copy: "капіяваць"
+2 -1
View File
@@ -71,6 +71,8 @@ bg:
open_modal: "Редактирай"
modal:
key_title: "Ключ"
filters:
reset: "Нулиране"
rag:
uploads:
uploading: "Качва се..."
@@ -111,7 +113,6 @@ bg:
ai_helper:
context_menu:
cancel: "Прекрати"
regen: "Опитайте отново"
discard: "Отхвърляне"
post_options_menu:
close: "Затвори"
+2 -1
View File
@@ -66,6 +66,8 @@ bs_BA:
open_modal: "Edit"
modal:
key_title: "Ključ"
filters:
reset: "Resetovati"
rag:
uploads:
title: "Uploads"
@@ -101,7 +103,6 @@ bs_BA:
ai_helper:
context_menu:
cancel: "Odustani"
regen: "Pokušaj ponovo"
discard: "Odbaci"
post_options_menu:
close: "Zatvori"
+2 -1
View File
@@ -69,6 +69,8 @@ ca:
open_modal: "Edita"
modal:
key_title: "Clau"
filters:
reset: "Restableix"
rag:
uploads:
title: "Càrregues"
@@ -105,7 +107,6 @@ ca:
ai_helper:
context_menu:
cancel: "Cancel·la"
regen: "Torna-ho a intentar"
confirm: "Confirma"
discard: "Descarta"
post_options_menu:
+2 -1
View File
@@ -79,6 +79,8 @@ cs:
open_modal: "Upravit"
modal:
key_title: "Klíč"
filters:
reset: "obnovit výchozí"
rag:
uploads:
title: "Nahrané soubory"
@@ -152,7 +154,6 @@ cs:
trigger: "Zeptejte se AI"
loading: "Umělá inteligence generuje"
cancel: "Zrušit"
regen: "Zkusit znovu"
confirm: "Potvrdit"
discard: "Zrušit"
changes: "Navrhované úpravy"
+2 -1
View File
@@ -74,6 +74,8 @@ da:
open_modal: "Rediger"
modal:
key_title: "Nøgle"
filters:
reset: "Nulstil"
rag:
uploads:
title: "Overførsler"
@@ -117,7 +119,6 @@ da:
ai_helper:
context_menu:
cancel: "Annuller"
regen: "Prøv igen"
confirm: "Bekræft"
discard: "Kassér"
post_options_menu:
+6 -1
View File
@@ -353,6 +353,12 @@ de:
ai_bot:
title: "KI-Bot-Optionen"
save_first: "Weitere KI-Bot-Optionen werden verfügbar, sobald du die Persona gespeichert hast."
filters:
reset: "Zurücksetzen"
features_list:
one: "Funktion:"
other: "Funktionen:"
llms_list: "LLM:"
rag:
title: "RAG"
options:
@@ -562,7 +568,6 @@ de:
trigger: "KI fragen"
loading: "KI generiert"
cancel: "Abbrechen"
regen: "Erneut versuchen"
confirm: "Bestätigen"
discard: "Verwerfen"
changes: "Empfohlene Änderungen"
+2 -1
View File
@@ -73,6 +73,8 @@ el:
open_modal: "Επεξεργασία"
modal:
key_title: "Κλειδί"
filters:
reset: "Επαναφορά"
rag:
uploads:
title: "Μεταφορτώσεις"
@@ -116,7 +118,6 @@ el:
ai_helper:
context_menu:
cancel: "Ακύρωση"
regen: "Δοκίμασε ξανά"
discard: "Απόρριψη"
post_options_menu:
close: "Κλείσιμο"
+12 -7
View File
@@ -539,14 +539,16 @@ en:
model_description:
none: "General settings that work for most language models"
anthropic-claude-3-7-sonnet: "Anthropic's most intelligent model"
anthropic-claude-3-5-haiku: "Fast and cost-effective"
anthropic-claude-3-opus: "Excels at writing and complex tasks"
google-gemini-2-5-pro: "Mid-sized multimodal model capable of a wide range of tasks"
google-gemini-2-0-flash: "Lightweight, fast, and cost-efficient with multimodal reasoning"
anthropic-claude-opus-4-0: "Anthropic's most intelligent model"
anthropic-claude-sonnet-4-0: "Optimal balance of speed and cost"
anthropic-claude-3-7-sonnet-latest: "Optimal balance of speed and cost (previous generation)"
anthropic-claude-3-5-haiku-latest: "Fast and cost-effective"
google-gemini-2-5-pro: "Large multimodal model capable of a wide range of tasks"
google-gemini-2-0-flash: "Lightweight, fast, and cost-efficient with multimodal reasoning (previous generation)"
google-gemini-2-5-flash: "Lightweight, fast, and cost-efficient with multimodal reasoning"
google-gemini-2-0-flash-lite: "Cost efficient and low latency model"
open_ai-o1: "Open AI's most capable reasoning model"
open_ai-o3-mini: "Advanced Cost-efficient reasoning model"
open_ai-o3: "Open AI's most capable reasoning model"
open_ai-o4-mini: "Advanced Cost-efficient reasoning model"
open_ai-gpt-4-1: "Open AI's flagship model. It is well suited for problem solving across domains"
open_ai-gpt-4-1-mini: "Provides a balance between intelligence, speed, and cost that makes it an attractive model for many use cases."
open_ai-gpt-4-1-nano: "Fastest, most cost-effective GPT-4.1 model."
@@ -554,6 +556,9 @@ en:
samba_nova-Meta-Llama-3-3-70B-Instruct": "Powerful multipurpose model"
mistral-mistral-large-latest: "Mistral's most powerful model"
mistral-pixtral-large-latest: "Mistral's most powerful vision capable model"
open_router-x-ai-grok-3-beta: "xAI's latest model"
open_router-deepseek-deepseek-r1-0528-free: "DeepSeek's latest reasoning model"
open_router-meta-llama-3-3-70b-instruct: "Highly capable multilingual model"
preseeded_model_description: "Pre-configured open-source model utilizing %{model}"

Some files were not shown because too many files have changed in this diff Show More