FEATURE: Experimental search results from an AI Persona. (#1139)

* FEATURE: Experimental search results from an AI Persona.

When a user searches discourse, we'll send the query to an AI Persona to provide additional context and enrich the results. The feature depends on the user being a member of a group to which the persona has access.

* Update assets/stylesheets/common/ai-blinking-animation.scss

Co-authored-by: Keegan George <[email protected]>

---------

Co-authored-by: Keegan George <[email protected]>
This commit is contained in:
Roman Rizzi
2025-02-20 14:37:58 -03:00
committed by GitHub
co-authored by Keegan George
parent 24f0e1262d
commit 6765a13a40
20 changed files with 803 additions and 259 deletions
@@ -0,0 +1,115 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { fn } from "@ember/helper";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import didUpdate from "@ember/render-modifiers/modifiers/did-update";
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
import { cancel } from "@ember/runloop";
import concatClass from "discourse/helpers/concat-class";
import discourseLater from "discourse/lib/later";
class Block {
@tracked show = false;
@tracked shown = false;
@tracked blinking = false;
constructor(args = {}) {
this.show = args.show ?? false;
this.shown = args.shown ?? false;
}
}
const BLOCKS_SIZE = 20; // changing this requires to change css accordingly
export default class AiBlinkingAnimation extends Component {
blocks = [...Array.from({ length: BLOCKS_SIZE }, () => new Block())];
#nextBlockBlinkingTimer;
#blockBlinkingTimer;
#blockShownTimer;
@action
setupAnimation() {
this.blocks.firstObject.show = true;
this.blocks.firstObject.shown = true;
}
@action
onBlinking(block) {
if (!block.blinking) {
return;
}
block.show = false;
this.#nextBlockBlinkingTimer = discourseLater(
this,
() => {
this.#nextBlock(block).blinking = true;
},
250
);
this.#blockBlinkingTimer = discourseLater(
this,
() => {
block.blinking = false;
},
500
);
}
@action
onShowing(block) {
if (!block.show) {
return;
}
this.#blockShownTimer = discourseLater(
this,
() => {
this.#nextBlock(block).show = true;
this.#nextBlock(block).shown = true;
if (this.blocks.lastObject === block) {
this.blocks.firstObject.blinking = true;
}
},
250
);
}
@action
teardownAnimation() {
cancel(this.#blockShownTimer);
cancel(this.#nextBlockBlinkingTimer);
cancel(this.#blockBlinkingTimer);
}
#nextBlock(currentBlock) {
if (currentBlock === this.blocks.lastObject) {
return this.blocks.firstObject;
} else {
return this.blocks.objectAt(this.blocks.indexOf(currentBlock) + 1);
}
}
<template>
<ul class="ai-blinking-animation" {{didInsert this.setupAnimation}}>
{{#each this.blocks as |block|}}
<li
class={{concatClass
"ai-blinking-animation__list-item"
(if block.show "show")
(if block.shown "is-shown")
(if block.blinking "blink")
}}
{{didUpdate (fn this.onBlinking block) block.blinking}}
{{didUpdate (fn this.onShowing block) block.show}}
{{willDestroy this.teardownAnimation}}
></li>
{{/each}}
</ul>
</template>
}
@@ -0,0 +1,204 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
import { cancel, later } from "@ember/runloop";
import { service } from "@ember/service";
import CookText from "discourse/components/cook-text";
import DButton from "discourse/components/d-button";
import { ajax } from "discourse/lib/ajax";
import { bind } from "discourse/lib/decorators";
import { i18n } from "discourse-i18n";
import AiBlinkingAnimation from "./ai-blinking-animation";
const DISCOVERY_TIMEOUT_MS = 10000;
const BUFFER_WORDS_COUNT = 50;
function setUpBuffer(discovery, bufferTarget) {
const paragraphs = discovery.split(/\n+/);
let wordCount = 0;
const paragraphBuffer = [];
for (const paragraph of paragraphs) {
const wordsInParagraph = paragraph.split(/\s+/);
wordCount += wordsInParagraph.length;
if (wordCount >= bufferTarget) {
paragraphBuffer.push(paragraph.concat("..."));
return paragraphBuffer.join("\n");
} else {
paragraphBuffer.push(paragraph);
paragraphBuffer.push("\n");
}
}
return null;
}
export default class AiSearchDiscoveries extends Component {
@service search;
@service messageBus;
@service discobotDiscoveries;
@tracked loadingDiscoveries = false;
@tracked hideDiscoveries = false;
@tracked fulldiscoveryToggled = false;
discoveryTimeout = null;
@bind
async _updateDiscovery(update) {
if (this.query === update.query) {
if (this.discoveryTimeout) {
cancel(this.discoveryTimeout);
}
if (!this.discobotDiscoveries.discoveryPreview) {
const buffered = setUpBuffer(
update.ai_discover_reply,
BUFFER_WORDS_COUNT
);
if (buffered) {
this.discobotDiscoveries.discoveryPreview = buffered;
this.loadingDiscoveries = false;
}
}
this.discobotDiscoveries.modelUsed = update.model_used;
this.discobotDiscoveries.discovery = update.ai_discover_reply;
// Handling short replies.
if (update.done) {
if (!this.discobotDiscoveries.discoveryPreview) {
this.discobotDiscoveries.discoveryPreview = update.ai_discover_reply;
}
this.discobotDiscoveries.discovery = update.ai_discover_reply;
this.loadingDiscoveries = false;
}
}
}
@bind
unsubscribe() {
this.messageBus.unsubscribe(
"/discourse-ai/ai-bot/discover",
this._updateDiscovery
);
}
@bind
subscribe() {
this.messageBus.subscribe(
"/discourse-ai/ai-bot/discover",
this._updateDiscovery
);
}
get query() {
return this.args?.searchTerm || this.search.activeGlobalSearchTerm;
}
get toggleLabel() {
if (this.fulldiscoveryToggled) {
return "discourse_ai.discobot_discoveries.collapse";
} else {
return "discourse_ai.discobot_discoveries.tell_me_more";
}
}
get toggleIcon() {
if (this.fulldiscoveryToggled) {
return "chevron-up";
} else {
return "";
}
}
get toggleMakesSense() {
return (
this.discobotDiscoveries.discoveryPreview &&
this.discobotDiscoveries.discoveryPreview !==
this.discobotDiscoveries.discovery
);
}
@action
async triggerDiscovery() {
if (this.discobotDiscoveries.lastQuery === this.query) {
this.hideDiscoveries = false;
return;
} else {
this.discobotDiscoveries.resetDiscovery();
}
this.hideDiscoveries = false;
this.loadingDiscoveries = true;
this.discoveryTimeout = later(
this,
this.timeoutDiscovery,
DISCOVERY_TIMEOUT_MS
);
try {
this.discobotDiscoveries.lastQuery = this.query;
await ajax("/discourse-ai/ai-bot/discover", {
data: { query: this.query },
});
} catch {
this.hideDiscoveries = true;
}
}
@action
toggleDiscovery() {
this.fulldiscoveryToggled = !this.fulldiscoveryToggled;
}
timeoutDiscovery() {
this.loadingDiscoveries = false;
this.discobotDiscoveries.discoveryPreview = "";
this.discobotDiscoveries.discovery = "";
this.discobotDiscoveries.discoveryTimedOut = true;
}
<template>
<div
class="ai-search-discoveries"
{{didInsert this.subscribe}}
{{didInsert this.triggerDiscovery this.query}}
{{willDestroy this.unsubscribe}}
>
<div class="ai-search-discoveries__completion">
{{#if this.loadingDiscoveries}}
<AiBlinkingAnimation />
{{else if this.discobotDiscoveries.discoveryTimedOut}}
{{i18n "discourse_ai.discobot_discoveries.timed_out"}}
{{else}}
{{#if this.fulldiscoveryToggled}}
<div class="ai-search-discoveries__discovery-full cooked">
<CookText @rawText={{this.discobotDiscoveries.discovery}} />
</div>
{{else}}
<div class="ai-search-discoveries___discovery-preview cooked">
<CookText
@rawText={{this.discobotDiscoveries.discoveryPreview}}
/>
</div>
{{/if}}
{{#if this.toggleMakesSense}}
<DButton
class="btn-flat btn-text ai-search-discoveries__toggle"
@label={{this.toggleLabel}}
@icon={{this.toggleIcon}}
@action={{this.toggleDiscovery}}
/>
{{/if}}
{{/if}}
</div>
</div>
</template>
}
@@ -1,126 +1,18 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { fn } from "@ember/helper";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import didUpdate from "@ember/render-modifiers/modifiers/did-update";
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
import { cancel } from "@ember/runloop";
import concatClass from "discourse/helpers/concat-class";
import discourseLater from "discourse/lib/later";
import { i18n } from "discourse-i18n";
import AiBlinkingAnimation from "./ai-blinking-animation";
import AiIndicatorWave from "./ai-indicator-wave";
class Block {
@tracked show = false;
@tracked shown = false;
@tracked blinking = false;
const AiSummarySkeleton = <template>
<div class="ai-summary__container">
<AiBlinkingAnimation />
constructor(args = {}) {
this.show = args.show ?? false;
this.shown = args.shown ?? false;
}
}
<span>
<div class="ai-summary__generating-text">
{{i18n "summary.in_progress"}}
</div>
<AiIndicatorWave @loading={{true}} />
</span>
</div>
</template>;
const BLOCKS_SIZE = 20; // changing this requires to change css accordingly
export default class AiSummarySkeleton extends Component {
blocks = [...Array.from({ length: BLOCKS_SIZE }, () => new Block())];
#nextBlockBlinkingTimer;
#blockBlinkingTimer;
#blockShownTimer;
@action
setupAnimation() {
this.blocks.firstObject.show = true;
this.blocks.firstObject.shown = true;
}
@action
onBlinking(block) {
if (!block.blinking) {
return;
}
block.show = false;
this.#nextBlockBlinkingTimer = discourseLater(
this,
() => {
this.#nextBlock(block).blinking = true;
},
250
);
this.#blockBlinkingTimer = discourseLater(
this,
() => {
block.blinking = false;
},
500
);
}
@action
onShowing(block) {
if (!block.show) {
return;
}
this.#blockShownTimer = discourseLater(
this,
() => {
this.#nextBlock(block).show = true;
this.#nextBlock(block).shown = true;
if (this.blocks.lastObject === block) {
this.blocks.firstObject.blinking = true;
}
},
250
);
}
@action
teardownAnimation() {
cancel(this.#blockShownTimer);
cancel(this.#nextBlockBlinkingTimer);
cancel(this.#blockBlinkingTimer);
}
#nextBlock(currentBlock) {
if (currentBlock === this.blocks.lastObject) {
return this.blocks.firstObject;
} else {
return this.blocks.objectAt(this.blocks.indexOf(currentBlock) + 1);
}
}
<template>
<div class="ai-summary__container">
<ul class="ai-summary__list" {{didInsert this.setupAnimation}}>
{{#each this.blocks as |block|}}
<li
class={{concatClass
"ai-summary__list-item"
(if block.show "show")
(if block.shown "is-shown")
(if block.blinking "blink")
}}
{{didUpdate (fn this.onBlinking block) block.blinking}}
{{didUpdate (fn this.onShowing block) block.show}}
{{willDestroy this.teardownAnimation}}
></li>
{{/each}}
</ul>
<span>
<div class="ai-summary__generating-text">
{{i18n "summary.in_progress"}}
</div>
<AiIndicatorWave @loading={{true}} />
</span>
</div>
</template>
}
export default AiSummarySkeleton;
@@ -0,0 +1,34 @@
import Component from "@glimmer/component";
import { service } from "@ember/service";
import icon from "discourse/helpers/d-icon";
import { i18n } from "discourse-i18n";
import AiSearchDiscoveries from "../../components/ai-search-discoveries";
export default class AiFullPageDiscobotDiscoveries extends Component {
static shouldRender(_args, { siteSettings, currentUser }) {
return (
siteSettings.ai_bot_discover_persona &&
currentUser.can_use_ai_bot_discover_persona
);
}
@service discobotDiscoveries;
get hasDiscoveries() {
return this.args.outletArgs?.model?.topics?.length > 0;
}
<template>
{{#if this.hasDiscoveries}}
<h3
class="ai-search-discoveries__discoveries-title full-page-discoveries"
>
{{icon "robot"}}
{{i18n "discourse_ai.discobot_discoveries.main_title"}}
</h3>
<div class="full-page-discoveries">
<AiSearchDiscoveries @searchTerm={{@outletArgs.search}} />
</div>
{{/if}}
</template>
}
@@ -0,0 +1,33 @@
import Component from "@glimmer/component";
import { service } from "@ember/service";
import icon from "discourse/helpers/d-icon";
import { i18n } from "discourse-i18n";
import AiSearchDiscoveries from "../../components/ai-search-discoveries";
export default class AiDiscobotDiscoveries extends Component {
static shouldRender(args, { siteSettings, currentUser }) {
return (
args.resultType.type === "topic" &&
siteSettings.ai_bot_discover_persona &&
currentUser.can_use_ai_bot_discover_persona
);
}
@service discobotDiscoveries;
<template>
<div class="ai-discobot-discoveries">
<h3 class="ai-search-discoveries__discoveries-title">
{{icon "robot"}}
{{i18n "discourse_ai.discobot_discoveries.main_title"}}
</h3>
<AiSearchDiscoveries />
<h3 class="ai-discobot-discoveries__regular-results-title">
{{icon "bars-staggered"}}
{{i18n "discourse_ai.discobot_discoveries.regular_results"}}
</h3>
</div>
</template>
}
@@ -0,0 +1,19 @@
import { tracked } from "@glimmer/tracking";
import Service from "@ember/service";
export default class DiscobotDiscoveries extends Service {
// We use this to retain state after search menu gets closed.
// Similar to discourse/discourse#25504
@tracked discoveryPreview = "";
@tracked discovery = "";
@tracked lastQuery = "";
@tracked discoveryTimedOut = false;
@tracked modelUsed = "";
resetDiscovery() {
this.discoveryPreview = "";
this.discovery = "";
this.discoveryTimedOut = false;
this.modelUsed = "";
}
}