feat: pasting image within buffer (#331)

Signed-off-by: Aaron Pham <contact@aarnphm.xyz>
This commit is contained in:
Aaron Pham 2024-08-28 14:43:14 -04:00 committed by GitHub
parent 46a621e9de
commit c635f73748
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 166 additions and 295 deletions

View File

@ -32,6 +32,21 @@ Install `avante.nvim` using [lazy.nvim](https://github.com/folke/lazy.nvim):
"MunifTanjim/nui.nvim", "MunifTanjim/nui.nvim",
--- The below dependencies are optional, --- The below dependencies are optional,
"nvim-tree/nvim-web-devicons", -- or echasnovski/mini.icons "nvim-tree/nvim-web-devicons", -- or echasnovski/mini.icons
{
-- support for image pasting
"HakonHarnes/img-clip.nvim",
event = "VeryLazy",
opts = {
-- recommended settings
default = {
embed_image_as_base64 = false,
prompt_for_file_name = false,
drag_and_drop = {
insert_mode = true,
},
},
},
},
{ {
-- Make sure to setup it properly if you have lazy=true -- Make sure to setup it properly if you have lazy=true
'MeanderingProgrammer/render-markdown.nvim', 'MeanderingProgrammer/render-markdown.nvim',
@ -241,7 +256,7 @@ We would like to express our heartfelt gratitude to the contributors of the foll
| --- | --- | --- | --- | | --- | --- | --- | --- |
| [git-conflict.nvim](https://github.com/akinsho/git-conflict.nvim) | No License | Diff comparison functionality | https://github.com/yetone/avante.nvim/blob/main/lua/avante/diff.lua | | [git-conflict.nvim](https://github.com/akinsho/git-conflict.nvim) | No License | Diff comparison functionality | https://github.com/yetone/avante.nvim/blob/main/lua/avante/diff.lua |
| [ChatGPT.nvim](https://github.com/jackMort/ChatGPT.nvim) | Apache 2.0 License | Calculation of tokens count | https://github.com/yetone/avante.nvim/blob/main/lua/avante/utils/tokens.lua | | [ChatGPT.nvim](https://github.com/jackMort/ChatGPT.nvim) | Apache 2.0 License | Calculation of tokens count | https://github.com/yetone/avante.nvim/blob/main/lua/avante/utils/tokens.lua |
| [img-clip.nvim](https://github.com/HakonHarnes/img-clip.nvim) | MIT License | Clipboard image support | https://github.com/yetone/avante.nvim/blob/main/lua/avante/clipboard/init.lua | | [img-clip.nvim](https://github.com/HakonHarnes/img-clip.nvim) | MIT License | Clipboard image support | https://github.com/yetone/avante.nvim/blob/main/lua/avante/clipboard.lua |
The high quality and ingenuity of these projects' source code have been immensely beneficial throughout our development process. We extend our sincere thanks and respect to the authors and contributors of these projects. It is the selfless dedication of the open-source community that drives projects like avante.nvim forward. The high quality and ingenuity of these projects' source code have been immensely beneficial throughout our development process. We extend our sincere thanks and respect to the authors and contributors of these projects. It is the selfless dedication of the open-source community that drives projects like avante.nvim forward.

26
after/plugin/avante.lua Normal file
View File

@ -0,0 +1,26 @@
--- NOTE: We will override vim.paste if img-clip.nvim is available to work with avante.nvim internal logic paste
local Clipboard = require("avante.clipboard")
local Config = require("avante.config")
if Config.support_paste_image() then
vim.paste = (function(overriden)
---@param lines string[]
---@param phase -1|1|2|3
return function(lines, phase)
local bufnr = vim.api.nvim_get_current_buf()
local filetype = vim.api.nvim_get_option_value("filetype", { buf = bufnr })
if filetype ~= "AvanteInput" then
return overriden(lines, phase)
end
---@type string
local line = lines[1]
local ok = Clipboard.paste_image(line)
if not ok then
return overriden(lines, phase)
end
end
end)(vim.paste)
end

74
lua/avante/clipboard.lua Normal file
View File

@ -0,0 +1,74 @@
---NOTE: this module is inspired by https://github.com/HakonHarnes/img-clip.nvim/tree/main
---@see https://github.com/ekickx/clipboard-image.nvim/blob/main/lua/clipboard-image/paste.lua
local Path = require("plenary.path")
local Utils = require("avante.utils")
local Config = require("avante.config")
---@module "img-clip"
local ImgClip = nil
---@class AvanteClipboard
---@field get_base64_content fun(filepath: string): string | nil
---
---@class avante.Clipboard: AvanteClipboard
local M = {}
---@type Path
local paste_directory = nil
---@return Path
local function get_paste_directory()
if paste_directory then
return paste_directory
end
paste_directory = Path:new(Config.history.storage_path):joinpath("pasted_images")
return paste_directory
end
M.support_paste_image = Config.support_paste_image
M.setup = function()
get_paste_directory()
if not paste_directory:exists() then
paste_directory:mkdir({ parent = true })
end
if M.support_paste_image() and ImgClip == nil then
ImgClip = require("img-clip")
end
end
---@param line string
M.paste_image = function(line)
if not Config.support_paste_image() then
return false
end
return ImgClip.paste_image({
dir_path = paste_directory:absolute(),
prompt_for_file_name = false,
filetypes = {
AvanteInput = { url_encode_path = true, template = "\nimage: $FILE_PATH\n" },
},
}, line)
end
---@param filepath string
M.get_base64_content = function(filepath)
local os_mapping = Utils.get_os_name()
---@type vim.SystemCompleted
local output
if os_mapping == "darwin" or os_mapping == "linux" then
output = Utils.shell_run(("cat %s | base64 | tr -d '\n'"):format(filepath))
if output.code == 0 then
return output.stdout
else
error("Failed to convert image to base64")
end
else
Utils.warn("Windows is not supported yet", { title = "Avante" })
end
end
return M

View File

@ -1,85 +0,0 @@
local Utils = require("avante.utils")
---@class AvanteClipboard
local M = {}
---@alias DarwinClipboardCommand "pngpaste" | "osascript"
M.clip_cmd = nil
---@return DarwinClipboardCommand
M.get_clip_cmd = function()
if M.clip_cmd then
return M.clip_cmd
end
if vim.fn.executable("pngpaste") == 1 then
M.clip_cmd = "pngpaste"
elseif vim.fn.executable("osascript") == 1 then
M.clip_cmd = "osascript"
end
return M.clip_cmd
end
M.has_content = function()
local cmd = M.get_clip_cmd()
---@type vim.SystemCompleted
local output
if cmd == "pngpaste" then
output = Utils.shell_run("pngpaste -")
return output.code == 0
elseif cmd == "osascript" then
output = Utils.shell_run("osascript -e 'clipboard info'")
return output.code == 0 and output.stdout ~= nil and output.stdout:find("class PNGf") ~= nil
end
Utils.warn("Failed to validate clipboard content", { title = "Avante" })
return false
end
M.save_content = function(filepath)
local cmd = M.get_clip_cmd()
---@type vim.SystemCompleted
local output
if cmd == "pngpaste" then
output = Utils.shell_run(('pngpaste - > "%s"'):format(filepath))
return output.code == 0
elseif cmd == "osascript" then
output = Utils.shell_run(
string.format(
[[osascript -e 'set theFile to (open for access POSIX file "%s" with write permission)' ]]
.. [[-e 'try' -e 'write (the clipboard as «class PNGf») to theFile' -e 'end try' ]]
.. [[-e 'close access theFile' -e 'do shell script "cat %s > %s"']],
filepath,
filepath,
filepath
)
)
return output.code == 0
end
return false
end
M.get_base64_content = function()
local cmd = M.get_clip_cmd()
---@type vim.SystemCompleted
local output
if cmd == "pngpaste" then
output = Utils.shell_run("pngpaste - | base64 | tr -d '\n'")
if output.code == 0 then
return output.stdout
end
elseif cmd == "osascript" then
output = Utils.shell_run(
[[osascript -e 'set theFile to (open for access POSIX file "/tmp/image.png" with write permission)' -e 'try' -e 'write (the clipboard as «class PNGf») to theFile' -e 'end try' -e 'close access theFile'; ]]
.. [[cat /tmp/image.png | base64 | tr -d '\n']]
)
if output.code == 0 then
return output.stdout
end
end
error("Failed to get clipboard content")
end
return M

View File

@ -1,48 +0,0 @@
---NOTE: this module is inspired by https://github.com/HakonHarnes/img-clip.nvim/tree/main
---@see https://github.com/ekickx/clipboard-image.nvim/blob/main/lua/clipboard-image/paste.lua
local Path = require("plenary.path")
local Utils = require("avante.utils")
local Config = require("avante.config")
---@class AvanteClipboard
---@field clip_cmd string
---@field get_clip_cmd fun(): string
---@field has_content fun(): boolean
---@field get_base64_content fun(): string
---@field save_content fun(filename: string): boolean
---
---@class avante.Clipboard: AvanteClipboard
local M = {}
---@type Path
local paste_directory = nil
---@return Path
local function get_paste_directory()
if paste_directory then
return paste_directory
end
paste_directory = Path:new(Config.history.storage_path):joinpath("pasted_images")
return paste_directory
end
M.setup = function()
get_paste_directory()
if not paste_directory:exists() then
paste_directory:mkdir({ parent = true })
end
end
return setmetatable(M, {
__index = function(t, k)
local os_mapping = Utils.get_os_name()
---@type AvanteClipboard
local impl = require("avante.clipboard." .. os_mapping)
if impl[k] ~= nil then
return impl[k]
end
return t[k]
end,
})

View File

@ -1,74 +0,0 @@
local Utils = require("avante.utils")
---@class AvanteClipboard
local M = {}
M.clip_cmd = nil
M.get_clip_cmd = function()
if M.clip_cmd then
return M.clip_cmd
end
-- Wayland
if os.getenv("WAYLAND_DISPLAY") ~= nil and vim.fn.executable("wl-paste") == 1 then
M.clip_cmd = "wl-paste"
-- X11
elseif os.getenv("DISPLAY") ~= nil and vim.fn.executable("xclip") == 1 then
M.clip_cmd = "xclip"
end
return M.clip_cmd
end
M.has_content = function()
local cmd = M.get_clip_cmd()
---@type vim.SystemCompleted
local output
-- X11
if cmd == "xclip" then
output = Utils.shell_run("xclip -selection clipboard -t TARGETS -o")
return output.code == 0 and output.stdout:find("image/png") ~= nil
elseif cmd == "wl-paste" then
output = Utils.shell_run("wl-paste --list-types")
return output.code == 0 and output.stdout:find("image/png") ~= nil
end
Utils.warn("Failed to validate clipboard content", { title = "Avante" })
return false
end
M.save_content = function(filepath)
local cmd = M.get_clip_cmd()
---@type vim.SystemCompleted
local output
if cmd == "xclip" then
output = Utils.shell_run(('xclip -selection clipboard -o -t image/png > "%s"'):format(filepath))
return output.code == 0
elseif cmd == "wl-paste" then
output = Utils.shell_run(('wl-paste --type image/png > "%s"'):format(filepath))
return output.code == 0
end
return false
end
M.get_base64_content = function()
local cmd = M.get_clip_cmd()
---@type vim.SystemCompleted
local output
if cmd == "xclip" then
output = Utils.shell_run("xclip -selection clipboard -o -t image/png | base64 | tr -d '\n'")
if output.code == 0 then
return output.stdout
end
elseif cmd == "osascript" then
output = Utils.shell_run("wl-paste --type image/png | base64 | tr -d '\n'")
if output.code == 0 then
return output.stdout
end
end
error("Failed to get clipboard content")
end
return M

View File

@ -1,67 +0,0 @@
local Utils = require("avante.utils")
---@class AvanteClipboard
local M = {}
M.clip_cmd = nil
M.get_clip_cmd = function()
if M.clip_cmd then
return M.clip_cmd
end
if (vim.fn.has("win32") > 0 or vim.fn.has("wsl") > 0) and vim.fn.executable("powershell.exe") then
M.clip_cmd = "powershell.exe"
end
return M.clip_cmd
end
M.has_content = function()
local cmd = M.get_clip_cmd()
---@type vim.SystemCompleted
local output
if cmd == "powershell.exe" then
output =
Utils.shell_run("Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.Clipboard]::GetImage()")
return output.code == 0 and output.stdout:find("Width") ~= nil
end
Utils.warn("Failed to validate clipboard content", { title = "Avante" })
return false
end
M.save_content = function(filepath)
local cmd = M.get_clip_cmd()
---@type vim.SystemCompleted
local output
if cmd == "powershell.exe" then
output = Utils.shell_run(
("Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.Clipboard]::GetImage().Save('%s')"):format(
filepath
)
)
return output.code == 0
end
return false
end
M.get_base64_content = function()
local cmd = M.get_clip_cmd()
---@type vim.SystemCompleted
local output
if cmd == "powershell.exe" then
output = Utils.shell_run(
[[Add-Type -AssemblyName System.Windows.Forms; $ms = New-Object System.IO.MemoryStream;]]
.. [[ [System.Windows.Forms.Clipboard]::GetImage().Save($ms, [System.Drawing.Imaging.ImageFormat]::Png);]]
.. [[ [System.Convert]::ToBase64String($ms.ToArray())]]
)
if output.code == 0 then
return output.stdout:gsub("\r\n", ""):gsub("\n", ""):gsub("\r", "")
end
end
error("Failed to get clipboard content")
end
return M

View File

@ -1,6 +1,8 @@
---NOTE: user will be merged with defaults and ---NOTE: user will be merged with defaults and
---we add a default var_accessor for this table to config values. ---we add a default var_accessor for this table to config values.
---
local Utils = require("avante.utils")
---@class avante.CoreConfig: avante.Config ---@class avante.CoreConfig: avante.Config
local M = {} local M = {}
@ -65,7 +67,7 @@ M.defaults = {
---1. auto_apply_diff_after_generation: Whether to automatically apply diff after LLM response. ---1. auto_apply_diff_after_generation: Whether to automatically apply diff after LLM response.
--- This would simulate similar behaviour to cursor. Default to false. --- This would simulate similar behaviour to cursor. Default to false.
---2. auto_set_highlight_group: Whether to automatically set the highlight group for the current line. Default to true. ---2. auto_set_highlight_group: Whether to automatically set the highlight group for the current line. Default to true.
---3. support_paste_from_clipboard: Whether to support pasting image from clipboard. Note that we will override vim.paste for this. Default to false. ---3. support_paste_from_clipboard: Whether to support pasting image from clipboard. This will be determined automatically based whether img-clip is available or not.
behaviour = { behaviour = {
auto_set_highlight_group = true, auto_set_highlight_group = true,
auto_apply_diff_after_generation = false, auto_apply_diff_after_generation = false,
@ -151,7 +153,17 @@ M.hints = {}
---@param opts? avante.Config ---@param opts? avante.Config
function M.setup(opts) function M.setup(opts)
M.options = vim.tbl_deep_extend("force", M.defaults, opts or {}) M.options = vim.tbl_deep_extend(
"force",
M.defaults,
opts or {},
---@type avante.Config
{
behaviour = {
support_paste_from_clipboard = M.support_paste_image(),
},
}
)
M.diff = vim.tbl_deep_extend( M.diff = vim.tbl_deep_extend(
"force", "force",
@ -168,6 +180,14 @@ function M.setup(opts)
end end
end end
M.support_paste_image = function()
local supported = Utils.has("img-clip.nvim")
if not supported then
Utils.warn("img-clip.nvim is not installed. Pasting image will be disabled.", { once = true })
end
return supported
end
---@param opts? avante.Config ---@param opts? avante.Config
function M.override(opts) function M.override(opts)
opts = opts or {} opts = opts or {}

View File

@ -96,11 +96,27 @@ M.stream = function(question, code_lang, code_content, selected_content_content,
mode = mode or "planning" mode = mode or "planning"
local provider = Config.provider local provider = Config.provider
-- Check if the question contains an image path
local image_path = nil
local original_question = question
if question:match("image: ") then
local lines = vim.split(question, "\n")
for i, line in ipairs(lines) do
if line:match("^image: ") then
image_path = line:gsub("^image: ", "")
table.remove(lines, i)
original_question = table.concat(lines, "\n")
break
end
end
end
---@type AvantePromptOptions ---@type AvantePromptOptions
local code_opts = { local code_opts = {
base_prompt = mode == "planning" and planning_mode_prompt or editing_mode_prompt, base_prompt = mode == "planning" and planning_mode_prompt or editing_mode_prompt,
system_prompt = system_prompt, system_prompt = system_prompt,
question = question, question = original_question,
image_path = image_path,
code_lang = code_lang, code_lang = code_lang,
code_content = code_content, code_content = code_content,
selected_code_content = selected_content_content, selected_code_content = selected_content_content,
@ -188,13 +204,6 @@ M.stream = function(question, code_lang, code_content, selected_content_content,
on_complete("API request failed with status " .. result.status .. ". Body: " .. vim.inspect(result.body)) on_complete("API request failed with status " .. result.status .. ". Body: " .. vim.inspect(result.body))
end end
end) end)
else
vim.schedule(function()
if not completed then
completed = true
on_complete(nil)
end
end)
end end
active_job = nil active_job = nil
end, end,

View File

@ -39,22 +39,22 @@ M.parse_message = function(opts)
table.insert(message_content, selected_code_obj) table.insert(message_content, selected_code_obj)
end end
table.insert(message_content, { if Clipboard.support_paste_image() and opts.image_path then
type = "text",
text = string.format("<question>%s</question>", opts.question),
})
if Config.behaviour.support_paste_from_clipboard and Clipboard.has_content() then
table.insert(message_content, { table.insert(message_content, {
type = "image", type = "image",
source = { source = {
type = "base64", type = "base64",
media_type = "image/png", media_type = "image/png",
data = Clipboard.get_base64_content(), data = Clipboard.get_base64_content(opts.image_path),
}, },
}) })
end end
table.insert(message_content, {
type = "text",
text = string.format("<question>%s</question>", opts.question),
})
local user_prompt = opts.base_prompt local user_prompt = opts.base_prompt
local user_prompt_obj = { local user_prompt_obj = {

View File

@ -12,6 +12,7 @@ local Dressing = require("avante.ui.dressing")
---@field base_prompt AvanteBasePrompt ---@field base_prompt AvanteBasePrompt
---@field system_prompt AvanteSystemPrompt ---@field system_prompt AvanteSystemPrompt
---@field question string ---@field question string
---@field image_path? string
---@field code_lang string ---@field code_lang string
---@field code_content string ---@field code_content string
---@field selected_code_content? string ---@field selected_code_content? string

View File

@ -63,12 +63,12 @@ end
M.parse_message = function(opts) M.parse_message = function(opts)
---@type string | OpenAIMessage[] ---@type string | OpenAIMessage[]
local user_content local user_content
if Config.behaviour.support_paste_from_clipboard and Clipboard.has_content() then if Config.behaviour.support_paste_from_clipboard and opts.image_path then
user_content = {} user_content = {}
table.insert(user_content, { table.insert(user_content, {
type = "image_url", type = "image_url",
image_url = { image_url = {
url = "data:image/png;base64," .. Clipboard.get_base64_content(), url = "data:image/png;base64," .. Clipboard.get_base64_content(opts.image_path),
}, },
}) })
table.insert(user_content, { type = "text", text = M.get_user_message(opts) }) table.insert(user_content, { type = "text", text = M.get_user_message(opts) })