indentlabs/notebook

View on GitHub
app/controllers/conversation_controller.rb

Summary

Maintainability
A
2 hrs
Test Coverage
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