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 %> <% end %> \ No newline at end of file diff --git a/app/views/conversation/character_index.html.erb b/app/views/conversation/character_index.html.erb new file mode 100644 index 00000000..98b822ed --- /dev/null +++ b/app/views/conversation/character_index.html.erb @@ -0,0 +1,60 @@ +
+
+

+ forum + Talk to your characters +

+

+ 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. +

+
+
+ +
+
+ <% if @universe_scope %> +
+ <%= Universe.icon %> + Showing <%= pluralize @characters.count, 'character' %> from <%= @universe_scope.name %>. + <%= link_to "Show characters from all universes instead.", conversation_path(universe: "all"), class: 'purple-text text-lighten-4' %> +
+ <% end %> + +
+ <% @characters.each do |content| %> + <%= link_to talk_path(content) do %> +
+
+
+ <%= image_tag content.random_image_including_private(format: :medium), style: 'height: 200px' %> + <%= content.name %> +
+
+
+ <% end %> + <% end %> + + <% if @characters.empty? %> +
+ You haven't created any character pages yet. +

+ + <%= link_to new_character_path, class: '' do %> +
+ add + Create character +
+ <% end %> +
+ <% end %> +
+
+
+ + diff --git a/app/views/conversation/character_landing.html.erb b/app/views/conversation/character_landing.html.erb new file mode 100644 index 00000000..e01c6085 --- /dev/null +++ b/app/views/conversation/character_landing.html.erb @@ -0,0 +1,94 @@ +
+
+ <%= link_to @character do %> + <%= image_tag @character.random_image_including_private, style: "width: 100%" %> + <% end %> + +
+
+
+ Talk to <%= @character.name %> +
+

+ 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! +

+
+ <%= link_to "Back to notebook page", @character %> +
+
+
+ <%= form_tag export_character_path(@character.id), method: :post, target: '_blank' do |f| %> + + <%= hidden_field_tag "name", @character.name %> + <%= hidden_field_tag "avatar", @character.random_image_including_private %> + <%# TODO background image/music/etc? %> + +
+

+ Persona export for <%= @character.name %> + <% if user_signed_in? && @character.user_id == current_user.id %> +
+ (editable because you created <%= @character.name %>) + <% end %> +

+

+ +
+ <%= text_area_tag 'scenario', nil, disabled: false, style: 'min-height: 100px', placeholder: "Is there a specific scenario/context you want to have this conversation in?", class: 'materialize-textarea' %> + +
+ +
+ <%= text_area_tag 'char_greeting', @first_greeting, disabled: false, placeholder: "This will be the first thing your character says to you. It can be a simple greeting, or you can use it to set a specific topic, tone, or speaking style.", class: 'materialize-textarea' %> + +
+ +
+ <%= text_area_tag 'personality', @personality, disabled: false, class: 'materialize-textarea' %> + +
+ +
+ <%= text_area_tag 'description', @description, disabled: false, class: 'materialize-textarea' %> + +
+ +
+ <%= text_area_tag 'example_dialogue', nil, disabled: false, style: 'min-height: 100px', placeholder: "If you have any dialogue examples, quotes, or other phrases your character says, you can use this field to include them and adjust their speaking style closer to the examples. Write as little or as much as you'd like!", class: 'materialize-textarea' %> + +
+
+ +
+
+ Note: An OpenAI API key is required by OpenCharacters +
+ +

+ 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. +

+
+ +
+
+ <%= submit_tag "Chat with #{@character.name}", class: 'hoverable btn blue white-text' %> + using OpenCharacters +
+ + <% 10.times do %>
<% end %> + <% end %> +
+
\ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 00cfde77..ec334730 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -26,6 +26,12 @@ Rails.application.routes.draw do get '/:content_type/:id', to: 'basil#content', as: :basil_content post '/:content_type/:id', to: 'basil#commission', as: :basil_commission end + + scope :talk do + get '/', to: 'conversation#character_index', as: :conversation + get '/to/:character_id', to: 'conversation#character_landing', as: :talk + post '/export/:character_id', to: 'conversation#export', as: :export_character + end end scope :stream, path: '/stream', as: :stream do diff --git a/test/controllers/conversation_controller_test.rb b/test/controllers/conversation_controller_test.rb new file mode 100644 index 00000000..587294b5 --- /dev/null +++ b/test/controllers/conversation_controller_test.rb @@ -0,0 +1,8 @@ +require "test_helper" + +class ConversationControllerTest < ActionDispatch::IntegrationTest + test "should get content" do + get conversation_content_url + assert_response :success + end +end