Action

ChatGPT Conversation

Posted by reorx, Last update 2 months ago

UPDATES

2 months ago

Add support for new models including: gpt-4o, gp4-4o-mini, o1-preview, o1-mini

show all updates...

2 months ago

Add support for new models including: gpt-4o, gp4-4o-mini, o1-preview, o1-mini

9 months ago

  • Use Drafts builtin OpenAI integration instead of manually written HTTP calls
  • Update models pricing to the latest
  • Change default model from gpt-3.5 to gpt-4

over 1 year ago

display tokens usage and cost after running the action, provide an option in template tags to enable this feature or not

over 1 year ago

remove api-base-url template tag, use credential to store apiBaseUrl

over 1 year ago

enhance error handling, show alert with error and HTTP info to help user debugging

over 1 year ago

  • Copy the result content to clipboard after running the action

over 1 year ago

  • fix error when draft is empty

over 1 year ago

  • add new template tag api-base-url, allowing for setting the API URL being used, by default https://api.openai.com/v1/ is used if leave the tag empty
  • fix response message not trimmed before wrapping into block

over 1 year ago

Support undo/redo for the new message appended by this action.

over 1 year ago

Update description

over 1 year ago

update description

Topic link: https://forums.getdrafts.com/t/chatgpt-conversation-action/14042

Have a conversation with ChatGPT in the note. Any new responses will be appended at the end.

Upon running the action, the OpenAI API key is required, which can be obtained from https://platform.openai.com/account/api-keys.

The simplest way to use it is to create a new draft and type whatever you would like to ask. Then, call the action, and the result will be appended to the end of the draft.

You can also add <!-- block system --> at the top of the draft to tell ChatGPT what you want it to be. There are some curated prompts for your inspiration in this repo: Awesome ChatGPT Prompts

Note that the messages ChatGPT returns will be wrapped in <!-- block assistant -->. The action will recognize them as assistant role messages and send them to the ChatGPT API as part of the conversation. So, don’t make the conversation too long; otherwise, the token will be consumed very quickly.

You can change the default behavior of using the whole draft as the context in the conversation by setting the template tag keep-context to false. This will make the action only send the system role message and the last user role message to ChatGPT API.

Steps

  • defineTemplateTag

    name
    gpt-model
    template
    gpt-4o
  • defineTemplateTag

    name
    extra-tag
    template
    chatgpt
  • defineTemplateTag

    name
    keep-context
    template
    true
  • defineTemplateTag

    name
    show-tokens-usage
    template
    true
  • script

    // credentials
    const credential = Credential.create('ChatGPT Conversation', "Credentials for ChatGPT Conversation");
          credential.addPasswordField('openaiAPIKey', "OpenAI API Key");
    	  credential.addURLField('openaiAPIBaseUrl', "OpenAI API Base URL (optional)");
          credential.authorize();
    const apiKey = credential.getValue('openaiAPIKey');
    const apiBaseUrl = credential.getValue('openaiAPIBaseUrl') || 'https://api.openai.com/v1';
    
    // validate values
    if (!apiKey) {
    	alert(`OpenAI API Key must be configured to run this action.`)
    	context.cancel()
    }
    
    // tags
    let gptModel = draft.processTemplate("[[gpt-model]]") || 'gpt-4'
    let extraTag = draft.processTemplate("[[extra-tag]]")
    let keepContext = draft.processTemplate("[[keep-context]]") === 'true'
    let showTokensUsage = draft.processTemplate("[[show-tokens-usage]]") === 'true'
  • script

    /**
      * params:
      * - gptModel: string
      * - extraTag: string
      * - keepContext: boolean
      * - showTokensUsage: boolean
      **/
    function main() {
      const content = draft.content
      let blocks = parseTextToBlocks(content)
    
      if (blocks.length === 0) {
        alert("Please provide a prompt.")
        context.cancel()
        return
      }
    
      // check last block, must be user role message
      const lastBlock = blocks[blocks.length - 1]
      if (lastBlock.role !== ROLE_NAME.user) {
        alert("Please provide a prompt instead of just sending system and assistant messages.")
        context.cancel()
        return
      }
    
      // keep context or only use system role message and last user role message
      if (!keepContext) {
        const systemBlocks = blocks.filter(block => block.role === ROLE_NAME.system)
        const userBlocks = blocks.filter(block => block.role === ROLE_NAME.user)
        blocks = systemBlocks
        if (userBlocks.length) {
          blocks.push(userBlocks[userBlocks.length - 1])
        }
      }
    
      const res = sendChat(blocksToMessages(blocks))
    
      const handleData = (data) => {
        const resultMessage = data.choices[0].message
        const resultContent = resultMessage.content.trim()
        draft.saveVersion() // save the draft, just in case
        const textToAppend = '\n' + wrapTextToBlock(resultContent, resultMessage.role)
        // Use "editor" object enables undo/redo ability whereas "draft.append" cannot.
        editor.setTextInRange(draft.content.length, 1, '\n' + textToAppend)
        draft.addTag(extraTag)
        draft.update()
        app.setClipboard(resultContent)
        // display token usage
        if (showTokensUsage) {
          const { tokens, cost } = calUsage(data, gptModel)
          const msg = `Used ${tokens} tokens of $${cost}`
          console.log(msg)
          app.displayInfoMessage(msg)
        }
      }
    
      if (res.success) {
        try {
          handleData(res.responseData)
          console.log(`Chat success`)
        } catch (err) {
          const msg = `Chat failed when handling response data.\n\nError:${err}\n\nBody:\n${res.responseText}`
          console.log(msg)
          alert(msg)
          context.fail()
        }
      } else {
        const msg = `Chat failed with HTTP status ${res.statusCode}.\n\nBody:\n${res.responseText}`
        console.log(msg)
        alert(msg)
        context.fail()
      }
    }
    
    // https://openai.com/pricing
    // https://platform.openai.com/docs/models
    // price is per 1k token
    // last update: 2024-02-29
    const modelPriceRegexMap = {
      // o1-mini
      'o1-mini(-[-\d]+)?$': {
        prompt: 0.003,
        completion: 0.012,
      },
      // o1-preview
      'o1-preview(-[-\d]+)?$': {
        prompt: 0.015,
        completion: 0.06,
      },
      // gpt-4o-mini
      'gpt-4o-mini(-[-\d]+)?$': {
        prompt: 0.00015,
        completion: 0.0006,
      },
      // gpt-4o
      'gpt-4o(-[-\d]+)?$': {
        prompt: 0.005,
        completion: 0.015,
      },
      // gpt-4 turbo
      'gpt-4-(\w+)-preview$': {
        prompt: 0.01,
        completion: 0.03,
      },
      // gpt-4
      'gpt-4(-\d+)?$': {
        prompt: 0.03,
        completion: 0.06,
      },
      // gpt-4 32k
      'gpt-4-32k(-\d+)?$': {
        prompt: 0.06,
        completion: 0.12,
      },
      // gpt-3.5 turbo
      'gpt-3.5-turbo(-\d+)?$': {
        prompt: 0.0005,
        completion: 0.0015,
      }
    }
    
    function calUsage(data, model) {
      //  'usage': {'prompt_tokens': 56, 'completion_tokens': 31, 'total_tokens': 87},
      const { prompt_tokens, completion_tokens, total_tokens } = data.usage
      let priceObj
      for (let key in modelPriceRegexMap) {
        const re = new RegExp(key)
        if (re.test(model)) {
          priceObj = modelPriceRegexMap[key]
          break
        }
      }
      if (!priceObj) {
        throw Error(`could not get price from model ${model}`)
      }
      const cost = (prompt_tokens * priceObj.prompt / 1000) + (completion_tokens * priceObj.completion / 1000)
      const costFixed = cost.toFixed(6)
      console.log(`calUsage: usage=${JSON.stringify(data.usage)} model=${model} priceObj=${JSON.stringify(priceObj)} cost=${cost} costFixed=${costFixed}`)
      return {
        tokens: total_tokens,
        cost: costFixed,
      }
    }
    
    function sendChat(messages) {
      let ai = OpenAI.create(apiKey, apiBaseUrl)
      ai.model = gptModel
      let res = ai.request({
        path: '/chat/completions',
        method: 'POST',
        data: {
          model: gptModel,
          messages,
        }
      })
      return res
    }
    
    
    const ROLE_NAME = {
      system: 'system',
      assistant: 'assistant',
      user: 'user',
    }
    
    const blockStartRegex = /^\<\!--\s?block (\w+)\s?--\>$/
    const blockEndRegex = /^\<\!--\s?endblock\s?--\>$/
    
    function parseStatement(line) {
      const obj = {
        role: null,
        blockStart: false,
        blockEnd: false,
      }
      let match = line.match(blockStartRegex)
      if (match) {
        obj.role = match[1]
        obj.blockStart = true
        return obj
      }
      match = line.match(blockEndRegex)
      if (match) {
        obj.blockEnd = true
      }
      return obj
    }
    
    function parseTextToBlocks(text) {
      let lineno = 0
      let currentBlock = null
      let blocks = []
    
      const createBlock = (role) => {
        const block = {
          role,
          lines: [],
        }
        blocks.push(block)
        return block
      }
    
      text.trim().split('\n').forEach(line => {
        lineno += 1
        const statement = parseStatement(line)
        if (statement.blockStart) {
          if (currentBlock && currentBlock.role !== ROLE_NAME.user) {
            throw `Invalid role-block start at line ${lineno}: ${statement.role} started before ${currentBlock.role} ends`
          }
          currentBlock = createBlock(statement.role)
        } else if (statement.blockEnd) {
          if (!currentBlock) {
            throw `Invalid role-block end at line ${lineno}: no role-block exists`
          }
          currentBlock = null
        } else {
          if (!currentBlock) {
            currentBlock = createBlock(ROLE_NAME.user)
          }
          currentBlock.lines.push(line)
        }
      })
    
      if (currentBlock) {
        if (currentBlock.role !== ROLE_NAME.user) {
          throw `Invalid role-block at line ${lineno}: ${currentBlock.role} is not ended`
        }
        currentBlock = null
      }
    
      // filter empty blocks
      blocks = blocks.filter(block => {
        if (block.lines.join('')) {
          return true
        }
        return false
      })
    
      return blocks
    }
    
    function blocksToMessages(blocks) {
      return blocks.map(block => ({
        role: block.role,
        content: block.lines.join('\n').trim(),
      }))
    }
    
    function wrapTextToBlock(text, role) {
      return `<!-- block ${role} -->
    ${text}
    <!-- endblock -->`
    }
    
    // call main
    main()
    

Options

  • After Success Default
    Notification Error
    Log Level Info
Items available in the Drafts Directory are uploaded by community members. Use appropriate caution reviewing downloaded items before use.