Skip to content
This repository was archived by the owner on Jul 22, 2025. It is now read-only.

Commit ba915a7

Browse files
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]>
1 parent 86775b1 commit ba915a7

File tree

20 files changed

+803
-259
lines changed

20 files changed

+803
-259
lines changed

app/controllers/discourse_ai/ai_bot/bot_controller.rb

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,31 @@ def show_bot_username
4444

4545
render json: { bot_username: bot_user.username_lower }, status: 200
4646
end
47+
48+
def discover
49+
ai_persona =
50+
AiPersona.all_personas.find do |persona|
51+
persona.id == SiteSetting.ai_bot_discover_persona.to_i
52+
end
53+
54+
if ai_persona.nil? || !current_user.in_any_groups?(ai_persona.allowed_group_ids.to_a)
55+
raise Discourse::InvalidAccess.new
56+
end
57+
58+
if ai_persona.default_llm_id.blank?
59+
render_json_error "Discover persona is missing a default LLM model.", status: 503
60+
return
61+
end
62+
63+
query = params[:query]
64+
raise Discourse::InvalidParameters.new("Missing query to discover") if query.blank?
65+
66+
RateLimiter.new(current_user, "ai_bot_discover_#{current_user.id}", 3, 1.minute).performed!
67+
68+
Jobs.enqueue(:stream_discover_reply, user_id: current_user.id, query: query)
69+
70+
render json: {}, status: 200
71+
end
4772
end
4873
end
4974
end
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# frozen_string_literal: true
2+
3+
module Jobs
4+
class StreamDiscoverReply < ::Jobs::Base
5+
sidekiq_options retry: false
6+
7+
def execute(args)
8+
return if (user = User.find_by(id: args[:user_id])).nil?
9+
return if (query = args[:query]).blank?
10+
11+
ai_persona_klass =
12+
AiPersona.all_personas.find do |persona|
13+
persona.id == SiteSetting.ai_bot_discover_persona.to_i
14+
end
15+
16+
if ai_persona_klass.nil? || !user.in_any_groups?(ai_persona_klass.allowed_group_ids.to_a)
17+
return
18+
end
19+
return if (llm_model = LlmModel.find_by(id: ai_persona_klass.default_llm_id)).nil?
20+
21+
bot =
22+
DiscourseAi::AiBot::Bot.as(
23+
Discourse.system_user,
24+
persona: ai_persona_klass.new,
25+
model: llm_model,
26+
)
27+
28+
streamed_reply = +""
29+
start = Time.now
30+
31+
base = { query: query, model_used: llm_model.display_name }
32+
33+
bot.reply(
34+
{ conversation_context: [{ type: :user, content: query }], skip_tool_details: true },
35+
) do |partial|
36+
streamed_reply << partial
37+
38+
# Throttle updates.
39+
if (Time.now - start > 0.3) || Rails.env.test?
40+
payload = base.merge(done: false, ai_discover_reply: streamed_reply)
41+
publish_update(user, payload)
42+
start = Time.now
43+
end
44+
end
45+
46+
publish_update(user, base.merge(done: true, ai_discover_reply: streamed_reply))
47+
end
48+
49+
def publish_update(user, payload)
50+
MessageBus.publish("/discourse-ai/ai-bot/discover", payload, user_ids: [user.id])
51+
end
52+
end
53+
end
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import Component from "@glimmer/component";
2+
import { tracked } from "@glimmer/tracking";
3+
import { fn } from "@ember/helper";
4+
import { action } from "@ember/object";
5+
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
6+
import didUpdate from "@ember/render-modifiers/modifiers/did-update";
7+
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
8+
import { cancel } from "@ember/runloop";
9+
import concatClass from "discourse/helpers/concat-class";
10+
import discourseLater from "discourse/lib/later";
11+
12+
class Block {
13+
@tracked show = false;
14+
@tracked shown = false;
15+
@tracked blinking = false;
16+
17+
constructor(args = {}) {
18+
this.show = args.show ?? false;
19+
this.shown = args.shown ?? false;
20+
}
21+
}
22+
23+
const BLOCKS_SIZE = 20; // changing this requires to change css accordingly
24+
25+
export default class AiBlinkingAnimation extends Component {
26+
blocks = [...Array.from({ length: BLOCKS_SIZE }, () => new Block())];
27+
28+
#nextBlockBlinkingTimer;
29+
#blockBlinkingTimer;
30+
#blockShownTimer;
31+
32+
@action
33+
setupAnimation() {
34+
this.blocks.firstObject.show = true;
35+
this.blocks.firstObject.shown = true;
36+
}
37+
38+
@action
39+
onBlinking(block) {
40+
if (!block.blinking) {
41+
return;
42+
}
43+
44+
block.show = false;
45+
46+
this.#nextBlockBlinkingTimer = discourseLater(
47+
this,
48+
() => {
49+
this.#nextBlock(block).blinking = true;
50+
},
51+
250
52+
);
53+
54+
this.#blockBlinkingTimer = discourseLater(
55+
this,
56+
() => {
57+
block.blinking = false;
58+
},
59+
500
60+
);
61+
}
62+
63+
@action
64+
onShowing(block) {
65+
if (!block.show) {
66+
return;
67+
}
68+
69+
this.#blockShownTimer = discourseLater(
70+
this,
71+
() => {
72+
this.#nextBlock(block).show = true;
73+
this.#nextBlock(block).shown = true;
74+
75+
if (this.blocks.lastObject === block) {
76+
this.blocks.firstObject.blinking = true;
77+
}
78+
},
79+
250
80+
);
81+
}
82+
83+
@action
84+
teardownAnimation() {
85+
cancel(this.#blockShownTimer);
86+
cancel(this.#nextBlockBlinkingTimer);
87+
cancel(this.#blockBlinkingTimer);
88+
}
89+
90+
#nextBlock(currentBlock) {
91+
if (currentBlock === this.blocks.lastObject) {
92+
return this.blocks.firstObject;
93+
} else {
94+
return this.blocks.objectAt(this.blocks.indexOf(currentBlock) + 1);
95+
}
96+
}
97+
98+
<template>
99+
<ul class="ai-blinking-animation" {{didInsert this.setupAnimation}}>
100+
{{#each this.blocks as |block|}}
101+
<li
102+
class={{concatClass
103+
"ai-blinking-animation__list-item"
104+
(if block.show "show")
105+
(if block.shown "is-shown")
106+
(if block.blinking "blink")
107+
}}
108+
{{didUpdate (fn this.onBlinking block) block.blinking}}
109+
{{didUpdate (fn this.onShowing block) block.show}}
110+
{{willDestroy this.teardownAnimation}}
111+
></li>
112+
{{/each}}
113+
</ul>
114+
</template>
115+
}

0 commit comments

Comments
 (0)