Skip to content
This repository was archived by the owner on Jul 22, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions app/controllers/discourse_ai/ai_bot/bot_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,31 @@ def show_bot_username

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 changes: 53 additions & 0 deletions app/jobs/regular/stream_discover_reply.rb
Original file line number Diff line number Diff line change
@@ -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
115 changes: 115 additions & 0 deletions assets/javascripts/discourse/components/ai-blinking-animation.gjs
Original file line number Diff line number Diff line change
@@ -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>
}
Loading