feat: history manager (#1644)

This commit is contained in:
yetone
2025-03-19 17:28:05 +08:00
committed by GitHub
parent f226df8348
commit 1c8cac1958
10 changed files with 271 additions and 51 deletions

View File

@@ -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")

View File

@@ -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"

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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, {

View File

@@ -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

View File

@@ -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" })