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

Commit 117c062

Browse files
authored
FEATURE: allow artifacts to be updated (#980)
Add support for versioned artifacts with improved diff handling * Add versioned artifacts support allowing artifacts to be updated and tracked - New `ai_artifact_versions` table to store version history - Support for updating artifacts through a new `UpdateArtifact` tool - Add version-aware artifact rendering in posts - Include change descriptions for version tracking * Enhance artifact rendering and security - Add support for module-type scripts and external JS dependencies - Expand CSP to allow trusted CDN sources (unpkg, cdnjs, jsdelivr, googleapis) - Improve JavaScript handling in artifacts * Implement robust diff handling system (this is dormant but ready to use once LLMs catch up) - Add new DiffUtils module for applying changes to artifacts - Support for unified diff format with multiple hunks - Intelligent handling of whitespace and line endings - Comprehensive error handling for diff operations * Update routes and UI components - Add versioned artifact routes - Update markdown processing for versioned artifacts Also - Tweaks summary prompt - Improves upload support in custom tool to also provide urls
1 parent 0ac18d1 commit 117c062

File tree

21 files changed

+1007
-32
lines changed

21 files changed

+1007
-32
lines changed

app/controllers/discourse_ai/ai_bot/artifacts_controller.rb

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,22 +19,33 @@ def show
1919
raise Discourse::NotFound if !guardian.can_see?(post)
2020
end
2121

22+
name = artifact.name
23+
24+
if params[:version].present?
25+
artifact = artifact.versions.find_by(version_number: params[:version])
26+
raise Discourse::NotFound if !artifact
27+
end
28+
29+
js = artifact.js || ""
30+
if !js.match?(%r{\A\s*<script.*</script>}mi)
31+
mod = ""
32+
mod = " type=\"module\"" if js.match?(/\A\s*import.*/)
33+
js = "<script#{mod}>\n#{js}\n</script>"
34+
end
2235
# Prepare the inner (untrusted) HTML document
2336
untrusted_html = <<~HTML
2437
<!DOCTYPE html>
2538
<html>
2639
<head>
2740
<meta charset="UTF-8">
28-
<title>#{ERB::Util.html_escape(artifact.name)}</title>
41+
<title>#{ERB::Util.html_escape(name)}</title>
2942
<style>
3043
#{artifact.css}
3144
</style>
3245
</head>
3346
<body>
3447
#{artifact.html}
35-
<script>
36-
#{artifact.js}
37-
</script>
48+
#{js}
3849
</body>
3950
</html>
4051
HTML
@@ -45,7 +56,7 @@ def show
4556
<html>
4657
<head>
4758
<meta charset="UTF-8">
48-
<title>#{ERB::Util.html_escape(artifact.name)}</title>
59+
<title>#{ERB::Util.html_escape(name)}</title>
4960
<style>
5061
html, body, iframe {
5162
margin: 0;
@@ -67,7 +78,9 @@ def show
6778
HTML
6879

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

7386
# Render the content

app/models/ai_artifact.rb

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,29 @@
11
# frozen_string_literal: true
22

33
class AiArtifact < ActiveRecord::Base
4+
has_many :versions, class_name: "AiArtifactVersion", dependent: :destroy
45
belongs_to :user
56
belongs_to :post
67
validates :html, length: { maximum: 65_535 }
78
validates :css, length: { maximum: 65_535 }
89
validates :js, length: { maximum: 65_535 }
910

10-
def self.iframe_for(id)
11+
def self.iframe_for(id, version = nil)
1112
<<~HTML
1213
<div class='ai-artifact'>
13-
<iframe src='#{url(id)}' frameborder="0" height="100%" width="100%"></iframe>
14-
<a href='#{url(id)}' target='_blank'>#{I18n.t("discourse_ai.ai_artifact.link")}</a>
14+
<iframe src='#{url(id, version)}' frameborder="0" height="100%" width="100%"></iframe>
15+
<a href='#{url(id, version)}' target='_blank'>#{I18n.t("discourse_ai.ai_artifact.link")}</a>
1516
</div>
1617
HTML
1718
end
1819

19-
def self.url(id)
20-
Discourse.base_url + "/discourse-ai/ai-bot/artifacts/#{id}"
20+
def self.url(id, version = nil)
21+
url = Discourse.base_url + "/discourse-ai/ai-bot/artifacts/#{id}"
22+
if version
23+
"#{url}/#{version}"
24+
else
25+
url
26+
end
2127
end
2228

2329
def self.share_publicly(id:, post:)
@@ -33,6 +39,37 @@ def self.unshare_publicly(id:)
3339
def url
3440
self.class.url(id)
3541
end
42+
43+
def apply_diff(html_diff: nil, css_diff: nil, js_diff: nil, change_description: nil)
44+
differ = DiscourseAi::Utils::DiffUtils
45+
46+
html = html_diff ? differ.apply_hunk(self.html, html_diff) : self.html
47+
css = css_diff ? differ.apply_hunk(self.css, css_diff) : self.css
48+
js = js_diff ? differ.apply_hunk(self.js, js_diff) : self.js
49+
50+
create_new_version(html: html, css: css, js: js, change_description: change_description)
51+
end
52+
53+
def create_new_version(html: nil, css: nil, js: nil, change_description: nil)
54+
latest_version = versions.order(version_number: :desc).first
55+
new_version_number = latest_version ? latest_version.version_number + 1 : 1
56+
version = nil
57+
58+
transaction do
59+
# Create the version record
60+
version =
61+
versions.create!(
62+
version_number: new_version_number,
63+
html: html || self.html,
64+
css: css || self.css,
65+
js: js || self.js,
66+
change_description: change_description,
67+
)
68+
save!
69+
end
70+
71+
version
72+
end
3673
end
3774

3875
# == Schema Information

app/models/ai_artifact_version.rb

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# frozen_string_literal: true
2+
class AiArtifactVersion < ActiveRecord::Base
3+
belongs_to :ai_artifact
4+
validates :html, length: { maximum: 65_535 }
5+
validates :css, length: { maximum: 65_535 }
6+
validates :js, length: { maximum: 65_535 }
7+
end
8+
9+
# == Schema Information
10+
#
11+
# Table name: ai_artifact_versions
12+
#
13+
# id :bigint not null, primary key
14+
# ai_artifact_id :bigint not null
15+
# version_number :integer not null
16+
# html :string(65535)
17+
# css :string(65535)
18+
# js :string(65535)
19+
# metadata :jsonb
20+
# change_description :string
21+
# created_at :datetime not null
22+
# updated_at :datetime not null
23+
#
24+
# Indexes
25+
#
26+
# index_ai_artifact_versions_on_ai_artifact_id_and_version_number (ai_artifact_id,version_number) UNIQUE
27+
#

app/models/shared_ai_conversation.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,9 +190,11 @@ def self.cook_artifacts(post)
190190
.css("div.ai-artifact")
191191
.each do |node|
192192
id = node["data-ai-artifact-id"].to_i
193+
version = node["data-ai-artifact-version"]
194+
version_number = version.to_i if version
193195
if id > 0
194196
AiArtifact.share_publicly(id: id, post: post)
195-
node.replace(AiArtifact.iframe_for(id))
197+
node.replace(AiArtifact.iframe_for(id, version_number))
196198
end
197199
end
198200

assets/javascripts/discourse/components/ai-artifact.gjs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,12 @@ export default class AiArtifactComponent extends Component {
3737
}
3838

3939
get artifactUrl() {
40-
return getURL(`/discourse-ai/ai-bot/artifacts/${this.args.artifactId}`);
40+
let url = getURL(`/discourse-ai/ai-bot/artifacts/${this.args.artifactId}`);
41+
42+
if (this.args.artifactVersion) {
43+
url = `${url}/${this.args.artifactVersion}`;
44+
}
45+
return url;
4146
}
4247

4348
@action

assets/javascripts/initializers/ai-artifacts.gjs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,15 @@ function initializeAiArtifacts(api) {
1414
"data-ai-artifact-id"
1515
);
1616

17+
const artifactVersion = artifactElement.getAttribute(
18+
"data-ai-artifact-version"
19+
);
20+
1721
helper.renderGlimmer(artifactElement, <template>
18-
<AiArtifact @artifactId={{artifactId}} />
22+
<AiArtifact
23+
@artifactId={{artifactId}}
24+
@artifactVersion={{artifactVersion}}
25+
/>
1926
</template>);
2027
}
2128
);
Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
export function setup(helper) {
22
helper.allowList(["details[class=ai-quote]"]);
3-
helper.allowList(["div[class=ai-artifact]", "div[data-ai-artifact-id]"]);
3+
helper.allowList([
4+
"div[class=ai-artifact]",
5+
"div[data-ai-artifact-id]",
6+
"div[data-ai-artifact-version]",
7+
]);
48
}

config/locales/server.en.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ en:
205205
ai_artifact:
206206
link: "Show Artifact in new tab"
207207
view_source: "View Source"
208+
view_changes: "View Changes"
208209
unknown_model: "Unknown AI model"
209210

210211
tools:
@@ -309,6 +310,7 @@ en:
309310
name: "Base Search Query"
310311
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."
311312
tool_summary:
313+
update_artifact: "Update a web artifact"
312314
create_artifact: "Create web artifact"
313315
web_browser: "Browse Web"
314316
github_search_files: "GitHub search files"
@@ -331,6 +333,7 @@ en:
331333
search_meta_discourse: "Search Meta Discourse"
332334
javascript_evaluator: "Evaluate JavaScript"
333335
tool_help:
336+
update_artifact: "Update a web artifact using the AI Bot"
334337
create_artifact: "Create a web artifact using the AI Bot"
335338
web_browser: "Browse web page using the AI Bot"
336339
github_search_code: "Search for code in a GitHub repository"
@@ -353,6 +356,7 @@ en:
353356
search_meta_discourse: "Search Meta Discourse"
354357
javascript_evaluator: "Evaluate JavaScript"
355358
tool_description:
359+
update_artifact: "Updated a web artifact using the AI Bot"
356360
create_artifact: "Created a web artifact using the AI Bot"
357361
web_browser: "Reading <a href='%{url}'>%{url}</a>"
358362
github_search_files: "Searched for '%{keywords}' in %{repo}/%{branch}"

config/routes.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636

3737
scope module: :ai_bot, path: "/ai-bot/artifacts" do
3838
get "/:id" => "artifacts#show"
39+
get "/:id/:version" => "artifacts#show"
3940
end
4041

4142
scope module: :summarization, path: "/summarization", defaults: { format: :json } do
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# frozen_string_literal: true
2+
class AddArtifactVersions < ActiveRecord::Migration[7.0]
3+
def change
4+
create_table :ai_artifact_versions do |t|
5+
t.bigint :ai_artifact_id, null: false
6+
t.integer :version_number, null: false
7+
t.string :html, limit: 65_535
8+
t.string :css, limit: 65_535
9+
t.string :js, limit: 65_535
10+
t.jsonb :metadata
11+
t.string :change_description
12+
t.timestamps
13+
14+
t.index %i[ai_artifact_id version_number], unique: true
15+
end
16+
end
17+
end

0 commit comments

Comments
 (0)