mirror of
https://github.com/discourse/discourse-ai.git
synced 2025-06-24 16:42:15 +00:00
FEATURE: persistent key-value storage for AI Artifacts (#1417)
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.
This commit is contained in:
parent
f7e0ea888d
commit
fdf0ff8a25
@ -0,0 +1,120 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseAi
|
||||
module AiBot
|
||||
class ArtifactKeyValuesController < ::ApplicationController
|
||||
requires_plugin DiscourseAi::PLUGIN_NAME
|
||||
before_action :ensure_logged_in, only: %i[set destroy]
|
||||
before_action :find_artifact
|
||||
|
||||
PER_PAGE_MAX = 100
|
||||
|
||||
def index
|
||||
page = index_params[:page].to_i
|
||||
page = 1 if page < 1
|
||||
per_page = index_params[:per_page].to_i
|
||||
per_page = PER_PAGE_MAX if per_page < 1 || per_page > PER_PAGE_MAX
|
||||
|
||||
query = build_index_query
|
||||
|
||||
total_count = query.count
|
||||
key_values =
|
||||
query
|
||||
.includes(:user)
|
||||
.order(:user_id, :key, :created_at)
|
||||
.offset((page - 1) * per_page)
|
||||
.limit(per_page + 1)
|
||||
|
||||
has_more = key_values.length > per_page
|
||||
key_values = key_values.first(per_page) if has_more
|
||||
|
||||
render json: {
|
||||
key_values:
|
||||
ActiveModel::ArraySerializer.new(
|
||||
key_values,
|
||||
each_serializer: AiArtifactKeyValueSerializer,
|
||||
keys_only: params[:keys_only] == "true",
|
||||
).as_json,
|
||||
has_more: has_more,
|
||||
total_count: total_count,
|
||||
users:
|
||||
key_values
|
||||
.map { |kv| kv.user }
|
||||
.uniq
|
||||
.map { |u| BasicUserSerializer.new(u, root: nil).as_json },
|
||||
}
|
||||
end
|
||||
|
||||
def destroy
|
||||
if params[:key].blank?
|
||||
render json: { error: "Key parameter is required" }, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
key_value = @artifact.key_values.find_by(user_id: current_user.id, key: params[:key])
|
||||
|
||||
if key_value.nil?
|
||||
render json: { error: "Key not found" }, status: :not_found
|
||||
elsif key_value.destroy
|
||||
head :ok
|
||||
else
|
||||
render json: { errors: key_value.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def set
|
||||
key_value =
|
||||
@artifact.key_values.find_or_initialize_by(
|
||||
user: current_user,
|
||||
key: key_value_params[:key],
|
||||
)
|
||||
|
||||
key_value.assign_attributes(key_value_params.except(:key))
|
||||
|
||||
if key_value.save
|
||||
render json: AiArtifactKeyValueSerializer.new(key_value).as_json
|
||||
else
|
||||
render json: { errors: key_value.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def key_value_params
|
||||
params.permit(:key, :value, :public)
|
||||
end
|
||||
|
||||
def index_params
|
||||
@index_params ||= params.permit(:page, :per_page, :key, :keys_only, :all_users)
|
||||
end
|
||||
|
||||
def build_index_query
|
||||
query = @artifact.key_values
|
||||
|
||||
query =
|
||||
if current_user&.admin?
|
||||
query
|
||||
elsif current_user
|
||||
query.where("user_id = ? OR public = true", current_user.id)
|
||||
else
|
||||
query.where(public: true)
|
||||
end
|
||||
|
||||
query = query.where("key = ?", index_params[:key]) if index_params[:key].present?
|
||||
|
||||
if !index_params[:all_users].to_s == "true" && current_user
|
||||
query = query.where(user_id: current_user.id)
|
||||
end
|
||||
|
||||
query
|
||||
end
|
||||
|
||||
def find_artifact
|
||||
@artifact = AiArtifact.find_by(id: params[:artifact_id])
|
||||
raise Discourse::NotFound if !@artifact
|
||||
raise Discourse::NotFound if !@artifact.public? && guardian.anonymous?
|
||||
raise Discourse::NotFound if !@artifact.public? && !guardian.can_see?(@artifact.post)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
@ -20,20 +20,26 @@ module DiscourseAi
|
||||
end
|
||||
|
||||
name = artifact.name
|
||||
artifact_version = nil
|
||||
|
||||
if params[:version].present?
|
||||
artifact = artifact.versions.find_by(version_number: params[:version])
|
||||
raise Discourse::NotFound if !artifact
|
||||
artifact_version = artifact.versions.find_by(version_number: params[:version])
|
||||
raise Discourse::NotFound if !artifact_version
|
||||
end
|
||||
|
||||
js = artifact.js || ""
|
||||
if !js.match?(%r{\A\s*<script.*</script>}mi)
|
||||
mod = ""
|
||||
mod = " type=\"module\"" if js.match?(/\A\s*import.*/)
|
||||
js = "<script#{mod}>\n#{js}\n</script>"
|
||||
end
|
||||
# Prepare the inner (untrusted) HTML document
|
||||
untrusted_html = <<~HTML
|
||||
untrusted_html = build_untrusted_html(artifact_version || artifact, name)
|
||||
trusted_html = build_trusted_html(artifact, artifact_version, name, untrusted_html)
|
||||
|
||||
set_security_headers
|
||||
render html: trusted_html.html_safe, layout: false, content_type: "text/html"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_untrusted_html(artifact, name)
|
||||
js = prepare_javascript(artifact.js)
|
||||
|
||||
<<~HTML
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
@ -42,21 +48,7 @@ module DiscourseAi
|
||||
<style>
|
||||
#{artifact.css}
|
||||
</style>
|
||||
<script>
|
||||
window._discourse_user_data = {
|
||||
#{current_user ? "username: #{current_user.username.to_json}" : "username: null"}
|
||||
};
|
||||
window.discourseArtifactReady = new Promise(resolve => {
|
||||
window._resolveArtifactData = resolve;
|
||||
});
|
||||
window.addEventListener('message', function(event) {
|
||||
if (event.data && event.data.type === 'discourse-artifact-data') {
|
||||
window.discourseArtifactData = event.data.dataset || {};
|
||||
Object.assign(window.discourseArtifactData, window._discourse_user_data);
|
||||
window._resolveArtifactData(window.discourseArtifactData);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
#{build_iframe_javascript}
|
||||
</head>
|
||||
<body>
|
||||
#{artifact.html}
|
||||
@ -64,15 +56,17 @@ module DiscourseAi
|
||||
</body>
|
||||
</html>
|
||||
HTML
|
||||
end
|
||||
|
||||
# Prepare the outer (trusted) HTML document
|
||||
trusted_html = <<~HTML
|
||||
def build_trusted_html(artifact, artifact_version, name, untrusted_html)
|
||||
<<~HTML
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>#{ERB::Util.html_escape(name)}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, user-scalable=yes, viewport-fit=cover, interactive-widget=resizes-content">
|
||||
<meta name="csrf-token" content="#{form_authenticity_token}">
|
||||
<style>
|
||||
html, body, iframe {
|
||||
margin: 0;
|
||||
@ -89,35 +83,256 @@ module DiscourseAi
|
||||
</head>
|
||||
<body>
|
||||
<iframe sandbox="allow-scripts allow-forms" height="100%" width="100%" srcdoc="#{ERB::Util.html_escape(untrusted_html)}" frameborder="0"></iframe>
|
||||
<script>
|
||||
document.querySelector('iframe').addEventListener('load', function() {
|
||||
try {
|
||||
const iframeWindow = this.contentWindow;
|
||||
const message = { type: 'discourse-artifact-data', dataset: {} };
|
||||
|
||||
if (window.frameElement && window.frameElement.dataset) {
|
||||
Object.assign(message.dataset, window.frameElement.dataset);
|
||||
}
|
||||
iframeWindow.postMessage(message, '*');
|
||||
} catch (e) { console.error('Error passing data to artifact:', e); }
|
||||
});
|
||||
</script>
|
||||
#{build_parent_javascript(artifact)}
|
||||
</body>
|
||||
</html>
|
||||
HTML
|
||||
end
|
||||
|
||||
def prepare_javascript(js)
|
||||
return "" if js.blank?
|
||||
|
||||
if !js.match?(%r{\A\s*<script.*</script>}mi)
|
||||
mod = ""
|
||||
mod = " type=\"module\"" if js.match?(/\A\s*import.*/)
|
||||
js = "<script#{mod}>\n#{js}\n</script>"
|
||||
end
|
||||
js
|
||||
end
|
||||
|
||||
def user_data
|
||||
{
|
||||
username: current_user ? current_user.username : nil,
|
||||
user_id: current_user ? current_user.id : nil,
|
||||
name: current_user ? current_user.name : nil,
|
||||
}
|
||||
end
|
||||
|
||||
def build_iframe_javascript
|
||||
<<~JAVASCRIPT
|
||||
<script>
|
||||
window._discourse_user_data = #{user_data.to_json};
|
||||
|
||||
window.discourseArtifactReady = new Promise(resolve => {
|
||||
window._resolveArtifactData = resolve;
|
||||
});
|
||||
|
||||
// Key-value store API
|
||||
window.discourseArtifact = {
|
||||
get: function(key) {
|
||||
return window._postMessageRequest('get', { key: key });
|
||||
},
|
||||
set: function(key, value, options = {}) {
|
||||
return window._postMessageRequest('set', {
|
||||
key: key,
|
||||
value: value,
|
||||
public: options.public || false
|
||||
});
|
||||
},
|
||||
delete: function(key) {
|
||||
return window._postMessageRequest('delete', { key: key });
|
||||
},
|
||||
index: function(filter = {}) {
|
||||
return window._postMessageRequest('index', filter);
|
||||
}
|
||||
};
|
||||
|
||||
window._postMessageRequest = function(action, data) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const requestId = Math.random().toString(36).substr(2, 9);
|
||||
const messageHandler = function(event) {
|
||||
if (event.data && event.data.requestId === requestId) {
|
||||
window.removeEventListener('message', messageHandler);
|
||||
if (event.data.error) {
|
||||
reject(new Error(event.data.error));
|
||||
} else {
|
||||
resolve(event.data.result);
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', messageHandler);
|
||||
window.parent.postMessage({
|
||||
type: 'discourse-artifact-kv',
|
||||
action: action,
|
||||
data: data,
|
||||
requestId: requestId
|
||||
}, '*');
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('message', function(event) {
|
||||
if (event.data && event.data.type === 'discourse-artifact-data') {
|
||||
window.discourseArtifactData = event.data.dataset || {};
|
||||
Object.assign(window.discourseArtifactData, window._discourse_user_data);
|
||||
window._resolveArtifactData(window.discourseArtifactData);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
JAVASCRIPT
|
||||
end
|
||||
|
||||
def build_parent_javascript(artifact)
|
||||
<<~JAVASCRIPT
|
||||
<script>
|
||||
document.querySelector('iframe').addEventListener('load', function() {
|
||||
try {
|
||||
const iframeWindow = this.contentWindow;
|
||||
const message = { type: 'discourse-artifact-data', dataset: {} };
|
||||
|
||||
if (window.frameElement && window.frameElement.dataset) {
|
||||
Object.assign(message.dataset, window.frameElement.dataset);
|
||||
}
|
||||
iframeWindow.postMessage(message, '*');
|
||||
} catch (e) {
|
||||
console.error('Error passing data to artifact:', e);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle key-value store requests from iframe
|
||||
window.addEventListener('message', async function(event) {
|
||||
if (event.data && event.data.type === 'discourse-artifact-kv') {
|
||||
const { action, data, requestId } = event.data;
|
||||
const artifactId = #{artifact.id};
|
||||
|
||||
try {
|
||||
const result = await handleKeyValueRequest(action, data, artifactId);
|
||||
event.source.postMessage({
|
||||
requestId: requestId,
|
||||
result: result
|
||||
}, '*');
|
||||
} catch (error) {
|
||||
event.source.postMessage({
|
||||
requestId: requestId,
|
||||
error: error.message
|
||||
}, '*');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function handleKeyValueRequest(action, data, artifactId) {
|
||||
const baseUrl = '/discourse-ai/ai-bot/artifact-key-values/' + artifactId + ".json";
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
|
||||
switch (action) {
|
||||
case 'get':
|
||||
return await handleGetRequest(baseUrl, data, csrfToken);
|
||||
case 'set':
|
||||
return await handleSetRequest(baseUrl, data, csrfToken);
|
||||
case 'index':
|
||||
return await handleIndexRequest(baseUrl, data, csrfToken);
|
||||
case 'delete':
|
||||
return await handleDeleteRequest(baseUrl, data, csrfToken);
|
||||
default:
|
||||
throw new Error('Unknown action: ' + action);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGetRequest(baseUrl, data, csrfToken) {
|
||||
const response = await fetch(baseUrl + '?key=' + encodeURIComponent(data.key), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-CSRF-Token': csrfToken,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to get key-value');
|
||||
|
||||
const result = await response.json();
|
||||
const keyValue = result.key_values.find(kv => kv.key === data.key);
|
||||
return keyValue ? keyValue.value : null;
|
||||
}
|
||||
|
||||
async function handleSetRequest(baseUrl, data, csrfToken) {
|
||||
const response = await fetch(baseUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-Token': csrfToken,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({
|
||||
key: data.key,
|
||||
value: data.value,
|
||||
public: data.public
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.errors ? errorData.errors.join(', ') : 'Failed to set key-value');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async function handleDeleteRequest(baseUrl, data, csrfToken) {
|
||||
const response = await fetch(baseUrl, {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ key: data.key }),
|
||||
headers: {
|
||||
'X-CSRF-Token': csrfToken,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error('Key not found');
|
||||
}
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.errors ? errorData.errors.join(', ') : 'Failed to delete key-value');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleIndexRequest(baseUrl, data, csrfToken) {
|
||||
const params = new URLSearchParams();
|
||||
if (data.key) params.append('key', data.key);
|
||||
if (data.all_users) params.append('all_users', data.all_users);
|
||||
if (data.keys_only) params.append('keys_only', data.keys_only);
|
||||
if (data.page) params.append('page', data.page);
|
||||
if (data.per_page) params.append('per_page', data.per_page);
|
||||
|
||||
const response = await fetch(baseUrl + '?' + params.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-CSRF-Token': csrfToken,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to get key-values');
|
||||
|
||||
const result = await response.json();
|
||||
const userMap = {};
|
||||
result.users.forEach(user => {
|
||||
userMap[user.id] = user;
|
||||
});
|
||||
result.key_values.forEach(kv => {
|
||||
if (kv.user_id && userMap[kv.user_id]) {
|
||||
kv.user = userMap[kv.user_id];
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
</script>
|
||||
JAVASCRIPT
|
||||
end
|
||||
|
||||
def set_security_headers
|
||||
response.headers.delete("X-Frame-Options")
|
||||
response.headers[
|
||||
"Content-Security-Policy"
|
||||
] = "script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' #{AiArtifact::ALLOWED_CDN_SOURCES.join(" ")};"
|
||||
response.headers["X-Robots-Tag"] = "noindex"
|
||||
|
||||
# Render the content
|
||||
render html: trusted_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])
|
||||
|
@ -2,6 +2,7 @@
|
||||
|
||||
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 }
|
||||
@ -15,13 +16,25 @@ class AiArtifact < ActiveRecord::Base
|
||||
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>
|
||||
<a href='#{url(id, version)}' target='_blank'>#{I18n.t("discourse_ai.ai_artifact.link")}</a>
|
||||
<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
|
||||
@ -37,7 +50,11 @@ class AiArtifact < ActiveRecord::Base
|
||||
|
||||
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
|
||||
if artifact&.post&.topic&.id == post.topic.id
|
||||
artifact.metadata ||= {}
|
||||
artifact.metadata[:public] = true
|
||||
artifact.save!
|
||||
end
|
||||
end
|
||||
|
||||
def self.unshare_publicly(id:)
|
||||
|
56
app/models/ai_artifact_key_value.rb
Normal file
56
app/models/ai_artifact_key_value.rb
Normal file
@ -0,0 +1,56 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AiArtifactKeyValue < ActiveRecord::Base
|
||||
belongs_to :ai_artifact
|
||||
belongs_to :user
|
||||
|
||||
validates :key, presence: true, length: { maximum: 50 }
|
||||
validates :value,
|
||||
presence: true,
|
||||
length: {
|
||||
maximum: ->(_) { SiteSetting.ai_artifact_kv_value_max_length },
|
||||
}
|
||||
attribute :public, :boolean, default: false
|
||||
validates :ai_artifact, presence: true
|
||||
validates :user, presence: true
|
||||
validates :key, uniqueness: { scope: %i[ai_artifact_id user_id] }
|
||||
|
||||
validate :validate_max_keys_per_user_per_artifact
|
||||
|
||||
private
|
||||
|
||||
def validate_max_keys_per_user_per_artifact
|
||||
return unless ai_artifact_id && user_id
|
||||
|
||||
max_keys = SiteSetting.ai_artifact_max_keys_per_user_per_artifact
|
||||
existing_count = self.class.where(ai_artifact_id: ai_artifact_id, user_id: user_id).count
|
||||
|
||||
# Don't count the current record if it's being updated
|
||||
existing_count -= 1 if persisted?
|
||||
|
||||
if existing_count >= max_keys
|
||||
errors.add(
|
||||
:base,
|
||||
I18n.t("discourse_ai.ai_artifact.errors.max_keys_exceeded", count: max_keys),
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: ai_artifact_key_values
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# ai_artifact_id :bigint not null
|
||||
# user_id :integer not null
|
||||
# key :string(50) not null
|
||||
# value :string(20000) not null
|
||||
# public :boolean default(FALSE), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_ai_artifact_kv_unique (ai_artifact_id,user_id,key) UNIQUE
|
||||
#
|
@ -37,7 +37,9 @@ class SharedAiConversation < ActiveRecord::Base
|
||||
|
||||
maybe_topic = conversation.target
|
||||
if maybe_topic.is_a?(Topic)
|
||||
AiArtifact.where(post: maybe_topic.posts).update_all(metadata: { public: false })
|
||||
AiArtifact.where(post: maybe_topic.posts).update_all(
|
||||
"metadata = jsonb_set(COALESCE(metadata, '{}'), '{public}', 'false')",
|
||||
)
|
||||
end
|
||||
|
||||
::Jobs.enqueue(
|
||||
|
9
app/serializers/ai_artifact_key_value_serializer.rb
Normal file
9
app/serializers/ai_artifact_key_value_serializer.rb
Normal file
@ -0,0 +1,9 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AiArtifactKeyValueSerializer < ApplicationSerializer
|
||||
attributes :id, :key, :value, :public, :user_id, :created_at, :updated_at
|
||||
|
||||
def include_value?
|
||||
!options[:keys_only]
|
||||
end
|
||||
end
|
@ -59,6 +59,22 @@
|
||||
document.querySelectorAll('pre code').forEach((el) => {
|
||||
hljs.highlightElement(el);
|
||||
});
|
||||
|
||||
document.querySelectorAll('.ai-artifact-controls .copy-embed').forEach((el) => {
|
||||
el.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const url = el.dataset.url;
|
||||
const embedCode = `<iframe src="${url}" width="100%" height="600" frameborder="0"></iframe>`;
|
||||
navigator.clipboard.writeText(embedCode).then(() => {
|
||||
el.textContent = '<%= I18n.t("discourse_ai.ai_artifact.copied") %>';
|
||||
setTimeout(() => {
|
||||
el.textContent = '<%= I18n.t("discourse_ai.ai_artifact.copy_embed") %>';
|
||||
}, 2000);
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy: ', err);
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -1,5 +1,6 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { on } from "@ember/modifier";
|
||||
import { action } from "@ember/object";
|
||||
import { service } from "@ember/service";
|
||||
import { htmlSafe } from "@ember/template";
|
||||
@ -88,6 +89,33 @@ export default class ShareModal extends Component {
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
async maybeCopyEmbed(event) {
|
||||
if (!event.target.classList.contains("copy-embed")) {
|
||||
return true;
|
||||
}
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
let version = "";
|
||||
if (event.target.dataset.artifactVersion) {
|
||||
version = `data-ai-artifact-version="${event.target.dataset.artifactVersion}"`;
|
||||
}
|
||||
const artifactEmbed = `<div class="ai-artifact" ${version} data-ai-artifact-id="${event.target.dataset.artifactId}"></div>`;
|
||||
const promise = new Promise((resolve) => {
|
||||
resolve(artifactEmbed);
|
||||
});
|
||||
|
||||
await clipboardCopyAsync(() => promise);
|
||||
|
||||
this.toasts.success({
|
||||
duration: 3000,
|
||||
data: {
|
||||
message: i18n("discourse_ai.ai_bot.embed_copied"),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
<template>
|
||||
<DModal
|
||||
class="ai-share-full-topic-modal"
|
||||
@ -95,9 +123,14 @@ export default class ShareModal extends Component {
|
||||
@closeModal={{@closeModal}}
|
||||
>
|
||||
<:body>
|
||||
<div class="ai-share-full-topic-modal__body">
|
||||
{{! template-lint-disable no-invalid-interactive }}
|
||||
<div
|
||||
class="ai-share-full-topic-modal__body"
|
||||
{{on "click" this.maybeCopyEmbed}}
|
||||
>
|
||||
{{this.htmlContext}}
|
||||
</div>
|
||||
{{! template-lint-enable}}
|
||||
</:body>
|
||||
|
||||
<:footer>
|
||||
|
@ -134,3 +134,10 @@ html.ai-artifact-expanded {
|
||||
z-index: z("fullscreen");
|
||||
}
|
||||
}
|
||||
|
||||
.ai-share-full-topic-modal__body {
|
||||
.ai-artifact-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
@ -722,6 +722,7 @@ en:
|
||||
shortcut_title: "Start a PM with an AI bot"
|
||||
share: "Copy AI conversation"
|
||||
conversation_shared: "Conversation copied"
|
||||
embed_copied: "Embed copied to clipboard"
|
||||
debug_ai: "View raw AI request and response"
|
||||
sidebar_empty: "Bot conversation history will appear here."
|
||||
debug_ai_modal:
|
||||
|
@ -219,10 +219,16 @@ en:
|
||||
|
||||
discourse_ai:
|
||||
ai_artifact:
|
||||
link: "Show Artifact in new tab"
|
||||
errors:
|
||||
max_keys_exceeded:
|
||||
one: "You can only have %{count} key in the artifact."
|
||||
other: "You can only have %{count} keys in the artifact."
|
||||
link: "Show in new tab"
|
||||
copy_embed: "Copy embed"
|
||||
view_source: "View Source"
|
||||
view_changes: "View Changes"
|
||||
change_description: "Change Description"
|
||||
copied: "Copied to clipboard"
|
||||
unknown_model: "Unknown AI model"
|
||||
|
||||
tools:
|
||||
|
@ -46,6 +46,13 @@ DiscourseAi::Engine.routes.draw do
|
||||
get "/:id/:version" => "artifacts#show"
|
||||
end
|
||||
|
||||
scope module: :ai_bot, path: "/ai-bot/artifact-key-values/:artifact_id" do
|
||||
get "/" => "artifact_key_values#index"
|
||||
post "/" => "artifact_key_values#set"
|
||||
delete "/:key" => "artifact_key_values#destroy"
|
||||
delete "/" => "artifact_key_values#destroy"
|
||||
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"
|
||||
|
@ -525,3 +525,9 @@ discourse_ai:
|
||||
type: enum
|
||||
enum: "DiscourseAi::Configuration::PersonaEnumerator"
|
||||
area: "ai-features/inference"
|
||||
ai_artifact_kv_value_max_length:
|
||||
default: 5000
|
||||
hidden: true
|
||||
ai_artifact_max_keys_per_user_per_artifact:
|
||||
default: 100
|
||||
hidden: true
|
||||
|
18
db/migrate/20250607071239_create_ai_artifacts_key_values.rb
Normal file
18
db/migrate/20250607071239_create_ai_artifacts_key_values.rb
Normal file
@ -0,0 +1,18 @@
|
||||
# frozen_string_literal: true
|
||||
class CreateAiArtifactsKeyValues < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
create_table :ai_artifact_key_values do |t|
|
||||
t.bigint :ai_artifact_id, null: false
|
||||
t.integer :user_id, null: false
|
||||
t.string :key, null: false, limit: 50
|
||||
t.string :value, null: false, limit: 20_000
|
||||
t.boolean :public, null: false, default: false
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :ai_artifact_key_values,
|
||||
%i[ai_artifact_id user_id key],
|
||||
unique: true,
|
||||
name: "index_ai_artifact_kv_unique"
|
||||
end
|
||||
end
|
@ -31,6 +31,12 @@ module DiscourseAi
|
||||
apply_changes(parsed_changes)
|
||||
end
|
||||
|
||||
def storage_api
|
||||
if @artifact.metadata.is_a?(Hash) && @artifact.metadata["requires_storage"]
|
||||
DiscourseAi::Personas::Tools::CreateArtifact.storage_api
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_changes(&progress)
|
||||
|
@ -186,6 +186,8 @@ module DiscourseAi
|
||||
JavaScript libraries must be sourced from the following CDNs, otherwise CSP will reject it:
|
||||
#{AiArtifact::ALLOWED_CDN_SOURCES.join("\n")}
|
||||
|
||||
#{storage_api}
|
||||
|
||||
Reply Format:
|
||||
[HTML]
|
||||
(changes or empty if no changes or entire HTML)
|
||||
|
@ -76,6 +76,8 @@ module DiscourseAi
|
||||
JavaScript libraries must be sourced from the following CDNs, otherwise CSP will reject it:
|
||||
#{AiArtifact::ALLOWED_CDN_SOURCES.join("\n")}
|
||||
|
||||
#{storage_api}
|
||||
|
||||
Always adhere to the format when replying:
|
||||
|
||||
[HTML]
|
||||
|
@ -64,6 +64,13 @@ module DiscourseAi
|
||||
description: specification_description,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "requires_storage",
|
||||
description:
|
||||
"Does the artifact require storage for data? (e.g., user input, settings)",
|
||||
type: "boolean",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
end
|
||||
@ -223,6 +230,7 @@ module DiscourseAi
|
||||
js: code[:js],
|
||||
metadata: {
|
||||
specification: parameters[:specification],
|
||||
requires_storage: !!parameters[:requires_storage],
|
||||
},
|
||||
)
|
||||
end
|
||||
@ -265,9 +273,72 @@ module DiscourseAi
|
||||
- Include basic error handling
|
||||
- Follow accessibility guidelines
|
||||
- No explanatory text, only code
|
||||
|
||||
#{storage_api}
|
||||
PROMPT
|
||||
end
|
||||
|
||||
def storage_api
|
||||
return if !parameters[:requires_storage]
|
||||
self.class.storage_api
|
||||
end
|
||||
|
||||
def self.storage_api
|
||||
<<~API
|
||||
## Storage API
|
||||
|
||||
Your artifact has access to a persistent key-value storage system via `window.discourseArtifact`:
|
||||
|
||||
### Methods Available:
|
||||
|
||||
**get(key)**
|
||||
- Parameters: key (string) - The key to retrieve
|
||||
- Returns: Promise<string|null> - The stored value or null if not found
|
||||
- Example: `const value = await window.discourseArtifact.get('user_name');`
|
||||
|
||||
**set(key, value, options)**
|
||||
- Parameters:
|
||||
- key (string) - The key to store (max 50 characters)
|
||||
- value (string) - The value to store (max 5000 characters)
|
||||
- options (object, optional) - { public: boolean } - Whether other users can read this value
|
||||
- Returns: Promise<object> - The created/updated key-value record
|
||||
- Example: `await window.discourseArtifact.set('score', '100', { public: true });`
|
||||
|
||||
**delete(key)**
|
||||
- Parameters: key (string) - The key to delete
|
||||
- Returns: Promise<boolean> - true if successful
|
||||
- Example: `await window.discourseArtifact.delete('temp_data');`
|
||||
|
||||
**index(filter)**
|
||||
- Parameters: filter (object, optional) - Filtering options:
|
||||
- key (string) - Filter by specific key
|
||||
- all_users (boolean) - Include other users' public values
|
||||
- keys_only (boolean) - Return only keys, not values
|
||||
- page (number) - Page number for pagination
|
||||
- per_page (number) - Items per page (max 100, default 100)
|
||||
- Returns: Promise<object> - { key_values: Array(key, value, user(username, name, avatar_template)), has_more: boolean, total_count: number }
|
||||
- Example: `const result = await window.discourseArtifact.index({ keys_only: true });`
|
||||
|
||||
- avatar_template: string - URL template for user avatars, MUST replace {size} with desired size in pixels (eg: 22)
|
||||
|
||||
### User info:
|
||||
|
||||
To get current user info:
|
||||
const initData = await window.discourseArtifactReady;
|
||||
initData.username; // current username
|
||||
initData.name; // current user's name
|
||||
initData.user_id; // current user ID
|
||||
|
||||
### Storage Rules:
|
||||
- Each user can store up to 100 keys per artifact
|
||||
- Keys are scoped to the current user and artifact
|
||||
- Private values are only accessible to the user who created them
|
||||
- Public values can be read by anyone who can view the artifact
|
||||
- All operations are asynchronous and return Promises
|
||||
```
|
||||
API
|
||||
end
|
||||
|
||||
def update_custom_html(artifact)
|
||||
html_preview = <<~MD
|
||||
[details="View Source"]
|
||||
|
@ -20,7 +20,9 @@ module DiscourseAi
|
||||
- Focus on visual appeal and smooth animations
|
||||
- Write clean, efficient code
|
||||
- Build progressively (HTML structure → CSS styling → JavaScript interactivity)
|
||||
- Keep components focused and purposeful
|
||||
- Artifacts run in a sandboxed IFRAME environmment
|
||||
- Artifacts Discourse persistent storage - requires storage support
|
||||
- Artifacts have access to current user data (username, name, id) - requires storage support
|
||||
|
||||
When creating:
|
||||
1. Understand the desired user experience
|
||||
|
@ -632,3 +632,9 @@ aside.onebox h3 {
|
||||
height: 600px;
|
||||
max-height: 600px;
|
||||
}
|
||||
|
||||
.ai-artifact-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
|
@ -9,6 +9,14 @@ Fabricator(:ai_artifact) do
|
||||
metadata { { public: false } }
|
||||
end
|
||||
|
||||
Fabricator(:ai_artifact_key_value) do
|
||||
ai_artifact
|
||||
user
|
||||
key { sequence(:key) { |i| "key_#{i}" } }
|
||||
value { "value" }
|
||||
public { false }
|
||||
end
|
||||
|
||||
Fabricator(:ai_artifact_version) do
|
||||
ai_artifact
|
||||
version_number { sequence(:version_number) { |i| i } }
|
||||
|
33
spec/models/ai_artifact_key_value_spec.rb
Normal file
33
spec/models/ai_artifact_key_value_spec.rb
Normal file
@ -0,0 +1,33 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
RSpec.describe AiArtifactKeyValue, type: :model do
|
||||
fab!(:user)
|
||||
fab!(:ai_artifact)
|
||||
|
||||
describe "#validate_max_keys_per_user_per_artifact" do
|
||||
before { SiteSetting.ai_artifact_max_keys_per_user_per_artifact = 2 }
|
||||
|
||||
it "prevents creation when at the limit" do
|
||||
2.times do |i|
|
||||
described_class.create!(
|
||||
ai_artifact: ai_artifact,
|
||||
user: user,
|
||||
key: "key_#{i}",
|
||||
value: "value_#{i}",
|
||||
)
|
||||
end
|
||||
|
||||
new_record =
|
||||
described_class.new(
|
||||
ai_artifact: ai_artifact,
|
||||
user: user,
|
||||
key: "new_key",
|
||||
value: "new_value",
|
||||
)
|
||||
expect(new_record).not_to be_valid
|
||||
expect(new_record.errors[:base]).to include(
|
||||
I18n.t("discourse_ai.ai_artifact.errors.max_keys_exceeded", count: 2),
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
@ -74,6 +74,44 @@ RSpec.describe SharedAiConversation, type: :model do
|
||||
expect(populated_context[1].user.id).to eq(post2.user.id)
|
||||
end
|
||||
|
||||
it "shares artifacts publicly when conversation is shared" do
|
||||
# Create a post with an AI artifact
|
||||
artifact =
|
||||
Fabricate(
|
||||
:ai_artifact,
|
||||
post: post1,
|
||||
user: user,
|
||||
metadata: {
|
||||
public: false,
|
||||
something: "good",
|
||||
},
|
||||
)
|
||||
|
||||
_post_with_artifact =
|
||||
Fabricate(
|
||||
:post,
|
||||
topic: topic,
|
||||
post_number: 3,
|
||||
raw: "Here's an artifact",
|
||||
cooked:
|
||||
"<div class='ai-artifact' data-ai-artifact-id='#{artifact.id}' data-ai-artifact-version='1'></div>",
|
||||
)
|
||||
|
||||
expect(artifact.public?).to be_falsey
|
||||
|
||||
conversation = described_class.share_conversation(user, topic)
|
||||
artifact.reload
|
||||
|
||||
expect(artifact.metadata["something"]).to eq("good")
|
||||
expect(artifact.public?).to be_truthy
|
||||
|
||||
described_class.destroy_conversation(conversation)
|
||||
artifact.reload
|
||||
|
||||
expect(artifact.metadata["something"]).to eq("good")
|
||||
expect(artifact.public?).to be_falsey
|
||||
end
|
||||
|
||||
it "escapes HTML" do
|
||||
conversation = described_class.share_conversation(user, topic)
|
||||
onebox = conversation.onebox
|
||||
|
443
spec/requests/ai_bot/artifact_key_values_controller_spec.rb
Normal file
443
spec/requests/ai_bot/artifact_key_values_controller_spec.rb
Normal file
@ -0,0 +1,443 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
RSpec.describe DiscourseAi::AiBot::ArtifactKeyValuesController do
|
||||
fab!(:user)
|
||||
fab!(:admin)
|
||||
fab!(:other_user) { Fabricate(:user) }
|
||||
fab!(:private_message_topic) { Fabricate(:private_message_topic, user: user) }
|
||||
fab!(:private_message_post) { Fabricate(:post, topic: private_message_topic, user: user) }
|
||||
fab!(:artifact) do
|
||||
Fabricate(:ai_artifact, post: private_message_post, metadata: { public: true })
|
||||
end
|
||||
fab!(:private_artifact) { Fabricate(:ai_artifact, post: private_message_post) }
|
||||
|
||||
before do
|
||||
SiteSetting.discourse_ai_enabled = true
|
||||
SiteSetting.ai_bot_enabled = true
|
||||
end
|
||||
|
||||
describe "#index" do
|
||||
fab!(:public_key_value) do
|
||||
Fabricate(
|
||||
:ai_artifact_key_value,
|
||||
ai_artifact: artifact,
|
||||
user: user,
|
||||
key: "test_key",
|
||||
value: "test_value",
|
||||
public: true,
|
||||
)
|
||||
end
|
||||
|
||||
fab!(:private_key_value) do
|
||||
Fabricate(
|
||||
:ai_artifact_key_value,
|
||||
ai_artifact: artifact,
|
||||
user: user,
|
||||
key: "private_key",
|
||||
value: "private_value",
|
||||
public: false,
|
||||
)
|
||||
end
|
||||
|
||||
fab!(:other_user_key_value) do
|
||||
Fabricate(
|
||||
:ai_artifact_key_value,
|
||||
ai_artifact: artifact,
|
||||
user: other_user,
|
||||
key: "other_key",
|
||||
value: "other_value",
|
||||
public: true,
|
||||
)
|
||||
end
|
||||
|
||||
context "when not logged in" do
|
||||
it "returns only public key values" do
|
||||
get "/discourse-ai/ai-bot/artifact-key-values/#{artifact.id}.json"
|
||||
|
||||
expect(response.status).to eq(200)
|
||||
json = response.parsed_body
|
||||
expect(json["key_values"].length).to eq(2) # public_key_value and other_user_key_value
|
||||
expect(json["key_values"].map { |kv| kv["key"] }).to contain_exactly(
|
||||
"test_key",
|
||||
"other_key",
|
||||
)
|
||||
expect(json["has_more"]).to eq(false)
|
||||
expect(json["total_count"]).to eq(2)
|
||||
|
||||
expect(json["key_values"].map { |kv| kv["user_id"] }).to contain_exactly(
|
||||
user.id,
|
||||
other_user.id,
|
||||
)
|
||||
expect(json["users"].map { |u| u["id"] }).to contain_exactly(user.id, other_user.id)
|
||||
end
|
||||
|
||||
it "returns 404 for private artifact" do
|
||||
get "/discourse-ai/ai-bot/artifact-key-values/#{private_artifact.id}.json"
|
||||
expect(response.status).to eq(404)
|
||||
end
|
||||
|
||||
it "returns 404 for non-existent artifact" do
|
||||
get "/discourse-ai/ai-bot/artifact-key-values/999999.json"
|
||||
expect(response.status).to eq(404)
|
||||
end
|
||||
end
|
||||
|
||||
context "when logged in as regular user" do
|
||||
before { sign_in(user) }
|
||||
|
||||
it "returns public key values and own private key values" do
|
||||
get "/discourse-ai/ai-bot/artifact-key-values/#{artifact.id}.json"
|
||||
|
||||
expect(response.status).to eq(200)
|
||||
json = response.parsed_body
|
||||
expect(json["key_values"].length).to eq(3) # all key values
|
||||
expect(json["key_values"].map { |kv| kv["key"] }).to contain_exactly(
|
||||
"test_key",
|
||||
"private_key",
|
||||
"other_key",
|
||||
)
|
||||
end
|
||||
|
||||
it "filters by current user when all_users is not true" do
|
||||
get "/discourse-ai/ai-bot/artifact-key-values/#{artifact.id}.json"
|
||||
|
||||
expect(response.status).to eq(200)
|
||||
json = response.parsed_body
|
||||
# Should only show user's own key values when all_users is not explicitly true
|
||||
user_key_values = json["key_values"].select { |kv| kv["user_id"] == user.id }
|
||||
expect(user_key_values.length).to be > 0
|
||||
end
|
||||
|
||||
it "shows all users' key values when all_users=true" do
|
||||
get "/discourse-ai/ai-bot/artifact-key-values/#{artifact.id}.json?all_users=true"
|
||||
|
||||
expect(response.status).to eq(200)
|
||||
json = response.parsed_body
|
||||
expect(json["key_values"].length).to eq(3)
|
||||
end
|
||||
|
||||
it "filters by key when specified" do
|
||||
get "/discourse-ai/ai-bot/artifact-key-values/#{artifact.id}.json?key=test_key"
|
||||
|
||||
expect(response.status).to eq(200)
|
||||
json = response.parsed_body
|
||||
expect(json["key_values"].length).to eq(1)
|
||||
expect(json["key_values"].first["key"]).to eq("test_key")
|
||||
end
|
||||
|
||||
it "returns keys only when keys_only=true" do
|
||||
get "/discourse-ai/ai-bot/artifact-key-values/#{artifact.id}.json?keys_only=true"
|
||||
|
||||
expect(response.status).to eq(200)
|
||||
json = response.parsed_body
|
||||
expect(json["key_values"]).to be_present
|
||||
# The serializer should handle keys_only option
|
||||
end
|
||||
end
|
||||
|
||||
context "when logged in as admin" do
|
||||
before { sign_in(admin) }
|
||||
|
||||
it "returns all key values including private ones from other users" do
|
||||
get "/discourse-ai/ai-bot/artifact-key-values/#{artifact.id}.json"
|
||||
|
||||
expect(response.status).to eq(200)
|
||||
json = response.parsed_body
|
||||
expect(json["key_values"].length).to eq(3)
|
||||
expect(json["key_values"].map { |kv| kv["key"] }).to contain_exactly(
|
||||
"test_key",
|
||||
"private_key",
|
||||
"other_key",
|
||||
)
|
||||
end
|
||||
|
||||
it "can access private artifacts" do
|
||||
get "/discourse-ai/ai-bot/artifact-key-values/#{private_artifact.id}.json"
|
||||
expect(response.status).to eq(200)
|
||||
end
|
||||
end
|
||||
|
||||
context "when paginating" do
|
||||
before do
|
||||
sign_in(user)
|
||||
# Create more key values to test pagination
|
||||
15.times do |i|
|
||||
Fabricate(
|
||||
:ai_artifact_key_value,
|
||||
ai_artifact: artifact,
|
||||
user: user,
|
||||
key: "key_#{i}",
|
||||
value: "value_#{i}",
|
||||
public: true,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
it "paginates results correctly" do
|
||||
get "/discourse-ai/ai-bot/artifact-key-values/#{artifact.id}.json?per_page=5&page=1"
|
||||
|
||||
expect(response.status).to eq(200)
|
||||
json = response.parsed_body
|
||||
expect(json["key_values"].length).to eq(5)
|
||||
expect(json["has_more"]).to eq(true)
|
||||
end
|
||||
|
||||
it "respects per_page limit" do
|
||||
get "/discourse-ai/ai-bot/artifact-key-values/#{artifact.id}.json?per_page=200"
|
||||
|
||||
expect(response.status).to eq(200)
|
||||
json = response.parsed_body
|
||||
# Should be limited to PER_PAGE_MAX (100)
|
||||
expect(json["key_values"].length).to be <= 100
|
||||
end
|
||||
|
||||
it "defaults to page 1 for invalid page numbers" do
|
||||
get "/discourse-ai/ai-bot/artifact-key-values/#{artifact.id}.json?page=0"
|
||||
|
||||
expect(response.status).to eq(200)
|
||||
json = response.parsed_body
|
||||
expect(json["key_values"]).to be_present
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#destroy" do
|
||||
fab!(:key_value_to_delete) do
|
||||
Fabricate(
|
||||
:ai_artifact_key_value,
|
||||
ai_artifact: artifact,
|
||||
user: user,
|
||||
key: "delete_me",
|
||||
value: "delete_value",
|
||||
public: false,
|
||||
)
|
||||
end
|
||||
|
||||
fab!(:other_user_key_value_to_delete) do
|
||||
Fabricate(
|
||||
:ai_artifact_key_value,
|
||||
ai_artifact: artifact,
|
||||
user: other_user,
|
||||
key: "other_delete_me",
|
||||
value: "other_delete_value",
|
||||
public: false,
|
||||
)
|
||||
end
|
||||
|
||||
context "when not logged in" do
|
||||
it "returns 403 forbidden" do
|
||||
delete "/discourse-ai/ai-bot/artifact-key-values/#{artifact.id}/delete_me.json"
|
||||
expect(response.status).to eq(403)
|
||||
end
|
||||
end
|
||||
|
||||
context "when logged in" do
|
||||
before { sign_in(user) }
|
||||
|
||||
it "deletes own key value successfully" do
|
||||
expect {
|
||||
delete "/discourse-ai/ai-bot/artifact-key-values/#{artifact.id}/delete_me.json"
|
||||
expect(response.status).to eq(200)
|
||||
}.to change { artifact.key_values.count }.by(-1)
|
||||
|
||||
expect(artifact.key_values.find_by(key: "delete_me")).to be_nil
|
||||
end
|
||||
|
||||
it "returns 404 for non-existent key" do
|
||||
delete "/discourse-ai/ai-bot/artifact-key-values/#{artifact.id}/non_existent_key.json"
|
||||
|
||||
expect(response.status).to eq(404)
|
||||
json = response.parsed_body
|
||||
expect(json["error"]).to eq("Key not found")
|
||||
end
|
||||
|
||||
it "returns 404 when trying to delete another user's key" do
|
||||
delete "/discourse-ai/ai-bot/artifact-key-values/#{artifact.id}/other_delete_me.json"
|
||||
|
||||
expect(response.status).to eq(404)
|
||||
json = response.parsed_body
|
||||
expect(json["error"]).to eq("Key not found")
|
||||
|
||||
# Verify the key still exists
|
||||
expect(artifact.key_values.find_by(key: "other_delete_me")).to be_present
|
||||
end
|
||||
|
||||
it "returns 404 for non-existent artifact" do
|
||||
delete "/discourse-ai/ai-bot/artifact-key-values/999999/delete_me.json"
|
||||
|
||||
expect(response.status).to eq(404)
|
||||
end
|
||||
|
||||
it "returns 404 for private artifact user cannot see" do
|
||||
topic = Fabricate(:private_message_topic, user: other_user)
|
||||
private_post = Fabricate(:post, topic: topic)
|
||||
private_artifact = Fabricate(:ai_artifact, post: private_post)
|
||||
Fabricate(
|
||||
:ai_artifact_key_value,
|
||||
ai_artifact: private_artifact,
|
||||
user: other_user,
|
||||
key: "private_key",
|
||||
)
|
||||
|
||||
delete "/discourse-ai/ai-bot/artifact-key-values/#{private_artifact.id}/private_key.json"
|
||||
|
||||
expect(response.status).to eq(404)
|
||||
end
|
||||
|
||||
context "with URL encoding" do
|
||||
fab!(:special_key_value) do
|
||||
Fabricate(
|
||||
:ai_artifact_key_value,
|
||||
ai_artifact: artifact,
|
||||
user: user,
|
||||
key: "key with spaces & symbols!",
|
||||
value: "special_value",
|
||||
)
|
||||
end
|
||||
|
||||
it "handles URL encoded keys correctly" do
|
||||
expect {
|
||||
delete "/discourse-ai/ai-bot/artifact-key-values/#{artifact.id}.json",
|
||||
params: {
|
||||
key: "key with spaces & symbols!",
|
||||
}
|
||||
expect(response.status).to eq(200)
|
||||
}.to change { artifact.key_values.count }.by(-1)
|
||||
|
||||
expect(artifact.key_values.find_by(key: "key with spaces & symbols!")).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "when logged in as admin" do
|
||||
before { sign_in(admin) }
|
||||
|
||||
it "can only delete own keys, not other users' keys" do
|
||||
# Even admins should only be able to delete their own keys in this context
|
||||
delete "/discourse-ai/ai-bot/artifact-key-values/#{artifact.id}/other_delete_me.json"
|
||||
|
||||
expect(response.status).to eq(404)
|
||||
json = response.parsed_body
|
||||
expect(json["error"]).to eq("Key not found")
|
||||
|
||||
# Verify the key still exists
|
||||
expect(artifact.key_values.find_by(key: "other_delete_me")).to be_present
|
||||
end
|
||||
|
||||
it "can delete own keys" do
|
||||
_admin_key =
|
||||
Fabricate(
|
||||
:ai_artifact_key_value,
|
||||
ai_artifact: artifact,
|
||||
user: admin,
|
||||
key: "admin_key",
|
||||
value: "admin_value",
|
||||
)
|
||||
|
||||
expect {
|
||||
delete "/discourse-ai/ai-bot/artifact-key-values/#{artifact.id}/admin_key.json"
|
||||
expect(response.status).to eq(200)
|
||||
}.to change { artifact.key_values.count }.by(-1)
|
||||
end
|
||||
|
||||
it "can access private artifacts" do
|
||||
_admin_key =
|
||||
Fabricate(
|
||||
:ai_artifact_key_value,
|
||||
ai_artifact: private_artifact,
|
||||
user: admin,
|
||||
key: "private_admin_key",
|
||||
value: "private_admin_value",
|
||||
)
|
||||
|
||||
expect {
|
||||
delete "/discourse-ai/ai-bot/artifact-key-values/#{private_artifact.id}/private_admin_key.json"
|
||||
expect(response.status).to eq(200)
|
||||
}.to change { private_artifact.key_values.count }.by(-1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#set" do
|
||||
let(:valid_params) do
|
||||
{ artifact_id: artifact.id, key: "new_key", value: "new_value", public: true }
|
||||
end
|
||||
|
||||
context "when not logged in" do
|
||||
it "returns 403 forbidden" do
|
||||
post "/discourse-ai/ai-bot/artifact-key-values/#{artifact.id}.json", params: valid_params
|
||||
|
||||
expect(response.status).to eq(403)
|
||||
end
|
||||
end
|
||||
|
||||
context "when logged in" do
|
||||
before { sign_in(user) }
|
||||
|
||||
it "creates a new key value successfully" do
|
||||
expect {
|
||||
post "/discourse-ai/ai-bot/artifact-key-values/#{artifact.id}.json", params: valid_params
|
||||
expect(response.status).to eq(200)
|
||||
}.to change { artifact.key_values.count }.by(1)
|
||||
|
||||
json = response.parsed_body
|
||||
key_value = json["ai_artifact_key_value"]
|
||||
expect(key_value["key"]).to eq("new_key")
|
||||
expect(key_value["value"]).to eq("new_value")
|
||||
|
||||
key_value = artifact.key_values.last
|
||||
expect(key_value.user).to eq(user)
|
||||
end
|
||||
|
||||
it "returns validation errors for invalid data" do
|
||||
post "/discourse-ai/ai-bot/artifact-key-values/#{artifact.id}.json",
|
||||
params: {
|
||||
artifact_id: artifact.id,
|
||||
key: "", # invalid empty key
|
||||
value: "value",
|
||||
}
|
||||
|
||||
expect(response.status).to eq(422)
|
||||
json = response.parsed_body
|
||||
expect(json["errors"]).to be_present
|
||||
end
|
||||
|
||||
it "returns 404 for non-existent artifact" do
|
||||
post "/discourse-ai/ai-bot/artifact-key-values/999999.json", params: valid_params
|
||||
|
||||
expect(response.status).to eq(404)
|
||||
end
|
||||
|
||||
it "returns 404 for private artifact user cannot see" do
|
||||
topic = Fabricate(:private_message_topic, user: other_user)
|
||||
private_post = Fabricate(:post, topic: topic)
|
||||
private_artifact = Fabricate(:ai_artifact, post: private_post)
|
||||
|
||||
post "/discourse-ai/ai-bot/artifact-key-values/#{private_artifact.id}.json",
|
||||
params: valid_params
|
||||
|
||||
expect(response.status).to eq(404)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "private methods" do
|
||||
let(:controller) { described_class.new }
|
||||
|
||||
before do
|
||||
controller.instance_variable_set(:@artifact, artifact)
|
||||
allow(controller).to receive(:params).and_return(
|
||||
ActionController::Parameters.new(test_params),
|
||||
)
|
||||
end
|
||||
|
||||
describe "#key_value_params" do
|
||||
let(:test_params) { { key: "test", value: "value", public: true, extra: "ignored" } }
|
||||
|
||||
it "permits only allowed parameters" do
|
||||
# This would need to be tested by calling the actual method or through integration tests
|
||||
# since private methods are typically tested through their public interfaces
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
63
spec/system/ai_artifact_key_value_api_spec.rb
Normal file
63
spec/system/ai_artifact_key_value_api_spec.rb
Normal file
@ -0,0 +1,63 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
RSpec.describe "AI Artifact Key-Value API", type: :system, js: true do
|
||||
fab!(:user)
|
||||
fab!(:private_message_topic) { Fabricate(:private_message_topic, user: user) }
|
||||
fab!(:private_message_post) { Fabricate(:post, topic: private_message_topic, user: user) }
|
||||
fab!(:artifact) do
|
||||
Fabricate(
|
||||
:ai_artifact,
|
||||
post: private_message_post,
|
||||
metadata: {
|
||||
public: true,
|
||||
},
|
||||
html: '<div id="log">Artifact Loaded</div>',
|
||||
js: <<~JS,
|
||||
const logElement = document.getElementById('log');
|
||||
|
||||
window.addEventListener('load', async function() {
|
||||
try {
|
||||
logElement.innerHTML = "TESTING KEY-VALUE API...";
|
||||
const log = [];
|
||||
await window.discourseArtifact.set('test_key', 'test_value');
|
||||
log.push('Set operation completed');
|
||||
logElement.innerHTML = log.join('<br>');
|
||||
|
||||
const value = await window.discourseArtifact.get('test_key');
|
||||
log.push('Got value:' + value);
|
||||
|
||||
await window.discourseArtifact.delete('test_key');
|
||||
log.push('Delete operation completed');
|
||||
|
||||
const deletedValue = await window.discourseArtifact.get('test_key');
|
||||
log.push('Deleted value should be null:' + deletedValue);
|
||||
|
||||
logElement.innerHTML = log.join('<br>');
|
||||
logElement.setAttribute('data-test-complete', 'true');
|
||||
} catch (error) {
|
||||
logElement.innerHTML = error.message;
|
||||
logElement.setAttribute('data-test-error', 'true');
|
||||
}
|
||||
});
|
||||
JS
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
SiteSetting.discourse_ai_enabled = true
|
||||
SiteSetting.ai_bot_enabled = true
|
||||
sign_in(user)
|
||||
end
|
||||
|
||||
it "provides working key-value API in artifact JavaScript" do
|
||||
visit "/discourse-ai/ai-bot/artifacts/#{artifact.id}"
|
||||
|
||||
within_frame(find("iframe")) do
|
||||
expect(page).to have_selector("#log", wait: 2)
|
||||
expect(page).to have_selector("#log[data-test-complete='true']", wait: 2)
|
||||
expect(page).to have_no_selector("#log[data-test-error]")
|
||||
end
|
||||
|
||||
expect(artifact.key_values.find_by(key: "test_key", user: user)).to be_nil
|
||||
end
|
||||
end
|
0
spec/system/ai_bot/artifact_key_value_spec.rb
Normal file
0
spec/system/ai_bot/artifact_key_value_spec.rb
Normal file
Loading…
x
Reference in New Issue
Block a user