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

Commit 0d7f353

Browse files
authored
FEATURE: AI artifacts (#898)
This is a significant PR that introduces AI Artifacts functionality to the discourse-ai plugin along with several other improvements. Here are the key changes: 1. AI Artifacts System: - Adds a new `AiArtifact` model and database migration - Allows creation of web artifacts with HTML, CSS, and JavaScript content - Introduces security settings (`strict`, `lax`, `disabled`) for controlling artifact execution - Implements artifact rendering in iframes with sandbox protection - New `CreateArtifact` tool for AI to generate interactive content 2. Tool System Improvements: - Adds support for partial tool calls, allowing incremental updates during generation - Better handling of tool call states and progress tracking - Improved XML tool processing with CDATA support - Fixes for tool parameter handling and duplicate invocations 3. LLM Provider Updates: - Updates for Anthropic Claude models with correct token limits - Adds support for native/XML tool modes in Gemini integration - Adds new model configurations including Llama 3.1 models - Improvements to streaming response handling 4. UI Enhancements: - New artifact viewer component with expand/collapse functionality - Security controls for artifact execution (click-to-run in strict mode) - Improved dialog and response handling - Better error management for tool execution 5. Security Improvements: - Sandbox controls for artifact execution - Public/private artifact sharing controls - Security settings to control artifact behavior - CSP and frame-options handling for artifacts 6. Technical Improvements: - Better post streaming implementation - Improved error handling in completions - Better memory management for partial tool calls - Enhanced testing coverage 7. Configuration: - New site settings for artifact security - Extended LLM model configurations - Additional tool configuration options This PR significantly enhances the plugin's capabilities for generating and displaying interactive content while maintaining security and providing flexible configuration options for administrators.
1 parent 4fb686a commit 0d7f353

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

46 files changed

+1707
-179
lines changed
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# frozen_string_literal: true
2+
3+
module DiscourseAi
4+
module AiBot
5+
class ArtifactsController < ApplicationController
6+
requires_plugin DiscourseAi::PLUGIN_NAME
7+
before_action :require_site_settings!
8+
9+
skip_before_action :preload_json, :check_xhr, only: %i[show]
10+
11+
def show
12+
artifact = AiArtifact.find(params[:id])
13+
14+
post = Post.find_by(id: artifact.post_id)
15+
if artifact.metadata&.dig("public")
16+
# no guardian needed
17+
else
18+
raise Discourse::NotFound if !post&.topic&.private_message?
19+
raise Discourse::NotFound if !guardian.can_see?(post)
20+
end
21+
22+
# Prepare the HTML document
23+
html = <<~HTML
24+
<!DOCTYPE html>
25+
<html>
26+
<head>
27+
<meta charset="UTF-8">
28+
<title>#{ERB::Util.html_escape(artifact.name)}</title>
29+
<style>
30+
#{artifact.css}
31+
</style>
32+
</head>
33+
<body>
34+
#{artifact.html}
35+
<script>
36+
#{artifact.js}
37+
</script>
38+
</body>
39+
</html>
40+
HTML
41+
42+
response.headers.delete("X-Frame-Options")
43+
response.headers.delete("Content-Security-Policy")
44+
45+
# Render the content
46+
render html: html.html_safe, layout: false, content_type: "text/html"
47+
end
48+
49+
private
50+
51+
def require_site_settings!
52+
if !SiteSetting.discourse_ai_enabled ||
53+
!SiteSetting.ai_artifact_security.in?(%w[lax strict])
54+
raise Discourse::NotFound
55+
end
56+
end
57+
end
58+
end
59+
end

app/models/ai_artifact.rb

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# frozen_string_literal: true
2+
3+
class AiArtifact < ActiveRecord::Base
4+
belongs_to :user
5+
belongs_to :post
6+
validates :html, length: { maximum: 65_535 }
7+
validates :css, length: { maximum: 65_535 }
8+
validates :js, length: { maximum: 65_535 }
9+
10+
def self.iframe_for(id)
11+
<<~HTML
12+
<iframe sandbox="allow-scripts allow-forms" height="600px" src='#{url(id)}' frameborder="0" width="100%"></iframe>
13+
HTML
14+
end
15+
16+
def self.url(id)
17+
Discourse.base_url + "/discourse-ai/ai-bot/artifacts/#{id}"
18+
end
19+
20+
def self.share_publicly(id:, post:)
21+
artifact = AiArtifact.find_by(id: id)
22+
artifact.update!(metadata: { public: true }) if artifact&.post&.topic&.id == post.topic.id
23+
end
24+
25+
def self.unshare_publicly(id:)
26+
artifact = AiArtifact.find_by(id: id)
27+
artifact&.update!(metadata: { public: false })
28+
end
29+
30+
def url
31+
self.class.url(id)
32+
end
33+
end
34+
35+
# == Schema Information
36+
#
37+
# Table name: ai_artifacts
38+
#
39+
# id :bigint not null, primary key
40+
# user_id :integer not null
41+
# post_id :integer not null
42+
# name :string(255) not null
43+
# html :string(65535)
44+
# css :string(65535)
45+
# js :string(65535)
46+
# metadata :jsonb
47+
# created_at :datetime not null
48+
# updated_at :datetime not null
49+
#

app/models/llm_model.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ def self.provider_params
2626
},
2727
open_ai: {
2828
organization: :text,
29+
disable_native_tools: :checkbox,
30+
},
31+
google: {
32+
disable_native_tools: :checkbox,
33+
},
34+
azure: {
35+
disable_native_tools: :checkbox,
2936
},
3037
hugging_face: {
3138
disable_system_prompt: :checkbox,

app/models/shared_ai_conversation.rb

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@ def self.share_conversation(user, target, max_posts: DEFAULT_MAX_POSTS)
3434

3535
def self.destroy_conversation(conversation)
3636
conversation.destroy
37+
38+
maybe_topic = conversation.target
39+
if maybe_topic.is_a?(Topic)
40+
AiArtifact.where(post: maybe_topic.posts).update_all(metadata: { public: false })
41+
end
42+
3743
::Jobs.enqueue(
3844
:shared_conversation_adjust_upload_security,
3945
target_id: conversation.target_id,
@@ -165,7 +171,7 @@ def self.build_conversation_data(topic, max_posts: DEFAULT_MAX_POSTS, include_us
165171
id: post.id,
166172
user_id: post.user_id,
167173
created_at: post.created_at,
168-
cooked: post.cooked,
174+
cooked: cook_artifacts(post),
169175
}
170176

171177
mapped[:persona] = persona if ai_bot_participant&.id == post.user_id
@@ -175,6 +181,24 @@ def self.build_conversation_data(topic, max_posts: DEFAULT_MAX_POSTS, include_us
175181
}
176182
end
177183

184+
def self.cook_artifacts(post)
185+
html = post.cooked
186+
return html if !%w[lax strict].include?(SiteSetting.ai_artifact_security)
187+
188+
doc = Nokogiri::HTML5.fragment(html)
189+
doc
190+
.css("div.ai-artifact")
191+
.each do |node|
192+
id = node["data-ai-artifact-id"].to_i
193+
if id > 0
194+
AiArtifact.share_publicly(id: id, post: post)
195+
node.replace(AiArtifact.iframe_for(id))
196+
end
197+
end
198+
199+
doc.to_s
200+
end
201+
178202
private
179203

180204
def populate_user_info!(posts)
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import Component from "@glimmer/component";
2+
import { tracked } from "@glimmer/tracking";
3+
import { on } from "@ember/modifier";
4+
import { action } from "@ember/object";
5+
import { inject as service } from "@ember/service";
6+
import DButton from "discourse/components/d-button";
7+
import htmlClass from "discourse/helpers/html-class";
8+
import getURL from "discourse-common/lib/get-url";
9+
10+
export default class AiArtifactComponent extends Component {
11+
@service siteSettings;
12+
@tracked expanded = false;
13+
@tracked showingArtifact = false;
14+
15+
constructor() {
16+
super(...arguments);
17+
this.keydownHandler = this.handleKeydown.bind(this);
18+
}
19+
20+
willDestroy() {
21+
super.willDestroy(...arguments);
22+
window.removeEventListener("keydown", this.keydownHandler);
23+
}
24+
25+
@action
26+
handleKeydown(event) {
27+
if (event.key === "Escape" || event.key === "Esc") {
28+
this.expanded = false;
29+
}
30+
}
31+
32+
get requireClickToRun() {
33+
if (this.showingArtifact) {
34+
return false;
35+
}
36+
return this.siteSettings.ai_artifact_security === "strict";
37+
}
38+
39+
get artifactUrl() {
40+
return getURL(`/discourse-ai/ai-bot/artifacts/${this.args.artifactId}`);
41+
}
42+
43+
@action
44+
showArtifact() {
45+
this.showingArtifact = true;
46+
}
47+
48+
@action
49+
toggleView() {
50+
this.expanded = !this.expanded;
51+
if (this.expanded) {
52+
window.addEventListener("keydown", this.keydownHandler);
53+
} else {
54+
window.removeEventListener("keydown", this.keydownHandler);
55+
}
56+
}
57+
58+
get wrapperClasses() {
59+
return `ai-artifact__wrapper ${
60+
this.expanded ? "ai-artifact__expanded" : ""
61+
}`;
62+
}
63+
64+
@action
65+
artifactPanelHover() {
66+
// retrrigger animation
67+
const panel = document.querySelector(".ai-artifact__panel");
68+
panel.style.animation = "none"; // Stop the animation
69+
setTimeout(() => {
70+
panel.style.animation = ""; // Re-trigger the animation by removing the none style
71+
}, 0);
72+
}
73+
74+
<template>
75+
{{#if this.expanded}}
76+
{{htmlClass "ai-artifact-expanded"}}
77+
{{/if}}
78+
<div class={{this.wrapperClasses}}>
79+
<div
80+
class="ai-artifact__panel--wrapper"
81+
{{on "mouseleave" this.artifactPanelHover}}
82+
>
83+
<div class="ai-artifact__panel">
84+
<DButton
85+
class="btn-flat btn-icon-text"
86+
@icon="discourse-compress"
87+
@label="discourse_ai.ai_artifact.collapse_view_label"
88+
@action={{this.toggleView}}
89+
/>
90+
</div>
91+
</div>
92+
{{#if this.requireClickToRun}}
93+
<div class="ai-artifact__click-to-run">
94+
<DButton
95+
class="btn btn-primary"
96+
@icon="play"
97+
@label="discourse_ai.ai_artifact.click_to_run_label"
98+
@action={{this.showArtifact}}
99+
/>
100+
</div>
101+
{{else}}
102+
<iframe
103+
title="AI Artifact"
104+
src={{this.artifactUrl}}
105+
width="100%"
106+
frameborder="0"
107+
sandbox="allow-scripts allow-forms"
108+
></iframe>
109+
{{/if}}
110+
{{#unless this.requireClickToRun}}
111+
<div class="ai-artifact__footer">
112+
<DButton
113+
class="btn-flat btn-icon-text ai-artifact__expand-button"
114+
@icon="discourse-expand"
115+
@label="discourse_ai.ai_artifact.expand_view_label"
116+
@action={{this.toggleView}}
117+
/>
118+
</div>
119+
{{/unless}}
120+
</div>
121+
</template>
122+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { withPluginApi } from "discourse/lib/plugin-api";
2+
import AiArtifact from "../discourse/components/ai-artifact";
3+
4+
function initializeAiArtifacts(api) {
5+
api.decorateCookedElement(
6+
(element, helper) => {
7+
if (!helper.renderGlimmer) {
8+
return;
9+
}
10+
11+
[...element.querySelectorAll("div.ai-artifact")].forEach(
12+
(artifactElement) => {
13+
const artifactId = artifactElement.getAttribute(
14+
"data-ai-artifact-id"
15+
);
16+
17+
helper.renderGlimmer(artifactElement, <template>
18+
<AiArtifact @artifactId={{artifactId}} />
19+
</template>);
20+
}
21+
);
22+
},
23+
{
24+
id: "ai-artifact",
25+
onlyStream: true,
26+
}
27+
);
28+
}
29+
30+
export default {
31+
name: "ai-artifact",
32+
initialize() {
33+
withPluginApi("0.8.7", initializeAiArtifacts);
34+
},
35+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export function setup(helper) {
22
helper.allowList(["details[class=ai-quote]"]);
3+
helper.allowList(["div[class=ai-artifact]", "div[data-ai-artifact-id]"]);
34
}

0 commit comments

Comments
 (0)