mirror of
https://github.com/discourse/discourse-ai.git
synced 2025-03-01 06:49:30 +00:00
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 <kgeorge13@gmail.com> --------- Co-authored-by: Keegan George <kgeorge13@gmail.com>
This commit is contained in:
parent
24f0e1262d
commit
6765a13a40
@ -44,6 +44,31 @@ module DiscourseAi
|
||||
|
||||
render json: { bot_username: bot_user.username_lower }, status: 200
|
||||
end
|
||||
|
||||
def discover
|
||||
ai_persona =
|
||||
AiPersona.all_personas.find do |persona|
|
||||
persona.id == SiteSetting.ai_bot_discover_persona.to_i
|
||||
end
|
||||
|
||||
if ai_persona.nil? || !current_user.in_any_groups?(ai_persona.allowed_group_ids.to_a)
|
||||
raise Discourse::InvalidAccess.new
|
||||
end
|
||||
|
||||
if ai_persona.default_llm_id.blank?
|
||||
render_json_error "Discover persona is missing a default LLM model.", status: 503
|
||||
return
|
||||
end
|
||||
|
||||
query = params[:query]
|
||||
raise Discourse::InvalidParameters.new("Missing query to discover") if query.blank?
|
||||
|
||||
RateLimiter.new(current_user, "ai_bot_discover_#{current_user.id}", 3, 1.minute).performed!
|
||||
|
||||
Jobs.enqueue(:stream_discover_reply, user_id: current_user.id, query: query)
|
||||
|
||||
render json: {}, status: 200
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
53
app/jobs/regular/stream_discover_reply.rb
Normal file
53
app/jobs/regular/stream_discover_reply.rb
Normal file
@ -0,0 +1,53 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Jobs
|
||||
class StreamDiscoverReply < ::Jobs::Base
|
||||
sidekiq_options retry: false
|
||||
|
||||
def execute(args)
|
||||
return if (user = User.find_by(id: args[:user_id])).nil?
|
||||
return if (query = args[:query]).blank?
|
||||
|
||||
ai_persona_klass =
|
||||
AiPersona.all_personas.find do |persona|
|
||||
persona.id == SiteSetting.ai_bot_discover_persona.to_i
|
||||
end
|
||||
|
||||
if ai_persona_klass.nil? || !user.in_any_groups?(ai_persona_klass.allowed_group_ids.to_a)
|
||||
return
|
||||
end
|
||||
return if (llm_model = LlmModel.find_by(id: ai_persona_klass.default_llm_id)).nil?
|
||||
|
||||
bot =
|
||||
DiscourseAi::AiBot::Bot.as(
|
||||
Discourse.system_user,
|
||||
persona: ai_persona_klass.new,
|
||||
model: llm_model,
|
||||
)
|
||||
|
||||
streamed_reply = +""
|
||||
start = Time.now
|
||||
|
||||
base = { query: query, model_used: llm_model.display_name }
|
||||
|
||||
bot.reply(
|
||||
{ conversation_context: [{ type: :user, content: query }], skip_tool_details: true },
|
||||
) do |partial|
|
||||
streamed_reply << partial
|
||||
|
||||
# Throttle updates.
|
||||
if (Time.now - start > 0.3) || Rails.env.test?
|
||||
payload = base.merge(done: false, ai_discover_reply: streamed_reply)
|
||||
publish_update(user, payload)
|
||||
start = Time.now
|
||||
end
|
||||
end
|
||||
|
||||
publish_update(user, base.merge(done: true, ai_discover_reply: streamed_reply))
|
||||
end
|
||||
|
||||
def publish_update(user, payload)
|
||||
MessageBus.publish("/discourse-ai/ai-bot/discover", payload, user_ids: [user.id])
|
||||
end
|
||||
end
|
||||
end
|
@ -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 = "";
|
||||
}
|
||||
}
|
131
assets/stylesheets/common/ai-blinking-animation.scss
Normal file
131
assets/stylesheets/common/ai-blinking-animation.scss
Normal file
@ -0,0 +1,131 @@
|
||||
.ai-blinking-animation {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
&__list-item {
|
||||
background: var(--primary-300);
|
||||
border-radius: var(--d-border-radius);
|
||||
margin-right: 8px;
|
||||
margin-bottom: 8px;
|
||||
height: 1em;
|
||||
opacity: 0;
|
||||
display: block;
|
||||
&:nth-child(1) {
|
||||
width: 10%;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
width: 12%;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
width: 18%;
|
||||
}
|
||||
|
||||
&:nth-child(4) {
|
||||
width: 14%;
|
||||
}
|
||||
|
||||
&:nth-child(5) {
|
||||
width: 18%;
|
||||
}
|
||||
|
||||
&:nth-child(6) {
|
||||
width: 14%;
|
||||
}
|
||||
|
||||
&:nth-child(7) {
|
||||
width: 22%;
|
||||
}
|
||||
|
||||
&:nth-child(8) {
|
||||
width: 5%;
|
||||
}
|
||||
|
||||
&:nth-child(9) {
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
&:nth-child(10) {
|
||||
width: 14%;
|
||||
}
|
||||
|
||||
&:nth-child(11) {
|
||||
width: 18%;
|
||||
}
|
||||
|
||||
&:nth-child(12) {
|
||||
width: 12%;
|
||||
}
|
||||
|
||||
&:nth-child(13) {
|
||||
width: 22%;
|
||||
}
|
||||
|
||||
&:nth-child(14) {
|
||||
width: 18%;
|
||||
}
|
||||
|
||||
&:nth-child(15) {
|
||||
width: 13%;
|
||||
}
|
||||
|
||||
&:nth-child(16) {
|
||||
width: 22%;
|
||||
}
|
||||
|
||||
&:nth-child(17) {
|
||||
width: 19%;
|
||||
}
|
||||
|
||||
&:nth-child(18) {
|
||||
width: 13%;
|
||||
}
|
||||
|
||||
&:nth-child(19) {
|
||||
width: 22%;
|
||||
}
|
||||
|
||||
&:nth-child(20) {
|
||||
width: 25%;
|
||||
}
|
||||
&.is-shown {
|
||||
opacity: 1;
|
||||
}
|
||||
&.show {
|
||||
animation: appear 0.5s cubic-bezier(0.445, 0.05, 0.55, 0.95) 0s forwards;
|
||||
@media (prefers-reduced-motion) {
|
||||
animation-duration: 0s;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
&.blink {
|
||||
animation: blink 0.5s cubic-bezier(0.55, 0.085, 0.68, 0.53) both;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes appear {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
.ai-search-discoveries {
|
||||
&__regular-results-title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&__completion {
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
&__discovery-preview {
|
||||
@include ellipsis;
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
&__discoveries-title,
|
||||
&__regular-results-title {
|
||||
padding-bottom: 0.5em;
|
||||
border-bottom: 1px solid var(--primary-low);
|
||||
}
|
||||
|
||||
&__toggle {
|
||||
padding-left: 0;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-discobot-discoveries {
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
||||
.full-page-discoveries {
|
||||
padding: 1em 10%;
|
||||
}
|
@ -28,115 +28,6 @@
|
||||
|
||||
.ai-summary-modal {
|
||||
.ai-summary {
|
||||
&__list {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
&__list-item {
|
||||
background: var(--primary-300);
|
||||
border-radius: var(--d-border-radius);
|
||||
margin-right: 8px;
|
||||
margin-bottom: 8px;
|
||||
height: 1em;
|
||||
opacity: 0;
|
||||
display: block;
|
||||
&:nth-child(1) {
|
||||
width: 10%;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
width: 12%;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
width: 18%;
|
||||
}
|
||||
|
||||
&:nth-child(4) {
|
||||
width: 14%;
|
||||
}
|
||||
|
||||
&:nth-child(5) {
|
||||
width: 18%;
|
||||
}
|
||||
|
||||
&:nth-child(6) {
|
||||
width: 14%;
|
||||
}
|
||||
|
||||
&:nth-child(7) {
|
||||
width: 22%;
|
||||
}
|
||||
|
||||
&:nth-child(8) {
|
||||
width: 05%;
|
||||
}
|
||||
|
||||
&:nth-child(9) {
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
&:nth-child(10) {
|
||||
width: 14%;
|
||||
}
|
||||
|
||||
&:nth-child(11) {
|
||||
width: 18%;
|
||||
}
|
||||
|
||||
&:nth-child(12) {
|
||||
width: 12%;
|
||||
}
|
||||
|
||||
&:nth-child(13) {
|
||||
width: 22%;
|
||||
}
|
||||
|
||||
&:nth-child(14) {
|
||||
width: 18%;
|
||||
}
|
||||
|
||||
&:nth-child(15) {
|
||||
width: 13%;
|
||||
}
|
||||
|
||||
&:nth-child(16) {
|
||||
width: 22%;
|
||||
}
|
||||
|
||||
&:nth-child(17) {
|
||||
width: 19%;
|
||||
}
|
||||
|
||||
&:nth-child(18) {
|
||||
width: 13%;
|
||||
}
|
||||
|
||||
&:nth-child(19) {
|
||||
width: 22%;
|
||||
}
|
||||
|
||||
&:nth-child(20) {
|
||||
width: 25%;
|
||||
}
|
||||
&.is-shown {
|
||||
opacity: 1;
|
||||
}
|
||||
&.show {
|
||||
animation: appear 0.5s cubic-bezier(0.445, 0.05, 0.55, 0.95) 0s forwards;
|
||||
@media (prefers-reduced-motion) {
|
||||
animation-duration: 0s;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
&.blink {
|
||||
animation: blink 0.5s cubic-bezier(0.55, 0.085, 0.68, 0.53) both;
|
||||
}
|
||||
}
|
||||
}
|
||||
&__generating-text {
|
||||
display: inline-block;
|
||||
margin-left: 3px;
|
||||
@ -204,24 +95,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes appear {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
@ -672,6 +672,16 @@ en:
|
||||
compact: "Compact"
|
||||
expanded: "Expanded"
|
||||
expanded_description: "with AI summaries"
|
||||
|
||||
discobot_discoveries:
|
||||
main_title: "Discobot discoveries"
|
||||
regular_results: "Topics"
|
||||
tell_me_more: "Tell me more..."
|
||||
collapse: "Collapse"
|
||||
timed_out: "Discobot couldn't find any discoveries. Something went wrong."
|
||||
tooltip:
|
||||
title: "AI powered search"
|
||||
body: "Natural language search powered by %{model}"
|
||||
review:
|
||||
types:
|
||||
reviewable_ai_post:
|
||||
|
@ -24,6 +24,8 @@ DiscourseAi::Engine.routes.draw do
|
||||
get "post/:post_id/show-debug-info" => "bot#show_debug_info"
|
||||
get "show-debug-info/:id" => "bot#show_debug_info_by_id"
|
||||
post "post/:post_id/stop-streaming" => "bot#stop_streaming_response"
|
||||
|
||||
get "discover" => "bot#discover"
|
||||
end
|
||||
|
||||
scope module: :ai_bot, path: "/ai-bot/shared-ai-conversations" do
|
||||
|
@ -311,6 +311,12 @@ discourse_ai:
|
||||
hidden: true
|
||||
type: list
|
||||
list_type: compact
|
||||
ai_bot_discover_persona:
|
||||
default: ""
|
||||
type: enum
|
||||
hidden: true
|
||||
client: true
|
||||
enum: "DiscourseAi::Configuration::PersonaEnumerator"
|
||||
ai_automation_max_triage_per_minute:
|
||||
default: 60
|
||||
hidden: true
|
||||
|
@ -166,8 +166,19 @@ module DiscourseAi
|
||||
scope.user.in_any_groups?(SiteSetting.ai_bot_public_sharing_allowed_groups_map)
|
||||
end
|
||||
|
||||
plugin.register_svg_icon("robot")
|
||||
plugin.register_svg_icon("info")
|
||||
plugin.add_to_serializer(
|
||||
:current_user,
|
||||
:can_use_ai_bot_discover_persona,
|
||||
include_condition: -> do
|
||||
SiteSetting.ai_bot_enabled && scope.authenticated? &&
|
||||
SiteSetting.ai_bot_discover_persona.present?
|
||||
end,
|
||||
) do
|
||||
persona_allowed_groups =
|
||||
AiPersona.find_by(id: SiteSetting.ai_bot_discover_persona)&.allowed_group_ids.to_a
|
||||
|
||||
scope.user.in_any_groups?(persona_allowed_groups)
|
||||
end
|
||||
|
||||
plugin.add_to_serializer(
|
||||
:topic_view,
|
||||
|
@ -7,9 +7,6 @@ module DiscourseAi
|
||||
Rails.root.join("plugins", "discourse-ai", "db", "fixtures", "ai_helper"),
|
||||
)
|
||||
|
||||
additional_icons = %w[spell-check language images far-copy]
|
||||
additional_icons.each { |icon| plugin.register_svg_icon(icon) }
|
||||
|
||||
plugin.add_to_serializer(:current_user, :can_use_assistant) do
|
||||
scope.user.in_any_groups?(SiteSetting.composer_ai_helper_allowed_groups_map)
|
||||
end
|
||||
|
@ -4,9 +4,6 @@ module DiscourseAi
|
||||
module Embeddings
|
||||
class EntryPoint
|
||||
def inject_into(plugin)
|
||||
# far-circle-question used by semantic search unavailable tooltip
|
||||
plugin.register_svg_icon "far-circle-question" if plugin.respond_to?(:register_svg_icon)
|
||||
|
||||
# Include random topics in the suggested list *only* if there are no related topics.
|
||||
plugin.register_modifier(
|
||||
:topic_view_suggested_topics_options,
|
||||
|
14
plugin.rb
14
plugin.rb
@ -25,6 +25,7 @@ gem "pdf-reader", "2.14.1", require: false
|
||||
enabled_site_setting :discourse_ai_enabled
|
||||
|
||||
register_asset "stylesheets/common/streaming.scss"
|
||||
register_asset "stylesheets/common/ai-blinking-animation.scss"
|
||||
|
||||
register_asset "stylesheets/modules/ai-helper/common/ai-helper.scss"
|
||||
register_asset "stylesheets/modules/ai-helper/desktop/ai-helper-fk-modals.scss", :desktop
|
||||
@ -37,6 +38,7 @@ register_asset "stylesheets/modules/summarization/common/ai-gists.scss"
|
||||
|
||||
register_asset "stylesheets/modules/ai-bot/common/bot-replies.scss"
|
||||
register_asset "stylesheets/modules/ai-bot/common/ai-persona.scss"
|
||||
register_asset "stylesheets/modules/ai-bot/common/ai-discobot-discoveries.scss"
|
||||
register_asset "stylesheets/modules/ai-bot/mobile/ai-persona.scss", :mobile
|
||||
|
||||
register_asset "stylesheets/modules/embeddings/common/semantic-related-topics.scss"
|
||||
@ -119,4 +121,16 @@ after_initialize do
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
plugin_icons = %w[
|
||||
spell-check
|
||||
language
|
||||
images
|
||||
far-copy
|
||||
robot
|
||||
info
|
||||
bars-staggered
|
||||
far-circle-question
|
||||
]
|
||||
plugin_icons.each { |icon| register_svg_icon(icon) }
|
||||
end
|
||||
|
52
spec/jobs/regular/stream_discover_reply_spec.rb
Normal file
52
spec/jobs/regular/stream_discover_reply_spec.rb
Normal file
@ -0,0 +1,52 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
RSpec.describe Jobs::StreamDiscoverReply do
|
||||
subject(:job) { described_class.new }
|
||||
|
||||
describe "#execute" do
|
||||
fab!(:user)
|
||||
fab!(:llm_model)
|
||||
fab!(:group)
|
||||
fab!(:ai_persona) do
|
||||
Fabricate(:ai_persona, allowed_group_ids: [group.id], default_llm_id: llm_model.id)
|
||||
end
|
||||
|
||||
before do
|
||||
SiteSetting.ai_bot_discover_persona = ai_persona.id
|
||||
group.add(user)
|
||||
end
|
||||
|
||||
def with_responses(responses)
|
||||
DiscourseAi::Completions::Llm.with_prepared_responses(responses) { yield }
|
||||
end
|
||||
|
||||
it "publishes updates with a partial summary" do
|
||||
with_responses(["dummy"]) do
|
||||
messages =
|
||||
MessageBus.track_publish("/discourse-ai/ai-bot/discover") do
|
||||
job.execute(user_id: user.id, query: "Testing search")
|
||||
end
|
||||
|
||||
partial_update = messages.first.data
|
||||
expect(partial_update[:done]).to eq(false)
|
||||
expect(partial_update[:model_used]).to eq(llm_model.display_name)
|
||||
expect(partial_update[:ai_discover_reply]).to eq("dummy")
|
||||
end
|
||||
end
|
||||
|
||||
it "publishes a final update to signal we're done" do
|
||||
with_responses(["dummy"]) do
|
||||
messages =
|
||||
MessageBus.track_publish("/discourse-ai/ai-bot/discover") do
|
||||
job.execute(user_id: user.id, query: "Testing search")
|
||||
end
|
||||
|
||||
final_update = messages.last.data
|
||||
expect(final_update[:done]).to eq(true)
|
||||
|
||||
expect(final_update[:model_used]).to eq(llm_model.display_name)
|
||||
expect(final_update[:ai_discover_reply]).to eq("dummy")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
@ -122,4 +122,50 @@ RSpec.describe DiscourseAi::AiBot::BotController do
|
||||
expect(response.parsed_body["bot_username"]).to eq(expected_username)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#discover" do
|
||||
before { SiteSetting.ai_bot_enabled = true }
|
||||
|
||||
fab!(:group)
|
||||
fab!(:ai_persona) { Fabricate(:ai_persona, allowed_group_ids: [group.id], default_llm_id: 1) }
|
||||
|
||||
context "when no persona is selected" do
|
||||
it "returns a 403" do
|
||||
get "/discourse-ai/ai-bot/discover", params: { query: "What is Discourse?" }
|
||||
|
||||
expect(response.status).to eq(403)
|
||||
end
|
||||
end
|
||||
|
||||
context "when the user doesn't have access to the persona" do
|
||||
before { SiteSetting.ai_bot_discover_persona = ai_persona.id }
|
||||
|
||||
it "returns a 403" do
|
||||
get "/discourse-ai/ai-bot/discover", params: { query: "What is Discourse?" }
|
||||
|
||||
expect(response.status).to eq(403)
|
||||
end
|
||||
end
|
||||
|
||||
context "when the user is allowed to use discover" do
|
||||
before do
|
||||
SiteSetting.ai_bot_discover_persona = ai_persona.id
|
||||
group.add(user)
|
||||
end
|
||||
|
||||
it "returns a 200 and queues a job to reply" do
|
||||
expect {
|
||||
get "/discourse-ai/ai-bot/discover", params: { query: "What is Discourse?" }
|
||||
}.to change(Jobs::StreamDiscoverReply.jobs, :size).by(1)
|
||||
|
||||
expect(response.status).to eq(200)
|
||||
end
|
||||
|
||||
it "retues a 400 if the query is missing" do
|
||||
get "/discourse-ai/ai-bot/discover"
|
||||
|
||||
expect(response.status).to eq(400)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
Loading…
x
Reference in New Issue
Block a user