diff options
| -rw-r--r-- | README.md | 32 | ||||
| -rw-r--r-- | autoload/vim_ai.vim | 27 | ||||
| -rw-r--r-- | autoload/vim_ai_config.vim | 5 | ||||
| -rw-r--r-- | doc/vim-ai.txt | 21 | ||||
| -rw-r--r-- | plugin/vim-ai.vim | 10 | ||||
| -rw-r--r-- | py/chat.py | 12 | ||||
| -rw-r--r-- | py/complete.py | 9 | ||||
| -rw-r--r-- | py/roles.py | 23 | ||||
| -rw-r--r-- | py/utils.py | 57 | ||||
| -rw-r--r-- | roles-example.ini | 22 |
10 files changed, 194 insertions, 24 deletions
@@ -12,6 +12,7 @@ To get an idea what is possible to do with AI commands see the [prompts](https:/ - Generate text or code, answer questions with AI - Edit selected text in-place with AI - Interactive conversation with ChatGPT +- Supports custom roles and more ## How it works @@ -89,13 +90,15 @@ To use an AI command, type the command followed by an instruction prompt. You ca **Tip:** Setup your own [key bindings](#key-bindings) or use command shortcuts - `:AIE`, `:AIC`, `:AIR` +**Tip:** A [custom role](#roles) {role} can be passed to the above commands by an initial parameter /{role}, for example `:AIEdit /grammar`. + **Tip:** Combine commands with a range `:help range`, for example to select the whole buffer - `:%AIE fix grammar` If you are interested in more tips or would like to level up your Vim with more commands like [`:GitCommitMessage`](https://github.com/madox2/vim-ai/wiki/Custom-commands#suggest-a-git-commit-message) - suggesting a git commit message, visit the [Community Wiki](https://github.com/madox2/vim-ai/wiki). ## Reference -In the documentation below, `<selection>` denotes a visual selection or any other range, `{instruction}` an instruction prompt and `?` symbol an optional parameter. +In the documentation below, `<selection>` denotes a visual selection or any other range, `{instruction}` an instruction prompt, `{role}` a [custom role](#roles) and `?` symbol an optional parameter. ### `:AI` @@ -107,18 +110,24 @@ In the documentation below, `<selection>` denotes a visual selection or any oth `<selection> :AI {instruction}` - complete the selection using the instruction +`<selection>? :AI /{role} {instruction}?` - use role to complete + ### `:AIEdit` `<selection>? :AIEdit` - edit the current line or the selection `<selection>? :AIEdit {instruction}` - edit the current line or the selection using the instruction +`<selection>? :AIEdit /{role} {instruction}?` - use role to edit + ### `:AIChat` `:AIChat` - continue or start a new conversation. `<selection>? :AIChat {instruction}?` - start a new conversation given the selection, the instruction or both +`<selection>? :AIChat /{role} {instruction}?` - use role to complete + When the AI finishes answering, you can continue the conversation by entering insert mode, adding your prompt, and then using the command `:AIChat` once again. #### `.aichat` files @@ -171,6 +180,27 @@ As a parameter you put an open chat command preset shortcut - `below`, `tab` or Use this immediately after `AI`/`AIEdit`/`AIChat` command in order to re-try or get an alternative completion. Note that the randomness of responses heavily depends on the [`temperature`](https://platform.openai.com/docs/api-reference/completions/create#completions/create-temperature) parameter. +## Roles + +In the context of this plugin, a role means a re-usable AI instruction and/or configuration. Roles are defined in the configuration `.ini` file. For example by defining a `grammar` role: + +```vim +let g:vim_ai_roles_config_file = '/path/to/my/roles.ini' +``` + +```ini +# /path/to/my/roles.ini + +[grammar] +prompt = fix spelling and grammar + +[grammar.options] +temperature = 0.4 +``` + +Now you can select text and run it with command `:AIEdit /grammar`. + +See [roles-example.ini](./roles-example.ini) for more examples. ## Key bindings diff --git a/autoload/vim_ai.vim b/autoload/vim_ai.vim index 114c5ad..8d5b3bc 100644 --- a/autoload/vim_ai.vim +++ b/autoload/vim_ai.vim @@ -3,6 +3,7 @@ call vim_ai_config#load() let s:plugin_root = expand('<sfile>:p:h:h') let s:complete_py = s:plugin_root . "/py/complete.py" let s:chat_py = s:plugin_root . "/py/chat.py" +let s:roles_py = s:plugin_root . "/py/roles.py" " remembers last command parameters to be used in AIRedoRun let s:last_is_selection = 0 @@ -78,15 +79,14 @@ function! s:OpenChatWindow(open_conf) endfunction function! s:set_paste(config) - if a:config['ui']['paste_mode'] - setlocal paste - endif -endfunction - -function! s:set_nopaste(config) - if a:config['ui']['paste_mode'] - setlocal nopaste - endif + if !a:config['ui']['paste_mode'] | return | endif + if &paste | return | endif + setlocal paste + augroup AiPaste + autocmd! + autocmd ModeChanged i:* exe 'set nopaste' + autocmd! AiPaste InsertLeave + augroup END endfunction function! s:GetSelectionOrRange(is_selection, ...) @@ -152,7 +152,6 @@ function! vim_ai#AIRun(config, ...) range endif execute "py3file " . s:complete_py execute "normal! " . a:lastline . "G" - call s:set_nopaste(l:config) endfunction " Edit prompt @@ -183,7 +182,6 @@ function! vim_ai#AIEditRun(config, ...) range call s:SelectSelectionOrRange(l:is_selection, a:firstline, a:lastline) execute "normal! c" execute "py3file " . s:complete_py - call s:set_nopaste(l:config) endfunction function! s:ReuseOrCreateChatWindow(config) @@ -250,7 +248,6 @@ function! vim_ai#AIChatRun(uses_range, config, ...) range let s:last_config = a:config execute "py3file " . s:chat_py - call s:set_nopaste(l:config) endfunction " Start a new chat @@ -273,3 +270,9 @@ function! vim_ai#AIRedoRun() call vim_ai#AIChatRun(0, s:last_config) endif endfunction + +function! vim_ai#RoleCompletion(A,L,P) abort + execute "py3file " . s:roles_py + call map(l:role_list, '"/" . v:val') + return filter(l:role_list, 'v:val =~ "^' . a:A . '"') +endfunction diff --git a/autoload/vim_ai_config.vim b/autoload/vim_ai_config.vim index ef3c863..41932de 100644 --- a/autoload/vim_ai_config.vim +++ b/autoload/vim_ai_config.vim @@ -1,3 +1,5 @@ +let s:plugin_root = expand('<sfile>:p:h:h') + let g:vim_ai_complete_default = { \ "engine": "complete", \ "options": { @@ -73,6 +75,9 @@ endif if !exists("g:vim_ai_token_file_path") let g:vim_ai_token_file_path = "~/.config/openai.token" endif +if !exists("g:vim_ai_roles_config_file") + let g:vim_ai_roles_config_file = s:plugin_root . "/roles-example.ini" +endif function! vim_ai_config#ExtendDeep(defaults, override) abort let l:result = a:defaults diff --git a/doc/vim-ai.txt b/doc/vim-ai.txt index 72535b3..c7aefde 100644 --- a/doc/vim-ai.txt +++ b/doc/vim-ai.txt @@ -161,6 +161,27 @@ You can also customize the options in the chat header: > generate a paragraph of lorem ipsum ... +ROLES + +Roles are defined in the `.ini` file: > + + let g:vim_ai_roles_config_file = '/path/to/my/roles.ini' + +Example of a role: > + + [grammar] + prompt = fix spelling and grammar + + [grammar.options] + temperature = 0.4 + +See roles-example.ini for more examples. + +The roles in g:vim_ai_roles_config_file are converted to a Vim dictionary whose +labels are the names of the roles. Optionally, roles can be added by setting +g:vim_ai_roles_config_function to the name of a Vimscript function returning a +dictionary of the same format as g:vim_ai_roles_config_file. + KEY BINDINGS Examples how configure key bindings and customize commands: > diff --git a/plugin/vim-ai.vim b/plugin/vim-ai.vim index 0e4957f..1ed5326 100644 --- a/plugin/vim-ai.vim +++ b/plugin/vim-ai.vim @@ -12,11 +12,11 @@ augroup vim_ai \ let g:vim_ai_is_selection_pending = mode() =~# "^[vV\<C-v>]" augroup END -command! -range -nargs=? AI <line1>,<line2>call vim_ai#AIRun({}, <q-args>) -command! -range -nargs=? AIEdit <line1>,<line2>call vim_ai#AIEditRun({}, <q-args>) +command! -range -nargs=? -complete=customlist,vim_ai#RoleCompletion AI <line1>,<line2>call vim_ai#AIRun({}, <q-args>) +command! -range -nargs=? -complete=customlist,vim_ai#RoleCompletion AIEdit <line1>,<line2>call vim_ai#AIEditRun({}, <q-args>) " Whereas AI and AIEdit default to passing the current line as range " AIChat defaults to passing nothing which is achieved by -range=0 and passing " <count> as described at https://stackoverflow.com/a/20133772 -command! -range=0 -nargs=? AIChat <line1>,<line2>call vim_ai#AIChatRun(<count>, {}, <q-args>) -command! -nargs=? AINewChat call vim_ai#AINewChatRun(<f-args>) -command! AIRedo call vim_ai#AIRedoRun() +command! -range=0 -nargs=? -complete=customlist,vim_ai#RoleCompletion AIChat <line1>,<line2>call vim_ai#AIChatRun(<count>, {}, <q-args>) +command! -nargs=? AINewChat call vim_ai#AINewChatRun(<f-args>) +command! AIRedo call vim_ai#AIRedoRun() @@ -4,10 +4,14 @@ import vim plugin_root = vim.eval("s:plugin_root") vim.command(f"py3file {plugin_root}/py/utils.py") +prompt, role_options = parse_prompt_and_role(vim.eval("l:prompt")) config = normalize_config(vim.eval("l:config")) -config_options = config['options'] +config_options = { + **config['options'], + **role_options['options_default'], + **role_options['options_chat'], +} config_ui = config['ui'] -prompt = vim.eval("l:prompt").strip() def initialize_chat_window(): lines = vim.eval('getline(1, "$")') @@ -38,7 +42,7 @@ def initialize_chat_window(): vim.command("normal! i\n>>> user\n\n") if prompt: - vim.command("normal! a" + prompt) + vim.command("normal! i" + prompt) vim_break_undo_sequence() vim.command("redraw") @@ -72,7 +76,7 @@ try: **openai_options } printDebug("[chat] request: {}", request) - url = config_options['endpoint_url'] + url = options['endpoint_url'] response = openai_request(url, request, http_options) def map_chunk(resp): printDebug("[chat] response: {}", resp) diff --git a/py/complete.py b/py/complete.py index debe275..f340e96 100644 --- a/py/complete.py +++ b/py/complete.py @@ -6,11 +6,16 @@ vim.command(f"py3file {plugin_root}/py/utils.py") config = normalize_config(vim.eval("l:config")) engine = config['engine'] -config_options = config['options'] + +prompt, role_options = parse_prompt_and_role(vim.eval("l:prompt")) +config_options = { + **config['options'], + **role_options['options_default'], + **role_options['options_complete'], +} openai_options = make_openai_options(config_options) http_options = make_http_options(config_options) -prompt = vim.eval("l:prompt").strip() is_selection = vim.eval("l:is_selection") def complete_engine(prompt): diff --git a/py/roles.py b/py/roles.py new file mode 100644 index 0000000..b7d19e1 --- /dev/null +++ b/py/roles.py @@ -0,0 +1,23 @@ +import vim + +# import utils +plugin_root = vim.eval("s:plugin_root") +vim.command(f"py3file {plugin_root}/py/utils.py") + +roles_config_path = os.path.expanduser(vim.eval("g:vim_ai_roles_config_file")) +if not os.path.exists(roles_config_path): + raise Exception(f"Role config file does not exist: {roles_config_path}") + +roles = configparser.ConfigParser() +roles.read(roles_config_path) + +enhance_roles_with_custom_function(roles) + +role_names = [name for name in roles.sections() if not '.' in name] + +role_list = [f'"{name}"' for name in role_names] +role_list = ", ".join(role_list) + +role_list = f"[{role_list}]" + +vim.command(f'let l:role_list = {role_list}') diff --git a/py/utils.py b/py/utils.py index 19de7c4..963377d 100644 --- a/py/utils.py +++ b/py/utils.py @@ -11,6 +11,7 @@ import re from urllib.error import URLError from urllib.error import HTTPError import traceback +import configparser is_debugging = vim.eval("g:vim_ai_debug") == "1" debug_log_file = vim.eval("g:vim_ai_debug_log_file") @@ -261,3 +262,59 @@ def handle_completion_error(error): def clear_echo_message(): # https://neovim.discourse.group/t/how-to-clear-the-echo-message-in-the-command-line/268/3 vim.command("call feedkeys(':','nx')") + +def enhance_roles_with_custom_function(roles): + if vim.eval("exists('g:vim_ai_roles_config_function')") == '1': + roles_config_function = vim.eval("g:vim_ai_roles_config_function") + if not vim.eval("exists('*" + roles_config_function + "')"): + raise Exception(f"Role config function does not exist: {roles_config_function}") + else: + roles.update(vim.eval(roles_config_function + "()")) + +def load_role_config(role): + roles_config_path = os.path.expanduser(vim.eval("g:vim_ai_roles_config_file")) + if not os.path.exists(roles_config_path): + raise Exception(f"Role config file does not exist: {roles_config_path}") + + roles = configparser.ConfigParser() + roles.read(roles_config_path) + + enhance_roles_with_custom_function(roles) + + if not role in roles: + raise Exception(f"Role `{role}` not found") + + options = roles[f"{role}.options"] if f"{role}.options" in roles else {} + options_complete =roles[f"{role}.options-complete"] if f"{role}.options-complete" in roles else {} + options_chat = roles[f"{role}.options-chat"] if f"{role}.options-chat" in roles else {} + + return { + 'role': dict(roles[role]), + 'options': { + 'options_default': dict(options), + 'options_complete': dict(options_complete), + 'options_chat': dict(options_chat), + }, + } + +empty_role_options = { + 'options_default': {}, + 'options_complete': {}, + 'options_chat': {}, +} + +def parse_prompt_and_role(raw_prompt): + prompt = raw_prompt.strip() + role = re.split(' |:', prompt)[0] + if not role.startswith('/'): + # does not require role + return (prompt, empty_role_options) + + prompt = prompt[len(role):].strip() + role = role[1:] + + config = load_role_config(role) + if 'prompt' in config['role'] and config['role']['prompt']: + delim = '' if prompt.startswith(':') else ':\n' + prompt = config['role']['prompt'] + delim + prompt + return (prompt, config['options']) diff --git a/roles-example.ini b/roles-example.ini new file mode 100644 index 0000000..90d32bc --- /dev/null +++ b/roles-example.ini @@ -0,0 +1,22 @@ +# .ini file structure: +# - https://docs.python.org/3/library/configparser.html#supported-ini-file-structure + +[grammar] +prompt = fix spelling and grammar + +[refactor] +prompt = + You are a Clean Code expert, I have the following code, + please refactor it in a more clean and concise way so that my colleagues + can maintain the code more easily. Also, explain why you want to refactor + the code so that I can add the explanation to the Pull Request. + +# common options for all engines +[refactor.options] +temperature = 0.4 + +# engine specific options: +[refactor.options-chat] +model = gpt-4 + +[refactor.options-complete] |