mirror of
https://github.com/discourse/discourse-ai.git
synced 2025-06-30 03:22:17 +00:00
Introduces a persistent, user-scoped key-value storage system for AI Artifacts, enabling them to be stateful and interactive. This transforms artifacts from static content into mini-applications that can save user input, preferences, and other data. The core components of this feature are: 1. **Model and API**: - A new `AiArtifactKeyValue` model and corresponding database table to store data associated with a user and an artifact. - A new `ArtifactKeyValuesController` provides a RESTful API for CRUD operations (`index`, `set`, `destroy`) on the key-value data. - Permissions are enforced: users can only modify their own data but can view public data from other users. 2. **Secure JavaScript Bridge**: - A `postMessage` communication bridge is established between the sandboxed artifact `iframe` and the parent Discourse window. - A JavaScript API is exposed to the artifact as `window.discourseArtifact` with async methods: `get(key)`, `set(key, value, options)`, `delete(key)`, and `index(filter)`. - The parent window handles these requests, makes authenticated calls to the new controller, and returns the results to the iframe. This ensures security by keeping untrusted JS isolated. 3. **AI Tool Integration**: - The `create_artifact` tool is updated with a `requires_storage` boolean parameter. - If an artifact requires storage, its metadata is flagged, and the system prompt for the code-generating AI is augmented with detailed documentation for the new storage API. 4. **Configuration**: - Adds hidden site settings `ai_artifact_kv_value_max_length` and `ai_artifact_max_keys_per_user_per_artifact` for throttling. This also includes a minor fix to use `jsonb_set` when updating artifact metadata, ensuring other metadata fields are preserved.
120 lines
3.3 KiB
Ruby
120 lines
3.3 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class AiArtifact < ActiveRecord::Base
|
|
has_many :versions, class_name: "AiArtifactVersion", dependent: :destroy
|
|
has_many :key_values, class_name: "AiArtifactKeyValue", dependent: :destroy
|
|
belongs_to :user
|
|
belongs_to :post
|
|
validates :html, length: { maximum: 65_535 }
|
|
validates :css, length: { maximum: 65_535 }
|
|
validates :js, length: { maximum: 65_535 }
|
|
|
|
ALLOWED_CDN_SOURCES = %w[
|
|
https://cdn.jsdelivr.net
|
|
https://cdnjs.cloudflare.com
|
|
https://unpkg.com
|
|
https://ajax.googleapis.com
|
|
https://d3js.org
|
|
https://code.jquery.com
|
|
https://esm.sh
|
|
]
|
|
|
|
def self.artifact_version_attribute(version)
|
|
if version
|
|
"data-artifact-version='#{version}'"
|
|
else
|
|
""
|
|
end
|
|
end
|
|
|
|
def self.iframe_for(id, version = nil)
|
|
<<~HTML
|
|
<div class='ai-artifact'>
|
|
<iframe src='#{url(id, version)}' frameborder="0" height="100%" width="100%"></iframe>
|
|
<div class='ai-artifact-controls'>
|
|
<a href='#{url(id, version)}' class='link-artifact' target='_blank'>#{I18n.t("discourse_ai.ai_artifact.link")}</a>
|
|
<a href class='copy-embed' data-artifact-id="#{id}" #{artifact_version_attribute(version)} data-url="#{url(id, version)}">#{I18n.t("discourse_ai.ai_artifact.copy_embed")}</a>
|
|
</div>
|
|
</div>
|
|
HTML
|
|
end
|
|
|
|
def self.url(id, version = nil)
|
|
url = Discourse.base_url + "/discourse-ai/ai-bot/artifacts/#{id}"
|
|
if version
|
|
"#{url}/#{version}"
|
|
else
|
|
url
|
|
end
|
|
end
|
|
|
|
def self.share_publicly(id:, post:)
|
|
artifact = AiArtifact.find_by(id: id)
|
|
if artifact&.post&.topic&.id == post.topic.id
|
|
artifact.metadata ||= {}
|
|
artifact.metadata[:public] = true
|
|
artifact.save!
|
|
end
|
|
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
|
|
|
|
def apply_diff(html_diff: nil, css_diff: nil, js_diff: nil, change_description: nil)
|
|
differ = DiscourseAi::Utils::DiffUtils
|
|
|
|
html = html_diff ? differ.apply_hunk(self.html, html_diff) : self.html
|
|
css = css_diff ? differ.apply_hunk(self.css, css_diff) : self.css
|
|
js = js_diff ? differ.apply_hunk(self.js, js_diff) : self.js
|
|
|
|
create_new_version(html: html, css: css, js: js, change_description: change_description)
|
|
end
|
|
|
|
def create_new_version(html: nil, css: nil, js: nil, change_description: nil)
|
|
latest_version = versions.order(version_number: :desc).first
|
|
new_version_number = latest_version ? latest_version.version_number + 1 : 1
|
|
version = nil
|
|
|
|
transaction do
|
|
# Create the version record
|
|
version =
|
|
versions.create!(
|
|
version_number: new_version_number,
|
|
html: html || self.html,
|
|
css: css || self.css,
|
|
js: js || self.js,
|
|
change_description: change_description,
|
|
)
|
|
save!
|
|
end
|
|
|
|
version
|
|
end
|
|
|
|
def public?
|
|
!!metadata&.dig("public")
|
|
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
|
|
#
|