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:
Generated
+81
@@ -467,6 +467,15 @@ dependencies = [
|
|||||||
"cfg-if",
|
"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]]
|
[[package]]
|
||||||
name = "crossbeam-deque"
|
name = "crossbeam-deque"
|
||||||
version = "0.8.6"
|
version = "0.8.6"
|
||||||
@@ -528,6 +537,15 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "deranged"
|
||||||
|
version = "0.5.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||||
|
dependencies = [
|
||||||
|
"powerfmt",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.10.7"
|
version = "0.10.7"
|
||||||
@@ -1127,6 +1145,7 @@ dependencies = [
|
|||||||
"sqlx",
|
"sqlx",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"tracing-appender",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
@@ -1652,6 +1671,12 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-conv"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-derive"
|
name = "num-derive"
|
||||||
version = "0.4.2"
|
version = "0.4.2"
|
||||||
@@ -1876,6 +1901,12 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "powerfmt"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ppv-lite86"
|
name = "ppv-lite86"
|
||||||
version = "0.2.21"
|
version = "0.2.21"
|
||||||
@@ -2752,6 +2783,12 @@ version = "2.6.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symlink"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.117"
|
version = "2.0.117"
|
||||||
@@ -2860,6 +2897,37 @@ dependencies = [
|
|||||||
"zune-jpeg",
|
"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]]
|
[[package]]
|
||||||
name = "tinystr"
|
name = "tinystr"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
@@ -3014,6 +3082,19 @@ dependencies = [
|
|||||||
"tracing-core",
|
"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]]
|
[[package]]
|
||||||
name = "tracing-attributes"
|
name = "tracing-attributes"
|
||||||
version = "0.1.31"
|
version = "0.1.31"
|
||||||
|
|||||||
@@ -37,3 +37,4 @@ async-trait = "0.1"
|
|||||||
# 流式处理
|
# 流式处理
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
sqlx = { version = "0.8.6", features = ["runtime-tokio-native-tls", "postgres", "uuid", "chrono", "derive", "migrate"] }
|
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';
|
||||||
@@ -60,4 +60,7 @@ pub enum Commands {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
model: Option<String>,
|
model: Option<String>,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// 后台服务模式(等同 listen --llm,日志写入文件)
|
||||||
|
Service,
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-17
@@ -30,11 +30,8 @@ fn is_cjk(c: char) -> bool {
|
|||||||
/// 构建给 LLM 的消息数组(带 token budget 管理)
|
/// 构建给 LLM 的消息数组(带 token budget 管理)
|
||||||
///
|
///
|
||||||
/// 返回消息数组和是否需要摘要的信息
|
/// 返回消息数组和是否需要摘要的信息
|
||||||
pub async fn build_context(
|
pub async fn build_context(session: &Arc<Mutex<ChatSession>>, system_prompt: &str) -> Vec<Message> {
|
||||||
session: &Arc<Mutex<ChatSession>>,
|
let s = session.lock().await;
|
||||||
system_prompt: &str,
|
|
||||||
) -> Vec<Message> {
|
|
||||||
let mut s = session.lock().await;
|
|
||||||
|
|
||||||
// ── 1. 空闲超时检查(消息到达前由调用方检查)──
|
// ── 1. 空闲超时检查(消息到达前由调用方检查)──
|
||||||
// 这里只做构建,超时触发在上层
|
// 这里只做构建,超时触发在上层
|
||||||
@@ -74,10 +71,14 @@ pub async fn build_context(
|
|||||||
included.reverse();
|
included.reverse();
|
||||||
|
|
||||||
// 保护:确保最近一条用户消息始终在上下文中
|
// 保护:确保最近一条用户消息始终在上下文中
|
||||||
if let Some(last_user) = recent.iter().rev().find(|m| m.role == crate::llm::types::Role::User) {
|
if let Some(last_user) = recent
|
||||||
let already_included = included.iter().any(|m| {
|
.iter()
|
||||||
m.role == last_user.role && m.content == last_user.content
|
.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 {
|
if !already_included {
|
||||||
let t = estimate_tokens(last_user);
|
let t = estimate_tokens(last_user);
|
||||||
if used + tail_used + t <= s.token_budget + 2000 {
|
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]
|
let to_summarize: Vec<_> = s.messages[start..end]
|
||||||
.iter()
|
.iter()
|
||||||
.take(40) // 最多 40 条
|
.take(40) // 最多 40 条
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -124,11 +125,15 @@ pub async fn trigger_overflow_summary(
|
|||||||
|
|
||||||
s.summaries.push(super::types::SummaryEntry {
|
s.summaries.push(super::types::SummaryEntry {
|
||||||
checkpoint: prev_checkpoint,
|
checkpoint: prev_checkpoint,
|
||||||
text: summary_text,
|
text: summary_text.clone(),
|
||||||
reason: super::types::SummaryReason::Overflow,
|
reason: super::types::SummaryReason::Overflow,
|
||||||
created_at: chrono::Utc::now(),
|
created_at: chrono::Utc::now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 持久化到数据库
|
||||||
|
s.save_summary_to_db(&summary_text, "overflow", to_summarize.len() as i32)
|
||||||
|
.await;
|
||||||
|
|
||||||
s.checkpoint = end;
|
s.checkpoint = end;
|
||||||
|
|
||||||
// 清理太旧的消息(checkpoint 之前的保留最近 100 条用于调试)
|
// 清理太旧的消息(checkpoint 之前的保留最近 100 条用于调试)
|
||||||
@@ -163,11 +168,15 @@ pub async fn trigger_idle_summary(
|
|||||||
|
|
||||||
s.summaries.push(super::types::SummaryEntry {
|
s.summaries.push(super::types::SummaryEntry {
|
||||||
checkpoint: cp,
|
checkpoint: cp,
|
||||||
text: summary_text,
|
text: summary_text.clone(),
|
||||||
reason: super::types::SummaryReason::Timeout,
|
reason: super::types::SummaryReason::Timeout,
|
||||||
created_at: chrono::Utc::now(),
|
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();
|
s.checkpoint = s.messages.len();
|
||||||
|
|
||||||
// 清理旧消息(checkpoint 之前的全部清除)
|
// 清理旧消息(checkpoint 之前的全部清除)
|
||||||
@@ -204,9 +213,15 @@ async fn generate_summary(messages: &[Message], summarizer: Option<&Summarizer>)
|
|||||||
fn build_summary_prompt(messages: &[Message]) -> String {
|
fn build_summary_prompt(messages: &[Message]) -> String {
|
||||||
let convo: String = messages
|
let convo: String = messages
|
||||||
.iter()
|
.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| {
|
.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();
|
let content: String = m.content.chars().take(200).collect();
|
||||||
format!("{}: {}", role, content)
|
format!("{}: {}", role, content)
|
||||||
})
|
})
|
||||||
@@ -224,7 +239,9 @@ fn summarize_messages(messages: &[Message]) -> String {
|
|||||||
// 简单实现:提取用户消息的前 80 个字符作为摘要
|
// 简单实现:提取用户消息的前 80 个字符作为摘要
|
||||||
let lines: Vec<String> = messages
|
let lines: Vec<String> = messages
|
||||||
.iter()
|
.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| {
|
.map(|m| {
|
||||||
let role = match m.role {
|
let role = match m.role {
|
||||||
crate::llm::types::Role::User => "用户",
|
crate::llm::types::Role::User => "用户",
|
||||||
@@ -239,9 +256,16 @@ fn summarize_messages(messages: &[Message]) -> String {
|
|||||||
if lines.is_empty() {
|
if lines.is_empty() {
|
||||||
"暂无历史对话".to_string()
|
"暂无历史对话".to_string()
|
||||||
} else {
|
} else {
|
||||||
let result = format!("历史对话摘要({} 条消息):\n{}", lines.len(), lines.join("\n"));
|
let result = format!(
|
||||||
|
"历史对话摘要({} 条消息):\n{}",
|
||||||
|
lines.len(),
|
||||||
|
lines.join("\n")
|
||||||
|
);
|
||||||
if result.chars().count() > 2000 {
|
if result.chars().count() > 2000 {
|
||||||
format!("{}...(已截断)", result.chars().take(2000).collect::<String>())
|
format!(
|
||||||
|
"{}...(已截断)",
|
||||||
|
result.chars().take(2000).collect::<String>()
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,4 +3,3 @@ pub mod tools;
|
|||||||
pub mod types;
|
pub mod types;
|
||||||
|
|
||||||
pub use tools::MemoryStore;
|
pub use tools::MemoryStore;
|
||||||
pub use types::ChatSession;
|
|
||||||
|
|||||||
+30
-18
@@ -64,29 +64,41 @@ impl MemoryStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// read_summaries 工具:读取历史摘要
|
/// read_summaries 工具:读取历史摘要(内存 + 数据库)
|
||||||
pub async fn read_summaries(
|
pub async fn read_summaries(
|
||||||
session: &Arc<Mutex<super::types::ChatSession>>,
|
session: &Arc<Mutex<super::types::ChatSession>>,
|
||||||
) -> String {
|
) -> String {
|
||||||
let s = session.lock().await;
|
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()
|
"暂无历史摘要".to_string()
|
||||||
} else {
|
} else {
|
||||||
s.summaries
|
lines.join("\n---\n")
|
||||||
.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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use crate::llm::types::Message;
|
use crate::llm::types::Message;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
/// 摘要原因
|
/// 摘要原因
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
@@ -27,6 +28,8 @@ pub struct SummaryEntry {
|
|||||||
/// 聊天会话状态
|
/// 聊天会话状态
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ChatSession {
|
pub struct ChatSession {
|
||||||
|
pub user_id: String,
|
||||||
|
pub db_pool: Option<Arc<sqlx::PgPool>>,
|
||||||
/// 摘要总结点——此位置之前的消息已被压缩
|
/// 摘要总结点——此位置之前的消息已被压缩
|
||||||
pub checkpoint: usize,
|
pub checkpoint: usize,
|
||||||
/// 全部消息(checkpoint 之后的保持完整)
|
/// 全部消息(checkpoint 之后的保持完整)
|
||||||
@@ -44,6 +47,8 @@ pub struct ChatSession {
|
|||||||
impl Default for ChatSession {
|
impl Default for ChatSession {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
user_id: "default".to_string(),
|
||||||
|
db_pool: None,
|
||||||
checkpoint: 0,
|
checkpoint: 0,
|
||||||
messages: Vec::new(),
|
messages: Vec::new(),
|
||||||
summaries: Vec::new(),
|
summaries: Vec::new(),
|
||||||
@@ -108,4 +113,54 @@ impl ChatSession {
|
|||||||
pub fn latest_overflow_summary(&self) -> Option<&SummaryEntry> {
|
pub fn latest_overflow_summary(&self) -> Option<&SummaryEntry> {
|
||||||
self.summaries.iter().rev().find(|s| s.reason == SummaryReason::Overflow)
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,3 +191,38 @@ pub async fn query_llm_usage_stats(
|
|||||||
|
|
||||||
Ok(row)
|
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())
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -3,6 +3,7 @@ mod config;
|
|||||||
mod context;
|
mod context;
|
||||||
mod db;
|
mod db;
|
||||||
mod llm;
|
mod llm;
|
||||||
|
mod logger;
|
||||||
mod scheduler;
|
mod scheduler;
|
||||||
mod state;
|
mod state;
|
||||||
mod tools;
|
mod tools;
|
||||||
@@ -24,12 +25,9 @@ use wechat::client::WeChatClient;
|
|||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
tracing_subscriber::fmt()
|
let cli = Cli::parse();
|
||||||
.with_env_filter(
|
let is_service = matches!(cli.command, Commands::Service);
|
||||||
EnvFilter::try_from_default_env()
|
logger::init_logger(Some(".data/logs"), is_service);
|
||||||
.unwrap_or_else(|_| EnvFilter::new("info")),
|
|
||||||
)
|
|
||||||
.init();
|
|
||||||
|
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
|
|
||||||
@@ -54,8 +52,6 @@ async fn main() {
|
|||||||
// 文件状态管理器(降级方案)
|
// 文件状态管理器(降级方案)
|
||||||
let file_state = state::StateManager::new(&config.storage.state_dir);
|
let file_state = state::StateManager::new(&config.storage.state_dir);
|
||||||
|
|
||||||
let cli = Cli::parse();
|
|
||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Commands::Login { timeout } => {
|
Commands::Login { timeout } => {
|
||||||
cmd_login(timeout, &database, &file_state).await;
|
cmd_login(timeout, &database, &file_state).await;
|
||||||
@@ -79,6 +75,9 @@ async fn main() {
|
|||||||
Commands::Usage { since, until, model } => {
|
Commands::Usage { since, until, model } => {
|
||||||
cmd_usage(&database, since, until, model).await;
|
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
|
// 创建 Conversation
|
||||||
let mut conv = match Conversation::new(cfg) {
|
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());
|
info!("LLM 已初始化: {} / {}", c.provider_name(), c.model());
|
||||||
c
|
c
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user