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

Commit f683d69

Browse files
committed
FEATURE: new endpoint for directly accessing a persona
The new `/admin/plugins/discourse-ai/ai-personas/stream-reply.json` was added. This endpoint streams data direct from a persona and can be used to access a persona from remote systems leaving a paper trail in PMs about the conversation that happened This endpoint is only accessible to admins.
1 parent c479b17 commit f683d69

File tree

8 files changed

+395
-6
lines changed

8 files changed

+395
-6
lines changed

app/controllers/discourse_ai/admin/ai_personas_controller.rb

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,192 @@ def destroy
7474
end
7575
end
7676

77+
class << self
78+
POOL_SIZE = 10
79+
def thread_pool
80+
@thread_pool ||=
81+
Concurrent::CachedThreadPool.new(min_threads: 0, max_threads: POOL_SIZE, idletime: 30)
82+
end
83+
84+
def queue_reply(&block)
85+
# think about a better way to handle cross thread connections
86+
if Rails.env.test?
87+
block.call
88+
return
89+
end
90+
91+
db = RailsMultisite::ConnectionManagement.current_db
92+
thread_pool.post do
93+
RailsMultisite::ConnectionManagement.with_connection(db) { block.call }
94+
end
95+
end
96+
end
97+
98+
CRLF = "\r\n"
99+
100+
def stream_reply
101+
persona =
102+
AiPersona.find_by(name: params[:persona_name]) ||
103+
AiPersona.find_by(id: params[:persona_id])
104+
return render_json_error(I18n.t("discourse_ai.errors.persona_not_found")) if persona.nil?
105+
106+
return render_json_error(I18n.t("discourse_ai.errors.persona_disabled")) if !persona.enabled
107+
108+
if !persona.default_llm.present?
109+
return render_json_error(I18n.t("discourse_ai.errors.no_default_llm"))
110+
end
111+
112+
if params[:query].blank?
113+
return render_json_error(I18n.t("discourse_ai.errors.no_query_specified"))
114+
end
115+
116+
if !persona.user_id
117+
return render_json_error(I18n.t("discourse_ai.errors.no_user_for_persona"))
118+
end
119+
120+
if !params[:username] && !params[:user_unique_id]
121+
return render_json_error(I18n.t("discourse_ai.errors.no_user_specified"))
122+
end
123+
124+
user = nil
125+
126+
if params[:username]
127+
user = User.find_by(username_lower: params[:username].downcase)
128+
return render_json_error(I18n.t("discourse_ai.errors.user_not_found")) if user.nil?
129+
elsif params[:user_unique_id]
130+
user = stage_user
131+
end
132+
133+
raise Discourse::NotFound if user.nil?
134+
135+
topic_id = params[:topic_id].to_i
136+
topic = nil
137+
post = nil
138+
139+
if topic_id > 0
140+
topic = Topic.find(topic_id)
141+
142+
raise Discourse::NotFound if topic.nil?
143+
144+
if topic.topic_allowed_users.where(user_id: user.id).empty?
145+
return render_json_error(I18n.t("discourse_ai.errors.user_not_allowed"))
146+
end
147+
148+
post =
149+
PostCreator.create!(
150+
user,
151+
topic_id: topic_id,
152+
raw: params[:query],
153+
skip_validations: true,
154+
)
155+
else
156+
post =
157+
PostCreator.create!(
158+
user,
159+
title: I18n.t("discourse_ai.ai_bot.default_pm_prefix"),
160+
raw: params[:question],
161+
archetype: Archetype.private_message,
162+
target_usernames: "#{user.username},#{persona.user.username}",
163+
skip_validations: true,
164+
)
165+
166+
topic = post.topic
167+
end
168+
169+
hijack = request.env["rack.hijack"]
170+
io = hijack.call
171+
172+
user = current_user
173+
174+
self.class.queue_reply do
175+
begin
176+
io.write "HTTP/1.1 200 OK"
177+
io.write CRLF
178+
io.write "Content-Type: application/json"
179+
io.write CRLF
180+
io.write "Transfer-Encoding: chunked"
181+
io.write CRLF
182+
io.write "Cache-Control: no-cache, no-store, must-revalidate"
183+
io.write CRLF
184+
io.write "Connection: keep-alive"
185+
io.write CRLF
186+
io.write "X-Accel-Buffering: no"
187+
io.write CRLF
188+
io.write CRLF
189+
io.flush
190+
191+
persona_class =
192+
DiscourseAi::AiBot::Personas::Persona.find_by(id: persona.id, user: user)
193+
bot = DiscourseAi::AiBot::Bot.as(persona.user, persona: persona_class.new)
194+
195+
topic_id = topic.id
196+
data =
197+
{ topic_id: topic.id, bot_user_id: persona.user.id, persona_id: persona.id }.to_json +
198+
"\n\n"
199+
200+
io.write data.bytesize.to_s(16)
201+
io.write CRLF
202+
io.write data
203+
io.write CRLF
204+
205+
io.flush
206+
207+
DiscourseAi::AiBot::Playground
208+
.new(bot)
209+
.reply_to(post) do |partial|
210+
data = { partial: partial }.to_json + "\n\n"
211+
212+
io.write data.bytesize.to_s(16)
213+
io.write CRLF
214+
io.write data
215+
io.write CRLF
216+
217+
io.flush
218+
end
219+
220+
# End the response with zero-length chunk
221+
io.write "0"
222+
io.write CRLF
223+
io.write CRLF
224+
io.flush
225+
rescue StandardError => e
226+
p e
227+
puts e.backtrace
228+
rescue IOError => e
229+
Rails.logger.error "Streaming error: #{e.message}"
230+
ensure
231+
io.close
232+
end
233+
end
234+
end
235+
77236
private
78237

238+
AI_STREAM_CONVERSATION_UNIQUE_ID = "ai-stream-conversation-unique-id"
239+
240+
def stage_user
241+
unique_id = params[:user_unique_id].to_s
242+
field = UserCustomField.find_by(name: AI_STREAM_CONVERSATION_UNIQUE_ID, value: unique_id)
243+
244+
if field
245+
field.user
246+
else
247+
preferred_username = params[:preferred_username]
248+
username = UserNameSuggester.suggest(preferred_username || unique_id)
249+
250+
user =
251+
User.new(
252+
username: username,
253+
email: "#{SecureRandom.hex}@invalid.com",
254+
staged: true,
255+
active: false,
256+
)
257+
user.custom_fields[AI_STREAM_CONVERSATION_UNIQUE_ID] = unique_id
258+
user.save!
259+
user
260+
end
261+
end
262+
79263
def find_ai_persona
80264
@ai_persona = AiPersona.find(params[:id])
81265
end

config/locales/server.en.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,3 +347,13 @@ en:
347347
llm_models:
348348
missing_provider_param: "%{param} can't be blank"
349349
bedrock_invalid_url: "Please complete all the fields to contact this model."
350+
351+
errors:
352+
no_query_specified: The query parameter is required, please specify it.
353+
no_user_for_persona: The persona specified does not have a user associated with it.
354+
persona_not_found: The persona specified does not exist. Check the persona_name or persona_id params.
355+
no_user_specified: The username or the user_unique_id parameter is required, please specify it.
356+
user_not_found: The user specified does not exist. Check the username param.
357+
persona_disabled: The persona specified is disabled. Check the persona_name or persona_id params.
358+
no_default_llm: The persona must have a default_llm defined.
359+
user_not_allowed: The user is not allowed to participate in the topic.

config/routes.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@
5050
path: "ai-personas",
5151
controller: "discourse_ai/admin/ai_personas"
5252

53+
post "/ai-personas/stream-reply" => "discourse_ai/admin/ai_personas#stream_reply"
54+
5355
resources(
5456
:ai_tools,
5557
only: %i[index create show update destroy],
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# frozen_string_literal: true
2+
class AddUniqueAiStreamConversationUserIdIndex < ActiveRecord::Migration[7.1]
3+
def change
4+
add_index :user_custom_fields, [:value], unique: true, where: "name = 'ai-stream-conversation-unique-id'"
5+
end
6+
end

lib/ai_bot/bot.rb

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ def reply(context, &update_blk)
113113
tool_found = true
114114
# a bit hacky, but extra newlines do no harm
115115
if needs_newlines
116-
update_blk.call("\n\n", cancel, nil)
116+
update_blk.call("\n\n", cancel)
117117
needs_newlines = false
118118
end
119119

@@ -123,7 +123,7 @@ def reply(context, &update_blk)
123123
end
124124
else
125125
needs_newlines = true
126-
update_blk.call(partial, cancel, nil)
126+
update_blk.call(partial, cancel)
127127
end
128128
end
129129

@@ -191,9 +191,9 @@ def invoke_tool(tool, llm, cancel, context, &update_blk)
191191
tool_details = build_placeholder(tool.summary, tool.details, custom_raw: tool.custom_raw)
192192

193193
if context[:skip_tool_details] && tool.custom_raw.present?
194-
update_blk.call(tool.custom_raw, cancel, nil)
194+
update_blk.call(tool.custom_raw, cancel, nil, :custom_raw)
195195
elsif !context[:skip_tool_details]
196-
update_blk.call(tool_details, cancel, nil)
196+
update_blk.call(tool_details, cancel, nil, :tool_details)
197197
end
198198

199199
result

lib/ai_bot/playground.rb

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -390,7 +390,7 @@ def get_context(participants:, conversation_context:, user:, skip_tool_details:
390390
result
391391
end
392392

393-
def reply_to(post)
393+
def reply_to(post, &blk)
394394
reply = +""
395395
start = Time.now
396396

@@ -441,11 +441,15 @@ def reply_to(post)
441441
context[:skip_tool_details] ||= !bot.persona.class.tool_details
442442

443443
new_custom_prompts =
444-
bot.reply(context) do |partial, cancel, placeholder|
444+
bot.reply(context) do |partial, cancel, placeholder, type|
445445
reply << partial
446446
raw = reply.dup
447447
raw << "\n\n" << placeholder if placeholder.present? && !context[:skip_tool_details]
448448

449+
if blk && type != :tool_details
450+
blk.call(partial)
451+
end
452+
449453
if stream_reply && !Discourse.redis.get(redis_stream_key)
450454
cancel&.call
451455
reply_post.update!(raw: reply, cooked: PrettyText.cook(reply))

lib/completions/endpoints/fake.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ def self.with_fake_content(content)
7272
@fake_content = nil
7373
end
7474

75+
def self.fake_content=(content)
76+
@fake_content = content
77+
end
78+
7579
def self.fake_content
7680
@fake_content || STOCK_CONTENT
7781
end
@@ -100,6 +104,13 @@ def self.last_call=(params)
100104
@last_call = params
101105
end
102106

107+
def self.reset!
108+
@last_call = nil
109+
@fake_content = nil
110+
@delays = nil
111+
@chunk_count = nil
112+
end
113+
103114
def perform_completion!(
104115
dialect,
105116
user,
@@ -111,6 +122,8 @@ def perform_completion!(
111122

112123
content = self.class.fake_content
113124

125+
content = content.shift if content.is_a?(Array)
126+
114127
if block_given?
115128
split_indices = (1...content.length).to_a.sample(self.class.chunk_count - 1).sort
116129
indexes = [0, *split_indices, content.length]

0 commit comments

Comments
 (0)