|
| 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