Compare commits
2 Commits
0299329fb2
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3476d8cbcb | |||
| 132089c8eb |
@@ -333,6 +333,33 @@ flowchart TB
|
||||
I --> J["发送给 LLM"]
|
||||
```
|
||||
|
||||
### 5.3.1 `/clear` 清空上下文流程
|
||||
|
||||
用户发送 `/clear` 指令时,主动截断上下文并把本次会话归档为摘要(按会话过期处理):
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
U(["📱 用户发送 /clear"]) --> K{"is_clear_command() 匹配?"}
|
||||
K -->|"否"| N(["走正常 LLM 对话流程"])
|
||||
K -->|"是"| L["加载历史(过滤掉 /clear 本身)"]
|
||||
L --> M{"历史非空?"}
|
||||
M -->|"否"| R2["回复: 上下文已是空的"]
|
||||
M -->|"是"| S["一次性 Conversation + summarizer"]
|
||||
S --> T["trigger_clear_summary() → LLM 生成摘要"]
|
||||
T --> DB[("session_summaries\nreason=clear")]
|
||||
DB --> D["丢弃该用户在途会话 (sessions.retain)"]
|
||||
D --> R1["回复确认 + 摘要预览"]
|
||||
R2 --> END(["📱 用户收到回复"])
|
||||
R1 --> END
|
||||
```
|
||||
|
||||
- **会话边界**:摘要的 `created_at` 作为边界,之后加载历史时通过
|
||||
`latest_session_boundary()` 过滤掉此前的消息(`/clear` 与 12h 空闲超时
|
||||
共用同一套边界机制)
|
||||
- **注入与否**:`/clear` 摘要**不会**注入下一次对话的 system prompt,
|
||||
只存档供 `read_summaries` 工具查询(与空闲超时摘要一致)
|
||||
- **在途会话**:`/clear` 会丢弃该用户尚未完成的工具往返,避免用旧上下文继续
|
||||
|
||||
---
|
||||
|
||||
## 6. 文件依赖矩阵
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- 为"按用户查询最近的会话边界(clear/timeout 摘要)"添加索引。
|
||||
-- list_recent_chat_records_for_context 会据此过滤掉已过期会话的消息。
|
||||
CREATE INDEX IF NOT EXISTS idx_session_summaries_user_created
|
||||
ON session_summaries (user_id, created_at DESC);
|
||||
+77
-3
@@ -260,13 +260,48 @@ pub async fn trigger_overflow_summary(
|
||||
/// 而是只保存到数据库,供 `read_summaries` 工具查询。
|
||||
///
|
||||
/// 生成摘要后会清空所有消息,checkpoint 重置为 0。
|
||||
/// 该摘要的 `created_at` 同时作为"会话边界"——之后加载历史时
|
||||
/// 不会包含此时间点之前的消息(见 `db::latest_session_boundary`)。
|
||||
pub async fn trigger_idle_summary(
|
||||
session: &Arc<Mutex<ChatSession>>,
|
||||
summarizer: Option<&Summarizer>,
|
||||
) {
|
||||
let _ = archive_session(session, summarizer, super::types::SummaryReason::Timeout, "timeout")
|
||||
.await;
|
||||
}
|
||||
|
||||
/// ## 触发清空上下文摘要(`/clear` 指令)
|
||||
///
|
||||
/// 用户发送 `/clear` 时调用。把当前会话消息交给 LLM 生成摘要并存档,
|
||||
/// 然后清空消息、重置 checkpoint,使会话"过期"。
|
||||
///
|
||||
/// 与空闲超时摘要一样:
|
||||
/// * 摘要**不会**注入到下一次对话,只存档供 `read_summaries` 查询
|
||||
/// * 摘要的 `created_at` 作为会话边界,之后加载历史不再包含此前的消息
|
||||
///
|
||||
/// 返回生成的摘要文本;若会话本就没有消息,返回 `None`。
|
||||
pub async fn trigger_clear_summary(
|
||||
session: &Arc<Mutex<ChatSession>>,
|
||||
summarizer: Option<&Summarizer>,
|
||||
) -> Option<String> {
|
||||
archive_session(session, summarizer, super::types::SummaryReason::Clear, "clear").await
|
||||
}
|
||||
|
||||
/// 归档当前会话:生成摘要 → 存库 → 清空消息 → 重置 checkpoint。
|
||||
///
|
||||
/// `trigger_idle_summary` 与 `trigger_clear_summary` 共用此逻辑,
|
||||
/// 仅 `reason` 不同(Timeout / Clear),二者都代表"会话过期"。
|
||||
///
|
||||
/// 返回生成的摘要文本;若会话没有消息可归档,返回 `None`。
|
||||
async fn archive_session(
|
||||
session: &Arc<Mutex<ChatSession>>,
|
||||
summarizer: Option<&Summarizer>,
|
||||
reason: super::types::SummaryReason,
|
||||
reason_str: &str,
|
||||
) -> Option<String> {
|
||||
let mut s = session.lock().await;
|
||||
if s.messages.is_empty() {
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
|
||||
let summary_text = generate_summary(&s.messages, summarizer).await;
|
||||
@@ -275,12 +310,12 @@ pub async fn trigger_idle_summary(
|
||||
s.summaries.push(super::types::SummaryEntry {
|
||||
checkpoint: cp,
|
||||
text: summary_text.clone(),
|
||||
reason: super::types::SummaryReason::Timeout,
|
||||
reason,
|
||||
created_at: chrono::Utc::now(),
|
||||
});
|
||||
|
||||
let msg_count = cp as i32;
|
||||
s.save_summary_to_db(&summary_text, "timeout", msg_count)
|
||||
s.save_summary_to_db(&summary_text, reason_str, msg_count)
|
||||
.await;
|
||||
|
||||
s.checkpoint = s.messages.len();
|
||||
@@ -296,6 +331,8 @@ pub async fn trigger_idle_summary(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(summary_text)
|
||||
}
|
||||
|
||||
/// 生成摘要:优先使用 LLM,不可用时回退到简单截断
|
||||
@@ -431,4 +468,41 @@ mod tests {
|
||||
assert!(has_anchor);
|
||||
assert!(has_preface);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trigger_clear_summary_archives_and_clears() {
|
||||
use crate::context::types::SummaryReason;
|
||||
|
||||
let mut session = ChatSession::new();
|
||||
session.add_user("今天天气怎么样".to_string());
|
||||
session.add_assistant("北京今天晴,25度".to_string());
|
||||
session.add_user("明天呢".to_string());
|
||||
session.add_assistant("明天多云转小雨".to_string());
|
||||
|
||||
let arc = Arc::new(Mutex::new(session));
|
||||
let summarizer: Summarizer = Arc::new(|_prompt: String| {
|
||||
Box::pin(async { Ok("摘要:讨论了北京近两天天气".to_string()) })
|
||||
});
|
||||
|
||||
let summary = trigger_clear_summary(&arc, Some(&summarizer)).await;
|
||||
|
||||
assert!(summary.is_some());
|
||||
assert_eq!(summary.unwrap(), "摘要:讨论了北京近两天天气");
|
||||
let s = arc.lock().await;
|
||||
assert!(s.messages.is_empty(), "清空后消息应被清空");
|
||||
assert_eq!(s.checkpoint, 0);
|
||||
assert!(s
|
||||
.summaries
|
||||
.iter()
|
||||
.any(|x| x.reason == SummaryReason::Clear));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trigger_clear_summary_empty_session_returns_none() {
|
||||
let arc = Arc::new(Mutex::new(ChatSession::new()));
|
||||
let summarizer: Summarizer =
|
||||
Arc::new(|_p: String| Box::pin(async { Ok("x".to_string()) }));
|
||||
let summary = trigger_clear_summary(&arc, Some(&summarizer)).await;
|
||||
assert!(summary.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
//! 2. AI 回复 → `add_assistant()` → 存入 `messages`
|
||||
//! 3. Token 超 budget → `trigger_overflow_summary()` → 压缩 checkpoint 前的消息
|
||||
//! 4. 12h 空闲 → `trigger_idle_summary()` → 生成摘要,清空消息
|
||||
//! 5. `/clear` 指令 → `trigger_clear_summary()` → 生成摘要归档,会话过期
|
||||
|
||||
use crate::llm::types::Message;
|
||||
use chrono::{DateTime, Utc};
|
||||
@@ -30,15 +31,18 @@ pub struct HistoryEntry {
|
||||
|
||||
/// ## 摘要原因
|
||||
///
|
||||
/// 区分两种触发摘要的场景:
|
||||
/// 区分触发摘要的场景:
|
||||
/// * `Overflow` — 上下文 token 总数超过预算(token budget),触发压缩
|
||||
/// * `Timeout` — 距上条用户消息超过 12 小时空闲,触发会话总结
|
||||
/// * `Clear` — 用户发送 `/clear` 主动清空上下文,触发会话过期归档
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum SummaryReason {
|
||||
/// 上下文 token 超 budget 触发
|
||||
Overflow,
|
||||
/// 距上条用户消息超过 12 小时触发
|
||||
Timeout,
|
||||
/// 用户发送 `/clear` 主动清空上下文触发
|
||||
Clear,
|
||||
}
|
||||
|
||||
/// ## 一条摘要记录
|
||||
@@ -77,6 +81,7 @@ pub struct SummaryEntry {
|
||||
/// 2. AI 回复 → `add_assistant()` → 存入 `messages`
|
||||
/// 3. Token 超 budget → `trigger_overflow_summary()` → 压缩 checkpoint 前的消息
|
||||
/// 4. 12h 空闲 → `trigger_idle_summary()` → 生成摘要,清空消息
|
||||
/// 5. `/clear` 指令 → `trigger_clear_summary()` → 生成摘要归档,会话过期
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChatSession {
|
||||
pub user_id: String,
|
||||
|
||||
+174
-11
@@ -39,6 +39,7 @@
|
||||
//! 采用统一的单进程 tokio task 架构。旧版 daemon+worker 分离模式(UDS IPC)已在 v2 中移除。
|
||||
|
||||
use crate::channel::ChannelId;
|
||||
use crate::context::builder;
|
||||
use crate::context::HistoryEntry;
|
||||
use crate::context::MemoryStore;
|
||||
use crate::db::Database;
|
||||
@@ -59,6 +60,10 @@ use tokio::sync::Mutex;
|
||||
use tracing::{error, info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// LLM Consumer 的会话缓存:`correlation_id → (Conversation, 创建时间, user_id)`。
|
||||
/// 额外记录 `user_id` 是为了在 `/clear` 时能按用户丢弃尚未完成的在途会话。
|
||||
type SessionMap = HashMap<Uuid, (Conversation, Instant, String)>;
|
||||
|
||||
/// ## Daemon 上下文 —— 消费者之间共享的全部状态
|
||||
///
|
||||
/// 这个结构体被所有三个消费者(LLM / Tool / Send)通过 `Arc` 共享访问。
|
||||
@@ -194,7 +199,7 @@ pub async fn run(database: &Arc<Database>, memory_store: &Arc<MemoryStore>) {
|
||||
sessions_for_clean
|
||||
.lock()
|
||||
.await
|
||||
.retain(|_, (conv, created)| {
|
||||
.retain(|_, (conv, created, _)| {
|
||||
let ttl_secs = if conv.has_pending_async() { 1800 } else { 300 };
|
||||
now.duration_since(*created).as_secs() < ttl_secs
|
||||
});
|
||||
@@ -270,9 +275,9 @@ fn spawn_consumers(
|
||||
enqueue: EnqueueHandle,
|
||||
) -> (
|
||||
Vec<tokio::task::JoinHandle<()>>,
|
||||
Arc<Mutex<HashMap<Uuid, (Conversation, Instant)>>>,
|
||||
Arc<Mutex<SessionMap>>,
|
||||
) {
|
||||
let sessions: Arc<Mutex<HashMap<Uuid, (Conversation, Instant)>>> =
|
||||
let sessions: Arc<Mutex<SessionMap>> =
|
||||
Arc::new(Mutex::new(HashMap::new()));
|
||||
|
||||
let mut handles = Vec::new();
|
||||
@@ -312,7 +317,7 @@ async fn llm_consumer_loop(
|
||||
rx: &mut tokio::sync::mpsc::Receiver<PipelineMessage>,
|
||||
ctx: &DaemonCtx,
|
||||
enqueue: &EnqueueHandle,
|
||||
sessions: &Arc<Mutex<HashMap<Uuid, (Conversation, Instant)>>>,
|
||||
sessions: &Arc<Mutex<SessionMap>>,
|
||||
) {
|
||||
while let Some(msg) = rx.recv().await {
|
||||
let user_id = msg.channel.user_id.clone();
|
||||
@@ -324,6 +329,19 @@ async fn llm_consumer_loop(
|
||||
message_id: _,
|
||||
context_token,
|
||||
} => {
|
||||
// /clear 指令:截断上下文 + 生成摘要 + 按会话过期处理
|
||||
if is_clear_command(&text) {
|
||||
handle_clear_command(
|
||||
ctx,
|
||||
enqueue,
|
||||
sessions,
|
||||
&user_id,
|
||||
correlation_id,
|
||||
context_token,
|
||||
)
|
||||
.await;
|
||||
continue;
|
||||
}
|
||||
// 加载数据
|
||||
ctx.memory_store.load_if_needed(&user_id).await;
|
||||
let history = load_history(&ctx.db, &user_id).await;
|
||||
@@ -428,7 +446,7 @@ async fn llm_consumer_loop(
|
||||
sessions
|
||||
.lock()
|
||||
.await
|
||||
.insert(correlation_id, (conv, Instant::now()));
|
||||
.insert(correlation_id, (conv, Instant::now(), user_id.clone()));
|
||||
|
||||
// 执行 LLM 对话
|
||||
run_llm_round(
|
||||
@@ -452,7 +470,7 @@ async fn llm_consumer_loop(
|
||||
// 从缓存恢复会话,注入工具结果并恢复 LLM 循环
|
||||
let should_resume = {
|
||||
let mut map = sessions.lock().await;
|
||||
if let Some((conv, last_access)) = map.get_mut(&correlation_id) {
|
||||
if let Some((conv, last_access, _)) = map.get_mut(&correlation_id) {
|
||||
conv.session().lock().await.add_tool_result(
|
||||
&tool_call_id,
|
||||
&tool_name,
|
||||
@@ -502,13 +520,13 @@ async fn run_llm_round(
|
||||
correlation_id: Uuid,
|
||||
context_token: Option<String>,
|
||||
text: &str,
|
||||
sessions: &Arc<Mutex<HashMap<Uuid, (Conversation, Instant)>>>,
|
||||
sessions: &Arc<Mutex<SessionMap>>,
|
||||
enqueue: &EnqueueHandle,
|
||||
) {
|
||||
let result = {
|
||||
let map = sessions.lock().await;
|
||||
let conv = match map.get(&correlation_id) {
|
||||
Some((c, _)) => c,
|
||||
Some((c, _, _)) => c,
|
||||
None => {
|
||||
error!(target: "ias::err","[llm-session] conv不在缓存");
|
||||
return;
|
||||
@@ -545,13 +563,13 @@ async fn resume_llm_round(
|
||||
user_id: &str,
|
||||
correlation_id: Uuid,
|
||||
context_token: Option<String>,
|
||||
sessions: &Arc<Mutex<HashMap<Uuid, (Conversation, Instant)>>>,
|
||||
sessions: &Arc<Mutex<SessionMap>>,
|
||||
enqueue: &EnqueueHandle,
|
||||
) {
|
||||
let result = {
|
||||
let map = sessions.lock().await;
|
||||
let conv = match map.get(&correlation_id) {
|
||||
Some((c, _)) => c,
|
||||
Some((c, _, _)) => c,
|
||||
None => {
|
||||
error!(target: "ias::err","[llm-resume] conv不在缓存");
|
||||
return;
|
||||
@@ -589,7 +607,7 @@ async fn handle_chat_result(
|
||||
correlation_id: Uuid,
|
||||
context_token: Option<String>,
|
||||
result: ChatResult,
|
||||
sessions: &Arc<Mutex<HashMap<Uuid, (Conversation, Instant)>>>,
|
||||
sessions: &Arc<Mutex<SessionMap>>,
|
||||
enqueue: &EnqueueHandle,
|
||||
) {
|
||||
if result.has_pending_async {
|
||||
@@ -617,6 +635,111 @@ async fn handle_chat_result(
|
||||
sessions.lock().await.remove(&correlation_id);
|
||||
}
|
||||
|
||||
// ─── /clear 指令处理 ───
|
||||
|
||||
/// 判断文本是否为清空上下文指令 `/clear`(大小写不敏感,允许前后空白)。
|
||||
fn is_clear_command(text: &str) -> bool {
|
||||
text.trim().eq_ignore_ascii_case("/clear")
|
||||
}
|
||||
|
||||
/// ## 处理 `/clear` 指令 —— 截断上下文 + 生成摘要 + 会话过期
|
||||
///
|
||||
/// 用户发送 `/clear` 时:
|
||||
/// 1. 加载当前历史,用 LLM 生成会话摘要并归档(reason = clear)
|
||||
/// 2. 摘要的 `created_at` 成为会话边界,之后加载历史不再包含此前的消息
|
||||
/// 3. 丢弃该用户尚未完成的在途会话(按会话过期处理)
|
||||
/// 4. 回复用户清空确认(附带摘要预览)
|
||||
async fn handle_clear_command(
|
||||
ctx: &DaemonCtx,
|
||||
enqueue: &EnqueueHandle,
|
||||
sessions: &Arc<Mutex<SessionMap>>,
|
||||
user_id: &str,
|
||||
correlation_id: Uuid,
|
||||
context_token: Option<String>,
|
||||
) {
|
||||
info!(target: "ias::msg", "[clear] {} 请求清空上下文", user_id);
|
||||
|
||||
// 1. 生成摘要:用一次性 Conversation 提供 summarizer + session
|
||||
// 过滤掉历史里的 /clear 指令本身,避免污染摘要
|
||||
let history: Vec<HistoryEntry> = load_history(&ctx.db, user_id)
|
||||
.await
|
||||
.into_iter()
|
||||
.filter(|h| !is_clear_command(&h.content))
|
||||
.collect();
|
||||
|
||||
let summary = if history.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let cfg = ConversationConfig {
|
||||
system_prompt: String::new(),
|
||||
model: ctx.model.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
match Conversation::new(cfg) {
|
||||
Ok(conv) => {
|
||||
{
|
||||
let s = conv.session();
|
||||
let mut s = s.lock().await;
|
||||
s.db_pool = Some(Arc::new(ctx.db.pool().clone()));
|
||||
s.load_from_history(&history);
|
||||
s.current_user_id = user_id.to_string();
|
||||
}
|
||||
let summarizer = conv.summarizer();
|
||||
builder::trigger_clear_summary(&conv.session(), Some(&summarizer)).await
|
||||
}
|
||||
Err(e) => {
|
||||
error!(target: "ias::err", "[clear] 初始化摘要器失败: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 2. 丢弃该用户在途的会话(按会话过期处理)
|
||||
sessions
|
||||
.lock()
|
||||
.await
|
||||
.retain(|_, (_, _, uid)| uid.as_str() != user_id);
|
||||
|
||||
// 3. 回复确认(附带摘要预览)
|
||||
let reply = build_clear_reply(&summary);
|
||||
|
||||
let msg = PipelineMessage::llm_reply_with_source(
|
||||
ChannelId::wechat(user_id),
|
||||
correlation_id,
|
||||
&reply,
|
||||
"clear",
|
||||
context_token,
|
||||
);
|
||||
enqueue.enqueue(msg).await;
|
||||
}
|
||||
|
||||
/// 根据 `trigger_clear_summary` 的返回值构建 `/clear` 回复文案。
|
||||
///
|
||||
/// * `None` — 无历史可清空
|
||||
/// * `Some("")` — 已清空但 LLM 摘要为空
|
||||
/// * `Some(text)` — 已清空,附带 300 字预览
|
||||
fn build_clear_reply(summary: &Option<String>) -> String {
|
||||
match summary {
|
||||
None => "🧹 上下文已是空的,无需清空。".to_string(),
|
||||
Some(text) if text.is_empty() => "🧹 已清空上下文,本次对话已归档。".to_string(),
|
||||
Some(text) => {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let (preview, suffix) = if chars.len() > 300 {
|
||||
(
|
||||
chars[..300].iter().collect::<String>(),
|
||||
"\n…(摘要已截断预览,完整内容已归档)",
|
||||
)
|
||||
} else {
|
||||
(text.clone(), "")
|
||||
};
|
||||
format!(
|
||||
"🧹 已清空上下文,本次对话已归档为摘要。\n\n📝 摘要预览:\n{}{}",
|
||||
preview, suffix
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tool Consumer ───
|
||||
|
||||
async fn tool_consumer_loop(
|
||||
@@ -964,3 +1087,43 @@ async fn load_summaries(db: &Arc<Database>, user_id: &str) -> Vec<String> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn clear_command_detection() {
|
||||
assert!(is_clear_command("/clear"));
|
||||
assert!(is_clear_command(" /clear "));
|
||||
assert!(is_clear_command("/CLEAR"));
|
||||
assert!(is_clear_command("/Clear"));
|
||||
assert!(is_clear_command("\t/clear\n"));
|
||||
// 非精确匹配不应触发
|
||||
assert!(!is_clear_command("/clear all"));
|
||||
assert!(!is_clear_command("请/clear"));
|
||||
assert!(!is_clear_command("清空"));
|
||||
assert!(!is_clear_command(""));
|
||||
assert!(!is_clear_command("/clea"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_reply_branches() {
|
||||
// 无历史可清空
|
||||
assert_eq!(build_clear_reply(&None), "🧹 上下文已是空的,无需清空。");
|
||||
// 已清空但摘要为空(不应误报“已是空的”)
|
||||
assert_eq!(
|
||||
build_clear_reply(&Some(String::new())),
|
||||
"🧹 已清空上下文,本次对话已归档。"
|
||||
);
|
||||
// 正常摘要:附带预览
|
||||
let r = build_clear_reply(&Some("讨论了天气".to_string()));
|
||||
assert!(r.starts_with("🧹 已清空上下文,本次对话已归档为摘要。"));
|
||||
assert!(r.contains("讨论了天气"));
|
||||
// 超长摘要:截断预览
|
||||
let long = "x".repeat(500);
|
||||
let r = build_clear_reply(&Some(long));
|
||||
assert!(r.contains("摘要已截断预览"));
|
||||
assert!(!r.contains(&"x".repeat(500)));
|
||||
}
|
||||
}
|
||||
|
||||
+37
-2
@@ -138,22 +138,31 @@ pub async fn insert_chat_record(
|
||||
}
|
||||
|
||||
/// 查询最近可进入 LLM 上下文的聊天记录(排除工具等待/审批等中间提示)。
|
||||
///
|
||||
/// 会自动应用"会话边界":最近一次 `/clear` 或 12h 空闲超时摘要之前的消息
|
||||
/// 已归档为摘要,不再进入新会话的上下文(见 [`latest_session_boundary`])。
|
||||
pub async fn list_recent_chat_records_for_context(
|
||||
pool: &PgPool,
|
||||
user_id: &str,
|
||||
limit: i64,
|
||||
) -> Result<Vec<ChatRecord>, String> {
|
||||
// 会话边界:最近一次"会话过期"摘要(/clear 或 12h 空闲超时)的时间点。
|
||||
// 边界之前的消息已归档为摘要,不再进入新会话的上下文。
|
||||
let boundary = latest_session_boundary(pool, user_id).await;
|
||||
|
||||
let records = sqlx::query_as::<_, ChatRecord>(
|
||||
r#"
|
||||
SELECT id, created_at, direction, user_id, account_id, text, source, context_token, message_id
|
||||
FROM chat_records
|
||||
WHERE user_id = $1
|
||||
AND source NOT IN ('llm_pending_tool', 'tool_approval')
|
||||
AND source NOT IN ('llm_pending_tool', 'tool_approval', 'clear')
|
||||
AND created_at > COALESCE($2, '-infinity'::timestamptz)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2
|
||||
LIMIT $3
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(boundary)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
@@ -162,6 +171,32 @@ pub async fn list_recent_chat_records_for_context(
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
/// ## 查询用户最近的"会话边界"时间点
|
||||
///
|
||||
/// 返回最近一次会话过期摘要(`/clear` 或 12h 空闲超时)的 `created_at`。
|
||||
/// 该时间点之前的聊天记录已归档为摘要,不应再进入新会话的上下文。
|
||||
///
|
||||
/// `Overflow` 摘要不构成边界(它发生在会话进行中,仅压缩早期消息)。
|
||||
pub async fn latest_session_boundary(
|
||||
pool: &PgPool,
|
||||
user_id: &str,
|
||||
) -> Option<DateTime<Utc>> {
|
||||
sqlx::query_as::<_, (DateTime<Utc>,)>(
|
||||
r#"
|
||||
SELECT created_at
|
||||
FROM session_summaries
|
||||
WHERE user_id = $1 AND reason IN ('clear', 'timeout')
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.ok()?
|
||||
.map(|(t,)| t)
|
||||
}
|
||||
|
||||
// ─── LLM 用量 ───
|
||||
|
||||
/// LLM 用量聚合统计
|
||||
|
||||
@@ -478,7 +478,10 @@ pub const DEFAULT_SYSTEM_PROMPT: &str = "\
|
||||
1. `query_capabilities` — 查询所有可用能力工具\n\
|
||||
2. `call_capability` — 调用能力工具,参数 {\"name\":\"工具名\",\"prompt\":{\"参数\":\"值\"}}\n\
|
||||
\n\
|
||||
流程:收到消息 → query_capabilities → call_capability → 回复\n\
|
||||
流程:收到消息 → query_capabilities → call_capability → 回复
|
||||
\
|
||||
\
|
||||
需要创建或修改本地工具时:先用 `tool_inspector`(action=read_tool)查看现有实现,再用 `tool_manager` 修改。`tool_manager` 是高风险操作,执行前会请求用户确认。\n\
|
||||
";
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
Generated
+105
@@ -0,0 +1,105 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.150"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tool_inspector"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "tool_inspector"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[[bin]]
|
||||
name = "tool_inspector"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1.0"
|
||||
@@ -0,0 +1,17 @@
|
||||
name: tool_inspector
|
||||
desc: 只读查看本地工具的源码、Cargo.toml 和 spec 配置。在用 tool_manager 修改某工具前,先用本工具查看其当前实现,避免盲目覆盖丢失逻辑。低风险,无需审批
|
||||
type: stdio
|
||||
tool: tool_inspector
|
||||
path: /target/release/tool_inspector
|
||||
risk_level: low
|
||||
timeout_secs: 10
|
||||
params:
|
||||
- name: action
|
||||
required: true
|
||||
desc: 操作类型:list(列出所有工具及文件构成)、read_tool(读取某工具的全部 Cargo.toml/specs/src 源码)、read_file(读取工具目录下单个文件)
|
||||
- name: tool_name
|
||||
required: false
|
||||
desc: 工具名称(read_tool/read_file 必填),只允许字母、数字、下划线和短横线
|
||||
- name: file
|
||||
required: false
|
||||
desc: read_file 时的相对路径,如 src/main.rs、Cargo.toml、specs/weather.tool.yaml;必须是相对路径,禁止 .. 和 target 目录
|
||||
@@ -0,0 +1,270 @@
|
||||
//! tool_inspector — 只读查看本地工具的源码与配置
|
||||
//!
|
||||
//! 用途:在用 tool_manager 修改工具前,先用本工具查看当前实现,避免盲目覆盖丢失逻辑。
|
||||
//!
|
||||
//! 安全约束:
|
||||
//! - 纯只读:不写文件、不执行命令、不联网
|
||||
//! - tool_name 与相对路径严格校验,禁止路径穿越
|
||||
//! - 跳过 target/ 构建产物目录
|
||||
//! - 低风险(risk_level: low),无需用户审批
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use std::fs;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
fn main() {
|
||||
let input: Value = match serde_json::from_reader(std::io::stdin()) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return result(&format!("读取输入失败: {e}")),
|
||||
};
|
||||
let params = &input["params"];
|
||||
let action = params["action"].as_str().unwrap_or("");
|
||||
|
||||
let out = match run(action, params) {
|
||||
Ok(s) => s,
|
||||
Err(e) => format!("查看失败: {e}"),
|
||||
};
|
||||
result(&out);
|
||||
}
|
||||
|
||||
fn run(action: &str, params: &Value) -> Result<String, String> {
|
||||
let tools_root = locate_tools_root()?;
|
||||
match action {
|
||||
"list" => list_tools(&tools_root),
|
||||
"read_tool" => {
|
||||
let name = required_str(params, "tool_name")?;
|
||||
validate_tool_name(name)?;
|
||||
read_tool(&tools_root, name)
|
||||
}
|
||||
"read_file" => {
|
||||
let name = required_str(params, "tool_name")?;
|
||||
validate_tool_name(name)?;
|
||||
let file = required_str(params, "file")?;
|
||||
read_file(&tools_root, name, file)
|
||||
}
|
||||
_ => Err("action 必须是 list、read_tool 或 read_file".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 列出 tools 目录下所有工具及其文件构成
|
||||
fn list_tools(tools_root: &Path) -> Result<String, String> {
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
for entry in fs::read_dir(tools_root).map_err(|e| e.to_string())? {
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
// 跳过产物目录与隐藏目录
|
||||
if name == "bin" || name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
// 只有含 specs/src/Cargo.toml 之一的目录才视为工具
|
||||
if path.join("specs").exists()
|
||||
|| path.join("src").exists()
|
||||
|| path.join("Cargo.toml").exists()
|
||||
{
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
names.sort();
|
||||
|
||||
if names.is_empty() {
|
||||
return Ok("tools 目录下没有工具".into());
|
||||
}
|
||||
|
||||
let mut out = format!("本地工具列表({} 个):\n", names.len());
|
||||
for n in &names {
|
||||
let dir = tools_root.join(n);
|
||||
let mark = |has: bool| if has { "✓" } else { "✗" };
|
||||
out.push_str(&format!(
|
||||
"• {n} (src:{} specs:{} Cargo.toml:{})\n",
|
||||
mark(dir.join("src").exists()),
|
||||
mark(dir.join("specs").exists()),
|
||||
mark(dir.join("Cargo.toml").exists()),
|
||||
));
|
||||
}
|
||||
out.push_str("\n用 read_tool 查看某工具的全部源码与配置,或用 read_file 读取单个文件。");
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// 一次性读取某工具的 Cargo.toml + 所有 specs + src 下所有 .rs 文件
|
||||
fn read_tool(tools_root: &Path, name: &str) -> Result<String, String> {
|
||||
let tool_dir = tools_root.join(name);
|
||||
if !tool_dir.exists() {
|
||||
return Err(format!("工具 {name} 不存在"));
|
||||
}
|
||||
let mut out = String::new();
|
||||
|
||||
if let Ok(c) = fs::read_to_string(tool_dir.join("Cargo.toml")) {
|
||||
out.push_str(&format!("===== {name}/Cargo.toml =====\n{c}\n\n"));
|
||||
}
|
||||
|
||||
// specs/*.tool.yaml(可能有多个文件),按文件名排序
|
||||
let specs_dir = tool_dir.join("specs");
|
||||
if specs_dir.exists() {
|
||||
let mut specs: Vec<PathBuf> = fs::read_dir(&specs_dir)
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.filter(|p| matches!(p.extension().and_then(|e| e.to_str()), Some("yaml" | "yml")))
|
||||
.collect();
|
||||
specs.sort();
|
||||
for spec in specs {
|
||||
let rel = spec.strip_prefix(&tool_dir).unwrap_or(&spec).display().to_string();
|
||||
match fs::read_to_string(&spec) {
|
||||
Ok(c) => out.push_str(&format!("===== {name}/{rel} =====\n{c}\n\n")),
|
||||
Err(e) => out.push_str(&format!("===== {name}/{rel} =====\n(读取失败: {e})\n\n")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// src 下所有 .rs 文件(递归,支持多模块工具)
|
||||
let mut src_files: Vec<PathBuf> = Vec::new();
|
||||
collect_rs_files(&tool_dir.join("src"), &mut src_files)?;
|
||||
if src_files.is_empty() {
|
||||
out.push_str(&format!("({name} 无 src/*.rs 文件)\n"));
|
||||
} else {
|
||||
src_files.sort();
|
||||
for f in src_files {
|
||||
let rel = f.strip_prefix(&tool_dir).unwrap_or(&f).display().to_string();
|
||||
match fs::read_to_string(&f) {
|
||||
Ok(c) => out.push_str(&format!("===== {name}/{rel} =====\n{c}\n\n")),
|
||||
Err(e) => out.push_str(&format!("===== {name}/{rel} =====\n(读取失败: {e})\n\n")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if out.is_empty() {
|
||||
return Err(format!("工具 {name} 没有可读的源码或配置"));
|
||||
}
|
||||
Ok(out.trim_end().to_string())
|
||||
}
|
||||
|
||||
/// 读取工具目录下指定的单个文件(严格防路径穿越)
|
||||
fn read_file(tools_root: &Path, name: &str, file: &str) -> Result<String, String> {
|
||||
let tool_dir = tools_root.join(name);
|
||||
if !tool_dir.exists() {
|
||||
return Err(format!("工具 {name} 不存在"));
|
||||
}
|
||||
let rel = sanitize_rel_path(file)?;
|
||||
let target = tool_dir.join(&rel);
|
||||
|
||||
// 规范化后必须仍在 tool_dir 内(双重防穿越)
|
||||
let canonical_tool = tool_dir.canonicalize().map_err(|e| e.to_string())?;
|
||||
let canonical_target = target
|
||||
.canonicalize()
|
||||
.map_err(|_| format!("文件不存在: {file}"))?;
|
||||
if !canonical_target.starts_with(&canonical_tool) {
|
||||
return Err("路径越界,禁止访问工具目录之外的文件".into());
|
||||
}
|
||||
if canonical_target.is_dir() {
|
||||
return Err(format!("{file} 是目录,请用 read_tool 查看或指定具体文件"));
|
||||
}
|
||||
let content = fs::read_to_string(&canonical_target)
|
||||
.map_err(|e| format!("读取 {file} 失败: {e}"))?;
|
||||
Ok(format!("===== {name}/{file} =====\n{content}"))
|
||||
}
|
||||
|
||||
/// 递归收集 .rs 文件,跳过隐藏目录
|
||||
fn collect_rs_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), String> {
|
||||
if !dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
for entry in fs::read_dir(dir).map_err(|e| e.to_string())? {
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
if !entry.file_name().to_string_lossy().starts_with('.') {
|
||||
collect_rs_files(&path, out)?;
|
||||
}
|
||||
} else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 校验相对路径:禁止绝对路径、禁止 ..、禁止 target 构建产物
|
||||
fn sanitize_rel_path(file: &str) -> Result<PathBuf, String> {
|
||||
if file.is_empty() {
|
||||
return Err("file 不能为空".into());
|
||||
}
|
||||
let p = PathBuf::from(file);
|
||||
for comp in p.components() {
|
||||
match comp {
|
||||
Component::Normal(_) | Component::CurDir => {}
|
||||
Component::ParentDir => return Err("file 禁止包含 ..".into()),
|
||||
Component::RootDir | Component::Prefix(_) => {
|
||||
return Err("file 必须是相对路径".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
if p.starts_with("target") {
|
||||
return Err("禁止读取 target 构建产物目录".into());
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
fn validate_tool_name(name: &str) -> Result<(), String> {
|
||||
if name.is_empty() {
|
||||
return Err("缺少 tool_name".into());
|
||||
}
|
||||
if !name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||
{
|
||||
return Err("tool_name 只允许字母、数字、下划线和短横线".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn required_str<'a>(params: &'a Value, key: &str) -> Result<&'a str, String> {
|
||||
params[key]
|
||||
.as_str()
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| format!("缺少参数 {key}"))
|
||||
}
|
||||
|
||||
/// 定位 tools 根目录。
|
||||
///
|
||||
/// 产物可能位于两处之一:
|
||||
/// - `tools/bin/tool_inspector`(统一产物目录,registry 优先使用)
|
||||
/// - `tools/tool_inspector/target/release/tool_inspector`(构建产物)
|
||||
///
|
||||
/// 因此不依赖固定向上层数,而是从 exe 向上找第一个“含 bin/ 子目录且至少含一个
|
||||
/// 工具子目录”的祖先,即为 tools 根。对两种位置均正确。
|
||||
fn locate_tools_root() -> Result<PathBuf, String> {
|
||||
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
|
||||
let mut cur = exe.parent();
|
||||
while let Some(dir) = cur {
|
||||
if dir.join("bin").is_dir() && has_tool_subdir(dir) {
|
||||
return Ok(dir.to_path_buf());
|
||||
}
|
||||
cur = dir.parent();
|
||||
}
|
||||
Err("无法定位 tools 根目录".into())
|
||||
}
|
||||
|
||||
/// 判断目录下是否存在至少一个工具子目录(含 specs/Cargo.toml/src 之一)
|
||||
fn has_tool_subdir(dir: &Path) -> bool {
|
||||
let Ok(entries) = fs::read_dir(dir) else {
|
||||
return false;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let p = entry.path();
|
||||
if p.is_dir()
|
||||
&& (p.join("specs").exists()
|
||||
|| p.join("Cargo.toml").exists()
|
||||
|| p.join("src").exists())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn result(content: &str) {
|
||||
println!("{}", json!({"type":"result","content":content}));
|
||||
}
|
||||
@@ -188,18 +188,42 @@ fn build_tool(tool_dir: &Path, tool_name: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 定位 tools 根目录。
|
||||
///
|
||||
/// 产物可能位于两处之一:
|
||||
/// - `tools/bin/tool_manager`(统一产物目录,registry 优先使用)
|
||||
/// - `tools/tool_manager/target/release/tool_manager`(构建产物)
|
||||
///
|
||||
/// 因此不依赖固定向上层数,而是从 exe 向上找第一个“含 bin/ 子目录且至少含一个
|
||||
/// 工具子目录”的祖先,即为 tools 根。对两种位置均正确。
|
||||
fn locate_tools_root() -> Result<PathBuf, String> {
|
||||
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
|
||||
// exe = tools/tool_manager/target/release/tool_manager
|
||||
let tool_dir = exe
|
||||
.parent() // .../target/release
|
||||
.and_then(Path::parent) // .../target
|
||||
.and_then(Path::parent) // .../tool_manager
|
||||
.ok_or_else(|| "无法定位 tool_manager 目录".to_string())?;
|
||||
tool_dir
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.ok_or_else(|| "无法定位 tools 根目录".to_string())
|
||||
let mut cur = exe.parent();
|
||||
while let Some(dir) = cur {
|
||||
if dir.join("bin").is_dir() && has_tool_subdir(dir) {
|
||||
return Ok(dir.to_path_buf());
|
||||
}
|
||||
cur = dir.parent();
|
||||
}
|
||||
Err("无法定位 tools 根目录".into())
|
||||
}
|
||||
|
||||
/// 判断目录下是否存在至少一个工具子目录(含 specs/Cargo.toml/src 之一)
|
||||
fn has_tool_subdir(dir: &Path) -> bool {
|
||||
let Ok(entries) = fs::read_dir(dir) else {
|
||||
return false;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let p = entry.path();
|
||||
if p.is_dir()
|
||||
&& (p.join("specs").exists()
|
||||
|| p.join("Cargo.toml").exists()
|
||||
|| p.join("src").exists())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn cargo_toml(tool_name: &str) -> String {
|
||||
|
||||
Reference in New Issue
Block a user