Compare commits

...

5 Commits

Author SHA1 Message Date
vadiml d6b68929ca Added some comands to manipulate memory 2026-07-24 21:36:27 +03:00
vadiml d1e9a8e934 Added summary of user dialogue 2026-07-24 21:26:30 +03:00
vadiml 2cf553dfc9 DB changes 2026-07-24 20:43:32 +03:00
vadiml 6b77bab275 Mod .gitignore 2026-07-24 20:43:21 +03:00
vadiml d909f00f64 Added config file 2026-07-24 20:42:25 +03:00
8 changed files with 403 additions and 57 deletions
+4
View File
@@ -1 +1,5 @@
/target /target
config.toml
chat_history.db
chat_history.db-shm
chat_history.db-wal
Generated
+60
View File
@@ -1398,6 +1398,7 @@ dependencies = [
"serde_json", "serde_json",
"sqlx", "sqlx",
"tokio", "tokio",
"toml",
] ]
[[package]] [[package]]
@@ -1579,6 +1580,15 @@ dependencies = [
"zmij", "zmij",
] ]
[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "serde_urlencoded" name = "serde_urlencoded"
version = "0.7.1" version = "0.7.1"
@@ -2120,6 +2130,47 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "toml"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
"serde_spanned",
"toml_datetime",
"toml_edit",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [
"serde",
]
[[package]]
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap",
"serde",
"serde_spanned",
"toml_datetime",
"toml_write",
"winnow",
]
[[package]]
name = "toml_write"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]] [[package]]
name = "tower" name = "tower"
version = "0.5.3" version = "0.5.3"
@@ -2590,6 +2641,15 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "wit-bindgen" name = "wit-bindgen"
version = "0.57.1" version = "0.57.1"
+1
View File
@@ -13,6 +13,7 @@ reqwest = { version = "0.12", features = ["json"] }
# Сериализация / Десериализация JSON # Сериализация / Десериализация JSON
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
toml = "0.8"
# Парсинг аргументов CLI (флаги --session-id и т.д.) # Парсинг аргументов CLI (флаги --session-id и т.д.)
clap = { version = "4.5", features = ["derive"] } clap = { version = "4.5", features = ["derive"] }
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
+42
View File
@@ -0,0 +1,42 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AppConfig {
pub api_url: String,
pub model: String,
pub max_history_messages: usize, // сколько последних сообщений подтягивать из БД
pub system_prompt: Option<String>, // задел на системный промпт
}
impl Default for AppConfig {
fn default() -> Self {
Self {
api_url: "http://192.168.0.50:6969/v1/chat/completions".to_string(),
model: "qwen2.5-coder".to_string(),
max_history_messages: 10,
system_prompt: Some("You are a helpful programming assistant.".to_string()),
}
}
}
impl AppConfig {
pub fn load_or_create<P: AsRef<Path>>(path: P) -> Result<Self, Box<dyn std::error::Error>> {
let path = path.as_ref();
if path.exists() {
// Если файл есть — читаем
let content = fs::read_to_string(path)?;
let config: AppConfig = toml::from_str(&content)?;
Ok(config)
} else {
// Если файла нет — создаём дефолтный
let default_config = AppConfig::default();
let toml_string = toml::to_string_pretty(&default_config)?;
fs::write(path, toml_string)?;
println!("[Config]: Создан стандартный файл конфигурации {:?}", path);
Ok(default_config)
}
}
}
+294 -55
View File
@@ -1,3 +1,6 @@
mod config;
use config::AppConfig;
use clap::Parser; use clap::Parser;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
@@ -10,20 +13,21 @@ use tokio::io::{AsyncBufReadExt, BufReader};
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)] #[command(author, version, about, long_about = None)]
struct Args { struct Args {
#[arg(short, long, default_value = "default_session")] #[arg(short, long)]
session_id: String, session_id: Option<String>,
#[arg( #[arg(long)]
short, api_url: Option<String>,
long,
default_value = "http://192.168.0.50:6969/v1/chat/completions"
)]
api_url: String,
#[arg(short, long, default_value = "qwen2.5-coder")] #[arg(short, long)]
model: String, model: Option<String>,
#[arg(long)]
limit: Option<usize>,
#[arg(short, long, default_value = "config.toml")]
config: String,
/// Путь к файлу базы данных SQLite
#[arg(long, default_value = "sqlite:chat_history.db?mode=rwc")] #[arg(long, default_value = "sqlite:chat_history.db?mode=rwc")]
db_url: String, db_url: String,
} }
@@ -34,18 +38,17 @@ struct ChatMessage {
content: String, content: String,
} }
/// Инициализация БД: создание таблицы, индексов и включение режима WAL /// Инициализация БД (режим WAL)
async fn init_db(db_url: &str) -> Result<Pool<Sqlite>, Box<dyn std::error::Error>> { async fn init_db(db_url: &str) -> Result<Pool<Sqlite>, Box<dyn std::error::Error>> {
let options = SqliteConnectOptions::from_str(db_url)? let options = SqliteConnectOptions::from_str(db_url)?
.create_if_missing(true) .create_if_missing(true)
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal); // WAL режим для высокой параллельности .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal);
let pool = SqlitePoolOptions::new() let pool = SqlitePoolOptions::new()
.max_connections(10) // До 10 параллельных соединений к БД .max_connections(10)
.connect_with(options) .connect_with(options)
.await?; .await?;
// Создаем таблицу и индекс, если их нет
sqlx::query( sqlx::query(
r#" r#"
CREATE TABLE IF NOT EXISTS messages ( CREATE TABLE IF NOT EXISTS messages (
@@ -62,10 +65,107 @@ async fn init_db(db_url: &str) -> Result<Pool<Sqlite>, Box<dyn std::error::Error
.execute(&pool) .execute(&pool)
.await?; .await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS session_summaries (
session_id TEXT PRIMARY KEY,
summary TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
"#,
)
.execute(&pool)
.await?;
Ok(pool) Ok(pool)
} }
/// Сохранение сообщения в историю async fn clear_session_data(pool: &Pool<Sqlite>, session_id: &str) -> Result<(), sqlx::Error> {
let mut tx = pool.begin().await?;
// Удаляем сообщения сессии
sqlx::query("DELETE FROM messages WHERE session_id = ?")
.bind(session_id)
.execute(&mut *tx)
.await?;
// Удаляем сжатую память (summary)
sqlx::query("DELETE FROM session_summaries WHERE session_id = ?")
.bind(session_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
async fn summarize_context(
client: &reqwest::Client,
api_url: &str,
model: &str,
current_summary: Option<&str>,
messages_to_summarize: &[ChatMessage],
) -> Result<String, Box<dyn std::error::Error>> {
let mut prompt = String::new();
if let Some(old_sum) = current_summary {
prompt.push_str(&format!(
"УЖЕ ИЗВЕСТНАЯ ПАМЯТЬ О ПОЛЬЗОВАТЕЛЕ И ЗАДАЧАХ:\n{}\n\n",
old_sum
));
}
prompt.push_str("НОВЫЕ СООБЩЕНИЯ ДИАЛОГА:\n");
for msg in messages_to_summarize {
if msg.role == "user" {
prompt.push_str(&format!("User: {}\n", msg.content));
}
}
prompt.push_str(
r#"
ИНСТРУКЦИЯ ПО ОБНОВЛЕНИЮ ПАМЯТИ:
Проанализируй диалог и обнови накопительный профиль памяти.
ЧТО НУЖНО СОХРАНЯТЬ (ТОЛЬКО ИЗ СООБЩЕНИЙ ПОЛЬЗОВАТЕЛЯ / USER):
1. Личные данные пользователя (имя, возраст, профессия, хобби, предпочтения, ограничения).
2. Контекст задачи или темы (над чем работаем, текущие цели, формат ответа, который просит пользователь).
3. Любые точные вводные данные (числа, имена, даты, пароли/коды, списки, специфические термины).
КРИТИЧЕСКИ ВАЖНЫЕ ПРАВИЛА:
- ИГНОРИРУЙ справочные ответы Ассистента (Assistant). Не сохраняй в память факты, стихи, статьи или ответы на энциклопедические вопросы, которые сгенерировал Ассистент.
- Сохраняй ТОЛЬКО то, что сообщил сам Пользователь, либо явные решения/итоги, к которым пришли.
- Пиши максимально кратко, сжато, в виде маркированного списка.
- Если новые сообщения не содержат новых фактов о пользователе или его задаче — оставь существующую память без изменений.
НЕ ЗАПИСЫВАЙ:
О чем пользователь спрашивал или интересовался (например: НЕ пиши "Спрашивал про точки Лагранжа" или "Интересуется столицами"). Записывай только утвердительные факты о самом пользователе или данные.
"#,
);
let payload = json!({
"model": model,
"messages": [
{
"role": "system",
"content": "Ты — автономный модуль памяти ассистента. Твоя единственная цель — вытаскивать и структурировать личные данные, контекст задач и явные указания ПОЛЬЗОВАТЕЛЯ. Ты не ответишь на вопросы из чата, ты только ведешь блокнот фактов."
},
{"role": "user", "content": prompt}
],
"temperature": 0.0
});
let response = client.post(api_url).json(&payload).send().await?;
let res_body: serde_json::Value = response.json().await?;
if let Some(summary) = res_body["choices"][0]["message"]["content"].as_str() {
Ok(summary.to_string())
} else {
Err("Не удалось сгенерировать резюме".into())
}
}
/// Сохранение реплики
async fn save_message( async fn save_message(
pool: &Pool<Sqlite>, pool: &Pool<Sqlite>,
session_id: &str, session_id: &str,
@@ -82,11 +182,34 @@ async fn save_message(
Ok(()) Ok(())
} }
/// Выборка последних N (5) сообщений контекста в хронологическом порядке /// Сохранение или обновление summary для сессии (UPSERT)
async fn save_session_summary(
pool: &Pool<Sqlite>,
session_id: &str,
summary: &str,
) -> Result<(), sqlx::Error> {
sqlx::query(
r#"
INSERT INTO session_summaries (session_id, summary, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(session_id) DO UPDATE SET
summary = excluded.summary,
updated_at = CURRENT_TIMESTAMP;
"#,
)
.bind(session_id)
.bind(summary)
.execute(pool)
.await?;
Ok(())
}
/// Выборка последних N сообщений
async fn get_recent_history( async fn get_recent_history(
pool: &Pool<Sqlite>, pool: &Pool<Sqlite>,
session_id: &str, session_id: &str,
limit: i64, limit: usize,
) -> Result<Vec<ChatMessage>, sqlx::Error> { ) -> Result<Vec<ChatMessage>, sqlx::Error> {
let rows = sqlx::query_as::<_, (String, String)>( let rows = sqlx::query_as::<_, (String, String)>(
r#" r#"
@@ -98,11 +221,11 @@ async fn get_recent_history(
ORDER BY id DESC ORDER BY id DESC
LIMIT ? LIMIT ?
) )
ORDER BY id ASC ORDER BY id ASC;
"#, "#,
) )
.bind(session_id) .bind(session_id)
.bind(limit * 2) .bind(limit as i64)
.fetch_all(pool) .fetch_all(pool)
.await?; .await?;
@@ -114,16 +237,57 @@ async fn get_recent_history(
Ok(history) Ok(history)
} }
/// Отправка запроса с учетом накопленного контекста async fn get_session_summary(
pool: &Pool<Sqlite>,
session_id: &str,
) -> Result<Option<String>, sqlx::Error> {
let result = sqlx::query_as::<_, (String,)>(
"SELECT summary FROM session_summaries WHERE session_id = ?",
)
.bind(session_id)
.fetch_optional(pool)
.await?;
Ok(result.map(|(s,)| s))
}
/// Отправка запроса с контекстом в LLM
async fn send_llm_request_with_history( async fn send_llm_request_with_history(
client: &reqwest::Client, client: &reqwest::Client,
api_url: &str, api_url: &str,
model: &str, model: &str,
system_prompt: Option<&str>,
summary: Option<&str>,
history: &[ChatMessage], history: &[ChatMessage],
) -> Result<String, Box<dyn std::error::Error>> { ) -> Result<String, Box<dyn std::error::Error>> {
let mut full_messages = Vec::new();
// 1. Системный промпт
if let Some(prompt) = system_prompt {
if !prompt.trim().is_empty() {
full_messages.push(ChatMessage {
role: "system".to_string(),
content: prompt.to_string(),
});
}
}
// 2. Долгосрочная сжатая память (если есть)
if let Some(sum) = summary {
if !sum.trim().is_empty() {
full_messages.push(ChatMessage {
role: "system".to_string(),
content: format!("[Краткий контекст прошлых сообщений]: {}", sum),
});
}
}
// 3. Последние N реплик из истории
full_messages.extend_from_slice(history);
let payload = json!({ let payload = json!({
"model": model, "model": model,
"messages": history, // Передаем весь сохраненный контекст "messages": full_messages,
"temperature": 0.7 "temperature": 0.7
}); });
@@ -145,33 +309,40 @@ async fn send_llm_request_with_history(
} }
#[tokio::main] #[tokio::main]
async fn main() { async fn main() -> Result<(), Box<dyn std::error::Error>> {
let args = Args::parse(); let args = Args::parse();
// 1. Загружаем или создаём config.toml
let file_config = AppConfig::load_or_create(&args.config)?;
// 2. Объединяем аргументы CLI и значения из конфига
let api_url = args.api_url.unwrap_or(file_config.api_url);
let model = args.model.unwrap_or(file_config.model);
let history_limit = args.limit.unwrap_or(file_config.max_history_messages);
let session_id = args
.session_id
.unwrap_or_else(|| "default_session".to_string());
// 3. Клиент HTTP и подключение к БД
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let db_pool = init_db(&args.db_url).await?;
// 1. Инициализируем БД let system_prompt = file_config.system_prompt;
let db_pool = match init_db(&args.db_url).await {
Ok(pool) => pool,
Err(e) => {
eprintln!("Ошибка инициализации БД: {}", e);
return;
}
};
println!("=================================================="); println!("==================================================");
println!(" LLM CLI Agent with Memory (Stage 2)"); println!(" LLM CLI Agent with Memory & Config");
println!(" Session ID: {}", args.session_id); println!(" Session ID: {}", session_id);
println!(" API URL: {}", args.api_url); println!(" API URL: {}", api_url);
println!(" Model: {}", args.model); println!(" Model: {}", model);
println!(" Database: SQLite (WAL Mode)"); println!(" History: последние {} сообщений", history_limit);
println!(" Config: {}", args.config);
println!("=================================================="); println!("==================================================");
println!("Введите сообщение. Для выхода наберите 'exit' или 'quit'.\n"); println!("Введите сообщение. Служебные команды: /summary, /flush, /help, /exit\n");
let stdin = tokio::io::stdin(); let stdin = tokio::io::stdin();
let mut reader = BufReader::new(stdin); let mut reader = BufReader::new(stdin);
loop { loop {
print!("[Session: {}] > ", args.session_id); print!("[Session: {}] > ", session_id);
io::stdout().flush().unwrap(); io::stdout().flush().unwrap();
let mut input = String::new(); let mut input = String::new();
@@ -187,20 +358,57 @@ async fn main() {
continue; continue;
} }
if trimmed_input.eq_ignore_ascii_case("exit") || trimmed_input.eq_ignore_ascii_case("quit") // Обработка встроенных команд
{ if trimmed_input.starts_with('/') {
println!("Завершение работы."); match trimmed_input {
"/flush" => {
if let Err(e) = clear_session_data(&db_pool, &session_id).await {
println!("[System]: Ошибка при очистке базы: {}", e);
} else {
println!("[System]: История диалога и память сессии успешно очищены.");
}
continue;
}
"/summary" => {
match get_session_summary(&db_pool, &session_id).await {
Ok(Some(summary)) => println!(
"\n--- ТЕКУЩАЯ ПАМЯТЬ СЕССИИ ---\n{}\n-----------------------------",
summary
),
Ok(None) => println!("[System]: Память сессии пока пуста."),
Err(e) => println!("[System]: Ошибка при получении summary: {}", e),
}
continue;
}
"/help" => {
println!("\n--- ДОСТУПНЫЕ КОМАНДЫ ---");
println!(" /help - Показать это справочное сообщение");
println!(" /summary - Посмотреть текущую сжатую память (профиль)");
println!(" /flush - Полностью очистить историю и память сессии");
println!(" /exit - Завершить работу агента");
println!(" /quit - Завершить работу агента");
println!("-------------------------\n");
continue;
}
"/exit" | "/quit" => {
println!("[System]: Завершение сеанса. До связи!");
break; break;
} }
_ => {
println!("[System]: Неизвестная команда. Введите /help для списка команд.");
continue;
}
}
}
// 2. Сохраняем сообщение пользователя в БД // 1. Сохраняем запрос юзера в историю
if let Err(e) = save_message(&db_pool, &args.session_id, "user", trimmed_input).await { if let Err(e) = save_message(&db_pool, &session_id, "user", trimmed_input).await {
eprintln!("[Ошибка записи в БД]: {}", e); eprintln!("[Ошибка записи в БД]: {}", e);
continue; continue;
} }
// 3. Достаем последние 5 сообщений из БД (включая только что сохраненное) // 2. Вычитываем историю и существующий summary
let history = match get_recent_history(&db_pool, &args.session_id, 5).await { let history = match get_recent_history(&db_pool, &session_id, history_limit).await {
Ok(h) => h, Ok(h) => h,
Err(e) => { Err(e) => {
eprintln!("[Ошибка чтения из БД]: {}", e); eprintln!("[Ошибка чтения из БД]: {}", e);
@@ -208,21 +416,50 @@ async fn main() {
} }
}; };
print!( let mut summary = match get_session_summary(&db_pool, &session_id).await {
"[Запрос к LLM с контекстом ({} сообщ.)...]\r", Ok(s) => s,
history.len() Err(e) => {
); eprintln!("[Ошибка чтения summary из БД]: {}", e);
None
}
};
// 3. Авто-суммаризация: сжимаем контекст при накоплении >= 6 сообщений
if history.len() >= 6 {
print!("[Сжатие контекста в фоновую память...]\r");
io::stdout().flush().unwrap(); io::stdout().flush().unwrap();
// 4. Отправляем контекст в LLM if let Ok(new_summary) =
match send_llm_request_with_history(&client, &args.api_url, &args.model, &history).await { summarize_context(&client, &api_url, &model, summary.as_deref(), &history).await
{
if let Err(e) = save_session_summary(&db_pool, &session_id, &new_summary).await {
eprintln!("[Ошибка сохранения summary]: {}", e);
} else {
summary = Some(new_summary);
}
}
print!("\r\x1B[2K");
}
// 4. Отправляем запрос в LLM
print!("[Запрос к LLM (контекст {} сообщ.)...]\r", history.len());
io::stdout().flush().unwrap();
match send_llm_request_with_history(
&client,
&api_url,
&model,
system_prompt.as_deref(),
summary.as_deref(),
&history,
)
.await
{
Ok(reply) => { Ok(reply) => {
// \r - в начало, \x1B[2K - очистить всю текущую строку терминала
print!("\r\x1B[2K"); print!("\r\x1B[2K");
println!("[LLM]: {}\n", reply); println!("[LLM]: {}\n", reply);
if let Err(e) = save_message(&db_pool, &args.session_id, "assistant", &reply).await if let Err(e) = save_message(&db_pool, &session_id, "assistant", &reply).await {
{
eprintln!("[Ошибка записи ответа в БД]: {}", e); eprintln!("[Ошибка записи ответа в БД]: {}", e);
} }
} }
@@ -232,4 +469,6 @@ async fn main() {
} }
} }
} }
Ok(())
} }