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

Commit c1b7804

Browse files
committed
read artifact tool
1 parent 646fae4 commit c1b7804

File tree

7 files changed

+362
-3
lines changed

7 files changed

+362
-3
lines changed

app/controllers/discourse_ai/ai_bot/artifacts_controller.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ def show
1212
artifact = AiArtifact.find(params[:id])
1313

1414
post = Post.find_by(id: artifact.post_id)
15-
if artifact.metadata&.dig("public")
15+
if artifact.public?
1616
# no guardian needed
1717
else
1818
raise Discourse::NotFound if !post&.topic&.private_message?

app/models/ai_artifact.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ def create_new_version(html: nil, css: nil, js: nil, change_description: nil)
7070

7171
version
7272
end
73+
74+
def public?
75+
!!metadata&.dig("public")
76+
end
7377
end
7478

7579
# == Schema Information

config/locales/server.en.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,7 @@ en:
327327
name: "Base Search Query"
328328
description: "Base query to use when searching. Example: '#urgent' will prepend '#urgent' to the search query and only include topics with the urgent category or tag."
329329
tool_summary:
330+
read_artifact: "Read a web artifact"
330331
update_artifact: "Update a web artifact"
331332
create_artifact: "Create web artifact"
332333
web_browser: "Browse Web"
@@ -350,6 +351,7 @@ en:
350351
search_meta_discourse: "Search Meta Discourse"
351352
javascript_evaluator: "Evaluate JavaScript"
352353
tool_help:
354+
read_artifact: "Read a web artifact using the AI Bot"
353355
update_artifact: "Update a web artifact using the AI Bot"
354356
create_artifact: "Create a web artifact using the AI Bot"
355357
web_browser: "Browse web page using the AI Bot"
@@ -373,6 +375,7 @@ en:
373375
search_meta_discourse: "Search Meta Discourse"
374376
javascript_evaluator: "Evaluate JavaScript"
375377
tool_description:
378+
read_artifact: "Read a web artifact using the AI Bot"
376379
update_artifact: "Updated a web artifact using the AI Bot"
377380
create_artifact: "Created a web artifact: %{name} - %{specification}"
378381
web_browser: "Reading <a href='%{url}'>%{url}</a>"

lib/ai_bot/personas/persona.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ def all_available_tools
102102
if SiteSetting.ai_artifact_security.in?(%w[lax strict])
103103
tools << Tools::CreateArtifact
104104
tools << Tools::UpdateArtifact
105+
tools << Tools::ReadArtifact
105106
end
106107

107108
tools << Tools::GithubSearchCode if SiteSetting.ai_bot_github_access_token.present?

lib/ai_bot/personas/web_artifact_creator.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@ module AiBot
55
module Personas
66
class WebArtifactCreator < Persona
77
def tools
8-
[Tools::CreateArtifact, Tools::UpdateArtifact]
8+
[Tools::CreateArtifact, Tools::UpdateArtifact, Tools::ReadArtifact]
99
end
1010

1111
def required_tools
12-
[Tools::CreateArtifact, Tools::UpdateArtifact]
12+
[Tools::CreateArtifact, Tools::UpdateArtifact, Tools::ReadArtifact]
1313
end
1414

1515
def system_prompt

lib/ai_bot/tools/read_artifact.rb

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
# frozen_string_literal: true
2+
3+
module DiscourseAi
4+
module AiBot
5+
module Tools
6+
class ReadArtifact < Tool
7+
MAX_HTML_SIZE = 30.kilobytes
8+
MAX_CSS_FILES = 5
9+
10+
def self.name
11+
"read_artifact"
12+
end
13+
14+
def self.signature
15+
{
16+
name: "read_artifact",
17+
description: "Read an artifact from a URL and convert to a local artifact",
18+
parameters: [
19+
{
20+
name: "url",
21+
type: "string",
22+
description: "URL of the artifact to read",
23+
required: true,
24+
},
25+
],
26+
}
27+
end
28+
29+
def invoke
30+
return error_response("Unknown context, feature only works in PMs") if !post
31+
32+
uri = URI.parse(parameters[:url])
33+
return error_response("Invalid URL") unless uri.is_a?(URI::HTTP)
34+
35+
if discourse_artifact?(uri)
36+
handle_discourse_artifact(uri)
37+
else
38+
handle_external_page(uri)
39+
end
40+
end
41+
42+
def chain_next_response?
43+
false
44+
end
45+
46+
private
47+
48+
def error_response(message)
49+
{ status: "error", error: message }
50+
end
51+
52+
def success_response(artifact)
53+
{ status: "success", artifact_id: artifact.id, message: "Artifact created successfully." }
54+
end
55+
56+
def discourse_artifact?(uri)
57+
uri.path.include?("/discourse-ai/ai-bot/artifacts/")
58+
end
59+
60+
def post
61+
@post ||= Post.find_by(id: context[:post_id])
62+
end
63+
64+
def handle_discourse_artifact(uri)
65+
if uri.path =~ %r{/discourse-ai/ai-bot/artifacts/(\d+)(?:/(\d+))?}
66+
artifact_id = $1.to_i
67+
version = $2&.to_i
68+
else
69+
return error_response("Invalid artifact URL format")
70+
end
71+
72+
if uri.host == Discourse.current_hostname
73+
source_artifact = AiArtifact.find_by(id: artifact_id)
74+
return error_response("Artifact not found") if !source_artifact
75+
76+
if !source_artifact.public? && !Guardian.new(post.user).can_see?(source_artifact.post)
77+
return error_response("Access denied")
78+
end
79+
new_artifact = clone_artifact(source_artifact, version)
80+
else
81+
response = fetch_page(uri)
82+
return error_response("Failed to fetch artifact") unless response
83+
84+
html, css, js = extract_discourse_artifact(response.body)
85+
return error_response("Invalid artifact format") unless html
86+
87+
new_artifact =
88+
create_artifact_from_web(
89+
html: html,
90+
css: css,
91+
js: js,
92+
name: "Imported Discourse Artifact",
93+
)
94+
end
95+
96+
if new_artifact&.persisted?
97+
update_custom_html(new_artifact)
98+
success_response(new_artifact)
99+
else
100+
error_response(
101+
new_artifact&.errors&.full_messages&.join(", ") || "Failed to create artifact",
102+
)
103+
end
104+
end
105+
106+
def extract_discourse_artifact(html)
107+
doc = Nokogiri.HTML(html)
108+
iframe = doc.at_css(".ai-artifact iframe")
109+
return nil unless iframe
110+
111+
response = fetch_page(URI.parse(iframe["src"]))
112+
return nil unless response
113+
114+
iframe_doc = Nokogiri.HTML(response.body)
115+
116+
content = iframe_doc.at_css("#content")&.inner_html.to_s[0...MAX_HTML_SIZE]
117+
style = iframe_doc.at_css("style")&.content.to_s[0...MAX_HTML_SIZE]
118+
script = iframe_doc.at_css("script:not([src])")&.content.to_s[0...MAX_HTML_SIZE]
119+
120+
[content, style, script]
121+
end
122+
123+
def handle_external_page(uri)
124+
response = fetch_page(uri)
125+
return error_response("Failed to fetch page") unless response
126+
127+
html, css, js = extract_content(response, uri)
128+
new_artifact =
129+
create_artifact_from_web(html: html, css: css, js: js, name: "external artifact")
130+
131+
if new_artifact&.persisted?
132+
update_custom_html(new_artifact)
133+
success_response(new_artifact)
134+
else
135+
error_response(
136+
new_artifact&.errors&.full_messages&.join(", ") || "Failed to create artifact",
137+
)
138+
end
139+
end
140+
141+
def extract_content(response, uri)
142+
doc = Nokogiri.HTML(response.body)
143+
144+
html = doc.at_css("body").to_html.to_s[0...MAX_HTML_SIZE]
145+
146+
css_files =
147+
doc
148+
.css('link[rel="stylesheet"]')
149+
.map { |link| URI.join(uri, link["href"]).to_s }
150+
.first(MAX_CSS_FILES)
151+
css = download_css_files(css_files).to_s[0...MAX_HTML_SIZE]
152+
153+
js = doc.css("script:not([src])").map(&:content).join("\n").to_s[0...MAX_HTML_SIZE]
154+
155+
[html, css, js]
156+
end
157+
158+
def clone_artifact(source, version = nil)
159+
source_version = version ? source.versions.find_by(version_number: version) : nil
160+
content = source_version || source
161+
162+
AiArtifact.create!(
163+
user: post.user,
164+
post: post,
165+
name: source.name,
166+
html: content.html,
167+
css: content.css,
168+
js: content.js,
169+
metadata: {
170+
cloned_from: source.id,
171+
cloned_version: source_version&.version_number,
172+
},
173+
)
174+
end
175+
176+
def create_artifact_from_web(html:, css:, js:, name:)
177+
AiArtifact.create(
178+
user: post.user,
179+
post: post,
180+
name: name,
181+
html: html,
182+
css: css,
183+
js: js,
184+
metadata: {
185+
imported_from: parameters[:url],
186+
},
187+
)
188+
end
189+
190+
def update_custom_html(artifact)
191+
self.custom_raw = <<~HTML
192+
### Artifact created successfully
193+
194+
<div class="ai-artifact" data-ai-artifact-id="#{artifact.id}"></div>
195+
HTML
196+
end
197+
198+
def fetch_page(uri)
199+
send_http_request(uri.to_s) { |response| response if response.code == "200" }
200+
end
201+
202+
def download_css_files(urls)
203+
urls.map { |url| fetch_page(URI.parse(url)).body }.join("\n")
204+
end
205+
end
206+
end
207+
end
208+
end

0 commit comments

Comments
 (0)