From 75e667423f83e757cd63bd95711d492ef56322f7 Mon Sep 17 00:00:00 2001 From: Sam Saffron Date: Mon, 9 Jun 2025 12:06:42 +1000 Subject: [PATCH] don't blow up metadata when sharing an artifact --- app/models/ai_artifact.rb | 6 ++- app/models/shared_ai_conversation.rb | 4 +- lib/personas/tools/create_artifact.rb | 61 ++++++++++++++++++++++ lib/personas/web_artifact_creator.rb | 3 +- spec/models/shared_ai_conversation_spec.rb | 38 ++++++++++++++ 5 files changed, 109 insertions(+), 3 deletions(-) diff --git a/app/models/ai_artifact.rb b/app/models/ai_artifact.rb index 43b18dd0..ac4531e1 100644 --- a/app/models/ai_artifact.rb +++ b/app/models/ai_artifact.rb @@ -38,7 +38,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:) diff --git a/app/models/shared_ai_conversation.rb b/app/models/shared_ai_conversation.rb index f9e96d33..60e852a3 100644 --- a/app/models/shared_ai_conversation.rb +++ b/app/models/shared_ai_conversation.rb @@ -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( diff --git a/lib/personas/tools/create_artifact.rb b/lib/personas/tools/create_artifact.rb index 548fb9aa..9b624c70 100644 --- a/lib/personas/tools/create_artifact.rb +++ b/lib/personas/tools/create_artifact.rb @@ -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,62 @@ 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 - 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 - 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 - 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 - { key_values: Array(key, value), has_more: boolean, total_count: number } + - Example: `const result = await window.discourseArtifact.index({ keys_only: true });` + + ### 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"] diff --git a/lib/personas/web_artifact_creator.rb b/lib/personas/web_artifact_creator.rb index 8fe5ef1c..b9ca6a70 100644 --- a/lib/personas/web_artifact_creator.rb +++ b/lib/personas/web_artifact_creator.rb @@ -20,7 +20,8 @@ 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 optionally have support for a Discourse user persistent storage When creating: 1. Understand the desired user experience diff --git a/spec/models/shared_ai_conversation_spec.rb b/spec/models/shared_ai_conversation_spec.rb index 349767f5..80f9b306 100644 --- a/spec/models/shared_ai_conversation_spec.rb +++ b/spec/models/shared_ai_conversation_spec.rb @@ -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: + "
", + ) + + 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