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

Commit e1a0eb6

Browse files
authored
FEATURE: support chain halting and upload creation support (#821)
This adds chain halting (ability to terminate llm chain in a tool) and the ability to create uploads in a tool Together this lets us integrate custom image generators into a custom tool.
1 parent 3170e14 commit e1a0eb6

File tree

7 files changed

+203
-29
lines changed

7 files changed

+203
-29
lines changed

app/controllers/discourse_ai/admin/ai_tools_controller.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ def test
4949
if params[:id].present?
5050
ai_tool = AiTool.find(params[:id])
5151
else
52-
ai_tool = AiTool.new(ai_tool_params)
52+
ai_tool = AiTool.new(ai_tool_params.except(:rag_uploads))
5353
end
5454

5555
parameters = params[:parameters].to_unsafe_h

app/models/ai_tool.rb

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,19 @@ def self.preamble
9393
* Returns:
9494
* Array of { fragment: string, metadata: string }
9595
*
96+
* 4. upload
97+
* upload.create(filename, base_64_content): Uploads a file.
98+
* Parameters:
99+
* filename (string): Name of the file.
100+
* base_64_content (string): Base64 encoded file content.
101+
* Returns:
102+
* { id: number, short_url: string }
103+
*
104+
* 5. chain
105+
* chain.setCustomRaw(raw): Sets the body of the post and exist chain.
106+
* Parameters:
107+
* raw (string): raw content to add to post.
108+
*
96109
* Constraints
97110
*
98111
* Execution Time: ≤ 2000ms
@@ -236,6 +249,70 @@ def self.presets
236249
SCRIPT
237250
summary: "Get real-time stock quotes using AlphaVantage API",
238251
},
252+
{
253+
preset_id: "image_generation",
254+
name: "image_generation",
255+
description:
256+
"Generate images using the FLUX model from Black Forest Labs using together.ai",
257+
parameters: [
258+
{
259+
name: "prompt",
260+
type: "string",
261+
required: true,
262+
description: "The text prompt for image generation",
263+
},
264+
{
265+
name: "seed",
266+
type: "number",
267+
required: false,
268+
description: "Optional seed for random number generation",
269+
},
270+
],
271+
script: <<~SCRIPT,
272+
#{preamble}
273+
const apiKey = "YOUR_KEY";
274+
const model = "black-forest-labs/FLUX.1.1-pro";
275+
276+
function invoke(params) {
277+
let seed = parseInt(params.seed);
278+
if (!(seed > 0)) {
279+
seed = Math.floor(Math.random() * 1000000) + 1;
280+
}
281+
282+
const prompt = params.prompt;
283+
const body = {
284+
model: model,
285+
prompt: prompt,
286+
width: 1024,
287+
height: 768,
288+
steps: 10,
289+
n: 1,
290+
seed: seed,
291+
response_format: "b64_json",
292+
};
293+
294+
const result = http.post("https://api.together.xyz/v1/images/generations", {
295+
headers: {
296+
"Authorization": `Bearer ${apiKey}`,
297+
"Content-Type": "application/json",
298+
},
299+
body: JSON.stringify(body),
300+
});
301+
302+
const base64Image = JSON.parse(result.body).data[0].b64_json;
303+
const image = upload.create("generated_image.png", base64Image);
304+
const raw = `\n![${prompt}](${image.short_url})\n`;
305+
chain.setCustomRaw(raw);
306+
307+
return { result: "Image generated successfully", seed: seed };
308+
}
309+
310+
function details() {
311+
return "Generates images based on a text prompt using the FLUX model.";
312+
}
313+
SCRIPT
314+
summary: "Generate image",
315+
},
239316
{ preset_id: "empty_tool", script: <<~SCRIPT },
240317
#{preamble}
241318
function invoke(params) {

config/locales/server.en.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,11 +128,13 @@ en:
128128
custom_name: "%{name} (custom)"
129129
presets:
130130
browse_web_jina:
131-
name: "Browse web using jina.ai"
131+
name: "Browse web (jina.ai)"
132132
exchange_rate:
133133
name: "Exchange rate"
134134
stock_quote:
135135
name: "Stock quote (AlphaVantage)"
136+
image_generation:
137+
name: "Flux image generator (Together.ai)"
136138
empty_tool:
137139
name: "Start from blank..."
138140

lib/ai_bot/tool_runner.rb

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ module DiscourseAi
44
module AiBot
55
class ToolRunner
66
attr_reader :tool, :parameters, :llm
7-
attr_accessor :running_attached_function, :timeout
7+
attr_accessor :running_attached_function, :timeout, :custom_raw
88

99
TooManyRequestsError = Class.new(StandardError)
1010

@@ -36,6 +36,8 @@ def mini_racer_context
3636
attach_truncate(ctx)
3737
attach_http(ctx)
3838
attach_index(ctx)
39+
attach_upload(ctx)
40+
attach_chain(ctx)
3941
ctx.eval(framework_script)
4042
ctx
4143
end
@@ -55,6 +57,15 @@ def framework_script
5557
const index = {
5658
search: _index_search,
5759
}
60+
61+
const upload = {
62+
create: _upload_create,
63+
}
64+
65+
const chain = {
66+
setCustomRaw: _chain_set_custom_raw,
67+
};
68+
5869
function details() { return ""; };
5970
JS
6071
end
@@ -176,6 +187,40 @@ def attach_index(mini_racer_context)
176187
)
177188
end
178189

190+
def attach_chain(mini_racer_context)
191+
mini_racer_context.attach("_chain_set_custom_raw", ->(raw) { self.custom_raw = raw })
192+
end
193+
194+
def attach_upload(mini_racer_context)
195+
mini_racer_context.attach(
196+
"_upload_create",
197+
->(filename, base_64_content) do
198+
begin
199+
self.running_attached_function = true
200+
# protect against misuse
201+
filename = File.basename(filename)
202+
203+
Tempfile.create(filename) do |file|
204+
file.binmode
205+
file.write(Base64.decode64(base_64_content))
206+
file.rewind
207+
208+
upload =
209+
UploadCreator.new(
210+
file,
211+
filename,
212+
for_private_message: @context[:private_message],
213+
).create_for(@bot_user.id)
214+
215+
{ id: upload.id, short_url: upload.short_url }
216+
end
217+
ensure
218+
self.running_attached_function = false
219+
end
220+
end,
221+
)
222+
end
223+
179224
def attach_http(mini_racer_context)
180225
mini_racer_context.attach(
181226
"_http_get",

lib/ai_bot/tools/custom.rb

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,18 @@ def self.name
3030
AiTool.where(id: tool_id).pluck(:name).first
3131
end
3232

33+
def initialize(*args, **kwargs)
34+
@chain_next_response = true
35+
super(*args, **kwargs)
36+
end
37+
3338
def invoke
34-
runner.invoke
39+
result = runner.invoke
40+
if runner.custom_raw
41+
self.custom_raw = runner.custom_raw
42+
@chain_next_response = false
43+
end
44+
result
3545
end
3646

3747
def runner
@@ -50,6 +60,10 @@ def details
5060
runner.details
5161
end
5262

63+
def chain_next_response?
64+
!!@chain_next_response
65+
end
66+
5367
def help
5468
# I do not think this is called, but lets make sure
5569
raise "Not implemented"

spec/lib/modules/ai_bot/playground_spec.rb

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -94,21 +94,52 @@
9494

9595
let(:playground) { DiscourseAi::AiBot::Playground.new(bot) }
9696

97+
it "can create uploads from a tool" do
98+
custom_tool.update!(script: <<~JS)
99+
let imageBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/wcAAgEB/awxUE0AAAAASUVORK5CYII="
100+
function invoke(params) {
101+
let image = upload.create("image.png", imageBase64);
102+
chain.setCustomRaw(`![image](${image.short_url})`);
103+
return image.id;
104+
};
105+
JS
106+
107+
tool_name = "custom-#{custom_tool.id}"
108+
ai_persona.update!(tools: [[tool_name, nil, true]], tool_details: false)
109+
110+
reply_post = nil
111+
prompts = nil
112+
113+
responses = [function_call]
114+
DiscourseAi::Completions::Llm.with_prepared_responses(responses) do |_, _, _prompts|
115+
new_post = Fabricate(:post, raw: "Can you use the custom tool?")
116+
reply_post = playground.reply_to(new_post)
117+
prompts = _prompts
118+
end
119+
120+
expect(prompts.length).to eq(1)
121+
upload_id = prompts[0].messages[3][:content].to_i
122+
123+
upload = Upload.find(upload_id)
124+
125+
expect(reply_post.raw).to eq("![image](#{upload.short_url})")
126+
end
127+
97128
it "can force usage of a tool" do
98129
tool_name = "custom-#{custom_tool.id}"
99-
ai_persona.update!(tools: [[tool_name, nil, "force"]])
130+
ai_persona.update!(tools: [[tool_name, nil, true]])
100131
responses = [function_call, "custom tool did stuff (maybe)"]
101132

102-
prompt = nil
103-
DiscourseAi::Completions::Llm.with_prepared_responses(responses) do |_, _, _prompt|
133+
prompts = nil
134+
DiscourseAi::Completions::Llm.with_prepared_responses(responses) do |_, _, _prompts|
104135
new_post = Fabricate(:post, raw: "Can you use the custom tool?")
105136
_reply_post = playground.reply_to(new_post)
106-
prompt = _prompt
137+
prompts = _prompts
107138
end
108139

109-
expect(prompt.length).to eq(2)
110-
expect(prompt[0].tool_choice).to eq("search")
111-
expect(prompt[1].tool_choice).to eq(nil)
140+
expect(prompts.length).to eq(2)
141+
expect(prompts[0].tool_choice).to eq("search")
142+
expect(prompts[1].tool_choice).to eq(nil)
112143
end
113144

114145
it "uses custom tool in conversation" do

spec/system/ai_bot/tool_spec.rb

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,26 +10,9 @@
1010
sign_in(admin)
1111
end
1212

13-
it "allows admin to create a new AI tool from preset" do
14-
visit "/admin/plugins/discourse-ai/ai-tools"
15-
16-
expect(page).to have_content("Tools")
17-
18-
find(".ai-tool-list-editor__new-button").click
19-
20-
select_kit = PageObjects::Components::SelectKit.new(".ai-tool-editor__presets")
21-
select_kit.expand
22-
select_kit.select_row_by_value("exchange_rate")
23-
24-
find(".ai-tool-editor__next").click
25-
26-
expect(page.first(".parameter-row__required-toggle").checked?).to eq(true)
27-
expect(page.first(".parameter-row__enum-toggle").checked?).to eq(false)
28-
13+
def ensure_can_run_test
2914
find(".ai-tool-editor__test-button").click
3015

31-
expect(page).not_to have_button(".ai-tool-editor__delete")
32-
3316
modal = PageObjects::Modals::AiToolTest.new
3417
modal.base_currency = "USD"
3518
modal.target_currency = "EUR"
@@ -48,14 +31,36 @@
4831
expect(modal).to have_content("0.85")
4932

5033
modal.close
34+
end
5135

36+
it "allows admin to create a new AI tool from preset" do
37+
visit "/admin/plugins/discourse-ai/ai-tools"
38+
39+
expect(page).to have_content("Tools")
40+
41+
find(".ai-tool-list-editor__new-button").click
42+
43+
select_kit = PageObjects::Components::SelectKit.new(".ai-tool-editor__presets")
44+
select_kit.expand
45+
select_kit.select_row_by_value("exchange_rate")
46+
47+
find(".ai-tool-editor__next").click
48+
49+
expect(page.first(".parameter-row__required-toggle").checked?).to eq(true)
50+
expect(page.first(".parameter-row__enum-toggle").checked?).to eq(false)
51+
52+
ensure_can_run_test
53+
54+
expect(page).not_to have_button(".ai-tool-editor__delete")
5255
find(".ai-tool-editor__save").click
5356

5457
expect(page).to have_content("Tool saved")
5558

5659
last_tool = AiTool.order("id desc").limit(1).first
5760
visit "/admin/plugins/discourse-ai/ai-tools/#{last_tool.id}"
5861

62+
ensure_can_run_test
63+
5964
expect(page.first(".parameter-row__required-toggle").checked?).to eq(true)
6065
expect(page.first(".parameter-row__enum-toggle").checked?).to eq(false)
6166

0 commit comments

Comments
 (0)