diff --git a/lua/avante/api.lua b/lua/avante/api.lua index aa67ad0..6e8592a 100644 --- a/lua/avante/api.lua +++ b/lua/avante/api.lua @@ -223,6 +223,20 @@ end function M.select_model() require("avante.model_selector").open() end +function M.select_history() + require("avante.history_selector").open(vim.api.nvim_get_current_buf(), function(filename) + local Path = require("avante.path") + Path.history.save_latest_filename(vim.api.nvim_get_current_buf(), filename) + local sidebar = require("avante").get() + if not sidebar then + require("avante.api").ask() + sidebar = require("avante").get() + end + sidebar:update_content_with_history() + if not sidebar:is_open() then sidebar:open({}) end + end) +end + return setmetatable(M, { __index = function(t, k) local module = require("avante") diff --git a/lua/avante/config.lua b/lua/avante/config.lua index 2c3f98d..44856ae 100644 --- a/lua/avante/config.lua +++ b/lua/avante/config.lua @@ -412,6 +412,7 @@ M._defaults = { add_current = "ac", -- Add current buffer to selected files }, select_model = "a?", -- Select model command + select_history = "ah", -- Select history command }, windows = { ---@alias AvantePosition "right" | "left" | "top" | "bottom" | "smart" diff --git a/lua/avante/history_selector.lua b/lua/avante/history_selector.lua new file mode 100644 index 0000000..c05d758 --- /dev/null +++ b/lua/avante/history_selector.lua @@ -0,0 +1,85 @@ +local Utils = require("avante.utils") +local Path = require("avante.path") + +---@class avante.HistorySelector +local M = {} + +---@param history avante.ChatHistory +---@return table? +local function to_selector_item(history) + local timestamp = #history.entries > 0 and history.entries[#history.entries].timestamp or history.timestamp + return { + name = history.title .. " - " .. timestamp .. " (" .. #history.entries .. ")", + filename = history.filename, + } +end + +---@param bufnr integer +---@param cb fun(filename: string) +function M.open(bufnr, cb) + local selector_items = {} + + local histories = Path.history.list(bufnr) + + for _, history in ipairs(histories) do + table.insert(selector_items, to_selector_item(history)) + end + + if #selector_items == 0 then + Utils.warn("No models available in config") + return + end + + local has_telescope, _ = pcall(require, "telescope") + if has_telescope then + local pickers = require("telescope.pickers") + local finders = require("telescope.finders") + local previewers = require("telescope.previewers") + local actions = require("telescope.actions") + local action_state = require("telescope.actions.state") + local conf = require("telescope.config").values + pickers + .new({}, { + prompt_title = "Select Avante History", + finder = finders.new_table(vim.iter(selector_items):map(function(item) return item.name end):totable()), + sorter = conf.generic_sorter({}), + previewer = previewers.new_buffer_previewer({ + title = "Preview", + define_preview = function(self, entry) + if not entry then return end + local item = vim.iter(selector_items):find(function(item) return item.name == entry.value end) + if not item then return end + local history = Path.history.load(vim.api.nvim_get_current_buf(), item.filename) + local Sidebar = require("avante.sidebar") + local content = Sidebar.render_history_content(history) + vim.api.nvim_buf_set_lines(self.state.bufnr, 0, -1, false, vim.split(content or "", "\n")) + vim.api.nvim_set_option_value("filetype", "markdown", { buf = self.state.bufnr }) + end, + }), + attach_mappings = function(prompt_bufnr, map) + map("i", "", function() + local selection = action_state.get_selected_entry() + if selection then + actions.close(prompt_bufnr) + local item = vim.iter(selector_items):find(function(item) return item.name == selection.value end) + if not item then return end + cb(item.filename) + end + end) + return true + end, + }) + :find() + return + end + + vim.ui.select(selector_items, { + prompt = "Select Avante History:", + format_item = function(item) return item.name end, + }, function(choice) + if not choice then return end + cb(choice.filename) + end) +end + +return M diff --git a/lua/avante/init.lua b/lua/avante/init.lua index e807362..7c2c7da 100644 --- a/lua/avante/init.lua +++ b/lua/avante/init.lua @@ -137,6 +137,12 @@ function H.keymaps() function() require("avante.api").select_model() end, { desc = "avante: select model" } ) + Utils.safe_keymap_set( + "n", + Config.mappings.select_history, + function() require("avante.api").select_history() end, + { desc = "avante: select history" } + ) end if Config.behaviour.auto_suggestions then diff --git a/lua/avante/llm.lua b/lua/avante/llm.lua index d5bb742..eac1628 100644 --- a/lua/avante/llm.lua +++ b/lua/avante/llm.lua @@ -19,6 +19,44 @@ M.CANCEL_PATTERN = "AvanteLLMEscape" local group = api.nvim_create_augroup("avante_llm", { clear = true }) +---@param content AvanteLLMMessageContent +---@param cb fun(title: string | nil): nil +function M.summarize_chat_thread_title(content, cb) + local system_prompt = + [[Summarize the content as a title for the chat thread. The title should be a concise and informative summary of the conversation, capturing the main points and key takeaways. It should be no longer than 100 words and should be written in a clear and engaging style. The title should be suitable for use as the title of a chat thread on a messaging platform or other communication medium.]] + local response_content = "" + local provider = Providers[Config.memory_summary_provider or Config.provider] + M.curl({ + provider = provider, + prompt_opts = { + system_prompt = system_prompt, + messages = { + { role = "user", content = content }, + }, + }, + handler_opts = { + on_start = function(_) end, + on_chunk = function(chunk) + if not chunk then return end + response_content = response_content .. chunk + end, + on_stop = function(stop_opts) + if stop_opts.error ~= nil then + Utils.error(string.format("summarize failed: %s", vim.inspect(stop_opts.error))) + return + end + if stop_opts.reason == "complete" then + response_content = Utils.trim_think_content(response_content) + response_content = Utils.trim(response_content, { prefix = "\n", suffix = "\n" }) + response_content = Utils.trim(response_content, { prefix = '"', suffix = '"' }) + local title = response_content + cb(title) + end + end, + }, + }) +end + ---@param bufnr integer ---@param history avante.ChatHistory ---@param cb fun(memory: avante.ChatMemory | nil): nil diff --git a/lua/avante/model_selector.lua b/lua/avante/model_selector.lua index 76b7736..496d32b 100644 --- a/lua/avante/model_selector.lua +++ b/lua/avante/model_selector.lua @@ -34,7 +34,7 @@ function M.open() end vim.ui.select(models, { - prompt = "Select Model:", + prompt = "Select Avante Model:", format_item = function(item) return item.name end, }, function(choice) if not choice then return end diff --git a/lua/avante/path.lua b/lua/avante/path.lua index 5f5d3cb..06da432 100644 --- a/lua/avante/path.lua +++ b/lua/avante/path.lua @@ -1,6 +1,5 @@ local fn = vim.fn local Utils = require("avante.utils") -local LRUCache = require("avante.utils.lru_cache") local Path = require("plenary.path") local Scan = require("plenary.scandir") local Config = require("avante.config") @@ -10,8 +9,6 @@ local Config = require("avante.config") ---@field cache_path Path local P = {} -local history_file_cache = LRUCache:new(12) - ---@param bufnr integer | nil ---@return string dirname local function generate_project_dirname_in_storage(bufnr) @@ -25,71 +22,134 @@ local function generate_project_dirname_in_storage(bufnr) return tostring(Path:new("projects"):joinpath(dirname)) end +local function filepath_to_filename(filepath) return tostring(filepath):sub(tostring(filepath:parent()):len() + 2) end + -- History path local History = {} +function History.get_history_dir(bufnr) + local dirname = generate_project_dirname_in_storage(bufnr) + local history_dir = Path:new(Config.history.storage_path):joinpath(dirname):joinpath("history") + if not history_dir:exists() then history_dir:mkdir({ parents = true }) end + return history_dir +end + +function History.list(bufnr) + local history_dir = History.get_history_dir(bufnr) + local files = vim.fn.glob(tostring(history_dir:joinpath("*.json")), true, true) + local latest_filename = History.get_latest_filename(bufnr, false) + local res = {} + for _, filename in ipairs(files) do + if not filename:match("metadata.json") then + local filepath = Path:new(filename) + local content = filepath:read() + local history = vim.json.decode(content) + history.filename = filepath_to_filename(filepath) + table.insert(res, history) + end + end + --- sort by timestamp + --- sort by latest_filename + table.sort(res, function(a, b) + if a.filename == latest_filename then return true end + if b.filename == latest_filename then return false end + local timestamp_a = #a.entries > 0 and a.entries[#a.entries].timestamp or a.timestamp + local timestamp_b = #b.entries > 0 and b.entries[#b.entries].timestamp or b.timestamp + return timestamp_a > timestamp_b + end) + return res +end + -- Get a chat history file name given a buffer ---@param bufnr integer ---@param new boolean ---@return Path -function History.filepath(bufnr, new) - local dirname = generate_project_dirname_in_storage(bufnr) - local history_dir = Path:new(Config.history.storage_path):joinpath(dirname):joinpath("history") - if not history_dir:exists() then history_dir:mkdir({ parents = true }) end - local pattern = tostring(history_dir:joinpath("*.json")) - local files = vim.fn.glob(pattern, true, true) - local filename = #files .. ".json" - if #files > 0 and not new then filename = (#files - 1) .. ".json" end +function History.get_latest_filepath(bufnr, new) + local history_dir = History.get_history_dir(bufnr) + local filename = History.get_latest_filename(bufnr, new) return history_dir:joinpath(filename) end --- Returns the Path to the chat history file for the given buffer. ----@param bufnr integer ----@return Path -function History.get(bufnr) return History.filepath(bufnr, false) end +function History.get_filepath(bufnr, filename) + local history_dir = History.get_history_dir(bufnr) + return history_dir:joinpath(filename) +end + +function History.get_metadata_filepath(bufnr) + local history_dir = History.get_history_dir(bufnr) + return history_dir:joinpath("metadata.json") +end + +function History.get_latest_filename(bufnr, new) + local history_dir = History.get_history_dir(bufnr) + local filename + local metadata_filepath = History.get_metadata_filepath(bufnr) + if metadata_filepath:exists() and not new then + local metadata_content = metadata_filepath:read() + local metadata = vim.json.decode(metadata_content) + filename = metadata.latest_filename + end + if not filename or filename == "" then + local pattern = tostring(history_dir:joinpath("*.json")) + local files = vim.fn.glob(pattern, true, true) + filename = #files .. ".json" + if #files > 0 and not new then filename = (#files - 1) .. ".json" end + end + return filename +end + +function History.save_latest_filename(bufnr, filename) + local metadata_filepath = History.get_metadata_filepath(bufnr) + local metadata + if not metadata_filepath:exists() then + metadata = {} + else + local metadata_content = metadata_filepath:read() + metadata = vim.json.decode(metadata_content) + end + metadata.latest_filename = filename + metadata_filepath:write(vim.json.encode(metadata), "w") +end ---@param bufnr integer function History.new(bufnr) - local filepath = History.filepath(bufnr, true) + local filepath = History.get_latest_filepath(bufnr, true) + ---@type avante.ChatHistory local history = { title = "untitled", timestamp = tostring(os.date("%Y-%m-%d %H:%M:%S")), entries = {}, + filename = filepath_to_filename(filepath), } - filepath:write(vim.json.encode(history), "w") + return history end -- Loads the chat history for the given buffer. ---@param bufnr integer +---@param filename string? ---@return avante.ChatHistory -function History.load(bufnr) - local history_file = History.get(bufnr) - local cached_key = tostring(history_file:absolute()) - local cached_value = history_file_cache:get(cached_key) - if cached_value ~= nil then return cached_value end - ---@type avante.ChatHistory - local value = { - title = "untitled", - timestamp = tostring(os.date("%Y-%m-%d %H:%M:%S")), - entries = {}, - } - if history_file:exists() then - local content = history_file:read() - value = content ~= nil and vim.json.decode(content) or value +function History.load(bufnr, filename) + local history_filepath = filename and History.get_filepath(bufnr, filename) + or History.get_latest_filepath(bufnr, false) + if history_filepath:exists() then + local content = history_filepath:read() + if content ~= nil then + local history = vim.json.decode(content) + history.filename = filepath_to_filename(history_filepath) + return history + end end - history_file_cache:set(cached_key, value) - return value + return History.new(bufnr) end -- Saves the chat history for the given buffer. ---@param bufnr integer ---@param history avante.ChatHistory -History.save = vim.schedule_wrap(function(bufnr, history) - local history_file = History.get(bufnr) - local cached_key = tostring(history_file:absolute()) - history_file:write(vim.json.encode(history), "w") - history_file_cache:set(cached_key, history) -end) +History.save = function(bufnr, history) + local history_filepath = History.get_filepath(bufnr, history.filename) + history_filepath:write(vim.json.encode(history), "w") + History.save_latest_filename(bufnr, history.filename) +end P.history = History diff --git a/lua/avante/sidebar.lua b/lua/avante/sidebar.lua index a30eda4..d980e76 100644 --- a/lua/avante/sidebar.lua +++ b/lua/avante/sidebar.lua @@ -1991,7 +1991,7 @@ function Sidebar:update_content(content, opts) opts = vim.tbl_deep_extend("force", { focus = false, scroll = true, stream = false, callback = nil }, opts or {}) if not opts.ignore_history then local chat_history = Path.history.load(self.code.bufnr) - content = self:render_history_content(chat_history) .. "-------\n\n" .. content + content = self.render_history_content(chat_history) .. "-------\n\n" .. content end if opts.stream then local function scroll_to_bottom() @@ -2114,7 +2114,7 @@ end ---@param history avante.ChatHistory ---@return string -function Sidebar:render_history_content(history) +function Sidebar.render_history_content(history) local content = "" for idx, entry in ipairs(history.entries) do if entry.visible == false then goto continue end @@ -2143,8 +2143,9 @@ function Sidebar:render_history_content(history) return content end -function Sidebar:update_content_with_history(history) - local content = self:render_history_content(history) +function Sidebar:update_content_with_history() + self:reload_chat_history() + local content = self.render_history_content(self.chat_history) self:update_content(content, { ignore_history = true }) end @@ -2202,7 +2203,8 @@ function Sidebar:clear_history(args, cb) end function Sidebar:new_chat(args, cb) - Path.history.new(self.code.bufnr) + local history = Path.history.new(self.code.bufnr) + Path.history.save(self.code.bufnr, history) self:reload_chat_history() self:update_content( "New chat", @@ -2228,7 +2230,14 @@ function Sidebar:add_chat_history(message, options) reset_memory = false, visible = options.visible, }) - Path.history.save(self.code.bufnr, self.chat_history) + if self.chat_history.title == "untitled" then + Llm.summarize_chat_thread_title(message.content, function(title) + if title then self.chat_history.title = title end + Path.history.save(self.code.bufnr, self.chat_history) + end) + else + Path.history.save(self.code.bufnr, self.chat_history) + end end function Sidebar:reset_memory(args, cb) @@ -2247,7 +2256,7 @@ function Sidebar:reset_memory(args, cb) }) Path.history.save(self.code.bufnr, chat_history) self:reload_chat_history() - local history_content = self:render_history_content(chat_history) + local history_content = self.render_history_content(chat_history) self:update_content(history_content, { focus = false, scroll = true, @@ -2691,7 +2700,14 @@ function Sidebar:create_input_container(opts) selected_code = selected_code, tool_histories = stop_opts.tool_histories, }) - Path.history.save(self.code.bufnr, self.chat_history) + if self.chat_history.title == "untitled" then + Llm.summarize_chat_thread_title(request, function(title) + if title then self.chat_history.title = title end + Path.history.save(self.code.bufnr, self.chat_history) + end) + else + Path.history.save(self.code.bufnr, self.chat_history) + end end get_generate_prompts_options(request, true, function(generate_prompts_options) @@ -3033,8 +3049,6 @@ end ---@param opts AskOptions function Sidebar:render(opts) - local chat_history = Path.history.load(self.code.bufnr) - local function get_position() return (opts and opts.win and opts.win.position) and opts.win.position or calculate_config_window_position() end @@ -3073,7 +3087,7 @@ function Sidebar:render(opts) self:create_selected_files_container() - self:update_content_with_history(chat_history) + self:update_content_with_history() -- reset states when buffer is closed api.nvim_buf_attach(self.code.bufnr, false, { diff --git a/lua/avante/types.lua b/lua/avante/types.lua index 795118f..fde6116 100644 --- a/lua/avante/types.lua +++ b/lua/avante/types.lua @@ -389,6 +389,7 @@ vim.g.avante_login = vim.g.avante_login ---@field timestamp string ---@field entries avante.ChatHistoryEntry[] ---@field memory avante.ChatMemory | nil +---@field filename string --- ---@class avante.ChatMemory ---@field content string diff --git a/plugin/avante.lua b/plugin/avante.lua index 679eb4c..3eadc70 100644 --- a/plugin/avante.lua +++ b/plugin/avante.lua @@ -155,3 +155,4 @@ end, { }) cmd("ShowRepoMap", function() require("avante.repo_map").show() end, { desc = "avante: show repo map" }) cmd("Models", function() require("avante.model_selector").open() end, { desc = "avante: show models" }) +cmd("History", function() require("avante.api").select_history() end, { desc = "avante: show histories" })