From 306fec2b246c448cc84f8bddf814bdf82ab99c05 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Tue, 3 Jun 2025 16:30:19 +0200 Subject: [PATCH 1/7] FIX: edit-topic is not invisible on desktop (#1394) Fix due to https://github.com/discourse/discourse/pull/32941 --- spec/system/ai_helper/ai_composer_helper_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/system/ai_helper/ai_composer_helper_spec.rb b/spec/system/ai_helper/ai_composer_helper_spec.rb index c8fd9fcba..84b56f70a 100644 --- a/spec/system/ai_helper/ai_composer_helper_spec.rb +++ b/spec/system/ai_helper/ai_composer_helper_spec.rb @@ -319,7 +319,7 @@ def trigger_composer_helper(content) DiscourseAi::AiHelper::SemanticCategorizer.any_instance.stubs(:tags).returns(response) topic_page.visit_topic(topic) - page.find(".edit-topic").click + page.find(".edit-topic", visible: false).click page.find(".ai-tag-suggester-trigger").click tag1_css = ".ai-tag-suggester-content btn[data-name='#{video.name}']" tag2_css = ".ai-tag-suggester-content btn[data-name='#{music.name}']" From fa51e9d94846cc738bba8d3eaee1fd2562722885 Mon Sep 17 00:00:00 2001 From: Kris Date: Tue, 3 Jun 2025 10:40:52 -0400 Subject: [PATCH 2/7] REFACTOR: update AI conversation sidebar to use sidebar sections for date grouping (#1389) --- .../ai-conversations-sidebar-manager.js | 309 ++++++++++++++++-- .../initializers/ai-conversations-sidebar.js | 307 +---------------- .../modules/ai-bot-conversations/common.scss | 42 --- spec/system/ai_bot/homepage_spec.rb | 15 +- .../page_objects/components/ai_pm_homepage.rb | 6 +- 5 files changed, 295 insertions(+), 384 deletions(-) diff --git a/assets/javascripts/discourse/services/ai-conversations-sidebar-manager.js b/assets/javascripts/discourse/services/ai-conversations-sidebar-manager.js index 9e7c45b9f..64c4f3762 100644 --- a/assets/javascripts/discourse/services/ai-conversations-sidebar-manager.js +++ b/assets/javascripts/discourse/services/ai-conversations-sidebar-manager.js @@ -1,48 +1,317 @@ import { tracked } from "@glimmer/tracking"; +import { scheduleOnce } from "@ember/runloop"; import Service, { service } from "@ember/service"; +import { htmlSafe } from "@ember/template"; +import { TrackedArray } from "@ember-compat/tracked-built-ins"; +import { ajax } from "discourse/lib/ajax"; +import discourseDebounce from "discourse/lib/debounce"; +import { autoUpdatingRelativeAge } from "discourse/lib/formatter"; import { ADMIN_PANEL, MAIN_PANEL } from "discourse/lib/sidebar/panels"; +import { i18n } from "discourse-i18n"; +import AiBotSidebarEmptyState from "../../discourse/components/ai-bot-sidebar-empty-state"; export const AI_CONVERSATIONS_PANEL = "ai-conversations"; +const SCROLL_BUFFER = 100; +const DEBOUNCE = 100; export default class AiConversationsSidebarManager extends Service { @service appEvents; @service sidebarState; + @service messageBus; - @tracked newTopicForceSidebar = false; + @tracked topics = []; + @tracked sections = new TrackedArray(); + @tracked isLoading = true; + + api = null; + isFetching = false; + page = 0; + hasMore = true; + _registered = new Set(); + _hasScrollListener = false; + _scrollElement = null; + _didInit = false; + + _debouncedScrollHandler = () => { + discourseDebounce( + this, + () => { + const element = this._scrollElement; + if (!element) { + return; + } + + const { scrollTop, scrollHeight, clientHeight } = element; + if ( + scrollHeight - scrollTop - clientHeight - SCROLL_BUFFER < 100 && + !this.isFetching && + this.hasMore + ) { + this.fetchMessages(); + } + }, + DEBOUNCE + ); + }; + + constructor() { + super(...arguments); + + this.appEvents.on( + "discourse-ai:bot-pm-created", + this, + this._handleNewBotPM + ); + + this.appEvents.on( + "discourse-ai:conversations-sidebar-updated", + this, + this._attachScrollListener + ); + } + + willDestroy() { + super.willDestroy(...arguments); + this.appEvents.off( + "discourse-ai:bot-pm-created", + this, + this._handleNewBotPM + ); + this.appEvents.off( + "discourse-ai:conversations-sidebar-updated", + this, + this._attachScrollListener + ); + } forceCustomSidebar() { - // Return early if we already have the correct panel, so we don't - // re-render it. - if (this.sidebarState.currentPanel?.key === AI_CONVERSATIONS_PANEL) { - return; - } + document.body.classList.add("has-ai-conversations-sidebar"); + this.sidebarState.isForcingSidebar = true; + // calling this before fetching data + // helps avoid flash of main sidebar mode this.sidebarState.setPanel(AI_CONVERSATIONS_PANEL); - // Use separated mode to ensure independence from hamburger menu + this.appEvents.trigger("discourse-ai:force-conversations-sidebar"); this.sidebarState.setSeparatedMode(); - - // Hide panel switching buttons to keep UI clean this.sidebarState.hideSwitchPanelButtons(); - this.sidebarState.isForcingSidebar = true; - document.body.classList.add("has-ai-conversations-sidebar"); - this.appEvents.trigger("discourse-ai:force-conversations-sidebar"); + // don't render sidebar multiple times + if (this._didInit) { + return true; + } + + this._didInit = true; + + this.fetchMessages().then(() => { + this.sidebarState.setPanel(AI_CONVERSATIONS_PANEL); + }); + return true; } + _attachScrollListener() { + const sections = document.querySelector( + ".sidebar-sections.ai-conversations-panel" + ); + this._scrollElement = sections; + + if (this._hasScrollListener || !this._scrollElement) { + return; + } + + sections.addEventListener("scroll", this._debouncedScrollHandler); + + this._hasScrollListener = true; + } + + _removeScrollListener() { + if (this._hasScrollListener) { + this._scrollElement.removeEventListener( + "scroll", + this._debouncedScrollHandler + ); + this._hasScrollListener = false; + this._scrollElement = null; + } + } + stopForcingCustomSidebar() { - // This method is called when leaving your route - // Only restore main panel if we previously forced ours document.body.classList.remove("has-ai-conversations-sidebar"); - const isAdminSidebarActive = - this.sidebarState.currentPanel?.key === ADMIN_PANEL; - // only restore main panel if we previously forced our sidebar - // and not if we are in admin sidebar - if (this.sidebarState.isForcingSidebar && !isAdminSidebarActive) { - this.sidebarState.setPanel(MAIN_PANEL); // Return to main sidebar panel + + const isAdmin = this.sidebarState.currentPanel?.key === ADMIN_PANEL; + if (this.sidebarState.isForcingSidebar && !isAdmin) { + this.sidebarState.setPanel(MAIN_PANEL); this.sidebarState.isForcingSidebar = false; this.appEvents.trigger("discourse-ai:stop-forcing-conversations-sidebar"); } + + this._removeScrollListener(); + } + + async fetchMessages() { + if (this.isFetching || !this.hasMore) { + return; + } + + const isFirstPage = this.page === 0; + this.isFetching = true; + + try { + let { conversations, meta } = await ajax( + "/discourse-ai/ai-bot/conversations.json", + { data: { page: this.page, per_page: 40 } } + ); + + if (isFirstPage) { + this.topics = conversations; + } else { + this.topics = [...this.topics, ...conversations]; + // force rerender when fetching more messages + this.sidebarState.setPanel(AI_CONVERSATIONS_PANEL); + } + + this.page += 1; + this.hasMore = meta.has_more; + + this._rebuildSections(); + } finally { + this.isFetching = false; + this.isLoading = false; + } + } + + _handleNewBotPM(topic) { + this.topics = [topic, ...this.topics]; + this._rebuildSections(); + this._watchForTitleUpdate(topic.id); + } + + _watchForTitleUpdate(topicId) { + if (this._subscribedTopicIds?.has(topicId)) { + return; + } + + this._subscribedTopicIds = this._subscribedTopicIds || new Set(); + this._subscribedTopicIds.add(topicId); + + const channel = `/discourse-ai/ai-bot/topic/${topicId}`; + + this.messageBus.subscribe(channel, (payload) => { + this._applyTitleUpdate(topicId, payload.title); + this.messageBus.unsubscribe(channel); + }); + } + + _applyTitleUpdate(topicId, newTitle) { + this.topics = this.topics.map((t) => + t.id === topicId ? { ...t, title: newTitle } : t + ); + + this._rebuildSections(); + } + + // organize by date and create a section for each date group + _rebuildSections() { + const now = Date.now(); + const fresh = []; + + this.topics.forEach((t) => { + const postedAtMs = new Date(t.last_posted_at || now).valueOf(); + const diffDays = Math.floor((now - postedAtMs) / 86400000); + let dateGroup; + + if (diffDays <= 1) { + dateGroup = "today"; + } else if (diffDays <= 7) { + dateGroup = "last-7-days"; + } else if (diffDays <= 30) { + dateGroup = "last-30-days"; + } else { + const d = new Date(postedAtMs); + const key = `${d.getFullYear()}-${d.getMonth()}`; + dateGroup = key; + } + + let sec = fresh.find((s) => s.name === dateGroup); + if (!sec) { + let title; + switch (dateGroup) { + case "today": + title = i18n("discourse_ai.ai_bot.conversations.today"); + break; + case "last-7-days": + title = i18n("discourse_ai.ai_bot.conversations.last_7_days"); + break; + case "last-30-days": + title = i18n("discourse_ai.ai_bot.conversations.last_30_days"); + break; + default: + title = autoUpdatingRelativeAge(new Date(t.last_posted_at)); + } + sec = { name: dateGroup, title, links: new TrackedArray() }; + fresh.push(sec); + } + + sec.links.push({ + key: t.id, + route: "topic.fromParamsNear", + models: [t.slug, t.id, t.last_read_post_number || 0], + title: t.title, + text: t.title, + classNames: `ai-conversation-${t.id}`, + }); + }); + + this.sections = new TrackedArray(fresh); + + // register each new section once + for (let sec of fresh) { + if (this._registered.has(sec.name)) { + continue; + } + this._registered.add(sec.name); + + this.api.addSidebarSection((BaseCustomSidebarSection) => { + return class extends BaseCustomSidebarSection { + @service("ai-conversations-sidebar-manager") manager; + @service("appEvents") events; + + constructor() { + super(...arguments); + scheduleOnce("afterRender", this, this.triggerEvent); + } + + triggerEvent() { + this.events.trigger("discourse-ai:conversations-sidebar-updated"); + } + + get name() { + return sec.name; + } + + get title() { + return sec.title; + } + + get text() { + return htmlSafe(sec.title); + } + + get links() { + return ( + this.manager.sections.find((s) => s.name === sec.name)?.links || + [] + ); + } + + get emptyStateComponent() { + if (!this.manager.isLoading && this.links.length === 0) { + return AiBotSidebarEmptyState; + } + } + }; + }, AI_CONVERSATIONS_PANEL); + } } } diff --git a/assets/javascripts/initializers/ai-conversations-sidebar.js b/assets/javascripts/initializers/ai-conversations-sidebar.js index e288f203c..de5e73053 100644 --- a/assets/javascripts/initializers/ai-conversations-sidebar.js +++ b/assets/javascripts/initializers/ai-conversations-sidebar.js @@ -1,12 +1,4 @@ -import { tracked } from "@glimmer/tracking"; -import { htmlSafe } from "@ember/template"; -import { TrackedArray } from "@ember-compat/tracked-built-ins"; -import { ajax } from "discourse/lib/ajax"; -import { bind } from "discourse/lib/decorators"; -import { autoUpdatingRelativeAge } from "discourse/lib/formatter"; import { withPluginApi } from "discourse/lib/plugin-api"; -import { i18n } from "discourse-i18n"; -import AiBotSidebarEmptyState from "../discourse/components/ai-bot-sidebar-empty-state"; import AiBotSidebarNewConversation from "../discourse/components/ai-bot-sidebar-new-conversation"; import { AI_CONVERSATIONS_PANEL } from "../discourse/services/ai-conversations-sidebar-manager"; @@ -28,8 +20,7 @@ export default { const aiConversationsSidebarManager = api.container.lookup( "service:ai-conversations-sidebar-manager" ); - const appEvents = api.container.lookup("service:app-events"); - const messageBus = api.container.lookup("service:message-bus"); + aiConversationsSidebarManager.api = api; api.addSidebarPanel( (BaseCustomSidebarPanel) => @@ -45,293 +36,6 @@ export default { "before-sidebar-sections", AiBotSidebarNewConversation ); - api.addSidebarSection( - (BaseCustomSidebarSection, BaseCustomSidebarSectionLink) => { - const AiConversationLink = class extends BaseCustomSidebarSectionLink { - route = "topic.fromParamsNear"; - - constructor(topic) { - super(...arguments); - this.topic = topic; - } - - get key() { - return this.topic.id; - } - - get name() { - return this.topic.title; - } - - get models() { - return [ - this.topic.slug, - this.topic.id, - this.topic.last_read_post_number || 0, - ]; - } - - get title() { - return this.topic.title; - } - - get text() { - return this.topic.title; - } - - get classNames() { - return `ai-conversation-${this.topic.id}`; - } - }; - - return class extends BaseCustomSidebarSection { - @tracked links = new TrackedArray(); - @tracked topics = []; - @tracked hasMore = []; - @tracked loadedTodayLabel = false; - @tracked loadedSevenDayLabel = false; - @tracked loadedThirtyDayLabel = false; - @tracked loadedMonthLabels = new Set(); - @tracked isLoading = true; - isFetching = false; - page = 0; - totalTopicsCount = 0; - - constructor() { - super(...arguments); - this.fetchMessages(); - - appEvents.on( - "discourse-ai:bot-pm-created", - this, - "addNewPMToSidebar" - ); - } - - @bind - willDestroy() { - this.removeScrollListener(); - appEvents.off( - "discourse-ai:bot-pm-created", - this, - "addNewPMToSidebar" - ); - } - - get name() { - return "ai-conversations-history"; - } - - get emptyStateComponent() { - if (!this.isLoading) { - return AiBotSidebarEmptyState; - } - } - - get text() { - return i18n( - "discourse_ai.ai_bot.conversations.messages_sidebar_title" - ); - } - - get sidebarElement() { - return document.querySelector( - ".sidebar-wrapper .sidebar-sections" - ); - } - - addNewPMToSidebar(topic) { - // Reset category labels since we're adding a new topic - this.loadedTodayLabel = false; - this.loadedSevenDayLabel = false; - this.loadedThirtyDayLabel = false; - this.loadedMonthLabels.clear(); - - this.topics = [topic, ...this.topics]; - this.buildSidebarLinks(); - - this.watchForTitleUpdate(topic); - } - - @bind - removeScrollListener() { - const sidebar = this.sidebarElement; - if (sidebar) { - sidebar.removeEventListener("scroll", this.scrollHandler); - } - } - - @bind - attachScrollListener() { - const sidebar = this.sidebarElement; - if (sidebar) { - sidebar.addEventListener("scroll", this.scrollHandler); - } - } - - @bind - scrollHandler() { - const sidebarElement = this.sidebarElement; - if (!sidebarElement) { - return; - } - - const scrollPosition = sidebarElement.scrollTop; - const scrollHeight = sidebarElement.scrollHeight; - const clientHeight = sidebarElement.clientHeight; - - // When user has scrolled to bottom with a small threshold - if (scrollHeight - scrollPosition - clientHeight < 100) { - if (this.hasMore && !this.isFetching) { - this.loadMore(); - } - } - } - - async fetchMessages(isLoadingMore = false) { - if (this.isFetching) { - return; - } - - try { - this.isFetching = true; - const data = await ajax( - "/discourse-ai/ai-bot/conversations.json", - { - data: { page: this.page, per_page: 40 }, - } - ); - - if (isLoadingMore) { - this.topics = [...this.topics, ...data.conversations]; - } else { - this.topics = data.conversations; - } - - this.totalTopicsCount = data.meta.total; - this.hasMore = data.meta.has_more; - this.isFetching = false; - this.removeScrollListener(); - this.buildSidebarLinks(); - this.attachScrollListener(); - } catch { - this.isFetching = false; - } finally { - this.isLoading = false; - } - } - - loadMore() { - if (this.isFetching || !this.hasMore) { - return; - } - - this.page = this.page + 1; - this.fetchMessages(true); - } - - groupByDate(topic) { - const now = new Date(); - const lastPostedAt = new Date(topic.last_posted_at); - const daysDiff = Math.round( - (now - lastPostedAt) / (1000 * 60 * 60 * 24) - ); - - if (daysDiff <= 1 || !topic.last_posted_at) { - if (!this.loadedTodayLabel) { - this.loadedTodayLabel = true; - return { - text: i18n("discourse_ai.ai_bot.conversations.today"), - classNames: "date-heading", - name: "date-heading-today", - }; - } - } - // Last 7 days group - else if (daysDiff <= 7) { - if (!this.loadedSevenDayLabel) { - this.loadedSevenDayLabel = true; - return { - text: i18n("discourse_ai.ai_bot.conversations.last_7_days"), - classNames: "date-heading", - name: "date-heading-last-7-days", - }; - } - } - // Last 30 days group - else if (daysDiff <= 30) { - if (!this.loadedThirtyDayLabel) { - this.loadedThirtyDayLabel = true; - return { - text: i18n( - "discourse_ai.ai_bot.conversations.last_30_days" - ), - classNames: "date-heading", - name: "date-heading-last-30-days", - }; - } - } - // Group by month for older conversations - else { - const month = lastPostedAt.getMonth(); - const year = lastPostedAt.getFullYear(); - const monthKey = `${year}-${month}`; - - if (!this.loadedMonthLabels.has(monthKey)) { - this.loadedMonthLabels.add(monthKey); - - const formattedDate = autoUpdatingRelativeAge( - new Date(topic.last_posted_at) - ); - - return { - text: htmlSafe(formattedDate), - classNames: "date-heading", - name: `date-heading-${monthKey}`, - }; - } - } - } - - buildSidebarLinks() { - // Reset date header tracking - this.loadedTodayLabel = false; - this.loadedSevenDayLabel = false; - this.loadedThirtyDayLabel = false; - this.loadedMonthLabels.clear(); - - this.links = [...this.topics].flatMap((topic) => { - const dateLabel = this.groupByDate(topic); - return dateLabel - ? [dateLabel, new AiConversationLink(topic)] - : [new AiConversationLink(topic)]; - }); - } - - watchForTitleUpdate(topic) { - const channel = `/discourse-ai/ai-bot/topic/${topic.id}`; - const callback = this.updateTopicTitle.bind(this); - messageBus.subscribe(channel, ({ title }) => { - callback(topic, title); - messageBus.unsubscribe(channel); - }); - } - - 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 - ); - } - }; - }, - AI_CONVERSATIONS_PANEL - ); const setSidebarPanel = (transition) => { if (transition?.to?.name === "discourse-ai-bot-conversations") { @@ -349,15 +53,6 @@ export default { return aiConversationsSidebarManager.forceCustomSidebar(); } - // newTopicForceSidebar is set to true when a new topic is created. We have - // this because the condition `postStream.posts` above will not be true as the bot response - // is not in the postStream yet when this initializer is ran. So we need to force - // the sidebar to open when creating a new topic. After that, we set it to false again. - if (aiConversationsSidebarManager.newTopicForceSidebar) { - aiConversationsSidebarManager.newTopicForceSidebar = false; - return aiConversationsSidebarManager.forceCustomSidebar(); - } - aiConversationsSidebarManager.stopForcingCustomSidebar(); }; diff --git a/assets/stylesheets/modules/ai-bot-conversations/common.scss b/assets/stylesheets/modules/ai-bot-conversations/common.scss index c6f0a02d4..8b3b01e26 100644 --- a/assets/stylesheets/modules/ai-bot-conversations/common.scss +++ b/assets/stylesheets/modules/ai-bot-conversations/common.scss @@ -27,48 +27,6 @@ body.has-ai-conversations-sidebar { display: none; } - .sidebar-wrapper, - .hamburger-dropdown-wrapper { - // ai related sidebar content - [data-section-name="ai-conversations-history"] { - .sidebar-section-header-wrapper { - display: none; - } - - .sidebar-section-link-wrapper { - .sidebar-section-link.date-heading { - pointer-events: none; - cursor: default; - color: var(--primary-medium); - opacity: 0.8; - font-weight: 700; - margin-top: 1em; - font-size: var(--font-down-2); - } - - .sidebar-section-link { - height: unset; - padding-block: 0.65em; - font-size: var(--font-down-1); - letter-spacing: 0.35px; - border-radius: 0 var(--border-radius) var(--border-radius) 0; - - .sidebar-section-link-content-text { - white-space: normal; - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; - overflow: hidden; - } - } - } - - .sidebar-section-link-prefix { - align-self: start; - } - } - } - // topic elements #topic-footer-button-share-and-invite, body:not(.staff) #topic-footer-button-archive, diff --git a/spec/system/ai_bot/homepage_spec.rb b/spec/system/ai_bot/homepage_spec.rb index 3510c32fd..f2b0bab17 100644 --- a/spec/system/ai_bot/homepage_spec.rb +++ b/spec/system/ai_bot/homepage_spec.rb @@ -222,8 +222,7 @@ header.click_bot_button expect(ai_pm_homepage).to have_homepage - expect(sidebar).to have_section("ai-conversations-history") - expect(sidebar).to have_section_link("Today") + expect(sidebar).to have_section("Today") expect(sidebar).to have_section_link(pm.title) end @@ -233,7 +232,7 @@ header.click_bot_button expect(ai_pm_homepage).to have_homepage - expect(sidebar).to have_section_link("Last 7 days") + expect(sidebar).to have_section("Last 7 days") end it "displays last_30_days label in the sidebar" do @@ -242,7 +241,7 @@ header.click_bot_button expect(ai_pm_homepage).to have_homepage - expect(sidebar).to have_section_link("Last 30 days") + expect(sidebar).to have_section("Last 30 days") end it "displays month and year label in the sidebar for older conversations" do @@ -251,7 +250,7 @@ header.click_bot_button expect(ai_pm_homepage).to have_homepage - expect(sidebar).to have_section_link("Apr 2024") + expect(sidebar).to have_section("2024-3") end it "navigates to the bot conversation when clicked" do @@ -328,12 +327,6 @@ expect(sidebar).to have_no_section_link(pm.title) end - it "renders empty state in sidebar with no bot PM history" do - sign_in(user_2) - ai_pm_homepage.visit - expect(ai_pm_homepage).to have_empty_state - end - it "Allows choosing persona and LLM" do ai_pm_homepage.visit diff --git a/spec/system/page_objects/components/ai_pm_homepage.rb b/spec/system/page_objects/components/ai_pm_homepage.rb index 69b93af37..06773b05b 100644 --- a/spec/system/page_objects/components/ai_pm_homepage.rb +++ b/spec/system/page_objects/components/ai_pm_homepage.rb @@ -52,13 +52,9 @@ def click_new_question_button page.find(".ai-new-question-button").click end - def has_empty_state? - page.has_css?(".ai-bot-sidebar-empty-state") - end - def click_fist_sidebar_conversation page.find( - ".sidebar-section[data-section-name='ai-conversations-history'] a.sidebar-section-link:not(.date-heading)", + ".sidebar-section-content a.sidebar-section-link", ).click end From 59f4b66ede75b4564be7286e30bf20a3b9c8754a Mon Sep 17 00:00:00 2001 From: Discourse Translator Bot Date: Tue, 3 Jun 2025 08:37:22 -0700 Subject: [PATCH 3/7] Update translations (#1395) --- config/locales/client.he.yml | 1 + config/locales/server.de.yml | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/config/locales/client.he.yml b/config/locales/client.he.yml index f3100ce36..89d0be323 100644 --- a/config/locales/client.he.yml +++ b/config/locales/client.he.yml @@ -498,6 +498,7 @@ he: provider_order: "סדר ספקים (רשימה מופרדת בפסיקים)" provider_quantizations: "סדר כימות הספקים (רשימה מופרדת בפסיקים, למשל: fp16,fp8)" reasoning_effort: "מאמץ נימוק (תקף רק על מודלים של נימוק)" + enable_reasoning: "הפעלת נימוק (תקף רק על מודלים עם אפשרות נימוק)" enable_thinking: "הפעלת חשיבה (רק במודלים תקפים לדוגמה: flash 2.5)" reasoning_tokens: "כמות אסימונים המשמשים לנימוק" related_topics: diff --git a/config/locales/server.de.yml b/config/locales/server.de.yml index 6c79ef6b3..a01860e68 100644 --- a/config/locales/server.de.yml +++ b/config/locales/server.de.yml @@ -59,6 +59,7 @@ de: ai_nsfw_flag_threshold_porn: "Schwellenwert, ab dem ein Bild, das als Porno eingestuft wird, als NSFW gilt." ai_nsfw_flag_threshold_sexy: "Schwellenwert, ab dem ein Bild, das als sexy eingestuft wird, als NSFW gilt." ai_nsfw_models: "Modelle, die für NSFW-Inferenz verwendet werden." + ai_spam_detection_enabled: "Aktiviere das KI-Spamerkennungsmodul" ai_openai_api_key: "API-Schlüssel für OpenAI-API. Wird NUR für Bilderstellung und -bearbeitung verwendet. Für GPT verwende die Registerkarte für die LLM-Konfiguration" ai_openai_image_generation_url: "URL für die OpenAI-Bilderstellungs-API" ai_openai_image_edit_url: "URL für die OpenAI-Bildbearbeitungs-API" @@ -265,6 +266,7 @@ de: invalid_error_type: "Ungültiger Fehlertyp angegeben" unexpected: "Ein unerwarteter Fehler ist aufgetreten" bot_user_update_failed: "Aktualisierung des Spam-Scan-Bot-Benutzers fehlgeschlagen" + configuration_missing: "Die Konfiguration der KI-Spamerkennung fehlt. Füge die Konfiguration in „Administration > Plug-ins > Discourse KI > Spam“ hinzu, bevor du sie aktivierst." ai_bot: reply_error: "Entschuldigung, es sieht so aus, als ob unser System beim Versuch, zu antworten, auf ein unerwartetes Problem gestoßen ist.\n\n[details='Fehlerdetails']\n%{details}\n[/details]" default_pm_prefix: "[KI-Bot-PN ohne Titel]" @@ -316,6 +318,15 @@ de: short_summarizer: name: "Zusammenfasser (Kurzform)" description: "Standard-Persona zur Erstellung von KI-Kurzzusammenfassungen für die Elemente der Themenlisten" + concept_finder: + name: "Konzept-Finder" + description: "KI-Bot spezialisiert auf die Identifizierung von Konzepten und Themen in Inhalten" + concept_matcher: + name: "Konzept-Verbinder" + description: "KI-Bot spezialisiert auf den Abgleich von Inhalten mit bestehenden Konzepten" + concept_deduplicator: + name: "Konzept-Deduplikator" + description: "KI-Bot spezialisiert auf das Entfernen von redundanten Daten in Konzepten" topic_not_found: "Zusammenfassung nicht verfügbar, Thema nicht gefunden!" summarizing: "Thema zusammenfassen" searching: "Suche nach: „%{query}“" @@ -527,6 +538,9 @@ de: discord_search: name: "Discord-Suche" description: "Fügt die Möglichkeit hinzu, Discord-Kanäle zu durchsuchen" + inferred_concepts: + name: "Abgeleitete Konzepte" + description: "Ordnet Themen und Beiträge in Interessensgebiete / Labels." errors: quota_exceeded: "Du hast das Kontingent für dieses Modell überschritten. Bitte versuche es erneut in %{relative_time}." quota_required: "Du musst die maximale Anzahl an Token oder Verwendungen für dieses Modell angeben" From 4f980d5514f571683596bb9042968d210feb0cef Mon Sep 17 00:00:00 2001 From: Kris Date: Tue, 3 Jun 2025 14:52:12 -0400 Subject: [PATCH 4/7] FIX: always render "today" on top of conversation sidebar (#1400) --- .../ai-conversations-sidebar-manager.js | 18 ++++++++++++++---- .../modules/ai-bot-conversations/common.scss | 5 +++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/assets/javascripts/discourse/services/ai-conversations-sidebar-manager.js b/assets/javascripts/discourse/services/ai-conversations-sidebar-manager.js index 64c4f3762..4628a236b 100644 --- a/assets/javascripts/discourse/services/ai-conversations-sidebar-manager.js +++ b/assets/javascripts/discourse/services/ai-conversations-sidebar-manager.js @@ -216,6 +216,13 @@ export default class AiConversationsSidebarManager extends Service { const now = Date.now(); const fresh = []; + const todaySection = { + name: "today", + title: i18n("discourse_ai.ai_bot.conversations.today"), + links: new TrackedArray(), + }; + fresh.push(todaySection); + this.topics.forEach((t) => { const postedAtMs = new Date(t.last_posted_at || now).valueOf(); const diffDays = Math.floor((now - postedAtMs) / 86400000); @@ -233,13 +240,16 @@ export default class AiConversationsSidebarManager extends Service { dateGroup = key; } - let sec = fresh.find((s) => s.name === dateGroup); + let sec; + if (dateGroup === "today") { + sec = todaySection; + } else { + sec = fresh.find((s) => s.name === dateGroup); + } + if (!sec) { let title; switch (dateGroup) { - case "today": - title = i18n("discourse_ai.ai_bot.conversations.today"); - break; case "last-7-days": title = i18n("discourse_ai.ai_bot.conversations.last_7_days"); break; diff --git a/assets/stylesheets/modules/ai-bot-conversations/common.scss b/assets/stylesheets/modules/ai-bot-conversations/common.scss index 8b3b01e26..db1954e0e 100644 --- a/assets/stylesheets/modules/ai-bot-conversations/common.scss +++ b/assets/stylesheets/modules/ai-bot-conversations/common.scss @@ -23,6 +23,11 @@ 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) { + display: none; + } + .sidebar-toggle-all-sections { display: none; } From 3e74eea1e5e3143888d67a8d8a11206df214dc24 Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 4 Jun 2025 16:39:43 +1000 Subject: [PATCH 5/7] FEATURE: add context and llm controls to researcher, fix username filter (#1401) Adds context length controls to researcher (max tokens per post and batch) Allow picking LLM for researcher Fix bug where unicode usernames were not working Fix documentation of OR logic --- config/locales/server.en.yml | 9 +++ lib/personas/tools/researcher.rb | 77 +++++++++++++++------- lib/utils/research/filter.rb | 10 +-- spec/lib/personas/tools/researcher_spec.rb | 52 ++++++++++++++- spec/lib/utils/research/filter_spec.rb | 15 +++++ 5 files changed, 131 insertions(+), 32 deletions(-) diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 87d870b46..a58066fe9 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -344,6 +344,15 @@ en: searching: "Searching for: '%{query}'" tool_options: researcher: + researcher_llm: + name: "LLM" + description: "Language model to use for research (default to current persona's LLM)" + max_tokens_per_batch: + name: "Maximum tokens per batch" + description: "Maximum number of tokens to use for each batch in the research" + max_tokens_per_post: + name: "Maximum tokens per post" + description: "Maximum number of tokens to use for each post in the research" max_results: name: "Maximum number of results" description: "Maximum number of results to include in a filter" diff --git a/lib/personas/tools/researcher.rb b/lib/personas/tools/researcher.rb index 0f612b81d..9fce813f9 100644 --- a/lib/personas/tools/researcher.rb +++ b/lib/personas/tools/researcher.rb @@ -31,26 +31,28 @@ def signature def filter_description <<~TEXT - Filter string to target specific content. - - Supports user (@username) - - post_type:first - only includes first posts in topics - - post_type:reply - only replies in topics - - date ranges (after:YYYY-MM-DD, before:YYYY-MM-DD for posts; topic_after:YYYY-MM-DD, topic_before:YYYY-MM-DD for topics) - - categories (category:category1,category2 or categories:category1,category2) - - tags (tag:tag1,tag2 or tags:tag1,tag2) - - groups (group:group1,group2 or groups:group1,group2) - - status (status:open, status:closed, status:archived, status:noreplies, status:single_user) - - keywords (keywords:keyword1,keyword2) - searches for specific words within post content using full-text search - - topic_keywords (topic_keywords:keyword1,keyword2) - searches for keywords within topics, returns all posts from matching topics - - topics (topic:topic_id1,topic_id2 or topics:topic_id1,topic_id2) - target specific topics by ID - - max_results (max_results:10) - limits the maximum number of results returned (optional) - - order (order:latest, order:oldest, order:latest_topic, order:oldest_topic, order:likes) - controls result ordering (optional, defaults to latest posts) - - Multiple filters can be combined with spaces for AND logic. Example: '@sam after:2023-01-01 tag:feature' - - Use OR to combine filter segments for inclusive logic. - Example: 'category:feature,bug OR tag:feature-tag' - includes posts in feature OR bug categories, OR posts with feature-tag tag - Example: '@sam category:bug' - includes posts by @sam AND in bug category + Filter string to target specific content. Space-separated filters use AND logic, OR creates separate filter groups. + + **Filters:** + - username:user1 or usernames:user1,user2 - posts by specific users + - group:group1 or groups:group1,group2 - posts by users in specific groups + - post_type:first|reply - first posts only or replies only + - keywords:word1,word2 - full-text search in post content + - topic_keywords:word1,word2 - full-text search in topics (returns all posts from matching topics) + - topic:123 or topics:123,456 - specific topics by ID + - category:name1 or categories:name1,name2 - posts in categories (by name/slug) + - tag:tag1 or tags:tag1,tag2 - posts in topics with tags + - after:YYYY-MM-DD, before:YYYY-MM-DD - filter by post creation date + - topic_after:YYYY-MM-DD, topic_before:YYYY-MM-DD - filter by topic creation date + - status:open|closed|archived|noreplies|single_user - topic status filters + - max_results:N - limit results (per OR group) + - order:latest|oldest|latest_topic|oldest_topic|likes - sort order + + **OR Logic:** Each OR group processes independently - filters don't cross boundaries. + + Examples: + - 'username:sam after:2023-01-01' - sam's posts after date + - 'max_results:50 category:bugs OR tag:urgent' - (≤50 bug posts) OR (all urgent posts) TEXT end @@ -60,9 +62,11 @@ def name def accepted_options [ + option(:researcher_llm, type: :llm), option(:max_results, type: :integer), option(:include_private, type: :boolean), option(:max_tokens_per_post, type: :integer), + option(:max_tokens_per_batch, type: :integer), ] end end @@ -134,17 +138,32 @@ def description_args protected MIN_TOKENS_FOR_RESEARCH = 8000 + MIN_TOKENS_FOR_POST = 50 + def process_filter(filter, goals, post, &blk) - if llm.max_prompt_tokens < MIN_TOKENS_FOR_RESEARCH + if researcher_llm.max_prompt_tokens < MIN_TOKENS_FOR_RESEARCH raise ArgumentError, "LLM max tokens too low for research. Minimum is #{MIN_TOKENS_FOR_RESEARCH}." end + + max_tokens_per_batch = options[:max_tokens_per_batch].to_i + if max_tokens_per_batch <= MIN_TOKENS_FOR_RESEARCH + max_tokens_per_batch = researcher_llm.max_prompt_tokens - 2000 + end + + max_tokens_per_post = options[:max_tokens_per_post] + if max_tokens_per_post.nil? + max_tokens_per_post = 2000 + elsif max_tokens_per_post < MIN_TOKENS_FOR_POST + max_tokens_per_post = MIN_TOKENS_FOR_POST + end + formatter = DiscourseAi::Utils::Research::LlmFormatter.new( filter, - max_tokens_per_batch: llm.max_prompt_tokens - 2000, - tokenizer: llm.tokenizer, - max_tokens_per_post: options[:max_tokens_per_post] || 2000, + max_tokens_per_batch: max_tokens_per_batch, + tokenizer: researcher_llm.tokenizer, + max_tokens_per_post: max_tokens_per_post, ) results = [] @@ -164,6 +183,14 @@ def process_filter(filter, goals, post, &blk) end end + def researcher_llm + @researcher_llm ||= + ( + options[:researcher_llm].present? && + LlmModel.find_by(id: options[:researcher_llm].to_i)&.to_llm + ) || self.llm + end + def run_inference(chunk_text, goals, post, &blk) return if context.cancel_manager&.cancelled? @@ -179,7 +206,7 @@ def run_inference(chunk_text, goals, post, &blk) ) results = [] - llm.generate( + researcher_llm.generate( prompt, user: post.user, feature_name: context.feature_name, diff --git a/lib/utils/research/filter.rb b/lib/utils/research/filter.rb index 2d9934783..1422622c3 100644 --- a/lib/utils/research/filter.rb +++ b/lib/utils/research/filter.rb @@ -153,12 +153,12 @@ def self.word_to_date(str) end end - register_filter(/\A\@(\w+)\z/i) do |relation, username, filter| - user = User.find_by(username_lower: username.downcase) - if user - relation.where("posts.user_id = ?", user.id) + register_filter(/\Ausernames?:(.+)\z/i) do |relation, username, filter| + user_ids = User.where(username_lower: username.split(",").map(&:downcase)).pluck(:id) + if user_ids.empty? + relation.where("1 = 0") else - relation.where("1 = 0") # No results if user doesn't exist + relation.where("posts.user_id IN (?)", user_ids) end end diff --git a/spec/lib/personas/tools/researcher_spec.rb b/spec/lib/personas/tools/researcher_spec.rb index 23ed98a7d..8e1a35a1c 100644 --- a/spec/lib/personas/tools/researcher_spec.rb +++ b/spec/lib/personas/tools/researcher_spec.rb @@ -21,6 +21,54 @@ before { SiteSetting.ai_bot_enabled = true } + it "uses custom researcher_llm and applies token limits correctly" do + # Create a second LLM model to test the researcher_llm option + secondary_llm_model = Fabricate(:llm_model, name: "secondary_model") + + # Create test content with long text to test token truncation + topic = Fabricate(:topic, category: category, tags: [tag_research]) + long_content = "zz " * 100 # This will exceed our token limit + _test_post = + Fabricate(:post, topic: topic, raw: long_content, user: user, skip_validation: true) + + prompts = nil + responses = [["Research completed"]] + researcher = nil + + DiscourseAi::Completions::Llm.with_prepared_responses( + responses, + llm: secondary_llm_model, + ) do |_, _, _prompts| + researcher = + described_class.new( + { filter: "category:research-category", goals: "analyze test content", dry_run: false }, + persona_options: { + "researcher_llm" => secondary_llm_model.id, + "max_tokens_per_post" => 50, # Very small to force truncation + "max_tokens_per_batch" => 8000, + }, + bot_user: bot_user, + llm: nil, + context: DiscourseAi::Personas::BotContext.new(user: user, post: post), + ) + + results = researcher.invoke(&progress_blk) + + expect(results[:dry_run]).to eq(false) + expect(results[:results]).to be_present + + prompts = _prompts + end + + expect(prompts).to be_present + + user_message = prompts.first.messages.find { |m| m[:type] == :user } + expect(user_message[:content]).to be_present + + # count how many times the the "zz " appears in the content (a bit of token magic, we lose a couple cause we redact) + expect(user_message[:content].scan("zz ").count).to eq(48) + end + describe "#invoke" do it "can correctly filter to a topic id" do researcher = @@ -104,7 +152,7 @@ researcher = described_class.new( { - filter: "category:research-category @#{user.username}", + filter: "category:research-category username:#{user.username}", goals: "find relevant content", dry_run: false, }, @@ -129,7 +177,7 @@ expect(results[:dry_run]).to eq(false) expect(results[:goals]).to eq("find relevant content") - expect(results[:filter]).to eq("category:research-category @#{user.username}") + expect(results[:filter]).to eq("category:research-category username:#{user.username}") expect(results[:results].first).to include("Found: Relevant content 1") end end diff --git a/spec/lib/utils/research/filter_spec.rb b/spec/lib/utils/research/filter_spec.rb index 4e23a393a..a08825c25 100644 --- a/spec/lib/utils/research/filter_spec.rb +++ b/spec/lib/utils/research/filter_spec.rb @@ -144,6 +144,21 @@ end end + describe "can find posts by users even with unicode usernames" do + before { SiteSetting.unicode_usernames = true } + let!(:unicode_user) { Fabricate(:user, username: "aאb") } + + it "can filter by unicode usernames" do + post = Fabricate(:post, user: unicode_user, topic: feature_topic) + filter = described_class.new("username:aאb") + expect(filter.search.pluck(:id)).to contain_exactly(post.id) + + filter = described_class.new("usernames:aאb,#{user.username}") + posts_ids = Post.where(user_id: [unicode_user.id, user.id]).pluck(:id) + expect(filter.search.pluck(:id)).to contain_exactly(*posts_ids) + end + end + describe "category filtering" do it "correctly filters posts by categories" do filter = described_class.new("category:Announcements") From 2842295b105f676c7dbdfbce0d384c73a153b43f Mon Sep 17 00:00:00 2001 From: Jarek Radosz Date: Wed, 4 Jun 2025 15:13:12 +0200 Subject: [PATCH 6/7] DEV: Remove duplicated i18n entries (#1402) Regressed in 3e74eea1e5e3143888d67a8d8a11206df214dc24 --- config/locales/server.en.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index a58066fe9..f8a8f9405 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -359,9 +359,6 @@ en: include_private: name: "Include private" description: "Include private topics in the filters" - max_tokens_per_post: - name: "Maximum tokens per post" - description: "Maximum number of tokens to use for each post in the filter" create_artifact: creator_llm: name: "LLM" From cab39839fdc192a0bdb328afba40fb2fb9c35a02 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Wed, 4 Jun 2025 14:13:45 +0100 Subject: [PATCH 7/7] Revert "DEV: Patch `Net::BufferedIO` to help debug spec flakes (#1375)" (#1403) This reverts commit ca78b1a1c588bd8708418bc42855837aafc6ab15. Problem resolved by https://github.com/discourse/discourse-perspective-api/pull/110 --- spec/lib/completions/cancel_manager_spec.rb | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/spec/lib/completions/cancel_manager_spec.rb b/spec/lib/completions/cancel_manager_spec.rb index c5e2ec47e..d57baa6bf 100644 --- a/spec/lib/completions/cancel_manager_spec.rb +++ b/spec/lib/completions/cancel_manager_spec.rb @@ -1,25 +1,5 @@ # frozen_string_literal: true -# Debugging https://github.com/ruby/net-protocol/issues/32 -# which seems to be happening inconsistently in CI -Net::BufferedIO.prepend( - Module.new do - def initialize(*args, **kwargs) - if kwargs[:debug_output] && !kwargs[:debug_output].respond_to(:<<) - raise ArgumentError, "debug_output must support <<" - end - super - end - - def debug_output=(debug_output) - if debug_output && !debug_output.respond_to?(:<<) - raise ArgumentError, "debug_output must support <<" - end - super - end - end, -) - describe DiscourseAi::Completions::CancelManager do fab!(:model) { Fabricate(:anthropic_model, name: "test-model") }