FEATURE: AI artifacts

Initial implementation of an artifact system which allows users
to generate HTML pages directly from the AI persona.
This commit is contained in:
Sam Saffron 2024-11-06 10:25:02 +11:00
parent bffe9dfa07
commit 0191b41877
No known key found for this signature in database
GPG Key ID: B9606168D2FFD9F5
11 changed files with 367 additions and 0 deletions

View File

@ -0,0 +1,45 @@
# frozen_string_literal: true
module DiscourseAi
module AiBot
class ArtifactsController < ApplicationController
requires_plugin DiscourseAi::PLUGIN_NAME
skip_before_action :preload_json, :check_xhr, only: %i[show]
def show
artifact = AiArtifact.find(params[:id])
post = Post.find_by(id: artifact.post_id)
raise Discourse::NotFound unless post && guardian.can_see?(post)
# Prepare the HTML document
html = <<~HTML
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>#{ERB::Util.html_escape(artifact.name)}</title>
<style>
#{artifact.css}
</style>
</head>
<body>
#{artifact.html}
<script>
#{artifact.js}
</script>
</body>
</html>
HTML
response.headers.delete("X-Frame-Options")
response.headers.delete("Content-Security-Policy")
# Render the content
render html: html.html_safe, layout: false, content_type: "text/html"
end
end
end
end

22
app/models/ai_artifact.rb Normal file
View File

@ -0,0 +1,22 @@
# frozen_string_literal: true
class AiArtifact < ActiveRecord::Base
belongs_to :user
belongs_to :post
end
# == Schema Information
#
# Table name: ai_artifacts
#
# id :bigint not null, primary key
# user_id :integer not null
# post_id :integer not null
# name :string(255) not null
# html :string(65535)
# css :string(65535)
# js :string(65535)
# metadata :jsonb
# created_at :datetime not null
# updated_at :datetime not null
#

View File

@ -0,0 +1,54 @@
import { withPluginApi } from "discourse/lib/plugin-api";
function initializeAiArtifactTabs(api) {
api.decorateCooked(
($element) => {
const element = $element[0];
const artifacts = element.querySelectorAll(".ai-artifact");
if (!artifacts.length) {
return;
}
artifacts.forEach((artifact) => {
const tabs = artifact.querySelectorAll(".ai-artifact-tab");
const panels = artifact.querySelectorAll(".ai-artifact-panel");
tabs.forEach((tab) => {
tab.addEventListener("click", (e) => {
e.preventDefault();
if (tab.hasAttribute("data-selected")) {
return;
}
const tabType = Object.keys(tab.dataset).find(
(key) => key !== "selected"
);
tabs.forEach((t) => t.removeAttribute("data-selected"));
panels.forEach((p) => p.removeAttribute("data-selected"));
tab.setAttribute("data-selected", "");
const targetPanel = artifact.querySelector(
`.ai-artifact-panel[data-${tabType}]`
);
if (targetPanel) {
targetPanel.setAttribute("data-selected", "");
}
});
});
});
},
{
id: "ai-artifact-tabs",
onlyStream: false,
}
);
}
export default {
name: "ai-artifact-tabs",
initialize() {
withPluginApi("0.8.7", initializeAiArtifactTabs);
},
};

View File

@ -1,3 +1,8 @@
export function setup(helper) {
helper.allowList(["details[class=ai-quote]"]);
helper.allowList(["div[class=ai-artifact]"]);
helper.allowList(["div[class=ai-artifact-tab]"]);
helper.allowList(["div[class=ai-artifact-tabs]"]);
helper.allowList(["div[class=ai-artifact-panels]"]);
helper.allowList(["div[class=ai-artifact-panel]"]);
}

View File

@ -0,0 +1,56 @@
.ai-artifact {
margin: 1em 0;
.ai-artifact-tabs {
display: flex;
gap: 0.20em;
border-bottom: 2px solid var(--primary-low);
padding: 0 0.2em;
.ai-artifact-tab {
margin-bottom: -2px;
&[data-selected] {
a {
color: var(--tertiary);
font-weight: 500;
border-bottom: 2px solid var(--tertiary);
}
}
&:hover:not([data-selected]) {
a {
color: var(--primary);
background: var(--primary-very-low);
}
}
a {
display: block;
padding: 0.5em 1em;
color: var(--primary-medium);
text-decoration: none;
cursor: pointer;
border-bottom: 2px solid transparent;
}
}
}
.ai-artifact-panels {
padding: 1em 0 0 0;
background: var(--blend-primary-secondary-5);
.ai-artifact-panel {
display: none;
min-height: 400px;
&[data-selected] {
display: block;
}
pre {
margin: 0;
}
}
}
}

View File

@ -220,6 +220,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:
create_artifact: "Create web artifact"
web_browser: "Browse Web"
github_search_files: "GitHub search files"
github_search_code: "GitHub code search"
@ -241,6 +242,7 @@ en:
search_meta_discourse: "Search Meta Discourse"
javascript_evaluator: "Evaluate JavaScript"
tool_help:
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"
github_search_files: "Search for files in a GitHub repository"
@ -262,6 +264,7 @@ en:
search_meta_discourse: "Search Meta Discourse"
javascript_evaluator: "Evaluate JavaScript"
tool_description:
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}"
github_search_code: "Searched for '%{query}' in %{repo}"

View File

@ -32,6 +32,10 @@ DiscourseAi::Engine.routes.draw do
get "/preview/:topic_id" => "shared_ai_conversations#preview"
end
scope module: :ai_bot, path: "/ai-bot/artifacts" do
get "/:id" => "artifacts#show"
end
scope module: :summarization, path: "/summarization", defaults: { format: :json } do
get "/t/:topic_id" => "summary#show", :constraints => { topic_id: /\d+/ }
get "/channels/:channel_id" => "chat_summary#show"

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
class AddAiArtifacts < ActiveRecord::Migration[7.1]
def change
create_table :ai_artifacts do |t|
t.integer :user_id, null: false
t.integer :post_id, null: false
t.string :name, null: false, limit: 255
t.string :html, limit: 65535 # ~64KB limit
t.string :css, limit: 65535 # ~64KB limit
t.string :js, limit: 65535 # ~64KB limit
t.jsonb :metadata # For any additional properties
t.timestamps
end
end
end

View File

@ -96,6 +96,7 @@ module DiscourseAi
Tools::GithubSearchFiles,
Tools::WebBrowser,
Tools::JavascriptEvaluator,
Tools::CreateArtifact,
]
tools << Tools::GithubSearchCode if SiteSetting.ai_bot_github_access_token.present?

View File

@ -0,0 +1,159 @@
# frozen_string_literal: true
module DiscourseAi
module AiBot
module Tools
class CreateArtifact < Tool
def self.name
"create_artifact"
end
def self.signature
{
name: "create_artifact",
description:
"Creates a web artifact with HTML, CSS, and JavaScript that can be displayed in an iframe",
parameters: [
{
name: "name",
description: "A name for the artifact (max 255 chars)",
type: "string",
required: true,
},
{
name: "html_content",
description: "The HTML content for the artifact",
type: "string",
required: true,
},
{ name: "css", description: "Optional CSS styles for the artifact", type: "string" },
{
name: "js",
description:
"Optional
JavaScript code for the artifact",
type: "string",
},
],
}
end
def invoke
# Get the current post from context
post = Post.find_by(id: context[:post_id])
return error_response("No post context found") unless post
html = parameters[:html_content].to_s
css = parameters[:css].to_s
js = parameters[:js].to_s
# Create the artifact
artifact =
AiArtifact.new(
user_id: bot_user.id,
post_id: post.id,
name: parameters[:name].to_s[0...255],
html: html,
css: css,
js: js,
metadata: parameters[:metadata],
)
if artifact.save
tabs = {
css: [css, "CSS"],
js: [js, "JavaScript"],
html: [html, "HTML"],
preview: [
"<iframe src=\"#{Discourse.base_url}/discourse-ai/ai-bot/artifacts/#{artifact.id}\" width=\"100%\" height=\"500\" frameborder=\"0\"></iframe>",
"Preview",
],
}
first = true
html_tabs =
tabs.map do |tab, (content, name)|
selected = " data-selected" if first
first = false
(<<~HTML).strip
<div class="ai-artifact-tab" data-#{tab}#{selected}>
<a>#{name}</a>
</div>
HTML
end
first = true
html_panels =
tabs.map do |tab, (content, name)|
selected = " data-selected" if first
first = false
inner_content =
if tab == :preview
content
else
<<~HTML
```#{tab}
#{content}
```
HTML
end
(<<~HTML).strip
<div class="ai-artifact-panel" data-#{tab}#{selected}>
#{inner_content}
</div>
HTML
end
self.custom_raw = <<~RAW
<div class="ai-artifact">
<div class="ai-artifact-tabs">
#{html_tabs.join("\n")}
</div>
<div class="ai-artifact-panels">
#{html_panels.join("\n")}
</div>
</div>
RAW
success_response(artifact)
else
error_response(artifact.errors.full_messages.join(", "))
end
end
def chain_next_response?
@chain_next_response
end
private
def success_response(artifact)
@chain_next_response = false
iframe_url = "#{Discourse.base_url}/discourse-ai/ai-bot/artifacts/#{artifact.id}"
{
status: "success",
artifact_id: artifact.id,
iframe_html:
"<iframe src=\"#{iframe_url}\" width=\"100%\" height=\"500\" frameborder=\"0\"></iframe>",
message: "Artifact created successfully and rendered to user.",
}
end
def error_response(message)
@chain_next_response = false
{ status: "error", error: message }
end
def help
"Creates a web artifact with HTML, CSS, and JavaScript that can be displayed in an iframe. " \
"Requires a name and HTML content. CSS and JavaScript are optional. " \
"The artifact will be associated with the current post and can be displayed using an iframe."
end
end
end
end
end

View File

@ -39,6 +39,8 @@ register_asset "stylesheets/modules/llms/common/ai-llms-editor.scss"
register_asset "stylesheets/modules/ai-bot/common/ai-tools.scss"
register_asset "stylesheets/modules/ai-bot/common/ai-artifact.scss"
module ::DiscourseAi
PLUGIN_NAME = "discourse-ai"
end