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:
parent
bffe9dfa07
commit
0191b41877
|
@ -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
|
|
@ -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
|
||||
#
|
|
@ -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);
|
||||
},
|
||||
};
|
|
@ -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]"]);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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}"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
|
@ -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?
|
||||
|
|
|
@ -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
|
Loading…
Reference in New Issue