diff options
Diffstat (limited to '')
| -rw-r--r-- | README.md | 73 | ||||
| -rw-r--r-- | autoload/vim_ai.vim | 43 | ||||
| -rw-r--r-- | autoload/vim_ai_config.vim | 17 | ||||
| -rw-r--r-- | doc/vim-ai.txt | 28 | ||||
| -rw-r--r-- | plugin/vim-ai.vim | 1 | ||||
| -rw-r--r-- | py/context.py | 5 | ||||
| -rw-r--r-- | py/image.py | 50 | ||||
| -rw-r--r-- | py/roles.py | 9 | ||||
| -rw-r--r-- | py/utils.py | 7 | ||||
| -rw-r--r-- | roles-default.ini | 6 | ||||
| -rw-r--r-- | tests/context_test.py | 36 | ||||
| -rw-r--r-- | tests/resources/roles.ini | 3 | ||||
| -rw-r--r-- | tests/roles_test.py | 4 |
13 files changed, 256 insertions, 26 deletions
@@ -14,6 +14,7 @@ To get an idea what is possible to do with AI commands see the [prompts](https:/ - Interactive conversation with ChatGPT - Custom roles - Vision capabilities (image to text) +- Generate images - Integrates with any OpenAI-compatible API ## How it works @@ -88,13 +89,14 @@ git clone https://github.com/madox2/vim-ai.git ~/.local/share/nvim/site/pack/plu To use an AI command, type the command followed by an instruction prompt. You can also combine it with a visual selection. Here is a brief overview of available commands: ``` -=========== Basic AI commands ============ +========== Basic AI commands ========== -:AI complete text -:AIEdit edit text -:AIChat continue or open new chat +:AI complete text +:AIEdit edit text +:AIChat continue or open new chat +:AIImage generate image -=============== Utilities ================ +============== Utilities ============== :AIRedo repeat last AI command :AIUtilRolesOpen open role config file @@ -106,7 +108,7 @@ To use an AI command, type the command followed by an instruction prompt. You ca **Tip:** Press `Ctrl-c` anytime to cancel completion -**Tip:** Use command shortcuts - `:AIE`, `:AIC`, `:AIR` or setup your own [key bindings](#key-bindings) +**Tip:** Use command shortcuts - `:AIE`, `:AIC`, `:AIR`, `:AII` or setup your own [key bindings](#key-bindings) **Tip:** Define and use [custom roles](#roles), e.g. `:AIEdit /grammar`. @@ -167,6 +169,16 @@ In the documentation below, `<selection>` denotes a visual selection or any oth `<selection>? :AIEdit /{role} {instruction}?` - use role to edit +### `:AIImage` + +`:AIImage {prompt}` - generate image with prompt + +`<selection> :AIImage` - generate image with seleciton + +`<selection>? :AI /{role} {instruction}?` - use role to generate + +[Pre-defined](./roles-default.ini) image roles: `/hd`, `/natural` + ### `:AIChat` `:AIChat` - continue or start a new conversation. @@ -177,6 +189,8 @@ In the documentation below, `<selection>` denotes a visual selection or any oth 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. +[Pre-defined](./roles-default.ini) chat roles: `/right`, `/below`, `/tab` + #### `.aichat` files You can edit and save the chat conversation to an `.aichat` file and restore it later. @@ -252,7 +266,7 @@ let g:vim_ai_chat = { Alternatively you can use special `default` role: ```ini -[default] +[default.chat] options.model = o1-preview options.stream = 0 options.temperature = 1 @@ -397,17 +411,29 @@ let g:vim_ai_chat = { \ }, \} -" Notes: -" ui.paste_mode -" - if disabled code indentation will work but AI doesn't always respond with a code block -" therefore it could be messed up -" - find out more in vim's help `:help paste` -" options.max_tokens -" - note that prompt + max_tokens must be less than model's token limit, see #42, #46 -" - setting max tokens to 0 will exclude it from the OpenAI API request parameters, it is -" unclear/undocumented what it exactly does, but it seems to resolve issues when the model -" hits token limit, which respond with `OpenAI: HTTPError 400` - +" :AIImage +" - prompt: optional prepended prompt +" - options: openai config (https://platform.openai.com/docs/api-reference/images/create) +" - options.request_timeout: request timeout in seconds +" - options.enable_auth: enable authorization using openai key +" - options.token_file_path: override global token configuration +" - options.download_dir: path to image download directory, `cwd` if not defined +let g:vim_ai_image_default = { +\ "prompt": "", +\ "options": { +\ "model": "dall-e-3", +\ "endpoint_url": "https://api.openai.com/v1/images/generations", +\ "quality": "standard", +\ "size": "1024x1024", +\ "style": "vivid", +\ "request_timeout": 20, +\ "enable_auth": 1, +\ "token_file_path": "", +\ }, +\ "ui": { +\ "download_dir": "", +\ }, +\} " custom roles file location let g:vim_ai_roles_config_file = s:plugin_root . "/roles-example.ini" @@ -418,6 +444,17 @@ let g:vim_ai_token_file_path = "~/.config/openai.token" " debug settings let g:vim_ai_debug = 0 let g:vim_ai_debug_log_file = "/tmp/vim_ai_debug.log" + +" Notes: +" ui.paste_mode +" - if disabled code indentation will work but AI doesn't always respond with a code block +" therefore it could be messed up +" - find out more in vim's help `:help paste` +" options.max_tokens +" - note that prompt + max_tokens must be less than model's token limit, see #42, #46 +" - setting max tokens to 0 will exclude it from the OpenAI API request parameters, it is +" unclear/undocumented what it exactly does, but it seems to resolve issues when the model +" hits token limit, which respond with `OpenAI: HTTPError 400` ``` ### Using custom API diff --git a/autoload/vim_ai.vim b/autoload/vim_ai.vim index af7b440..9b77e8f 100644 --- a/autoload/vim_ai.vim +++ b/autoload/vim_ai.vim @@ -13,7 +13,7 @@ let s:last_config = {} let s:scratch_buffer_name = ">>> AI chat" function! s:ImportPythonModules() - for py_module in ['utils', 'context', 'chat', 'complete', 'roles'] + for py_module in ['utils', 'context', 'chat', 'complete', 'roles', 'image'] if !py3eval("'" . py_module . "_py_imported' in globals()") execute "py3file " . s:plugin_root . "/py/" . py_module . ".py" endif @@ -209,6 +209,37 @@ function! vim_ai#AIEditRun(uses_range, config, ...) range abort endtry endfunction +" Generate image +" - uses_range - truty if range passed +" - config - function scoped vim_ai_image config +" - a:1 - optional instruction prompt +function! vim_ai#AIImageRun(uses_range, config, ...) range abort + call s:ImportPythonModules() + let l:instruction = a:0 > 0 ? a:1 : "" + let l:is_selection = a:uses_range && a:firstline == line("'<") && a:lastline == line("'>") + let l:selection = s:GetSelectionOrRange(l:is_selection, a:uses_range, a:firstline, a:lastline) + + let l:config_input = { + \ "config_default": g:vim_ai_image, + \ "config_extension": a:config, + \ "user_instruction": l:instruction, + \ "user_selection": l:selection, + \ "is_selection": l:is_selection, + \ "command_type": 'image', + \} + let l:context = py3eval("make_ai_context(unwrap('l:config_input'))") + let l:config = l:context['config'] + + let s:last_command = "image" + let s:last_config = a:config + let s:last_instruction = l:instruction + let s:last_is_selection = l:is_selection + let s:last_firstline = a:firstline + let s:last_lastline = a:lastline + + py3 run_ai_image(unwrap('l:context')) +endfunction + function! s:ReuseOrCreateChatWindow(config) let l:open_conf = a:config['ui']['open_chat_command'] @@ -292,11 +323,15 @@ endfunction " Repeat last AI command function! vim_ai#AIRedoRun() abort - undo + if s:last_command !=# "image" + undo + endif if s:last_command ==# "complete" exe s:last_firstline.",".s:last_lastline . "call vim_ai#AIRun(s:last_is_selection, s:last_config, s:last_instruction)" elseif s:last_command ==# "edit" exe s:last_firstline.",".s:last_lastline . "call vim_ai#AIEditRun(s:last_is_selection, s:last_config, s:last_instruction)" + elseif s:last_command ==# "image" + exe s:last_firstline.",".s:last_lastline . "call vim_ai#AIImageRun(s:last_is_selection, s:last_config, s:last_instruction)" elseif s:last_command ==# "chat" " chat does not need prompt, all information are in the buffer already call vim_ai#AIChatRun(0, s:last_config) @@ -314,6 +349,10 @@ function! vim_ai#RoleCompletionComplete(A,L,P) abort return s:RoleCompletion(a:A, 'complete') endfunction +function! vim_ai#RoleCompletionImage(A,L,P) abort + return s:RoleCompletion(a:A, 'image') +endfunction + function! vim_ai#RoleCompletionEdit(A,L,P) abort return s:RoleCompletion(a:A, 'edit') endfunction diff --git a/autoload/vim_ai_config.vim b/autoload/vim_ai_config.vim index 05b7459..d0ee972 100644 --- a/autoload/vim_ai_config.vim +++ b/autoload/vim_ai_config.vim @@ -48,6 +48,22 @@ let g:vim_ai_edit_default = { \ "paste_mode": 1, \ }, \} +let g:vim_ai_image_default = { +\ "prompt": "", +\ "options": { +\ "model": "dall-e-3", +\ "endpoint_url": "https://api.openai.com/v1/images/generations", +\ "quality": "standard", +\ "size": "1024x1024", +\ "style": "vivid", +\ "request_timeout": 20, +\ "enable_auth": 1, +\ "token_file_path": "", +\ }, +\ "ui": { +\ "download_dir": "", +\ }, +\} let s:initial_chat_prompt =<< trim END >>> system @@ -122,6 +138,7 @@ endfunction call s:MakeConfig("vim_ai_chat") call s:MakeConfig("vim_ai_complete") +call s:MakeConfig("vim_ai_image") call s:MakeConfig("vim_ai_edit") function! vim_ai_config#load() diff --git a/doc/vim-ai.txt b/doc/vim-ai.txt index 729390f..3d854e3 100644 --- a/doc/vim-ai.txt +++ b/doc/vim-ai.txt @@ -150,6 +150,32 @@ Globbing is expanded out via `glob.gob` and relative paths to the current working directory (as determined by `getcwd()`) will be resolved to absolute paths. + *:AIImage* + +<selection>? :AIImage {instruction}? generate image given the selection or + the instruction + +Options: > + let g:vim_ai_image_default = { + \ "prompt": "", + \ "options": { + \ "model": "dall-e-3", + \ "endpoint_url": "https://api.openai.com/v1/images/generations", + \ "quality": "standard", + \ "size": "1024x1024", + \ "style": "vivid", + \ "request_timeout": 20, + \ "enable_auth": 1, + \ "token_file_path": "", + \ }, + \ "ui": { + \ "download_dir": "", + \ }, + \} + +Check OpenAI docs for more information: +https://platform.openai.com/docs/api-reference/images/create + *:AIRedo* :AIRedo repeat last AI command in order to re-try @@ -182,7 +208,7 @@ a selection of options: > Alternatively you can use special `default` role: > - [default] + [default.chat] options.model=gpt-4 options.temperature=0.2 diff --git a/plugin/vim-ai.vim b/plugin/vim-ai.vim index 146504d..9de33c5 100644 --- a/plugin/vim-ai.vim +++ b/plugin/vim-ai.vim @@ -7,6 +7,7 @@ endif command! -range -nargs=? -complete=customlist,vim_ai#RoleCompletionComplete AI <line1>,<line2>call vim_ai#AIRun(<range>, {}, <q-args>) command! -range -nargs=? -complete=customlist,vim_ai#RoleCompletionEdit AIEdit <line1>,<line2>call vim_ai#AIEditRun(<range>, {}, <q-args>) command! -range -nargs=? -complete=customlist,vim_ai#RoleCompletionChat AIChat <line1>,<line2>call vim_ai#AIChatRun(<range>, {}, <q-args>) +command! -range -nargs=? -complete=customlist,vim_ai#RoleCompletionImage AIImage <line1>,<line2>call vim_ai#AIImageRun(<range>, {}, <q-args>) command! -nargs=? AINewChat call vim_ai#AINewChatDeprecatedRun(<f-args>) command! AIRedo call vim_ai#AIRedoRun() command! AIUtilRolesOpen call vim_ai#AIUtilRolesOpen() diff --git a/py/context.py b/py/context.py index 581f8ad..343a050 100644 --- a/py/context.py +++ b/py/context.py @@ -79,7 +79,7 @@ def load_role_config(role): enhance_roles_with_custom_function(roles) - postfixes = ["", ".complete", ".edit", ".chat"] + postfixes = ["", ".complete", ".edit", ".chat", ".image"] if not any([f"{role}{postfix}" in roles for postfix in postfixes]): raise Exception(f"Role `{role}` not found") @@ -91,6 +91,7 @@ def load_role_config(role): 'role_complete': parse_role_section(roles.get(f"{role}.complete", {})), 'role_edit': parse_role_section(roles.get(f"{role}.edit", {})), 'role_chat': parse_role_section(roles.get(f"{role}.chat", {})), + 'role_image': parse_role_section(roles.get(f"{role}.image", {})), } def parse_role_names(prompt): @@ -147,7 +148,7 @@ def make_ai_context(params): user_prompt, role_config = parse_prompt_and_role_config(user_instruction, command_type) final_config = merge_deep([config_default, config_extension, role_config]) - selection_boundary = final_config['options']['selection_boundary'] + selection_boundary = final_config['options'].get('selection_boundary', '') config_prompt = final_config.get('prompt', '') prompt = make_prompt(config_prompt, user_prompt, user_selection, selection_boundary) diff --git a/py/image.py b/py/image.py new file mode 100644 index 0000000..4af7b6b --- /dev/null +++ b/py/image.py @@ -0,0 +1,50 @@ +import vim +import datetime +import os + +image_py_imported = True + +def make_openai_image_options(options): + return { + 'model': options['model'], + 'quality': 'standard', + 'size': '1024x1024', + 'style': 'vivid', + 'response_format': 'b64_json', + } + +def make_image_path(ui): + download_dir = ui.get('download_dir', vim.eval('getcwd()')) + timestamp = datetime.datetime.now(datetime.UTC).strftime("%Y%m%dT%H%M%SZ") + filename = f'vim_ai_{timestamp}.png' + return os.path.join(download_dir, filename) + +def run_ai_image(context): + prompt = context['prompt'] + config = context['config'] + config_options = config['options'] + ui = config['ui'] + + try: + if prompt: + print('Generating...') + openai_options = make_openai_image_options(config_options) + http_options = make_http_options(config_options) + request = { 'prompt': prompt, **openai_options } + + print_debug("[image] text:\n" + prompt) + print_debug("[image] request: {}", request) + url = config_options['endpoint_url'] + + response, *_ = openai_request(url, request, http_options) + print_debug("[image] response: {}", { 'images_count': len(response['data']) }) + + path = make_image_path(ui) + b64_data = response['data'][0]['b64_json'] + save_b64_to_file(path, b64_data) + + clear_echo_message() + print(f"Image: {path}") + except BaseException as error: + handle_completion_error(error) + print_debug("[image] error: {}", traceback.format_exc()) diff --git a/py/roles.py b/py/roles.py index bb5356e..7f038b1 100644 --- a/py/roles.py +++ b/py/roles.py @@ -13,8 +13,13 @@ def load_ai_role_names(command_type): role_names = set() for name in roles.sections(): parts = name.split('.') - if len(parts) == 1 or parts[-1] == command_type: - role_names.add(parts[0]) + if command_type == 'image': + # special case - image type have to be explicitely defined + if len(parts) > 1 and parts[-1] == command_type: + role_names.add(parts[0]) + else: + if len(parts) == 1 or parts[-1] == command_type: + role_names.add(parts[0]) role_names = [name for name in role_names if name != DEFAULT_ROLE_NAME] diff --git a/py/utils.py b/py/utils.py index d118326..8cd204f 100644 --- a/py/utils.py +++ b/py/utils.py @@ -250,7 +250,7 @@ def openai_request(url, data, options): ) with urllib.request.urlopen(req, timeout=request_timeout) as response: - if not data['stream']: + if not data.get('stream', 0): yield json.loads(response.read().decode()) return for line_bytes in response: @@ -354,3 +354,8 @@ def read_role_files(): roles = configparser.ConfigParser() roles.read([default_roles_config_path, roles_config_path]) return roles + +def save_b64_to_file(path, b64_data): + f = open(path, "wb") + f.write(base64.b64decode(b64_data)) + f.close() diff --git a/roles-default.ini b/roles-default.ini index 521650c..74e71e2 100644 --- a/roles-default.ini +++ b/roles-default.ini @@ -14,3 +14,9 @@ ui.open_chat_command = preset_below [tab.chat] ui.force_new_chat = 1 ui.open_chat_command = preset_tab + +[hd.image] +options.quality = hd + +[natural.image] +options.style = natural diff --git a/tests/context_test.py b/tests/context_test.py index a1d9624..7cb4810 100644 --- a/tests/context_test.py +++ b/tests/context_test.py @@ -24,6 +24,23 @@ default_config = { }, } +default_image_config = { + "prompt": "", + "options": { + "model": "dall-e-3", + "endpoint_url": "https://api.openai.com/v1/images/generations", + "quality": "standard", + "size": "1024x1024", + "style": "vivid", + "request_timeout": "20", + "enable_auth": "1", + "token_file_path": "", + }, + "ui": { + "paste_mode": "1", + }, +} + def test_default_config(): actual_context = make_ai_context({ 'config_default': default_config, @@ -130,6 +147,25 @@ def test_chat_only_role(): actual_config = context['config'] assert 'preset_tab' == actual_config['options']['open_chat_command'] +def test_image_role(): + actual_context = make_ai_context({ + 'config_default': default_image_config, + 'config_extension': {}, + 'user_instruction': '/hd-image picture of the moon', + 'user_selection': '', + 'command_type': 'image', + }) + expected_options = { + **default_image_config['options'], + 'token_file_path': '/custom/path/ai.token', + 'quality': 'hd', + } + expected_context = { + 'config': { **default_image_config, 'options': expected_options }, + 'prompt': 'picture of the moon', + } + assert expected_context == actual_context + def test_default_roles(): base = { 'config_default': default_config, diff --git a/tests/resources/roles.ini b/tests/resources/roles.ini index 00903f7..560e06d 100644 --- a/tests/resources/roles.ini +++ b/tests/resources/roles.ini @@ -21,6 +21,9 @@ options.endpoint_url = https://localhost/edit [chat-only-role.chat] options.open_chat_command = preset_tab +[hd-image.image] +options.quality = hd + [deprecated-test-role-simple] prompt = simple role prompt [deprecated-test-role-simple.options] diff --git a/tests/roles_test.py b/tests/roles_test.py index da9b9a4..a0cc26a 100644 --- a/tests/roles_test.py +++ b/tests/roles_test.py @@ -22,3 +22,7 @@ def test_role_chat_only(): 'below', 'tab', } + +def test_explicit_image_roles(): + role_names = load_ai_role_names('image') + assert set(role_names) == { 'hd-image' } |