Merge branch 'master' into tailwind-redesign

This commit is contained in:
Andrew Brown 2023-04-27 16:47:03 -07:00
commit e27b133207
12 changed files with 444 additions and 18 deletions

View File

@ -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

View File

@ -1,2 +0,0 @@
module BasilHelper
end

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 %>
<ul class="collection content-tabs">
<li class="active center grey-text uppercase">
Tools
</li>
<li class="collection-item">
<%= link_to basil_content_path(content_type: content.class_name, id: content.id) do %>
<i class="material-icons left">palette</i>
Image Generation
<% end %>
</li>
<% if show_basil_tool %>
<li class="collection-item">
<%= link_to basil_content_path(content_type: content.class_name, id: content.id) do %>
<i class="material-icons left">palette</i>
Image Generation
<% end %>
</li>
<% end %>
<% if show_conversation %>
<li class="collection-item">
<%= link_to talk_path(character_id: content.id) do %>
<i class="material-icons left">message</i>
Talk to <%= content.name %>
<% end %>
</li>
<% end %>
</ul>
<% end %>

View File

@ -0,0 +1,60 @@
<div class="row">
<div class="col s12">
<h1 style="font-size: 2rem">
<i class="material-icons float-right ">forum</i>
Talk to your characters
</h1>
<p>
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.
</p>
<p>
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.
</p>
</div>
</div>
<div class="row">
<div class="col s12">
<% if @universe_scope %>
<div class="card-panel <%= Universe.color %> white-text">
<i class="material-icons left"><%= Universe.icon %></i>
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' %>
</div>
<% end %>
<div class="row">
<% @characters.each do |content| %>
<%= link_to talk_path(content) do %>
<div class="col s12 m4 l3">
<div class="hoverable card">
<div class="card-image">
<%= image_tag content.random_image_including_private(format: :medium), style: 'height: 200px' %>
<span class="card-title"><%= content.name %></span>
</div>
</div>
</div>
<% end %>
<% end %>
<% if @characters.empty? %>
<div class="center">
<strong>You haven't created any character pages yet.</strong>
<br /><br />
<%= link_to new_character_path, class: '' do %>
<div class="hoverable card-panel <%= Character.color %> white-text" style="width: 33%; margin: 0 auto">
<i class="material-icons left">add</i>
Create character
</div>
<% end %>
<div>
<% end %>
</div>
</div>
</div>

View File

@ -0,0 +1,94 @@
<div class="row">
<div class="col s12 m4">
<%= link_to @character do %>
<%= image_tag @character.random_image_including_private, style: "width: 100%" %>
<% end %>
<div class="card">
<div class="card-content">
<div class="card-title">
<strong>Talk to <%= @character.name %></strong>
</div>
<p>
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.
</p>
</div>
</div>
<p style="font-size: 0.9em">
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!
</p>
<div>
<%= link_to "Back to notebook page", @character %>
</div>
</div>
<div class="col s12 m8">
<%= 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? %>
<div class="card-panel">
<p class="center">
<strong>Persona export for <%= @character.name %></strong>
<% if user_signed_in? && @character.user_id == current_user.id %>
<br />
<em>(editable because you created <%= @character.name %>)</em>
<% end %>
</p>
<br /><br />
<div class="input-field">
<%= 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' %>
<label for="scenario">Optional: Scenario</label>
</div>
<div class="input-field">
<%= 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' %>
<label for="char_greeting">First greeting from <%= @character.name %></label>
</div>
<div class="input-field">
<%= text_area_tag 'personality', @personality, disabled: false, class: 'materialize-textarea' %>
<label for="personality">Brief personality</label>
</div>
<div class="input-field">
<%= text_area_tag 'description', @description, disabled: false, class: 'materialize-textarea' %>
<label for="description">In-depth personality</label>
</div>
<div class="input-field">
<%= 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' %>
<label for="example_dialogue">Optional: More dialogue examples</label>
</div>
</div>
<div class="card-panel">
<div class="center">
<strong>Note: An OpenAI API key is required by OpenCharacters</strong>
</div>
<p>
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.
</p>
</div>
<br />
<div class="center">
<%= submit_tag "Chat with #{@character.name}", class: 'hoverable btn blue white-text' %>
<span class="grey-text text-darken-1" style="margin-left: 0.5rem">using OpenCharacters</span>
</div>
<% 10.times do %><br /><% end %>
<% end %>
</div>
</div>

View File

@ -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

View File

@ -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