后端: - openai provider 流式响应启用 stream_options.include_usage,从 SSE 末帧捕获 usage - ChatMessage 新增 MessageUsage(prompt/completion/total/context_window_tokens) - agent_loop 三处 LLM 调用点持久化 usage,含 context_window_tokens - storage 新增 context_window_tokens 列及 migration,batch_session_token_stats 聚合查询 - last_prompt_tokens 查询过滤 prompt_tokens IS NOT NULL,跳过 error/cancel 消息 - build_topic_summaries 简化签名,context_window_tokens 从消息记录读取 - protocol 扩展 TopicTokenStats,按 session_id 聚合天然隔离子代理 前端: - protocol.ts 新增 TopicTokenStats 类型 - useTopics 映射 token_stats 到 Topic - TopicList 展示总 token 消耗与上下文占用百分比(绿/黄/红三色) 测试:outbound_dispatcher 4 个测试修复(drop(bus) 不关闭 bus,改用 abort)
260 lines
8.7 KiB
Rust
260 lines
8.7 KiB
Rust
use crate::command::Command;
|
|
use crate::command::context::CommandContext;
|
|
use crate::command::handler::{CommandHandler, CommandMetadata};
|
|
use crate::command::handlers::list_topics::build_topic_summaries;
|
|
use crate::command::response::{CommandError, CommandResponse, MessageKind};
|
|
use crate::storage::SessionStore;
|
|
use async_trait::async_trait;
|
|
use std::sync::Arc;
|
|
|
|
/// 重命名话题命令处理器
|
|
pub struct RenameTopicCommandHandler {
|
|
store: Arc<SessionStore>,
|
|
}
|
|
|
|
impl RenameTopicCommandHandler {
|
|
pub fn new(store: Arc<SessionStore>) -> Self {
|
|
Self { store }
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl CommandHandler for RenameTopicCommandHandler {
|
|
fn can_handle(&self, cmd: &Command) -> bool {
|
|
matches!(cmd, Command::RenameTopic { .. })
|
|
}
|
|
|
|
fn metadata(&self) -> Option<CommandMetadata> {
|
|
Some(CommandMetadata {
|
|
name: "rename",
|
|
description: "重命名指定话题",
|
|
usage: "/rename <topic_id> <new_title>",
|
|
})
|
|
}
|
|
|
|
async fn handle(
|
|
&self,
|
|
cmd: Command,
|
|
ctx: CommandContext,
|
|
) -> Result<CommandResponse, CommandError> {
|
|
match cmd {
|
|
Command::RenameTopic { topic_id, title } => {
|
|
handle_rename_topic(self, topic_id, title, ctx).await
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn handle_rename_topic(
|
|
handler: &RenameTopicCommandHandler,
|
|
topic_id: String,
|
|
title: String,
|
|
ctx: CommandContext,
|
|
) -> Result<CommandResponse, CommandError> {
|
|
let session_id = ctx
|
|
.session_id
|
|
.as_deref()
|
|
.ok_or_else(|| CommandError::new("NO_SESSION", "No active session"))?;
|
|
|
|
// 校验新标题非空
|
|
let trimmed_title = title.trim();
|
|
if trimmed_title.is_empty() {
|
|
return Err(CommandError::new(
|
|
"INVALID_TITLE",
|
|
"Topic title must not be empty",
|
|
));
|
|
}
|
|
|
|
// 验证话题存在
|
|
let topic = handler
|
|
.store
|
|
.get_topic(&topic_id)
|
|
.map_err(|e| CommandError::new("GET_TOPIC_ERROR", e.to_string()))?
|
|
.ok_or_else(|| {
|
|
CommandError::new("TOPIC_NOT_FOUND", format!("Topic not found: {}", topic_id))
|
|
})?;
|
|
|
|
let old_title = topic.title.clone();
|
|
|
|
// 标题未变化时直接返回当前列表,避免无意义写入
|
|
if old_title == trimmed_title {
|
|
let topics = handler
|
|
.store
|
|
.list_topics(session_id)
|
|
.map_err(|e| CommandError::new("LIST_TOPICS_ERROR", e.to_string()))?;
|
|
let topic_summaries = build_topic_summaries(handler.store.as_ref(), topics)?;
|
|
let topic_summaries_json = serde_json::to_string(&topic_summaries)
|
|
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;
|
|
|
|
return Ok(CommandResponse::success(ctx.request_id)
|
|
.with_message(
|
|
MessageKind::Notification,
|
|
&format!("✓ 话题标题未变化: {}", trimmed_title),
|
|
)
|
|
.with_metadata("topics", &topic_summaries_json)
|
|
.with_metadata("topic_id", &topic_id)
|
|
.with_metadata("title", trimmed_title)
|
|
.with_metadata("session_id", session_id));
|
|
}
|
|
|
|
// 执行重命名(存储层方法已存在)
|
|
handler
|
|
.store
|
|
.update_topic_title(&topic_id, trimmed_title)
|
|
.map_err(|e| CommandError::new("RENAME_TOPIC_ERROR", e.to_string()))?;
|
|
|
|
// 查询更新后的话题列表,返回给前端刷新侧边栏
|
|
let topics = handler
|
|
.store
|
|
.list_topics(session_id)
|
|
.map_err(|e| CommandError::new("LIST_TOPICS_ERROR", e.to_string()))?;
|
|
|
|
let topic_summaries = build_topic_summaries(handler.store.as_ref(), topics)?;
|
|
let topic_summaries_json = serde_json::to_string(&topic_summaries)
|
|
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;
|
|
|
|
let message = format!("✓ 已重命名话题: {} → {}", old_title, trimmed_title);
|
|
|
|
Ok(CommandResponse::success(ctx.request_id)
|
|
.with_message(MessageKind::Notification, &message)
|
|
.with_metadata("topics", &topic_summaries_json)
|
|
.with_metadata("topic_id", &topic_id)
|
|
.with_metadata("title", trimmed_title)
|
|
.with_metadata("session_id", session_id))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::storage::SessionStore;
|
|
|
|
fn create_test_handler() -> RenameTopicCommandHandler {
|
|
let store = Arc::new(SessionStore::in_memory().unwrap());
|
|
RenameTopicCommandHandler::new(store)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_rename_topic_success() {
|
|
let handler = create_test_handler();
|
|
let store = handler.store.clone();
|
|
|
|
let session = store.create_session("test_channel", Some("test")).unwrap();
|
|
let topic = store.create_topic(&session.id, "old title", None).unwrap();
|
|
|
|
let ctx = CommandContext::new("test", "test_channel")
|
|
.with_session_id(&session.id)
|
|
.with_chat_id(&session.id);
|
|
let cmd = Command::RenameTopic {
|
|
topic_id: topic.id.clone(),
|
|
title: "new title".to_string(),
|
|
};
|
|
|
|
let result = handler.handle(cmd, ctx).await;
|
|
assert!(result.is_ok());
|
|
|
|
let resp = result.unwrap();
|
|
assert!(resp.success);
|
|
assert_eq!(
|
|
resp.metadata.get("title").map(String::as_str),
|
|
Some("new title")
|
|
);
|
|
assert_eq!(
|
|
resp.metadata.get("topic_id").map(String::as_str),
|
|
Some(topic.id.as_str())
|
|
);
|
|
assert!(resp.metadata.contains_key("topics"));
|
|
|
|
// 验证存储层已更新
|
|
let updated = store.get_topic(&topic.id).unwrap().unwrap();
|
|
assert_eq!(updated.title, "new title");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_rename_topic_empty_title() {
|
|
let handler = create_test_handler();
|
|
let store = handler.store.clone();
|
|
|
|
let session = store.create_session("test_channel", Some("test")).unwrap();
|
|
let topic = store.create_topic(&session.id, "old title", None).unwrap();
|
|
|
|
let ctx = CommandContext::new("test", "test_channel")
|
|
.with_session_id(&session.id)
|
|
.with_chat_id(&session.id);
|
|
let cmd = Command::RenameTopic {
|
|
topic_id: topic.id.clone(),
|
|
title: " ".to_string(),
|
|
};
|
|
|
|
let result = handler.handle(cmd, ctx).await;
|
|
assert!(result.is_err());
|
|
let err = result.unwrap_err();
|
|
assert_eq!(err.code, "INVALID_TITLE");
|
|
|
|
// 标题未被修改
|
|
let unchanged = store.get_topic(&topic.id).unwrap().unwrap();
|
|
assert_eq!(unchanged.title, "old title");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_rename_nonexistent_topic() {
|
|
let handler = create_test_handler();
|
|
let store = handler.store.clone();
|
|
|
|
let session = store.create_session("test_channel", Some("test")).unwrap();
|
|
let ctx = CommandContext::new("test", "test_channel")
|
|
.with_session_id(&session.id)
|
|
.with_chat_id(&session.id);
|
|
let cmd = Command::RenameTopic {
|
|
topic_id: "nonexistent".to_string(),
|
|
title: "new title".to_string(),
|
|
};
|
|
|
|
let result = handler.handle(cmd, ctx).await;
|
|
assert!(result.is_err());
|
|
let err = result.unwrap_err();
|
|
assert_eq!(err.code, "TOPIC_NOT_FOUND");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_rename_topic_same_title_skips_write() {
|
|
let handler = create_test_handler();
|
|
let store = handler.store.clone();
|
|
|
|
let session = store.create_session("test_channel", Some("test")).unwrap();
|
|
let topic = store.create_topic(&session.id, "same title", None).unwrap();
|
|
let original_updated_at = store.get_topic(&topic.id).unwrap().unwrap().updated_at;
|
|
|
|
// 等待一秒确保 updated_at 会变化(如果真的写入)
|
|
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
|
|
|
let ctx = CommandContext::new("test", "test_channel")
|
|
.with_session_id(&session.id)
|
|
.with_chat_id(&session.id);
|
|
let cmd = Command::RenameTopic {
|
|
topic_id: topic.id.clone(),
|
|
title: "same title".to_string(),
|
|
};
|
|
|
|
let result = handler.handle(cmd, ctx).await;
|
|
assert!(result.is_ok());
|
|
|
|
// updated_at 未变化说明未触发写入
|
|
let after = store.get_topic(&topic.id).unwrap().unwrap();
|
|
assert_eq!(after.updated_at, original_updated_at);
|
|
}
|
|
|
|
#[test]
|
|
fn test_can_handle() {
|
|
let handler = create_test_handler();
|
|
assert!(handler.can_handle(&Command::RenameTopic {
|
|
topic_id: "test".to_string(),
|
|
title: "test".to_string(),
|
|
}));
|
|
assert!(!handler.can_handle(&Command::Help));
|
|
assert!(!handler.can_handle(&Command::DeleteTopic {
|
|
topic_id: "test".to_string(),
|
|
}));
|
|
}
|
|
}
|