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:
co-authored by
Keegan George
parent
24f0e1262d
commit
6765a13a40
@@ -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;
|
||||
|
||||
+34
@@ -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>
|
||||
}
|
||||
+33
@@ -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 = "";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user