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:
Sam 2025-06-11 06:59:46 +10:00 committed by GitHub
parent f7e0ea888d
commit fdf0ff8a25
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 1238 additions and 51 deletions

View File

@ -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

View File

@ -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])

View File

@ -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:)

View 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
#

View File

@ -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(

View 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

View File

@ -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>

View File

@ -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>

View File

@ -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;
}
}

View File

@ -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:

View File

@ -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:

View File

@ -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"

View File

@ -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

View 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

View File

@ -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)

View File

@ -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)

View File

@ -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]

View File

@ -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"]

View File

@ -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

View File

@ -632,3 +632,9 @@ aside.onebox h3 {
height: 600px;
max-height: 600px;
}
.ai-artifact-controls {
display: flex;
justify-content: space-between;
}

View File

@ -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 } }

View 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

View File

@ -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

View 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

View 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