FEATURE: allow passing in data attributes to an artifact (#1346)

Also allow artifact access to current username

Usage inside artifact is:

1. await window.discourseArtifactReady;
2. access data via window.discourseArtifactData;
This commit is contained in:
Sam 2025-05-19 15:44:37 +10:00 committed by GitHub
parent 925949de47
commit 3ac2359ff1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 130 additions and 0 deletions

View File

@ -42,6 +42,21 @@ module DiscourseAi
<style>
#{artifact.css}
</style>
<script>
window._discourse_user_data = {
#{current_user ? "username: #{current_user.username.to_json}" : "username: null"}
};
window.discourseArtifactReady = new Promise(resolve => {
window._resolveArtifactData = resolve;
});
window.addEventListener('message', function(event) {
if (event.data && event.data.type === 'discourse-artifact-data') {
window.discourseArtifactData = event.data.dataset || {};
Object.assign(window.discourseArtifactData, window._discourse_user_data);
window._resolveArtifactData(window.discourseArtifactData);
}
});
</script>
</head>
<body>
#{artifact.html}
@ -74,6 +89,19 @@ module DiscourseAi
</head>
<body>
<iframe sandbox="allow-scripts allow-forms" height="100%" width="100%" srcdoc="#{ERB::Util.html_escape(untrusted_html)}" frameborder="0"></iframe>
<script>
document.querySelector('iframe').addEventListener('load', function() {
try {
const iframeWindow = this.contentWindow;
const message = { type: 'discourse-artifact-data', dataset: {} };
if (window.frameElement && window.frameElement.dataset) {
Object.assign(message.dataset, window.frameElement.dataset);
}
iframeWindow.postMessage(message, '*');
} catch (e) { console.error('Error passing data to artifact:', e); }
});
</script>
</body>
</html>
HTML

View File

@ -1,6 +1,7 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import { service } from "@ember/service";
import DButton from "discourse/components/d-button";
import htmlClass from "discourse/helpers/html-class";
@ -88,6 +89,15 @@ export default class AiArtifactComponent extends Component {
}`;
}
@action
setDataAttributes(element) {
if (this.args.dataAttributes) {
Object.entries(this.args.dataAttributes).forEach(([key, value]) => {
element.setAttribute(key, value);
});
}
}
<template>
{{#if this.expanded}}
{{htmlClass "ai-artifact-expanded"}}
@ -118,6 +128,7 @@ export default class AiArtifactComponent extends Component {
src={{this.artifactUrl}}
width="100%"
frameborder="0"
{{didInsert this.setDataAttributes}}
></iframe>
{{/if}}
{{#unless this.requireClickToRun}}

View File

@ -18,12 +18,24 @@ function initializeAiArtifacts(api) {
"data-ai-artifact-version"
);
const dataAttributes = {};
for (const attr of artifactElement.attributes) {
if (
attr.name.startsWith("data-") &&
attr.name !== "data-ai-artifact-id" &&
attr.name !== "data-ai-artifact-version"
) {
dataAttributes[attr.name] = attr.value;
}
}
helper.renderGlimmer(
artifactElement,
<template>
<AiArtifact
@artifactId={{artifactId}}
@artifactVersion={{artifactVersion}}
@dataAttributes={{dataAttributes}}
/>
</template>
);

View File

@ -0,0 +1,79 @@
# frozen_string_literal: true
RSpec.describe "AI Artifact with Data Attributes", type: :system do
fab!(:admin)
fab!(:user)
fab!(:author) { Fabricate(:user) }
fab!(:category) { Fabricate(:category, user: admin, read_restricted: false) }
fab!(:topic) { Fabricate(:topic, category: category, user: author) }
fab!(:post) { Fabricate(:post, topic: topic, user: author) }
before { SiteSetting.discourse_ai_enabled = true }
it "correctly passes data attributes and user info to a public AI artifact embedded in a post" do
artifact_js = <<~JS
window.discourseArtifactReady.then(data => {
const displayElement = document.getElementById('data-display');
if (displayElement) {
displayElement.innerText = JSON.stringify(data);
}
}).catch(err => {
const displayElement = document.getElementById('data-display');
if (displayElement) {
displayElement.innerText = 'Error: ' + err.message;
}
console.error("Artifact JS Error:", err);
});
JS
ai_artifact =
Fabricate(
:ai_artifact,
user: author,
name: "Data Passing Test Artifact",
html: "<div id='data-display'>Waiting for data...</div>",
js: artifact_js.strip,
metadata: {
public: true,
},
)
raw_post_content =
"<div class='ai-artifact' data-ai-artifact-id='#{ai_artifact.id}' data-custom-message='hello-from-post' data-post-author-id='#{author.id}'></div>"
_post = Fabricate(:post, topic: topic, user: author, raw: raw_post_content)
sign_in(user)
visit "/t/#{topic.slug}/#{topic.id}"
find(".ai-artifact__click-to-run button").click
artifact_element_selector = ".ai-artifact[data-ai-artifact-id='#{ai_artifact.id}']"
iframe_selector = "#{artifact_element_selector} iframe"
expect(page).to have_css(iframe_selector)
iframe_element = find(iframe_selector)
expect(iframe_element["data-custom-message"]).to eq("hello-from-post")
expect(iframe_element["data-post-author-id"]).to eq(author.id.to_s)
# note: artifacts are within nested iframes for security reasons
page.within_frame(iframe_element) do
inner_iframe = find("iframe")
page.within_frame(inner_iframe) do
data_display_element = find("#data-display")
expect(data_display_element.text).not_to be_empty
expect(data_display_element.text).not_to eq("Waiting for data...")
expect(data_display_element.text).not_to include("Error:")
artifact_data_json = data_display_element.text
artifact_data = JSON.parse(artifact_data_json)
expect(artifact_data["customMessage"]).to eq("hello-from-post")
expect(artifact_data["postAuthorId"]).to eq(author.id.to_s)
expect(artifact_data["username"]).to eq(user.username)
end
end
end
end