Sam 0d7f353284
FEATURE: AI artifacts (#898)
This is a significant PR that introduces AI Artifacts functionality to the discourse-ai plugin along with several other improvements. Here are the key changes:

1. AI Artifacts System:
   - Adds a new `AiArtifact` model and database migration
   - Allows creation of web artifacts with HTML, CSS, and JavaScript content
   - Introduces security settings (`strict`, `lax`, `disabled`) for controlling artifact execution
   - Implements artifact rendering in iframes with sandbox protection
   - New `CreateArtifact` tool for AI to generate interactive content

2. Tool System Improvements:
   - Adds support for partial tool calls, allowing incremental updates during generation
   - Better handling of tool call states and progress tracking
   - Improved XML tool processing with CDATA support
   - Fixes for tool parameter handling and duplicate invocations

3. LLM Provider Updates:
   - Updates for Anthropic Claude models with correct token limits
   - Adds support for native/XML tool modes in Gemini integration
   - Adds new model configurations including Llama 3.1 models
   - Improvements to streaming response handling

4. UI Enhancements:
   - New artifact viewer component with expand/collapse functionality
   - Security controls for artifact execution (click-to-run in strict mode)
   - Improved dialog and response handling
   - Better error management for tool execution

5. Security Improvements:
   - Sandbox controls for artifact execution
   - Public/private artifact sharing controls
   - Security settings to control artifact behavior
   - CSP and frame-options handling for artifacts

6. Technical Improvements:
   - Better post streaming implementation
   - Improved error handling in completions
   - Better memory management for partial tool calls
   - Enhanced testing coverage

7. Configuration:
   - New site settings for artifact security
   - Extended LLM model configurations
   - Additional tool configuration options

This PR significantly enhances the plugin's capabilities for generating and displaying interactive content while maintaining security and providing flexible configuration options for administrators.
2024-11-19 09:22:39 +11:00

385 lines
11 KiB
Ruby

#frozen_string_literal: true
module DiscourseAi
module AiBot
module Personas
class Persona
class << self
def rag_conversation_chunks
10
end
def vision_enabled
false
end
def vision_max_pixels
1_048_576
end
def question_consolidator_llm
nil
end
def force_default_llm
false
end
def allow_chat_channel_mentions
false
end
def allow_chat_direct_messages
false
end
def system_personas
@system_personas ||= {
Personas::General => -1,
Personas::SqlHelper => -2,
Personas::Artist => -3,
Personas::SettingsExplorer => -4,
Personas::Researcher => -5,
Personas::Creative => -6,
Personas::DallE3 => -7,
Personas::DiscourseHelper => -8,
Personas::GithubHelper => -9,
Personas::WebArtifactCreator => -10,
}
end
def system_personas_by_id
@system_personas_by_id ||= system_personas.invert
end
def all(user:)
# listing tools has to be dynamic cause site settings may change
AiPersona.all_personas.filter do |persona|
next false if !user.in_any_groups?(persona.allowed_group_ids)
if persona.system
instance = persona.new
(
instance.required_tools == [] ||
(instance.required_tools - all_available_tools).empty?
)
else
true
end
end
end
def find_by(id: nil, name: nil, user:)
all(user: user).find { |persona| persona.id == id || persona.name == name }
end
def name
I18n.t("discourse_ai.ai_bot.personas.#{to_s.demodulize.underscore}.name")
end
def description
I18n.t("discourse_ai.ai_bot.personas.#{to_s.demodulize.underscore}.description")
end
def all_available_tools
tools = [
Tools::ListCategories,
Tools::Time,
Tools::Search,
Tools::Read,
Tools::DbSchema,
Tools::SearchSettings,
Tools::SettingContext,
Tools::RandomPicker,
Tools::DiscourseMetaSearch,
Tools::GithubFileContent,
Tools::GithubPullRequestDiff,
Tools::GithubSearchFiles,
Tools::WebBrowser,
Tools::JavascriptEvaluator,
]
tools << Tools::CreateArtifact if SiteSetting.ai_artifact_security.in?(%w[lax strict])
tools << Tools::GithubSearchCode if SiteSetting.ai_bot_github_access_token.present?
tools << Tools::ListTags if SiteSetting.tagging_enabled
tools << Tools::Image if SiteSetting.ai_stability_api_key.present?
tools << Tools::DallE if SiteSetting.ai_openai_api_key.present?
if SiteSetting.ai_google_custom_search_api_key.present? &&
SiteSetting.ai_google_custom_search_cx.present?
tools << Tools::Google
end
tools
end
end
def id
@ai_persona&.id || self.class.system_personas[self.class]
end
def tools
[]
end
def force_tool_use
[]
end
def forced_tool_count
-1
end
def required_tools
[]
end
def temperature
nil
end
def top_p
nil
end
def options
{}
end
def available_tools
self
.class
.all_available_tools
.filter { |tool| tools.include?(tool) }
.concat(tools.filter(&:custom?))
end
def craft_prompt(context, llm: nil)
system_insts =
system_prompt.gsub(/\{(\w+)\}/) do |match|
found = context[match[1..-2].to_sym]
found.nil? ? match : found.to_s
end
prompt_insts = <<~TEXT.strip
#{system_insts}
#{available_tools.map(&:custom_system_message).compact_blank.join("\n")}
TEXT
question_consolidator_llm = llm
if self.class.question_consolidator_llm.present?
question_consolidator_llm =
DiscourseAi::Completions::Llm.proxy(self.class.question_consolidator_llm)
end
if context[:custom_instructions].present?
prompt_insts << "\n"
prompt_insts << context[:custom_instructions]
end
fragments_guidance =
rag_fragments_prompt(
context[:conversation_context].to_a,
llm: question_consolidator_llm,
user: context[:user],
)&.strip
prompt_insts << fragments_guidance if fragments_guidance.present?
prompt =
DiscourseAi::Completions::Prompt.new(
prompt_insts,
messages: context[:conversation_context].to_a,
topic_id: context[:topic_id],
post_id: context[:post_id],
)
prompt.max_pixels = self.class.vision_max_pixels if self.class.vision_enabled
prompt.tools = available_tools.map(&:signature) if available_tools
prompt
end
def find_tool(partial, bot_user:, llm:, context:, existing_tools: [])
return nil if !partial.is_a?(DiscourseAi::Completions::ToolCall)
tool_instance(
partial,
bot_user: bot_user,
llm: llm,
context: context,
existing_tools: existing_tools,
)
end
def allow_partial_tool_calls?
available_tools.any? { |tool| tool.allow_partial_tool_calls? }
end
protected
def tool_instance(tool_call, bot_user:, llm:, context:, existing_tools:)
function_id = tool_call.id
function_name = tool_call.name
return nil if function_name.nil?
tool_klass = available_tools.find { |c| c.signature.dig(:name) == function_name }
return nil if tool_klass.nil?
arguments = {}
tool_klass.signature[:parameters].to_a.each do |param|
name = param[:name]
value = tool_call.parameters[name.to_sym]
if param[:type] == "array" && value
value =
begin
JSON.parse(value)
rescue JSON::ParserError
[value.to_s]
end
elsif param[:type] == "string" && value
value = strip_quotes(value).to_s
elsif param[:type] == "integer" && value
value = strip_quotes(value).to_i
end
if param[:enum] && value && !param[:enum].include?(value)
# invalid enum value
value = nil
end
arguments[name.to_sym] = value if value
end
tool_instance =
existing_tools.find { |t| t.name == function_name && t.tool_call_id == function_id }
if tool_instance
tool_instance.parameters = arguments
tool_instance
else
tool_klass.new(
arguments,
tool_call_id: function_id || function_name,
persona_options: options[tool_klass].to_h,
bot_user: bot_user,
llm: llm,
context: context,
)
end
end
def strip_quotes(value)
if value.is_a?(String)
if value.start_with?('"') && value.end_with?('"')
value = value[1..-2]
elsif value.start_with?("'") && value.end_with?("'")
value = value[1..-2]
else
value
end
else
value
end
end
def rag_fragments_prompt(conversation_context, llm:, user:)
upload_refs =
UploadReference.where(target_id: id, target_type: "AiPersona").pluck(:upload_id)
return nil if !SiteSetting.ai_embeddings_enabled?
return nil if conversation_context.blank? || upload_refs.blank?
latest_interactions =
conversation_context.select { |ctx| %i[model user].include?(ctx[:type]) }.last(10)
return nil if latest_interactions.empty?
# first response
if latest_interactions.length == 1
consolidated_question = latest_interactions[0][:content]
else
consolidated_question =
DiscourseAi::AiBot::QuestionConsolidator.consolidate_question(
llm,
latest_interactions,
user,
)
end
return nil if !consolidated_question
strategy = DiscourseAi::Embeddings::Strategies::Truncation.new
vector_rep =
DiscourseAi::Embeddings::VectorRepresentations::Base.current_representation(strategy)
reranker = DiscourseAi::Inference::HuggingFaceTextEmbeddings
interactions_vector = vector_rep.vector_from(consolidated_question)
rag_conversation_chunks = self.class.rag_conversation_chunks
candidate_fragment_ids =
vector_rep.asymmetric_rag_fragment_similarity_search(
interactions_vector,
target_type: "AiPersona",
target_id: id,
limit:
(
if reranker.reranker_configured?
rag_conversation_chunks * 5
else
rag_conversation_chunks
end
),
offset: 0,
)
fragments =
RagDocumentFragment.where(upload_id: upload_refs, id: candidate_fragment_ids).pluck(
:fragment,
:metadata,
)
if reranker.reranker_configured?
guidance = fragments.map { |fragment, _metadata| fragment }
ranks =
DiscourseAi::Inference::HuggingFaceTextEmbeddings
.rerank(conversation_context.last[:content], guidance)
.to_a
.take(rag_conversation_chunks)
.map { _1[:index] }
if ranks.empty?
fragments = fragments.take(rag_conversation_chunks)
else
fragments = ranks.map { |idx| fragments[idx] }
end
end
<<~TEXT
<guidance>
The following texts will give you additional guidance for your response.
We included them because we believe they are relevant to this conversation topic.
Texts:
#{
fragments
.map do |fragment, metadata|
if metadata.present?
["# #{metadata}", fragment].join("\n")
else
fragment
end
end
.join("\n")
}
</guidance>
TEXT
end
end
end
end
end