FEATURE: hybrid artifact security mode (#1431)

In hybrid mode ai artifacts can optionally automatically run.

This is useful for cases where you may want to embed a survey and so on.

Additionally, artifacts now allow for better fidelity around display:

<div class="ai-artifact" data-ai-artifact-id="501" data-ai-artifact-height="300px" data-ai-artifact-autorun data-ai-artifact-seamless></div>

User can supply height and seamless mode to be seamlessly rendered with no box shadow and show full screen button.
This commit is contained in:
Sam 2025-06-12 20:04:48 +10:00 committed by GitHub
parent b5a2ee31ab
commit 02bc9f645e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 83 additions and 11 deletions

View File

@ -335,7 +335,7 @@ module DiscourseAi
def require_site_settings! def require_site_settings!
if !SiteSetting.discourse_ai_enabled || if !SiteSetting.discourse_ai_enabled ||
!SiteSetting.ai_artifact_security.in?(%w[lax strict]) !SiteSetting.ai_artifact_security.in?(%w[lax hybrid strict])
raise Discourse::NotFound raise Discourse::NotFound
end end
end end

View File

@ -178,7 +178,7 @@ class SharedAiConversation < ActiveRecord::Base
def self.cook_artifacts(post) def self.cook_artifacts(post)
html = post.cooked html = post.cooked
return html if !%w[lax strict].include?(SiteSetting.ai_artifact_security) return html if !%w[lax hybrid strict].include?(SiteSetting.ai_artifact_security)
doc = Nokogiri::HTML5.fragment(html) doc = Nokogiri::HTML5.fragment(html)
doc doc

View File

@ -3,6 +3,7 @@ import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object"; import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert"; import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import { service } from "@ember/service"; import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import DButton from "discourse/components/d-button"; import DButton from "discourse/components/d-button";
import htmlClass from "discourse/helpers/html-class"; import htmlClass from "discourse/helpers/html-class";
import getURL from "discourse/lib/get-url"; import getURL from "discourse/lib/get-url";
@ -51,7 +52,21 @@ export default class AiArtifactComponent extends Component {
if (this.showingArtifact) { if (this.showingArtifact) {
return false; return false;
} }
return this.siteSettings.ai_artifact_security === "strict";
if (this.siteSettings.ai_artifact_security === "strict") {
return true;
}
if (this.siteSettings.ai_artifact_security === "hybrid") {
const shouldAutorun =
this.args.autorun === "true" ||
this.args.autorun === true ||
this.args.autorun === "1";
return !shouldAutorun;
}
return this.siteSettings.ai_artifact_security !== "lax";
} }
get artifactUrl() { get artifactUrl() {
@ -86,7 +101,7 @@ export default class AiArtifactComponent extends Component {
get wrapperClasses() { get wrapperClasses() {
return `ai-artifact__wrapper ${ return `ai-artifact__wrapper ${
this.expanded ? "ai-artifact__expanded" : "" this.expanded ? "ai-artifact__expanded" : ""
}`; } ${this.seamless ? "ai-artifact__seamless" : ""}`;
} }
@action @action
@ -98,11 +113,38 @@ export default class AiArtifactComponent extends Component {
} }
} }
get heightStyle() {
if (this.args.artifactHeight) {
let height = parseInt(this.args.artifactHeight, 10);
if (isNaN(height) || height <= 0) {
height = 500; // default height if the provided value is invalid
}
if (height > 2000) {
height = 2000; // cap the height to a maximum of 2000px
}
return htmlSafe(`height: ${height}px;`);
}
}
get seamless() {
return (
this.args.seamless === "true" ||
this.args.seamless === true ||
this.args.seamless === "1"
);
}
get showFooter() {
return !this.seamless && !this.requireClickToRun;
}
<template> <template>
{{#if this.expanded}} {{#if this.expanded}}
{{htmlClass "ai-artifact-expanded"}} {{htmlClass "ai-artifact-expanded"}}
{{/if}} {{/if}}
<div class={{this.wrapperClasses}}> <div class={{this.wrapperClasses}} style={{this.heightStyle}}>
<div class="ai-artifact__panel--wrapper"> <div class="ai-artifact__panel--wrapper">
<div class="ai-artifact__panel"> <div class="ai-artifact__panel">
<DButton <DButton
@ -131,7 +173,7 @@ export default class AiArtifactComponent extends Component {
{{didInsert this.setDataAttributes}} {{didInsert this.setDataAttributes}}
></iframe> ></iframe>
{{/if}} {{/if}}
{{#unless this.requireClickToRun}} {{#if this.showFooter}}
<div class="ai-artifact__footer"> <div class="ai-artifact__footer">
<DButton <DButton
class="btn-transparent btn-icon-text ai-artifact__expand-button" class="btn-transparent btn-icon-text ai-artifact__expand-button"
@ -140,7 +182,7 @@ export default class AiArtifactComponent extends Component {
@action={{this.toggleView}} @action={{this.toggleView}}
/> />
</div> </div>
{{/unless}} {{/if}}
</div> </div>
</template> </template>
} }

View File

@ -18,12 +18,27 @@ function initializeAiArtifacts(api) {
"data-ai-artifact-version" "data-ai-artifact-version"
); );
const artifactHeight = artifactElement.getAttribute(
"data-ai-artifact-height"
);
const autorun =
artifactElement.getAttribute("data-ai-artifact-autorun") ||
artifactElement.hasAttribute("data-ai-artifact-autorun");
const seamless =
artifactElement.getAttribute("data-ai-artifact-seamless") ||
artifactElement.hasAttribute("data-ai-artifact-seamless");
const dataAttributes = {}; const dataAttributes = {};
for (const attr of artifactElement.attributes) { for (const attr of artifactElement.attributes) {
if ( if (
attr.name.startsWith("data-") && attr.name.startsWith("data-") &&
attr.name !== "data-ai-artifact-id" && attr.name !== "data-ai-artifact-id" &&
attr.name !== "data-ai-artifact-version" attr.name !== "data-ai-artifact-version" &&
attr.name !== "data-ai-artifact-height" &&
attr.name !== "data-ai-artifact-autorun" &&
attr.name !== "data-ai-artifact-seamless"
) { ) {
dataAttributes[attr.name] = attr.value; dataAttributes[attr.name] = attr.value;
} }
@ -35,6 +50,9 @@ function initializeAiArtifacts(api) {
<AiArtifact <AiArtifact
@artifactId={{artifactId}} @artifactId={{artifactId}}
@artifactVersion={{artifactVersion}} @artifactVersion={{artifactVersion}}
@artifactHeight={{artifactHeight}}
@autorun={{autorun}}
@seamless={{seamless}}
@dataAttributes={{dataAttributes}} @dataAttributes={{dataAttributes}}
/> />
</template> </template>

View File

@ -4,5 +4,8 @@ export function setup(helper) {
"div[class=ai-artifact]", "div[class=ai-artifact]",
"div[data-ai-artifact-id]", "div[data-ai-artifact-id]",
"div[data-ai-artifact-version]", "div[data-ai-artifact-version]",
"div[data-ai-artifact-autorun]",
"div[data-ai-artifact-height]",
"div[data-ai-artifact-width]",
]); ]);
} }

View File

@ -20,7 +20,15 @@
height: calc(100% - 2em); height: calc(100% - 2em);
} }
&:not(.ai-artifact__expanded) { &.ai-artifact__seamless {
padding-bottom: 1em;
iframe {
height: 100%;
}
}
&:not(.ai-artifact__expanded, .ai-artifact__seamless) {
iframe { iframe {
box-shadow: var(--shadow-card); box-shadow: var(--shadow-card);
} }

View File

@ -25,7 +25,7 @@ en:
description: "Periodic report based on a large language model" description: "Periodic report based on a large language model"
site_settings: site_settings:
discourse_ai_enabled: "Enable the discourse AI plugin." discourse_ai_enabled: "Enable the discourse AI plugin."
ai_artifact_security: "The AI artifact system generates IFRAMEs with runnable code. Strict mode disables sharing and forces an extra click to run code. Lax mode allows sharing of artifacts and runs code directly. Disabled mode disables the artifact system." ai_artifact_security: "The AI artifact system generates IFRAMEs with runnable code. Strict mode forces an extra click to run code. Lax mode runs code immediately. Hybrid mode allows user to supply data-ai-artifact-autorun to show right away. Disabled mode disables the artifact system."
ai_toxicity_enabled: "Enable the toxicity module." ai_toxicity_enabled: "Enable the toxicity module."
ai_toxicity_inference_service_api_endpoint: "URL where the API is running for the toxicity module" ai_toxicity_inference_service_api_endpoint: "URL where the API is running for the toxicity module"
ai_toxicity_inference_service_api_key: "API key for the toxicity API" ai_toxicity_inference_service_api_key: "API key for the toxicity API"

View File

@ -9,6 +9,7 @@ discourse_ai:
choices: choices:
- "disabled" - "disabled"
- "lax" - "lax"
- "hybrid"
- "strict" - "strict"
ai_sentiment_enabled: ai_sentiment_enabled:

View File

@ -119,7 +119,7 @@ module DiscourseAi
Tools::Researcher, Tools::Researcher,
] ]
if SiteSetting.ai_artifact_security.in?(%w[lax strict]) if SiteSetting.ai_artifact_security.in?(%w[lax hybrid strict])
tools << Tools::CreateArtifact tools << Tools::CreateArtifact
tools << Tools::UpdateArtifact tools << Tools::UpdateArtifact
tools << Tools::ReadArtifact tools << Tools::ReadArtifact