feat: 后台服务模式 + 日志滚动 + 修复 llm_usage 表兼容性

- 新增 logger.rs(tracing-appender,日滚日志,.data/logs/)
- service 子命令:等同 listen --llm 但日志同时写入文件
- systemd/ias.service 单元文件
- 迁移 0007:ALTER TABLE llm_usage ADD COLUMN IF NOT EXISTS
  修复与 iPet 旧表的结构冲突
- ias usage 命令可正常查询 Token 统计
- 日志滚动保留 7 天,自动删除旧文件
This commit is contained in:
2026-06-01 17:45:17 +08:00
parent b9de3665d9
commit 00002e4740
13 changed files with 341 additions and 45 deletions
Generated
+81
View File
@@ -467,6 +467,15 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-channel"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
@@ -528,6 +537,15 @@ dependencies = [
"zeroize",
]
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
dependencies = [
"powerfmt",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -1127,6 +1145,7 @@ dependencies = [
"sqlx",
"tokio",
"tracing",
"tracing-appender",
"tracing-subscriber",
"uuid",
]
@@ -1652,6 +1671,12 @@ dependencies = [
"zeroize",
]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-derive"
version = "0.4.2"
@@ -1876,6 +1901,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@@ -2752,6 +2783,12 @@ version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "symlink"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
[[package]]
name = "syn"
version = "2.0.117"
@@ -2860,6 +2897,37 @@ dependencies = [
"zune-jpeg",
]
[[package]]
name = "time"
version = "0.3.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [
"deranged",
"itoa",
"num-conv",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
[[package]]
name = "time-macros"
version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tinystr"
version = "0.8.3"
@@ -3014,6 +3082,19 @@ dependencies = [
"tracing-core",
]
[[package]]
name = "tracing-appender"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c"
dependencies = [
"crossbeam-channel",
"symlink",
"thiserror",
"time",
"tracing-subscriber",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
+1
View File
@@ -37,3 +37,4 @@ async-trait = "0.1"
# 流式处理
futures-util = "0.3"
sqlx = { version = "0.8.6", features = ["runtime-tokio-native-tls", "postgres", "uuid", "chrono", "derive", "migrate"] }
tracing-appender = "0.2.5"
@@ -0,0 +1,12 @@
-- 会话摘要持久化
CREATE TABLE IF NOT EXISTS session_summaries (
id SERIAL PRIMARY KEY,
user_id TEXT NOT NULL DEFAULT 'default',
summary_text TEXT NOT NULL,
reason TEXT NOT NULL DEFAULT 'overflow',
message_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_session_summaries_created
ON session_summaries (created_at DESC);
@@ -0,0 +1,11 @@
-- 修复 llm_usage 表:从 iPet 旧格式迁移到 iAs 新格式
-- 旧表有 kind/entry 字段,新表需要 prompt_tokens/completion_tokens 等
ALTER TABLE llm_usage ADD COLUMN IF NOT EXISTS prompt_tokens INTEGER NOT NULL DEFAULT 0;
ALTER TABLE llm_usage ADD COLUMN IF NOT EXISTS completion_tokens INTEGER NOT NULL DEFAULT 0;
ALTER TABLE llm_usage ADD COLUMN IF NOT EXISTS total_tokens INTEGER NOT NULL DEFAULT 0;
ALTER TABLE llm_usage ADD COLUMN IF NOT EXISTS cache_hit_tokens INTEGER NOT NULL DEFAULT 0;
ALTER TABLE llm_usage ADD COLUMN IF NOT EXISTS cache_miss_tokens INTEGER NOT NULL DEFAULT 0;
ALTER TABLE llm_usage ADD COLUMN IF NOT EXISTS user_id TEXT NOT NULL DEFAULT '';
ALTER TABLE llm_usage ADD COLUMN IF NOT EXISTS model TEXT NOT NULL DEFAULT '';
ALTER TABLE llm_usage ADD COLUMN IF NOT EXISTS provider TEXT NOT NULL DEFAULT 'deepseek';
+3
View File
@@ -60,4 +60,7 @@ pub enum Commands {
#[arg(long)]
model: Option<String>,
},
/// 后台服务模式(等同 listen --llm,日志写入文件)
Service,
}
+41 -17
View File
@@ -30,11 +30,8 @@ fn is_cjk(c: char) -> bool {
/// 构建给 LLM 的消息数组(带 token budget 管理)
///
/// 返回消息数组和是否需要摘要的信息
pub async fn build_context(
session: &Arc<Mutex<ChatSession>>,
system_prompt: &str,
) -> Vec<Message> {
let mut s = session.lock().await;
pub async fn build_context(session: &Arc<Mutex<ChatSession>>, system_prompt: &str) -> Vec<Message> {
let s = session.lock().await;
// ── 1. 空闲超时检查(消息到达前由调用方检查)──
// 这里只做构建,超时触发在上层
@@ -74,10 +71,14 @@ pub async fn build_context(
included.reverse();
// 保护:确保最近一条用户消息始终在上下文中
if let Some(last_user) = recent.iter().rev().find(|m| m.role == crate::llm::types::Role::User) {
let already_included = included.iter().any(|m| {
m.role == last_user.role && m.content == last_user.content
});
if let Some(last_user) = recent
.iter()
.rev()
.find(|m| m.role == crate::llm::types::Role::User)
{
let already_included = included
.iter()
.any(|m| m.role == last_user.role && m.content == last_user.content);
if !already_included {
let t = estimate_tokens(last_user);
if used + tail_used + t <= s.token_budget + 2000 {
@@ -112,7 +113,7 @@ pub async fn trigger_overflow_summary(
let to_summarize: Vec<_> = s.messages[start..end]
.iter()
.take(40) // 最多 40 条
.take(40) // 最多 40 条
.cloned()
.collect();
@@ -124,11 +125,15 @@ pub async fn trigger_overflow_summary(
s.summaries.push(super::types::SummaryEntry {
checkpoint: prev_checkpoint,
text: summary_text,
text: summary_text.clone(),
reason: super::types::SummaryReason::Overflow,
created_at: chrono::Utc::now(),
});
// 持久化到数据库
s.save_summary_to_db(&summary_text, "overflow", to_summarize.len() as i32)
.await;
s.checkpoint = end;
// 清理太旧的消息(checkpoint 之前的保留最近 100 条用于调试)
@@ -163,11 +168,15 @@ pub async fn trigger_idle_summary(
s.summaries.push(super::types::SummaryEntry {
checkpoint: cp,
text: summary_text,
text: summary_text.clone(),
reason: super::types::SummaryReason::Timeout,
created_at: chrono::Utc::now(),
});
let msg_count = cp as i32;
s.save_summary_to_db(&summary_text, "timeout", msg_count)
.await;
s.checkpoint = s.messages.len();
// 清理旧消息(checkpoint 之前的全部清除)
@@ -204,9 +213,15 @@ async fn generate_summary(messages: &[Message], summarizer: Option<&Summarizer>)
fn build_summary_prompt(messages: &[Message]) -> String {
let convo: String = messages
.iter()
.filter(|m| m.role == crate::llm::types::Role::User || m.role == crate::llm::types::Role::Assistant)
.filter(|m| {
m.role == crate::llm::types::Role::User || m.role == crate::llm::types::Role::Assistant
})
.map(|m| {
let role = if m.role == crate::llm::types::Role::User { "用户" } else { "助手" };
let role = if m.role == crate::llm::types::Role::User {
"用户"
} else {
"助手"
};
let content: String = m.content.chars().take(200).collect();
format!("{}: {}", role, content)
})
@@ -224,7 +239,9 @@ fn summarize_messages(messages: &[Message]) -> String {
// 简单实现:提取用户消息的前 80 个字符作为摘要
let lines: Vec<String> = messages
.iter()
.filter(|m| m.role == crate::llm::types::Role::User || m.role == crate::llm::types::Role::Assistant)
.filter(|m| {
m.role == crate::llm::types::Role::User || m.role == crate::llm::types::Role::Assistant
})
.map(|m| {
let role = match m.role {
crate::llm::types::Role::User => "用户",
@@ -239,9 +256,16 @@ fn summarize_messages(messages: &[Message]) -> String {
if lines.is_empty() {
"暂无历史对话".to_string()
} else {
let result = format!("历史对话摘要({} 条消息):\n{}", lines.len(), lines.join("\n"));
let result = format!(
"历史对话摘要({} 条消息):\n{}",
lines.len(),
lines.join("\n")
);
if result.chars().count() > 2000 {
format!("{}...(已截断)", result.chars().take(2000).collect::<String>())
format!(
"{}...(已截断)",
result.chars().take(2000).collect::<String>()
)
} else {
result
}
-1
View File
@@ -3,4 +3,3 @@ pub mod tools;
pub mod types;
pub use tools::MemoryStore;
pub use types::ChatSession;
+30 -18
View File
@@ -64,29 +64,41 @@ impl MemoryStore {
}
}
/// read_summaries 工具:读取历史摘要
/// read_summaries 工具:读取历史摘要(内存 + 数据库)
pub async fn read_summaries(
session: &Arc<Mutex<super::types::ChatSession>>,
) -> String {
let s = session.lock().await;
if s.summaries.is_empty() {
let mut lines: Vec<String> = Vec::new();
// 内存中的摘要
for sum in &s.summaries {
let reason = match sum.reason {
super::types::SummaryReason::Overflow => "上下文压缩",
super::types::SummaryReason::Timeout => "空闲超时",
};
lines.push(format!(
"[{}] {} (原因: {})",
sum.created_at.format("%Y-%m-%d %H:%M"),
sum.text,
reason,
));
}
// 数据库中的历史摘要
if let Some(pool) = &s.db_pool {
if let Ok(db_summaries) = crate::db::models::load_summaries(pool, 5).await {
for text in db_summaries {
if !lines.iter().any(|l| l.contains(&text.chars().take(30).collect::<String>())) {
lines.push(format!("[数据库存档] {}", text));
}
}
}
}
if lines.is_empty() {
"暂无历史摘要".to_string()
} else {
s.summaries
.iter()
.map(|sum| {
let reason = match sum.reason {
super::types::SummaryReason::Overflow => "上下文压缩",
super::types::SummaryReason::Timeout => "空闲超时",
};
format!(
"[{}] {} (原因: {})",
sum.created_at.format("%Y-%m-%d %H:%M"),
sum.text,
reason,
)
})
.collect::<Vec<_>>()
.join("\n---\n")
lines.join("\n---\n")
}
}
+55
View File
@@ -1,6 +1,7 @@
use crate::llm::types::Message;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
/// 摘要原因
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -27,6 +28,8 @@ pub struct SummaryEntry {
/// 聊天会话状态
#[derive(Debug, Clone)]
pub struct ChatSession {
pub user_id: String,
pub db_pool: Option<Arc<sqlx::PgPool>>,
/// 摘要总结点——此位置之前的消息已被压缩
pub checkpoint: usize,
/// 全部消息(checkpoint 之后的保持完整)
@@ -44,6 +47,8 @@ pub struct ChatSession {
impl Default for ChatSession {
fn default() -> Self {
Self {
user_id: "default".to_string(),
db_pool: None,
checkpoint: 0,
messages: Vec::new(),
summaries: Vec::new(),
@@ -108,4 +113,54 @@ impl ChatSession {
pub fn latest_overflow_summary(&self) -> Option<&SummaryEntry> {
self.summaries.iter().rev().find(|s| s.reason == SummaryReason::Overflow)
}
/// 从数据库加载最近的聊天记录到会话中
pub async fn load_recent_messages(&mut self, limit: i64) {
let pool = match &self.db_pool {
Some(p) => p.clone(),
None => return,
};
let rows = sqlx::query_as::<_, (String, String, String, String)>(
"SELECT direction, user_id, text, message_id FROM chat_records ORDER BY created_at DESC LIMIT $1",
)
.bind(limit)
.fetch_all(pool.as_ref())
.await;
if let Ok(rows) = rows {
let mut msgs: Vec<Message> = rows
.into_iter()
.rev() // 恢复时间顺序
.map(|(dir, _uid, text, _mid)| {
if dir == "inbound" {
Message::user(text)
} else {
Message::assistant(text)
}
})
.collect();
if !msgs.is_empty() {
tracing::info!("从数据库恢复 {} 条历史消息", msgs.len());
self.messages = msgs;
self.checkpoint = 0;
self.last_user_at = Some(Utc::now());
}
}
}
/// 保存摘要到数据库
pub async fn save_summary_to_db(&self, text: &str, reason: &str, message_count: i32) {
if let Some(pool) = &self.db_pool {
let _ = sqlx::query(
"INSERT INTO session_summaries (user_id, summary_text, reason, message_count) VALUES ($1, $2, $3, $4)",
)
.bind(&self.user_id)
.bind(text)
.bind(reason)
.bind(message_count)
.execute(pool.as_ref())
.await;
}
}
}
+35
View File
@@ -191,3 +191,38 @@ pub async fn query_llm_usage_stats(
Ok(row)
}
// ─── 会话摘要 ───
/// 保存一条摘要到数据库
pub async fn save_summary(
pool: &PgPool,
user_id: &str,
text: &str,
reason: &str,
message_count: i32,
) -> Result<(), String> {
sqlx::query(
"INSERT INTO session_summaries (user_id, summary_text, reason, message_count) VALUES ($1, $2, $3, $4)",
)
.bind(user_id)
.bind(text)
.bind(reason)
.bind(message_count)
.execute(pool)
.await
.map_err(|e| format!("保存摘要失败: {}", e))?;
Ok(())
}
/// 加载最近的摘要(用于 read_summaries 工具)
pub async fn load_summaries(pool: &PgPool, limit: i64) -> Result<Vec<String>, String> {
let rows = sqlx::query_as::<_, (String,)>(
"SELECT summary_text FROM session_summaries ORDER BY created_at DESC LIMIT $1",
)
.bind(limit)
.fetch_all(pool)
.await
.map_err(|e| format!("查询摘要失败: {}", e))?;
Ok(rows.into_iter().map(|(t,)| t).collect())
}
+38
View File
@@ -0,0 +1,38 @@
use std::path::PathBuf;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::EnvFilter;
pub fn init_logger(log_dir: Option<&str>, with_file: bool) {
let env_filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info"));
let terminal = tracing_subscriber::fmt::layer()
.with_target(false)
.with_ansi(true);
if with_file {
if let Some(dir) = log_dir {
let dir_path = PathBuf::from(dir);
std::fs::create_dir_all(&dir_path).ok();
let file_appender = tracing_appender::rolling::daily(&dir_path, "ias.log");
let file_layer = tracing_subscriber::fmt::layer()
.with_target(false)
.with_ansi(false)
.with_writer(file_appender);
tracing_subscriber::registry()
.with(env_filter)
.with(terminal)
.with(file_layer)
.init();
return;
}
}
tracing_subscriber::registry()
.with(env_filter)
.with(terminal)
.init();
}
+17 -9
View File
@@ -3,6 +3,7 @@ mod config;
mod context;
mod db;
mod llm;
mod logger;
mod scheduler;
mod state;
mod tools;
@@ -24,12 +25,9 @@ use wechat::client::WeChatClient;
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info")),
)
.init();
let cli = Cli::parse();
let is_service = matches!(cli.command, Commands::Service);
logger::init_logger(Some(".data/logs"), is_service);
dotenvy::dotenv().ok();
@@ -54,8 +52,6 @@ async fn main() {
// 文件状态管理器(降级方案)
let file_state = state::StateManager::new(&config.storage.state_dir);
let cli = Cli::parse();
match cli.command {
Commands::Login { timeout } => {
cmd_login(timeout, &database, &file_state).await;
@@ -79,6 +75,9 @@ async fn main() {
Commands::Usage { since, until, model } => {
cmd_usage(&database, since, until, model).await;
}
Commands::Service => {
cmd_listen(true, false, &database, &file_state, &config, &memory_store).await
}
}
}
@@ -258,7 +257,16 @@ async fn cmd_listen(
// 创建 Conversation
let mut conv = match Conversation::new(cfg) {
Ok(c) => {
Ok(mut c) => {
// 设置 db_pool 并加载历史消息
if let Some(db) = database {
let session = c.session();
{
let mut s = session.lock().await;
s.db_pool = Some(Arc::new(db.pool().clone()));
s.load_recent_messages(20).await;
}
}
info!("LLM 已初始化: {} / {}", c.provider_name(), c.model());
c
}
+17
View File
@@ -0,0 +1,17 @@
[Unit]
Description=iAs - 微信 AI 智能助手
After=network.target postgresql.service
[Service]
Type=simple
User=xiao
WorkingDirectory=/home/xiao/project/iAs
EnvironmentFile=/home/xiao/project/iAs/.env
ExecStart=/home/xiao/project/iAs/target/release/iAs service
Restart=on-failure
RestartSec=10
StandardOutput=null
StandardError=null
[Install]
WantedBy=multi-user.target