Skip to content

FEATURE: AI artifacts #898

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 29 commits into from
Nov 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
41a002b
FEATURE: AI artifacts
SamSaffron Nov 5, 2024
1eb1993
Handle malformed gemini replies
SamSaffron Nov 14, 2024
7300737
- halt after tools
SamSaffron Nov 14, 2024
5c8d624
fix ollama tool support
SamSaffron Nov 14, 2024
730cef9
Support partial tool completions with xml tools
SamSaffron Nov 15, 2024
8862e55
partial tool calls are implemented
SamSaffron Nov 15, 2024
0f242bf
Fix progress tracker for tools
SamSaffron Nov 15, 2024
07bae2e
some bug fixes
SamSaffron Nov 15, 2024
8e2ff32
fix sorting
SamSaffron Nov 15, 2024
7a93496
Claude 3.5 models support 8192 output tokens....
SamSaffron Nov 15, 2024
d55aad3
implement full screen mode
SamSaffron Nov 16, 2024
3add2a9
remove uneeded code
SamSaffron Nov 16, 2024
2ed1bec
lint
SamSaffron Nov 16, 2024
d2f8a92
more linting
SamSaffron Nov 16, 2024
099aec0
eating a space prior to tool calls
SamSaffron Nov 16, 2024
527ab2b
support XML tools which work better for Google
SamSaffron Nov 16, 2024
d655138
start implementing artifact security
SamSaffron Nov 17, 2024
82d9079
lint
SamSaffron Nov 17, 2024
549cee3
support disabling native tools for open ai
SamSaffron Nov 17, 2024
43b800d
implement strict mode for artifacts
SamSaffron Nov 17, 2024
714e5b0
refresh model presets
SamSaffron Nov 18, 2024
a64b087
lint
SamSaffron Nov 18, 2024
f5befb5
We need to ship a persona or people will not understand this artifact…
SamSaffron Nov 18, 2024
c806ab6
remove unused text
SamSaffron Nov 18, 2024
f2c879a
fix o1 xml tool support
SamSaffron Nov 18, 2024
665fec2
fix spec
SamSaffron Nov 18, 2024
e933f38
handle pr comment and restrict artifacts to staff by default
SamSaffron Nov 18, 2024
ca6b87a
o1 and o1 mini support streaming now, remove hack
SamSaffron Nov 18, 2024
424091d
fix specs
SamSaffron Nov 18, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions app/controllers/discourse_ai/ai_bot/artifacts_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# frozen_string_literal: true

module DiscourseAi
module AiBot
class ArtifactsController < ApplicationController
requires_plugin DiscourseAi::PLUGIN_NAME
before_action :require_site_settings!

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)
if artifact.metadata&.dig("public")
# no guardian needed
else
raise Discourse::NotFound if !post&.topic&.private_message?
raise Discourse::NotFound if !guardian.can_see?(post)
end

# 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

private

def require_site_settings!
if !SiteSetting.discourse_ai_enabled ||
!SiteSetting.ai_artifact_security.in?(%w[lax strict])
raise Discourse::NotFound
end
end
end
end
end
49 changes: 49 additions & 0 deletions app/models/ai_artifact.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# frozen_string_literal: true

class AiArtifact < ActiveRecord::Base
belongs_to :user
belongs_to :post
validates :html, length: { maximum: 65_535 }
validates :css, length: { maximum: 65_535 }
validates :js, length: { maximum: 65_535 }

def self.iframe_for(id)
<<~HTML
<iframe sandbox="allow-scripts allow-forms" height="600px" src='#{url(id)}' frameborder="0" width="100%"></iframe>
HTML
end

def self.url(id)
Discourse.base_url + "/discourse-ai/ai-bot/artifacts/#{id}"
end

def self.share_publicly(id:, post:)
artifact = AiArtifact.find_by(id: id)
artifact.update!(metadata: { public: true }) if artifact&.post&.topic&.id == post.topic.id
end

def self.unshare_publicly(id:)
artifact = AiArtifact.find_by(id: id)
artifact&.update!(metadata: { public: false })
end

def url
self.class.url(id)
end
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
#
7 changes: 7 additions & 0 deletions app/models/llm_model.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ def self.provider_params
},
open_ai: {
organization: :text,
disable_native_tools: :checkbox,
},
google: {
disable_native_tools: :checkbox,
},
azure: {
disable_native_tools: :checkbox,
},
hugging_face: {
disable_system_prompt: :checkbox,
Expand Down
26 changes: 25 additions & 1 deletion app/models/shared_ai_conversation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ def self.share_conversation(user, target, max_posts: DEFAULT_MAX_POSTS)

def self.destroy_conversation(conversation)
conversation.destroy

maybe_topic = conversation.target
if maybe_topic.is_a?(Topic)
AiArtifact.where(post: maybe_topic.posts).update_all(metadata: { public: false })
end

::Jobs.enqueue(
:shared_conversation_adjust_upload_security,
target_id: conversation.target_id,
Expand Down Expand Up @@ -165,7 +171,7 @@ def self.build_conversation_data(topic, max_posts: DEFAULT_MAX_POSTS, include_us
id: post.id,
user_id: post.user_id,
created_at: post.created_at,
cooked: post.cooked,
cooked: cook_artifacts(post),
}

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

def self.cook_artifacts(post)
html = post.cooked
return html if !%w[lax strict].include?(SiteSetting.ai_artifact_security)

doc = Nokogiri::HTML5.fragment(html)
doc
.css("div.ai-artifact")
.each do |node|
id = node["data-ai-artifact-id"].to_i
if id > 0
AiArtifact.share_publicly(id: id, post: post)
node.replace(AiArtifact.iframe_for(id))
end
end

doc.to_s
end

private

def populate_user_info!(posts)
Expand Down
122 changes: 122 additions & 0 deletions assets/javascripts/discourse/components/ai-artifact.gjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import DButton from "discourse/components/d-button";
import htmlClass from "discourse/helpers/html-class";
import getURL from "discourse-common/lib/get-url";

export default class AiArtifactComponent extends Component {
@service siteSettings;
@tracked expanded = false;
@tracked showingArtifact = false;

constructor() {
super(...arguments);
this.keydownHandler = this.handleKeydown.bind(this);
}

willDestroy() {
super.willDestroy(...arguments);
window.removeEventListener("keydown", this.keydownHandler);
}

@action
handleKeydown(event) {
if (event.key === "Escape" || event.key === "Esc") {
this.expanded = false;
}
}

get requireClickToRun() {
if (this.showingArtifact) {
return false;
}
return this.siteSettings.ai_artifact_security === "strict";
}

get artifactUrl() {
return getURL(`/discourse-ai/ai-bot/artifacts/${this.args.artifactId}`);
}

@action
showArtifact() {
this.showingArtifact = true;
}

@action
toggleView() {
this.expanded = !this.expanded;
if (this.expanded) {
window.addEventListener("keydown", this.keydownHandler);
} else {
window.removeEventListener("keydown", this.keydownHandler);
}
}

get wrapperClasses() {
return `ai-artifact__wrapper ${
this.expanded ? "ai-artifact__expanded" : ""
}`;
}

@action
artifactPanelHover() {
// retrrigger animation
const panel = document.querySelector(".ai-artifact__panel");
panel.style.animation = "none"; // Stop the animation
setTimeout(() => {
panel.style.animation = ""; // Re-trigger the animation by removing the none style
}, 0);
}

<template>
{{#if this.expanded}}
{{htmlClass "ai-artifact-expanded"}}
{{/if}}
<div class={{this.wrapperClasses}}>
<div
class="ai-artifact__panel--wrapper"
{{on "mouseleave" this.artifactPanelHover}}
>
<div class="ai-artifact__panel">
<DButton
class="btn-flat btn-icon-text"
@icon="discourse-compress"
@label="discourse_ai.ai_artifact.collapse_view_label"
@action={{this.toggleView}}
/>
</div>
</div>
{{#if this.requireClickToRun}}
<div class="ai-artifact__click-to-run">
<DButton
class="btn btn-primary"
@icon="play"
@label="discourse_ai.ai_artifact.click_to_run_label"
@action={{this.showArtifact}}
/>
</div>
{{else}}
<iframe
title="AI Artifact"
src={{this.artifactUrl}}
width="100%"
frameborder="0"
sandbox="allow-scripts allow-forms"
></iframe>
{{/if}}
{{#unless this.requireClickToRun}}
<div class="ai-artifact__footer">
<DButton
class="btn-flat btn-icon-text ai-artifact__expand-button"
@icon="discourse-expand"
@label="discourse_ai.ai_artifact.expand_view_label"
@action={{this.toggleView}}
/>
</div>
{{/unless}}
</div>
</template>
}
35 changes: 35 additions & 0 deletions assets/javascripts/initializers/ai-artifacts.gjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { withPluginApi } from "discourse/lib/plugin-api";
import AiArtifact from "../discourse/components/ai-artifact";

function initializeAiArtifacts(api) {
api.decorateCookedElement(
(element, helper) => {
if (!helper.renderGlimmer) {
return;
}

[...element.querySelectorAll("div.ai-artifact")].forEach(
(artifactElement) => {
const artifactId = artifactElement.getAttribute(
"data-ai-artifact-id"
);

helper.renderGlimmer(artifactElement, <template>
<AiArtifact @artifactId={{artifactId}} />
</template>);
}
);
},
{
id: "ai-artifact",
onlyStream: true,
}
);
}

export default {
name: "ai-artifact",
initialize() {
withPluginApi("0.8.7", initializeAiArtifacts);
},
};
1 change: 1 addition & 0 deletions assets/javascripts/lib/discourse-markdown/ai-tags.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export function setup(helper) {
helper.allowList(["details[class=ai-quote]"]);
helper.allowList(["div[class=ai-artifact]", "div[data-ai-artifact-id]"]);
}
Loading