diff --git a/app/controllers/conversation_controller.rb b/app/controllers/conversation_controller.rb new file mode 100644 index 00000000..17fb7c98 --- /dev/null +++ b/app/controllers/conversation_controller.rb @@ -0,0 +1,196 @@ +class ConversationController < ApplicationController + before_action :authenticate_user!, only: [:character_index] + + before_action :set_character, only: [:character_landing, :export] + before_action :ensure_character_privacy, only: [:character_landing, :export] + + def character_index + @characters = @current_user_content.fetch('Character', []) + end + + def character_landing + @first_greeting = default_character_greeting + @personality = personality_for_character + @description = description_for_character + end + + def export + name = open_characters_persona_params.fetch('name', 'New character').strip + description = open_characters_persona_params.fetch('description', '') + + add_character_hash = base_open_characters_export.merge({ + "uuid": deterministic_uuid(@character.id), + "name": name, + "roleInstruction": full_role_instruction, + "reminderMessage": reminder_message, + }) + + # Add a character image if one has been uploaded to the page + avatar = @character.random_image_including_private + add_character_hash[:avatar][:url] = avatar if avatar.present? + + # Provide a default scenario if one wasn't given + add_character_hash[:scenario] ||= default_scenario + + # Redirect to OpenCharacters + base_oc_url = 'https://josephrocca.github.io/OpenCharacters/' + oc_params = { addCharacter: add_character_hash } + + redirect_to "#{base_oc_url}##{ERB::Util.url_encode(oc_params.to_json)}" + end + + private + + def deterministic_uuid(id) + static_prefix = "notebook-" + hashed_id = Digest::SHA1.hexdigest(static_prefix + id.to_s) + uuid = "#{hashed_id[0..7]}-#{hashed_id[8..11]}-#{hashed_id[12..15]}-#{hashed_id[16..19]}-#{hashed_id[20..31]}" + uuid + end + + def full_role_instruction + final_text = [ + "[SYSTEM]: You are roleplaying as #{@character.name}, #{personality_for_character}", + "", + "Follow this pattern:", + "\"Hello!\" - dialogue", + "*He jumps out of the bushes.* - action", + "", + "#{@character.name}'s personality is below:", + "#{open_characters_persona_params.fetch('description', '(not included)')}", + "", + "#{@character.name} will now respond while staying in character in an extremely descriptive manner at length, avoiding being repetitive, without advancing events by herself, avoiding implying conversations without a reply from the user first, and wait for the user's reply to advance events. Describe what #{@character.name} is feeling, saying, and doing with rich detail, but do not include any parenthetical thoughts and focus primarily on dialogue.", + ].join("\n") + end + + def reminder_message + "[SYSTEM]: (Thought: I need to rememeber to be creative, descriptive, and engaging! I should work very hard to avoid being repetitive as well! Unless the user speaks to me OOC, with parentheses around their input, first, I will not say anything OOC or in parentheses. I shouldn't ignore parts of the user's post, even if they move on to a new scene. I should at least write my characters thoughts and feelings towards the prior scene before continuing with the new one. I don't need to feel the need to write a long response if the user's post is short. In that case, I can feel free to write a short response myself - making sure to not take over writing the user's character's dialogue, thoughts, or actions!)" + end + + def personality_for_character + hobbies = @character.get_field_value('Nature', 'Hobbies') + + personality_parts = [ + "a #{@character.get_field_value('Overview', 'Gender', fallback='')} #{@character.get_field_value('Overview', 'Role', fallback='character')}, age #{@character.get_field_value('Overview', 'Age', fallback='irrelevant')}." + ] + personality_parts << "Their hobbies include #{hobbies}." if hobbies.present? + + ContentFormatterService.plaintext_show(text: personality_parts.join(' '), viewing_user: current_user) + end + + def description_for_character + occupation = @character.get_field_value('Social', 'Occupation') + background = @character.get_field_value('History', 'Background') + motivations = @character.get_field_value('Nature', 'Motivations') + mannerisms = @character.get_field_value('Nature', 'Mannerisms') + flaws = @character.get_field_value('Nature', 'Flaws') + prejudices = @character.get_field_value('Nature', 'Prejudices') + talents = @character.get_field_value('Nature', 'Talents') + hobbies = @character.get_field_value('Nature', 'Hobbies') + + description_parts = [] + description_parts.concat ["OCCUPATION", occupation, nil] if occupation.present? + description_parts.concat ["BACKGROUND", background, nil] if background.present? + description_parts.concat ["MOTIVATIONS", motivations, nil] if motivations.present? + description_parts.concat ["MANNERISMS", mannerisms, nil] if mannerisms.present? + description_parts.concat ["FLAWS", flaws, nil] if flaws.present? + description_parts.concat ["PREJUDICES", prejudices, nil] if prejudices.present? + description_parts.concat ["TALENTS", talents, nil] if talents.present? + description_parts.concat ["HOBBIES", hobbies, nil] if hobbies.present? + + ContentFormatterService.plaintext_show(text: description_parts.join("\n"), viewing_user: current_user) + end + + def set_character + @character = Character.find(params[:character_id].to_i) + end + + def ensure_character_privacy + unless (user_signed_in? && @character.user == current_user) || @character.privacy == 'public' + redirect_to root_path, notice: "That character is private!" + return + end + end + + def open_characters_persona_params + params.permit(:name, :avatar, :scenario, :char_greeting, :personality, :description, :example_dialogue) + end + + def default_scenario + "This character is interacting with the user within their own fictional universe. The character will respond in a way that is consistent with their personality and background." + end + + def default_custom_code + "" + + # TODO maybe include our standard (whisper) formatting on messages? + end + + def base_open_characters_export + { + "name": "New character", + "modelName": "gpt-3.5-turbo", + "fitMessagesInContextMethod": "summarizeOld", + "associativeMemoryMethod": "v1", + "associativeMemoryEmbeddingModelName": "text-embedding-ada-002", + "temperature": 0.7, + "customCode": default_custom_code, + "initialMessages": default_initial_messages, + "avatar": { + "url": "", + "size": 1, + "shape": "square" + }, + "scene": { + "background": { + "url": "" + }, + "music": { + "url": "" + } + }, + "userCharacter": { + "avatar": { + "url": current_user.avatar.url, + "size": 1, + "shape": "circle" + } + }, + "streamingResponse": true + } + end + + def default_initial_messages + [ + { + "author": "system", + "content": "Scenario: " + (open_characters_persona_params.fetch('scenario', nil).presence || default_scenario), + "hiddenFrom": [] # "ai", "user", "both", "neither" + }, + { + "author": "ai", + "content": open_characters_persona_params.fetch('char_greeting', default_character_greeting), + "hiddenFrom": [] + } + ] + end + + def default_export_metadata + { + "version": 1, + "created": @content.created_at.to_i, + "modified": @content.updated_at.to_i, + "ncid": @content.id, + "source": "https://www.notebook.ai/plan/characters#{@content.id}", + "tool": { + "name": "Notebook.ai Persona Export", + "version": "1.0.0", + "url": "https://www.notebook.ai" + } + } + end + + def default_character_greeting + "Hello!" + end +end diff --git a/app/helpers/basil_helper.rb b/app/helpers/basil_helper.rb deleted file mode 100644 index 8d483a2f..00000000 --- a/app/helpers/basil_helper.rb +++ /dev/null @@ -1,2 +0,0 @@ -module BasilHelper -end diff --git a/app/models/concerns/has_attributes.rb b/app/models/concerns/has_attributes.rb index b7a07108..c69dd5f7 100644 --- a/app/models/concerns/has_attributes.rb +++ b/app/models/concerns/has_attributes.rb @@ -300,6 +300,34 @@ module HasAttributes .detect { |v| v.entity_id == self.id }&.value.presence || (self.respond_to?(label.downcase) ? self.read_attribute(label.downcase) : nil) end + def get_field_value(category, field, fallback=nil) + category = AttributeCategory.find_by( + label: category, + entity_type: self.class.name.downcase, + user_id: self.user_id, + hidden: [nil, false] + ) + return fallback if category.nil? + + field = AttributeField.find_by( + label: field, + attribute_category_id: category.id, + user_id: self.user_id, + hidden: [nil, false] + ) + return fallback if field.nil? + + answer = Attribute.find_by( + attribute_field_id: field.id, + entity_type: self.class.name, + entity_id: self.id, + user_id: self.user_id + ) + return fallback if answer.nil? + + answer.value + end + def self.field_type_for(category, field) if field[:label] == 'Name' && category.name == 'overview' "name" diff --git a/app/models/concerns/has_image_uploads.rb b/app/models/concerns/has_image_uploads.rb index 16855610..684bb79a 100644 --- a/app/models/concerns/has_image_uploads.rb +++ b/app/models/concerns/has_image_uploads.rb @@ -26,7 +26,7 @@ module HasImageUploads # If we don't have any uploaded images, we look for saved Basil commissions if result.nil? && respond_to?(:basil_commissions) - result = basil_commissions.where.not(saved_at: nil).sample.try(:image) + result = basil_commissions.where.not(saved_at: nil).includes([:image_attachment]).sample.try(:image) end # Finally, if we have no image upload, we return the default image for this type diff --git a/app/models/page_types/content_page.rb b/app/models/page_types/content_page.rb index 56ca4b6f..2adfa399 100644 --- a/app/models/page_types/content_page.rb +++ b/app/models/page_types/content_page.rb @@ -12,7 +12,7 @@ class ContentPage < ApplicationRecord # TODO: this is gonna be an N+1 query any time we display a list of ContentPages with images def random_image_including_private(format: :small) ImageUpload.where(content_type: self.page_type, content_id: self.id).sample.try(:src, format) \ - || BasilCommission.where(entity_type: self.page_type, entity_id: self.id).where.not(saved_at: nil).sample.try(:image) \ + || BasilCommission.where(entity_type: self.page_type, entity_id: self.id).where.not(saved_at: nil).includes([:image_attachment]).sample.try(:image) \ || ActionController::Base.helpers.asset_path("card-headers/#{self.page_type.downcase.pluralize}.webp") end diff --git a/app/models/users/user.rb b/app/models/users/user.rb index b921e435..6e2a45f6 100644 --- a/app/models/users/user.rb +++ b/app/models/users/user.rb @@ -60,7 +60,7 @@ class User < ApplicationRecord has_many :user_blockings, dependent: :destroy has_many :blocked_users, through: :user_blockings, source: :blocked_user def blocked_by_users - User.where(id: UserBlocking.where(blocked_user_id: self.id).pluck(:user_id)) + @cached_blocked_by_users ||= User.where(id: UserBlocking.where(blocked_user_id: self.id).pluck(:user_id)) end def blocked_by?(user) blocked_by_users.pluck(:id).include?(user.id) @@ -208,7 +208,7 @@ class User < ApplicationRecord end def image_url(size=80) - if avatar.attached? # manually-uploaded avatar + @cached_user_image_url ||= if avatar.attached? # manually-uploaded avatar Rails.application.routes.url_helpers.rails_representation_url(avatar.variant(resize_to_limit: [size, size]).processed, only_path: true) else # otherwise, grab the default from Gravatar for this email address @@ -245,7 +245,7 @@ class User < ApplicationRecord end def active_promo_codes - PageUnlockPromoCode.where(id: active_promotions.pluck(:page_unlock_promo_code_id)) + @cached_active_promo_codes ||= PageUnlockPromoCode.where(id: active_promotions.pluck(:page_unlock_promo_code_id)) end def initialize_stripe_customer diff --git a/app/services/content_formatter_service.rb b/app/services/content_formatter_service.rb index 82538a7e..e1eb40d2 100644 --- a/app/services/content_formatter_service.rb +++ b/app/services/content_formatter_service.rb @@ -21,6 +21,16 @@ class ContentFormatterService < Service # https://s3.amazonaws.com/raw.paste.esk.io/Llb%2F64DJHK?versionId=19Lb_TtukDbo1J_IoCpkr.d.pwpW_vmH VALID_LINK_CLASSES = Rails.application.config.content_type_names[:all] + %w(Timeline Document) + def self.plaintext_show(text:, viewing_user: User.new) + formatted_text = markdown.render(text || '').html_safe + + tokens_to_replace(text).each do |token| + text.gsub!(token[:matched_string], replacement_for_token(token, viewing_user, true)) + end + + text + end + def self.show(text:, viewing_user: User.new) # We want to evaluate markdown first, because the markdown engine also happens # to strip out HTML tags. So: markdown, _then_ insert content links. @@ -67,7 +77,7 @@ class ContentFormatterService < Service end end - def self.replacement_for_token(token, viewing_user) + def self.replacement_for_token(token, viewing_user, plaintext=false) return unknown_link_template(token) unless token.key?(:content_type) && token.key?(:content_id) begin content_class = token[:content_type].titleize.constantize @@ -81,9 +91,17 @@ class ContentFormatterService < Service return unknown_link_template(token) unless content_model.present? if content_model.readable_by?(viewing_user) - link_template(content_model) + if plaintext + plaintext_replacement_template(content_model) + else + link_template(content_model) + end else - private_link_template(content_model) + if plaintext + plaintext_replacement_template(content_model) + else + private_link_template(content_model) + end end end @@ -95,6 +113,10 @@ class ContentFormatterService < Service inline_template(content_model.class) { link_to(content_model.name, link_for(content_model), class: 'grey-text content_link disabled') } end + def self.plaintext_replacement_template(content_model) + content_model.name + end + def self.unknown_link_template(attempted_key) attempted_key[:matched_string] end diff --git a/app/views/content/display/sidebar/_apps.html.erb b/app/views/content/display/sidebar/_apps.html.erb index 11a097f9..519f642b 100644 --- a/app/views/content/display/sidebar/_apps.html.erb +++ b/app/views/content/display/sidebar/_apps.html.erb @@ -1,20 +1,34 @@ <% creating = defined?(creating) && creating editing = defined?(editing) && editing - page_type_enabled = BasilService::ENABLED_PAGE_TYPES.include? content.class_name + show_basil_tool = BasilService::ENABLED_PAGE_TYPES.include? content.class_name + show_conversation = false && content.class_name == 'Character' + + show_tools_menu = show_basil_tool || show_conversation %> -<% if page_type_enabled %> +<% if show_tools_menu %>
+ There are a lot of new sites popping up for talking to AI characters. This page lets you + export your Notebook.ai characters to those sites, starting with the open-source project + OpenCharacters. +
++ Please note that these services aren't a part of Notebook.ai; this is just a way to get your + Notebook.ai characters into those sites! To get started, click on any character below. +
++ You can now export your Notebook.ai characters to the open-source project OpenCharacters + and talk to them in real-time! This is a great way to get to know your characters a little + better or to roleplay with them. +
++ The fields will be used to create a conversational AI persona of + your character. You can edit these fields before exporting if you want to change how your + character talks. If your character is public, you can also share this page with others + to let them talk to your character, too! +
+
+ Persona export for <%= @character.name %>
+ <% if user_signed_in? && @character.user_id == current_user.id %>
+
+ (editable because you created <%= @character.name %>)
+ <% end %>
+
+ OpenCharacters uses this persona data with OpenAI's GPT models + in a web app that runs entirely in your browser, rather than being hosted or stored on any server. + This means that you will need a valid OpenAI key to use OpenCharacters. +
+