Compare commits

...

8 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
vadiml 5cdf47fca8 Added short-term memory 2026-07-24 20:01:28 +03:00
vadiml 88ca4aecd2 Added readme & changed standard adress 2026-07-24 18:52:19 +03:00
vadiml fc06456db2 Small mistake 2026-07-24 18:41:41 +03:00
6 changed files with 1616 additions and 55 deletions
+4
View File
@@ -1 +1,5 @@
/target
config.toml
chat_history.db
chat_history.db-shm
chat_history.db-wal
Generated
+1155 -17
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -13,6 +13,10 @@ reqwest = { version = "0.12", features = ["json"] }
# Сериализация / Десериализация JSON
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.8"
# Парсинг аргументов CLI (флаги --session-id и т.д.)
clap = { version = "4.5", features = ["derive"] }
clap = { version = "4.5", features = ["derive"] }
# Асинхронная работа с SQLite
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "sqlite"] }
+13
View File
@@ -0,0 +1,13 @@
Как запускать и протестировать:
Запуск с дефолтными параметрами:
Bash
cargo run
Запуск с указанием конкретной сессии и своего URL (например, vLLM / llama.cpp / Ollama):
Bash
cargo run -- --session-id 12345 --api-url "http://localhost:8000/v1/chat/completions" --model "qwen2.5-coder"
Справка по доступным флагам:
Bash
cargo run -- --help
+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)
}
}
}
+397 -37
View File
@@ -1,44 +1,293 @@
mod config;
use config::AppConfig;
use clap::Parser;
use serde::{Deserialize, Serialize};
use serde_json::json;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use sqlx::{Pool, Sqlite};
use std::io::{self, Write};
use std::str::FromStr;
use tokio::io::{AsyncBufReadExt, BufReader};
/// CLI-утилита для взаимодействия с LLM в рамках указанной сессии
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Args {
/// Идентификатор сессии/беседы (на будущее для VK peer_id)
#[arg(short, long, default_value = "default_session")]
session_id: String,
#[arg(short, long)]
session_id: Option<String>,
/// URL OpenAI-совместимого эндпоинта (vLLM / llama.cpp / Ollama)
#[arg(
short,
long,
default_value = "http://localhost:11434/v1/chat/completions"
)]
api_url: String,
#[arg(long)]
api_url: Option<String>,
/// Название модели (для Ollama/vLLM)
#[arg(m_flag, short, long, default_value = "qwen2.5-coder")]
model: String,
#[arg(short, long)]
model: Option<String>,
#[arg(long)]
limit: Option<usize>,
#[arg(short, long, default_value = "config.toml")]
config: String,
#[arg(long, default_value = "sqlite:chat_history.db?mode=rwc")]
db_url: String,
}
/// Функция отправки Stateless-запроса к LLM
async fn send_llm_request(
#[derive(Serialize, Deserialize, Debug, Clone)]
struct ChatMessage {
role: String,
content: String,
}
/// Инициализация БД (режим WAL)
async fn init_db(db_url: &str) -> Result<Pool<Sqlite>, Box<dyn std::error::Error>> {
let options = SqliteConnectOptions::from_str(db_url)?
.create_if_missing(true)
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal);
let pool = SqlitePoolOptions::new()
.max_connections(10)
.connect_with(options)
.await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_messages_session_id_id
ON messages (session_id, id DESC);
"#,
)
.execute(&pool)
.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)
}
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,
user_message: &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": "user",
"content": user_message
}
"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(
pool: &Pool<Sqlite>,
session_id: &str,
role: &str,
content: &str,
) -> Result<(), sqlx::Error> {
sqlx::query("INSERT INTO messages (session_id, role, content) VALUES (?, ?, ?)")
.bind(session_id)
.bind(role)
.bind(content)
.execute(pool)
.await?;
Ok(())
}
/// Сохранение или обновление 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(
pool: &Pool<Sqlite>,
session_id: &str,
limit: usize,
) -> Result<Vec<ChatMessage>, sqlx::Error> {
let rows = sqlx::query_as::<_, (String, String)>(
r#"
SELECT role, content
FROM (
SELECT role, content, id
FROM messages
WHERE session_id = ?
ORDER BY id DESC
LIMIT ?
)
ORDER BY id ASC;
"#,
)
.bind(session_id)
.bind(limit as i64)
.fetch_all(pool)
.await?;
let history = rows
.into_iter()
.map(|(role, content)| ChatMessage { role, content })
.collect();
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(
client: &reqwest::Client,
api_url: &str,
model: &str,
system_prompt: Option<&str>,
summary: Option<&str>,
history: &[ChatMessage],
) -> 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!({
"model": model,
"messages": full_messages,
"temperature": 0.7
});
@@ -60,30 +309,47 @@ async fn send_llm_request(
}
#[tokio::main]
async fn main() {
async fn main() -> Result<(), Box<dyn std::error::Error>> {
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 db_pool = init_db(&args.db_url).await?;
let system_prompt = file_config.system_prompt;
println!("==================================================");
println!(" LLM CLI Agent (Stage 1)");
println!(" Session ID: {}", args.session_id);
println!(" API URL: {}", args.api_url);
println!(" Model: {}", args.model);
println!(" LLM CLI Agent with Memory & Config");
println!(" Session ID: {}", session_id);
println!(" API URL: {}", api_url);
println!(" Model: {}", model);
println!(" History: последние {} сообщений", history_limit);
println!(" Config: {}", args.config);
println!("==================================================");
println!("Введите сообщение и нажмите Enter. Для выхода наберите 'exit' или 'quit'.\n");
println!("Введите сообщение. Служебные команды: /summary, /flush, /help, /exit\n");
let stdin = tokio::io::stdin();
let mut reader = BufReader::new(stdin);
loop {
print!("[Session: {}] > ", args.session_id);
print!("[Session: {}] > ", session_id);
io::stdout().flush().unwrap();
let mut input = String::new();
let bytes_read = reader.read_line(&mut input).await.unwrap_or(0);
if bytes_read == 0 {
break; // EOF (Ctrl+D)
break;
}
let trimmed_input = input.trim();
@@ -92,23 +358,117 @@ async fn main() {
continue;
}
if trimmed_input.eq_ignore_ascii_case("exit")
|| trimmed_input.eq_ignore_ascii_case("quit")
{
println!("Завершение работы.");
break;
// Обработка встроенных команд
if trimmed_input.starts_with('/') {
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;
}
_ => {
println!("[System]: Неизвестная команда. Введите /help для списка команд.");
continue;
}
}
}
print!("[Ожидание ответа от LLM...]\r");
// 1. Сохраняем запрос юзера в историю
if let Err(e) = save_message(&db_pool, &session_id, "user", trimmed_input).await {
eprintln!("[Ошибка записи в БД]: {}", e);
continue;
}
// 2. Вычитываем историю и существующий summary
let history = match get_recent_history(&db_pool, &session_id, history_limit).await {
Ok(h) => h,
Err(e) => {
eprintln!("[Ошибка чтения из БД]: {}", e);
continue;
}
};
let mut summary = match get_session_summary(&db_pool, &session_id).await {
Ok(s) => s,
Err(e) => {
eprintln!("[Ошибка чтения summary из БД]: {}", e);
None
}
};
// 3. Авто-суммаризация: сжимаем контекст при накоплении >= 6 сообщений
if history.len() >= 6 {
print!("[Сжатие контекста в фоновую память...]\r");
io::stdout().flush().unwrap();
if let Ok(new_summary) =
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(&client, &args.api_url, &args.model, trimmed_input).await {
match send_llm_request_with_history(
&client,
&api_url,
&model,
system_prompt.as_deref(),
summary.as_deref(),
&history,
)
.await
{
Ok(reply) => {
println!("\r[LLM]: {}\n", reply);
print!("\r\x1B[2K");
println!("[LLM]: {}\n", reply);
if let Err(e) = save_message(&db_pool, &session_id, "assistant", &reply).await {
eprintln!("[Ошибка записи ответа в БД]: {}", e);
}
}
Err(e) => {
eprintln!("\r[Ошибка]: {}\n", e);
print!("\r\x1B[2K");
eprintln!("[Ошибка LLM]: {}\n", e);
}
}
}
Ok(())
}