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
26 changed files with 1238 additions and 51 deletions
@@ -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 [email protected]? && guardian.anonymous?
raise Discourse::NotFound if [email protected]? && !guardian.can_see?(@artifact.post)
end
end
end
end
File diff suppressed because it is too large Load Diff
+19 -2
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:)
+56
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
#
+3 -1
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(
@@ -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;
}
}
+1
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:
+7 -1
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:
+7
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"
+6
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
@@ -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]
+71
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"]
+3 -1
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
+6
View File
@@ -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
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
@@ -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
File diff suppressed because it is too large Load Diff
@@ -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