From fdf0ff8a25729db64e6044b4be08456926485c2d Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 11 Jun 2025 06:59:46 +1000 Subject: [PATCH] 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. --- .../ai_bot/artifact_key_values_controller.rb | 120 +++++ .../ai_bot/artifacts_controller.rb | 305 ++++++++++-- app/models/ai_artifact.rb | 21 +- app/models/ai_artifact_key_value.rb | 56 +++ app/models/shared_ai_conversation.rb | 4 +- .../ai_artifact_key_value_serializer.rb | 9 + .../shared_ai_conversations/show.html.erb | 16 + .../modal/share-full-topic-modal.gjs | 35 +- .../modules/ai-bot/common/ai-artifact.scss | 7 + config/locales/client.en.yml | 1 + config/locales/server.en.yml | 8 +- config/routes.rb | 7 + config/settings.yml | 6 + ...07071239_create_ai_artifacts_key_values.rb | 18 + .../artifact_update_strategies/base.rb | 6 + .../artifact_update_strategies/diff.rb | 2 + .../artifact_update_strategies/full.rb | 2 + lib/personas/tools/create_artifact.rb | 71 +++ lib/personas/web_artifact_creator.rb | 4 +- public/ai-share/share.css | 6 + spec/fabricators/ai_artifact_fabricator.rb | 8 + spec/models/ai_artifact_key_value_spec.rb | 33 ++ spec/models/shared_ai_conversation_spec.rb | 38 ++ .../artifact_key_values_controller_spec.rb | 443 ++++++++++++++++++ spec/system/ai_artifact_key_value_api_spec.rb | 63 +++ spec/system/ai_bot/artifact_key_value_spec.rb | 0 26 files changed, 1238 insertions(+), 51 deletions(-) create mode 100644 app/controllers/discourse_ai/ai_bot/artifact_key_values_controller.rb create mode 100644 app/models/ai_artifact_key_value.rb create mode 100644 app/serializers/ai_artifact_key_value_serializer.rb create mode 100644 db/migrate/20250607071239_create_ai_artifacts_key_values.rb create mode 100644 spec/models/ai_artifact_key_value_spec.rb create mode 100644 spec/requests/ai_bot/artifact_key_values_controller_spec.rb create mode 100644 spec/system/ai_artifact_key_value_api_spec.rb create mode 100644 spec/system/ai_bot/artifact_key_value_spec.rb diff --git a/app/controllers/discourse_ai/ai_bot/artifact_key_values_controller.rb b/app/controllers/discourse_ai/ai_bot/artifact_key_values_controller.rb new file mode 100644 index 00000000..a05a7488 --- /dev/null +++ b/app/controllers/discourse_ai/ai_bot/artifact_key_values_controller.rb @@ -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 diff --git a/app/controllers/discourse_ai/ai_bot/artifacts_controller.rb b/app/controllers/discourse_ai/ai_bot/artifacts_controller.rb index a40965b1..d5b6db76 100644 --- a/app/controllers/discourse_ai/ai_bot/artifacts_controller.rb +++ b/app/controllers/discourse_ai/ai_bot/artifacts_controller.rb @@ -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*}mi) - mod = "" - mod = " type=\"module\"" if js.match?(/\A\s*import.*/) - js = "\n#{js}\n" - 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 @@ -42,21 +48,7 @@ module DiscourseAi - + #{build_iframe_javascript} #{artifact.html} @@ -64,15 +56,17 @@ module DiscourseAi HTML + end - # Prepare the outer (trusted) HTML document - trusted_html = <<~HTML + def build_trusted_html(artifact, artifact_version, name, untrusted_html) + <<~HTML #{ERB::Util.html_escape(name)} +