Sam fdf0ff8a25
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.
2025-06-11 06:59:46 +10:00

301 lines
9.2 KiB
Ruby

# frozen_string_literal: true
module DiscourseAi
module Personas
module ArtifactUpdateStrategies
class Diff < Base
attr_reader :failed_searches
private
def initialize(**kwargs)
super
@failed_searches = []
end
def build_prompt
DiscourseAi::Completions::Prompt.new(
system_prompt,
messages: [{ type: :user, content: user_prompt }],
post_id: post.id,
topic_id: post.topic_id,
)
end
def parse_changes(response)
sections = { html: nil, css: nil, javascript: nil }
current_section = nil
lines = []
response.each_line do |line|
case line
when /^\[(HTML|CSS|JavaScript)\]$/
sections[current_section] = lines.join if current_section && !lines.empty?
current_section = line.match(/^\[(.+)\]$/)[1].downcase.to_sym
lines = []
when %r{^\[/(?:HTML|CSS|JavaScript)\]$}
sections[current_section] = lines.join if current_section && !lines.empty?
current_section = nil
else
lines << line if current_section
end
end
sections.each do |section, content|
sections[section] = extract_search_replace_blocks(content)
end
sections
end
def apply_changes(changes)
source = artifact_version || artifact
updated_content = { js: source.js, html: source.html, css: source.css }
%i[html css javascript].each do |section|
blocks = changes[section]
next unless blocks
content = source.public_send(section == :javascript ? :js : section)
blocks.each do |block|
begin
if !block[:search]
content = block[:replace]
else
content =
DiscourseAi::Utils::DiffUtils::SimpleDiff.apply(
content,
block[:search],
block[:replace],
)
end
rescue DiscourseAi::Utils::DiffUtils::SimpleDiff::NoMatchError
@failed_searches << { section: section, search: block[:search] }
# TODO, we may need to inform caller here, LLM made a mistake which it
# should correct
puts "Failed to find search: #{block[:search]}"
end
end
updated_content[section == :javascript ? :js : section] = content
end
artifact.create_new_version(
html: updated_content[:html],
css: updated_content[:css],
js: updated_content[:js],
change_description: instructions,
)
end
private
def extract_search_replace_blocks(content)
return nil if content.blank? || content.to_s.strip.downcase.match?(/^\(?no changes?\)?$/m)
return [{ replace: content }] if !content.include?("<<< SEARCH")
blocks = []
current_block = {}
state = :initial
search_lines = []
replace_lines = []
content.each_line do |line|
line = line.chomp
case state
when :initial
state = :collecting_search if line.match?(/^<<<* SEARCH/)
when :collecting_search
if line.start_with?("===")
current_block[:search] = search_lines.join("\n").strip
search_lines = []
state = :collecting_replace
else
search_lines << line
end
when :collecting_replace
if line.match?(/>>>* REPLACE/)
current_block[:replace] = replace_lines.join("\n").strip
replace_lines = []
blocks << current_block
current_block = {}
state = :initial
else
replace_lines << line
end
end
end
# Handle any remaining block
if state == :collecting_replace && !replace_lines.empty?
current_block[:replace] = replace_lines.join("\n").strip
blocks << current_block
end
blocks.empty? ? nil : blocks
end
def system_prompt
<<~PROMPT
You are a web development expert generating precise search/replace changes for updating HTML, CSS, and JavaScript code.
CRITICAL RULES:
1. Use EXACTLY this format for changes:
<<<<<<< SEARCH
(code to replace)
=======
(replacement code)
>>>>>>> REPLACE
2. SEARCH blocks MUST be 8 lines or less. Break larger changes into multiple smaller search/replace blocks.
3. DO NOT modify the markers or add spaces around them.
4. DO NOT add explanations or comments within sections.
5. ONLY include [HTML], [CSS], and [JavaScript] sections if they have changes.
6. HTML should not include <html>, <head>, or <body> tags, it is injected into a template.
7. NEVER EVER ask followup questions, ALL changes must be performed in a single response.
8. When performing a non-contiguous search, ALWAYS use ... to denote the skipped lines.
9. Be mindful that ... non-contiguous search is not greedy, it will only match the first occurrence.
10. Never mix a full section replacement with a search/replace block in the same section.
11. ALWAYS skip sections you do not want to change, do not include them in the response.
HANDLING LARGE CHANGES:
- Break large HTML structures into multiple smaller search/replace blocks.
- Use strategic anchor points like unique IDs or class names to target specific elements.
- Consider replacing entire components rather than modifying complex internals.
- When elements contain dynamic content, use precise context markers or replace entire containers.
VALIDATION CHECKLIST:
- Each SEARCH block is 8 lines or less
- Every SEARCH has exactly one matching REPLACE
- All blocks are properly closed
- No SEARCH/REPLACE blocks are nested
- Each change is a complete, separate block with its own SEARCH/REPLACE markers
WARNING: Never nest search/replace blocks. Each change must be a complete sequence.
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)
[/HTML]
[CSS]
(changes or empty if no changes or entire CSS)
[/CSS]
[JavaScript]
(changes or empty if no changes or entire JavaScript)
[/JavaScript]
EXAMPLE 1 - Multiple small changes in one file:
[JavaScript]
<<<<<<< SEARCH
console.log('old1');
=======
console.log('new1');
>>>>>>> REPLACE
<<<<<<< SEARCH
console.log('old2');
=======
console.log('new2');
>>>>>>> REPLACE
[/JavaScript]
EXAMPLE 2 - Breaking up large HTML changes:
[HTML]
<<<<<<< SEARCH
<div class="header">
<div class="logo">
<img src="old-logo.png">
</div>
=======
<div class="header">
<div class="logo">
<img src="new-logo.png">
</div>
>>>>>>> REPLACE
<<<<<<< SEARCH
<div class="navigation">
<ul>
<li>Home</li>
<li>Products</li>
=======
<div class="navigation">
<ul>
<li>Home</li>
<li>Services</li>
>>>>>>> REPLACE
[/HTML]
EXAMPLE 3 - Non-contiguous search in CSS:
[CSS]
<<<<<<< SEARCH
body {
...
background-color: green;
}
=======
body {
color: red;
}
>>>>>>> REPLACE
[/CSS]
EXAMPLE 4 - Full HTML replacement:
[HTML]
<div>something old</div>
<div>another something old</div>
[/HTML]
output:
[HTML]
<div>something new</div>
[/HTML]
PROMPT
end
def user_prompt
source = artifact_version || artifact
<<~CONTENT
Artifact code:
[HTML]
#{source.html}
[/HTML]
[CSS]
#{source.css}
[/CSS]
[JavaScript]
#{source.js}
[/JavaScript]
Instructions:
#{instructions}
CONTENT
end
end
end
end
end