feat: history manager (#1644)
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -412,6 +412,7 @@ M._defaults = {
|
||||
add_current = "<leader>ac", -- Add current buffer to selected files
|
||||
},
|
||||
select_model = "<leader>a?", -- Select model command
|
||||
select_history = "<leader>ah", -- Select history command
|
||||
},
|
||||
windows = {
|
||||
---@alias AvantePosition "right" | "left" | "top" | "bottom" | "smart"
|
||||
|
||||
85
lua/avante/history_selector.lua
Normal file
85
lua/avante/history_selector.lua
Normal file
@@ -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", "<CR>", 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" })
|
||||
|
||||
Reference in New Issue
Block a user