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: 19 additions & 6 deletions app/controllers/discourse_ai/ai_bot/artifacts_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,22 +19,33 @@ def show
raise Discourse::NotFound if !guardian.can_see?(post)
end

name = artifact.name

if params[:version].present?
artifact = artifact.versions.find_by(version_number: params[:version])
raise Discourse::NotFound if !artifact
end

js = artifact.js || ""
if !js.match?(%r{\A\s*<script.*</script>}mi)
mod = ""
mod = " type=\"module\"" if js.match?(/\A\s*import.*/)
js = "<script#{mod}>\n#{js}\n</script>"
end
# Prepare the inner (untrusted) HTML document
untrusted_html = <<~HTML
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>#{ERB::Util.html_escape(artifact.name)}</title>
<title>#{ERB::Util.html_escape(name)}</title>
<style>
#{artifact.css}
</style>
</head>
<body>
#{artifact.html}
<script>
#{artifact.js}
</script>
#{js}
</body>
</html>
HTML
Expand All @@ -45,7 +56,7 @@ def show
<html>
<head>
<meta charset="UTF-8">
<title>#{ERB::Util.html_escape(artifact.name)}</title>
<title>#{ERB::Util.html_escape(name)}</title>
<style>
html, body, iframe {
margin: 0;
Expand All @@ -67,7 +78,9 @@ def show
HTML

response.headers.delete("X-Frame-Options")
response.headers["Content-Security-Policy"] = "script-src 'unsafe-inline';"
response.headers[
"Content-Security-Policy"
] = "script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://unpkg.com https://cdnjs.cloudflare.com https://ajax.googleapis.com https://cdn.jsdelivr.net;"
response.headers["X-Robots-Tag"] = "noindex"

# Render the content
Expand Down
47 changes: 42 additions & 5 deletions app/models/ai_artifact.rb
Original file line number Diff line number Diff line change
@@ -1,23 +1,29 @@
# frozen_string_literal: true

class AiArtifact < ActiveRecord::Base
has_many :versions, class_name: "AiArtifactVersion", dependent: :destroy
belongs_to :user
belongs_to :post
validates :html, length: { maximum: 65_535 }
validates :css, length: { maximum: 65_535 }
validates :js, length: { maximum: 65_535 }

def self.iframe_for(id)
def self.iframe_for(id, version = nil)
<<~HTML
<div class='ai-artifact'>
<iframe src='#{url(id)}' frameborder="0" height="100%" width="100%"></iframe>
<a href='#{url(id)}' target='_blank'>#{I18n.t("discourse_ai.ai_artifact.link")}</a>
<iframe src='#{url(id, version)}' frameborder="0" height="100%" width="100%"></iframe>
<a href='#{url(id, version)}' target='_blank'>#{I18n.t("discourse_ai.ai_artifact.link")}</a>
</div>
HTML
end

def self.url(id)
Discourse.base_url + "/discourse-ai/ai-bot/artifacts/#{id}"
def self.url(id, version = nil)
url = Discourse.base_url + "/discourse-ai/ai-bot/artifacts/#{id}"
if version
"#{url}/#{version}"
else
url
end
end

def self.share_publicly(id:, post:)
Expand All @@ -33,6 +39,37 @@ def self.unshare_publicly(id:)
def url
self.class.url(id)
end

def apply_diff(html_diff: nil, css_diff: nil, js_diff: nil, change_description: nil)
differ = DiscourseAi::Utils::DiffUtils

html = html_diff ? differ.apply_hunk(self.html, html_diff) : self.html
css = css_diff ? differ.apply_hunk(self.css, css_diff) : self.css
js = js_diff ? differ.apply_hunk(self.js, js_diff) : self.js

create_new_version(html: html, css: css, js: js, change_description: change_description)
end

def create_new_version(html: nil, css: nil, js: nil, change_description: nil)
latest_version = versions.order(version_number: :desc).first
new_version_number = latest_version ? latest_version.version_number + 1 : 1
version = nil

transaction do
# Create the version record
version =
versions.create!(
version_number: new_version_number,
html: html || self.html,
css: css || self.css,
js: js || self.js,
change_description: change_description,
)
save!
end

version
end
end

# == Schema Information
Expand Down
27 changes: 27 additions & 0 deletions app/models/ai_artifact_version.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# frozen_string_literal: true
class AiArtifactVersion < ActiveRecord::Base
belongs_to :ai_artifact
validates :html, length: { maximum: 65_535 }
validates :css, length: { maximum: 65_535 }
validates :js, length: { maximum: 65_535 }
end

# == Schema Information
#
# Table name: ai_artifact_versions
#
# id :bigint not null, primary key
# ai_artifact_id :bigint not null
# version_number :integer not null
# html :string(65535)
# css :string(65535)
# js :string(65535)
# metadata :jsonb
# change_description :string
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_ai_artifact_versions_on_ai_artifact_id_and_version_number (ai_artifact_id,version_number) UNIQUE
#
4 changes: 3 additions & 1 deletion app/models/shared_ai_conversation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -190,9 +190,11 @@ def self.cook_artifacts(post)
.css("div.ai-artifact")
.each do |node|
id = node["data-ai-artifact-id"].to_i
version = node["data-ai-artifact-version"]
version_number = version.to_i if version
if id > 0
AiArtifact.share_publicly(id: id, post: post)
node.replace(AiArtifact.iframe_for(id))
node.replace(AiArtifact.iframe_for(id, version_number))
end
end

Expand Down
7 changes: 6 additions & 1 deletion assets/javascripts/discourse/components/ai-artifact.gjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,12 @@ export default class AiArtifactComponent extends Component {
}

get artifactUrl() {
return getURL(`/discourse-ai/ai-bot/artifacts/${this.args.artifactId}`);
let url = getURL(`/discourse-ai/ai-bot/artifacts/${this.args.artifactId}`);

if (this.args.artifactVersion) {
url = `${url}/${this.args.artifactVersion}`;
}
return url;
}

@action
Expand Down
9 changes: 8 additions & 1 deletion assets/javascripts/initializers/ai-artifacts.gjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,15 @@ function initializeAiArtifacts(api) {
"data-ai-artifact-id"
);

const artifactVersion = artifactElement.getAttribute(
"data-ai-artifact-version"
);

helper.renderGlimmer(artifactElement, <template>
<AiArtifact @artifactId={{artifactId}} />
<AiArtifact
@artifactId={{artifactId}}
@artifactVersion={{artifactVersion}}
/>
</template>);
}
);
Expand Down
6 changes: 5 additions & 1 deletion assets/javascripts/lib/discourse-markdown/ai-tags.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
export function setup(helper) {
helper.allowList(["details[class=ai-quote]"]);
helper.allowList(["div[class=ai-artifact]", "div[data-ai-artifact-id]"]);
helper.allowList([
"div[class=ai-artifact]",
"div[data-ai-artifact-id]",
"div[data-ai-artifact-version]",
]);
}
4 changes: 4 additions & 0 deletions config/locales/server.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ en:
ai_artifact:
link: "Show Artifact in new tab"
view_source: "View Source"
view_changes: "View Changes"
unknown_model: "Unknown AI model"

tools:
Expand Down Expand Up @@ -309,6 +310,7 @@ en:
name: "Base Search Query"
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."
tool_summary:
update_artifact: "Update a web artifact"
create_artifact: "Create web artifact"
web_browser: "Browse Web"
github_search_files: "GitHub search files"
Expand All @@ -331,6 +333,7 @@ en:
search_meta_discourse: "Search Meta Discourse"
javascript_evaluator: "Evaluate JavaScript"
tool_help:
update_artifact: "Update a web artifact using the AI Bot"
create_artifact: "Create a web artifact using the AI Bot"
web_browser: "Browse web page using the AI Bot"
github_search_code: "Search for code in a GitHub repository"
Expand All @@ -353,6 +356,7 @@ en:
search_meta_discourse: "Search Meta Discourse"
javascript_evaluator: "Evaluate JavaScript"
tool_description:
update_artifact: "Updated a web artifact using the AI Bot"
create_artifact: "Created a web artifact using the AI Bot"
web_browser: "Reading <a href='%{url}'>%{url}</a>"
github_search_files: "Searched for '%{keywords}' in %{repo}/%{branch}"
Expand Down
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@

scope module: :ai_bot, path: "/ai-bot/artifacts" do
get "/:id" => "artifacts#show"
get "/:id/:version" => "artifacts#show"
end

scope module: :summarization, path: "/summarization", defaults: { format: :json } do
Expand Down
17 changes: 17 additions & 0 deletions db/migrate/20241130003808_add_artifact_versions.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# frozen_string_literal: true
class AddArtifactVersions < ActiveRecord::Migration[7.0]
def change
create_table :ai_artifact_versions do |t|
t.bigint :ai_artifact_id, null: false
t.integer :version_number, null: false
t.string :html, limit: 65_535
t.string :css, limit: 65_535
t.string :js, limit: 65_535
t.jsonb :metadata
t.string :change_description
t.timestamps

t.index %i[ai_artifact_id version_number], unique: true
end
end
end
6 changes: 5 additions & 1 deletion lib/ai_bot/personas/persona.rb
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,11 @@ def all_available_tools
Tools::JavascriptEvaluator,
]

tools << Tools::CreateArtifact if SiteSetting.ai_artifact_security.in?(%w[lax strict])
if SiteSetting.ai_artifact_security.in?(%w[lax strict])
tools << Tools::CreateArtifact
tools << Tools::UpdateArtifact
end

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

tools << Tools::ListTags if SiteSetting.tagging_enabled
Expand Down
4 changes: 2 additions & 2 deletions lib/ai_bot/personas/web_artifact_creator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ module AiBot
module Personas
class WebArtifactCreator < Persona
def tools
[Tools::CreateArtifact]
[Tools::CreateArtifact, Tools::UpdateArtifact]
end

def required_tools
[Tools::CreateArtifact]
[Tools::CreateArtifact, Tools::UpdateArtifact]
end

def system_prompt
Expand Down
2 changes: 1 addition & 1 deletion lib/ai_bot/tool_runner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ def attach_upload(mini_racer_context)
for_private_message: @context[:private_message],
).create_for(@bot_user.id)

{ id: upload.id, short_url: upload.short_url }
{ id: upload.id, short_url: upload.short_url, url: upload.url }
end
ensure
self.running_attached_function = false
Expand Down
32 changes: 30 additions & 2 deletions lib/ai_bot/tools/create_artifact.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,33 @@ def self.name
"create_artifact"
end

def self.js_dependency_tip
<<~TIP
If you need to include a JavaScript library, you may include assets from:
- unpkg.com
- cdnjs.com
- jsdelivr.com
- ajax.googleapis.com

To include them ensure they are the last tag in your HTML body.
Example: <script crossorigin src="https://cdn.jsdelivr.net/npm/[email protected]/dist/vue.min.js"></script>
TIP
end

def self.js_script_tag_tip
<<~TIP
if you need a custom script tag, you can use the following format:

<script type="module">
// your script here
</script>

If you only need a regular script tag, you can use the following format:

// your script here
TIP
end

def self.signature
{
name: "create_artifact",
Expand All @@ -22,7 +49,8 @@ def self.signature
},
{
name: "html_body",
description: "The HTML content for the BODY tag (do not include the BODY tag)",
description:
"The HTML content for the BODY tag (do not include the BODY tag). #{js_dependency_tip}",
type: "string",
required: true,
},
Expand All @@ -31,7 +59,7 @@ def self.signature
name: "js",
description:
"Optional
JavaScript code for the artifact",
JavaScript code for the artifact, this will be the last <script> tag in the BODY. #{js_script_tag_tip}",
type: "string",
},
],
Expand Down
Loading