Compare commits
3 Commits
389e222b11
...
c7ee6bb519
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7ee6bb519 | ||
|
|
2a5a0277c0 | ||
|
|
c484a918b5 |
3
Makefile
3
Makefile
@ -28,10 +28,9 @@ dev-frontend:
|
||||
@echo "Starting frontend dev server..."
|
||||
cd web && npm run dev
|
||||
|
||||
# Build for production
|
||||
# Build for production (frontend is built automatically by cargo via build.rs)
|
||||
build:
|
||||
@echo "Building PicoBot Web UI..."
|
||||
cd web && npm run build
|
||||
cargo build --release
|
||||
@echo "Build complete!"
|
||||
|
||||
|
||||
63
build.rs
Normal file
63
build.rs
Normal file
@ -0,0 +1,63 @@
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
fn run_npm(args: &[&str], web_dir: &Path) {
|
||||
let is_windows = cfg!(target_os = "windows");
|
||||
|
||||
if is_windows {
|
||||
let mut cmd_args = vec!["/c", "npm"];
|
||||
cmd_args.extend_from_slice(args);
|
||||
let status = Command::new("cmd")
|
||||
.args(&cmd_args)
|
||||
.current_dir(web_dir)
|
||||
.status()
|
||||
.unwrap_or_else(|e| panic!("failed to spawn npm {}: {}", args.join(" "), e));
|
||||
|
||||
if !status.success() {
|
||||
panic!("npm {} failed with status {}", args.join(" "), status);
|
||||
}
|
||||
} else {
|
||||
let status = Command::new("npm")
|
||||
.args(args)
|
||||
.current_dir(web_dir)
|
||||
.status()
|
||||
.unwrap_or_else(|e| panic!("failed to spawn npm {}: {}", args.join(" "), e));
|
||||
|
||||
if !status.success() {
|
||||
panic!("npm {} failed with status {}", args.join(" "), status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-env-changed=SKIP_FRONTEND_BUILD");
|
||||
|
||||
let web_dir = Path::new("web");
|
||||
if web_dir.exists() {
|
||||
println!("cargo:rerun-if-changed=web/src");
|
||||
println!("cargo:rerun-if-changed=web/index.html");
|
||||
println!("cargo:rerun-if-changed=web/package.json");
|
||||
println!("cargo:rerun-if-changed=web/vite.config.ts");
|
||||
println!("cargo:rerun-if-changed=web/tailwind.config.js");
|
||||
println!("cargo:rerun-if-changed=web/postcss.config.js");
|
||||
println!("cargo:rerun-if-changed=web/tsconfig.json");
|
||||
}
|
||||
|
||||
if std::env::var("SKIP_FRONTEND_BUILD").is_ok() {
|
||||
println!("cargo:warning=SKIP_FRONTEND_BUILD is set, skipping frontend build");
|
||||
return;
|
||||
}
|
||||
|
||||
if !web_dir.exists() {
|
||||
println!("cargo:warning=web/ directory not found, skipping frontend build");
|
||||
return;
|
||||
}
|
||||
|
||||
println!("cargo:warning=building frontend (npm install)...");
|
||||
run_npm(&["install"], web_dir);
|
||||
|
||||
println!("cargo:warning=building frontend (npm run build)...");
|
||||
run_npm(&["run", "build"], web_dir);
|
||||
|
||||
println!("cargo:warning=frontend build complete, output in static/");
|
||||
}
|
||||
10
build.sh
10
build.sh
@ -12,15 +12,7 @@ if [ ! -f "Cargo.toml" ]; then
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Step 1: Building frontend..."
|
||||
echo "----------------------------------------"
|
||||
cd web
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
|
||||
echo ""
|
||||
echo "Step 2: Building Rust backend..."
|
||||
echo "Building PicoBot (frontend is built automatically by cargo via build.rs)..."
|
||||
echo "----------------------------------------"
|
||||
cargo build --release
|
||||
|
||||
|
||||
@ -14,18 +14,8 @@ $DistDir = "$Root\dist\$PackageName"
|
||||
|
||||
Write-Host "=== PicoBot v$Version Build ===" -ForegroundColor Cyan
|
||||
|
||||
# Step 1: build frontend
|
||||
Write-Host "[1/4] Building web frontend..." -ForegroundColor Yellow
|
||||
Push-Location "$Root\web"
|
||||
try {
|
||||
npm run build
|
||||
if ($LASTEXITCODE -ne 0) { throw "web build failed" }
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
# Step 2: build Rust backend
|
||||
Write-Host "[2/4] Building Rust release..." -ForegroundColor Yellow
|
||||
# Build Rust backend (frontend is built automatically via build.rs)
|
||||
Write-Host "[1/3] Building PicoBot (frontend auto-built by cargo)..." -ForegroundColor Yellow
|
||||
Push-Location $Root
|
||||
try {
|
||||
cargo build --release
|
||||
@ -34,16 +24,16 @@ try {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
# Step 3: assemble dist directory
|
||||
Write-Host "[3/4] Assembling package..." -ForegroundColor Yellow
|
||||
# Assemble dist directory
|
||||
Write-Host "[2/3] Assembling package..." -ForegroundColor Yellow
|
||||
New-Item -ItemType Directory -Force -Path $DistDir | Out-Null
|
||||
|
||||
# exe + package contents
|
||||
Copy-Item "$Root\target\release\picobot.exe" $DistDir
|
||||
Copy-Item "$Root\package\*" $DistDir
|
||||
|
||||
# Step 4: zip
|
||||
Write-Host "[4/4] Creating zip..." -ForegroundColor Yellow
|
||||
# Create zip
|
||||
Write-Host "[3/3] Creating zip..." -ForegroundColor Yellow
|
||||
$ZipPath = "$Root\dist\$PackageName.zip"
|
||||
if (Test-Path $ZipPath) { Remove-Item $ZipPath }
|
||||
Compress-Archive -Path $DistDir -DestinationPath $ZipPath
|
||||
|
||||
@ -84,7 +84,33 @@ impl OutputAdapter for WebSocketOutputAdapter {
|
||||
},
|
||||
MessageKind::Notification => {
|
||||
// 根据元数据判断具体类型
|
||||
if let Some(topics_json) = response.metadata.get("topics") {
|
||||
// 优先识别话题重命名(同时含 topics + topic_id + title)
|
||||
if let (Some(topics_json), Some(topic_id), Some(title)) = (
|
||||
response.metadata.get("topics"),
|
||||
response.metadata.get("topic_id"),
|
||||
response.metadata.get("title"),
|
||||
) {
|
||||
match serde_json::from_str::<Vec<crate::protocol::TopicSummary>>(topics_json) {
|
||||
Ok(topics) => {
|
||||
let session_id = response.metadata.get("session_id")
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
WsOutbound::TopicRenamed {
|
||||
topics,
|
||||
session_id,
|
||||
topic_id: topic_id.clone(),
|
||||
title: title.clone(),
|
||||
}
|
||||
}
|
||||
Err(_) => WsOutbound::AssistantResponse {
|
||||
id: response.request_id.to_string(),
|
||||
content: msg.content.clone(),
|
||||
role: "assistant".to_string(),
|
||||
attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()),
|
||||
reasoning_content: None, user_message_id: None,
|
||||
},
|
||||
}
|
||||
} else if let Some(topics_json) = response.metadata.get("topics") {
|
||||
// Topic 列表响应 - 优先检查 topics
|
||||
match serde_json::from_str::<Vec<crate::protocol::TopicSummary>>(topics_json) {
|
||||
Ok(topics) => {
|
||||
|
||||
@ -13,6 +13,7 @@ pub mod list_topics;
|
||||
pub mod load_chat_messages;
|
||||
pub mod load_task_messages;
|
||||
pub mod load_topic;
|
||||
pub mod rename_topic;
|
||||
pub mod save_session;
|
||||
pub mod save_topic;
|
||||
pub mod session;
|
||||
|
||||
268
src/command/handlers/rename_topic.rs
Normal file
268
src/command/handlers/rename_topic.rs
Normal file
@ -0,0 +1,268 @@
|
||||
use crate::command::context::CommandContext;
|
||||
use crate::command::handler::{CommandHandler, CommandMetadata};
|
||||
use crate::command::handlers::list_topics::TopicSummary;
|
||||
use crate::command::response::{CommandError, CommandResponse, MessageKind};
|
||||
use crate::command::Command;
|
||||
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 = serialize_summaries(&topics);
|
||||
|
||||
return Ok(CommandResponse::success(ctx.request_id)
|
||||
.with_message(MessageKind::Notification, &format!("✓ 话题标题未变化: {}", trimmed_title))
|
||||
.with_metadata("topics", &topic_summaries)
|
||||
.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 = serialize_summaries(&topics);
|
||||
|
||||
let message = format!("✓ 已重命名话题: {} → {}", old_title, trimmed_title);
|
||||
|
||||
Ok(CommandResponse::success(ctx.request_id)
|
||||
.with_message(MessageKind::Notification, &message)
|
||||
.with_metadata("topics", &topic_summaries)
|
||||
.with_metadata("topic_id", &topic_id)
|
||||
.with_metadata("title", trimmed_title)
|
||||
.with_metadata("session_id", session_id))
|
||||
}
|
||||
|
||||
fn serialize_summaries(topics: &[crate::storage::TopicRecord]) -> String {
|
||||
let summaries: Vec<TopicSummary> = topics
|
||||
.iter()
|
||||
.map(|t| TopicSummary {
|
||||
topic_id: t.id.clone(),
|
||||
session_id: t.session_id.clone(),
|
||||
title: t.title.clone(),
|
||||
description: t.description.clone().filter(|d| !d.is_empty()),
|
||||
message_count: t.message_count,
|
||||
created_at: t.created_at,
|
||||
last_active_at: t.last_active_at,
|
||||
})
|
||||
.collect();
|
||||
serde_json::to_string(&summaries).unwrap_or_else(|_| "[]".to_string())
|
||||
}
|
||||
|
||||
#[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(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@ -54,6 +54,8 @@ pub enum Command {
|
||||
},
|
||||
/// 删除指定话题
|
||||
DeleteTopic { topic_id: String },
|
||||
/// 重命名指定话题
|
||||
RenameTopic { topic_id: String, title: String },
|
||||
/// 停止当前正在执行的 Agent
|
||||
StopExecution,
|
||||
/// 列出所有记忆
|
||||
@ -100,6 +102,7 @@ impl Command {
|
||||
Command::ListSchedulerJobs => "list_scheduler_jobs",
|
||||
Command::LoadChatMessages { .. } => "load_chat_messages",
|
||||
Command::DeleteTopic { .. } => "delete_topic",
|
||||
Command::RenameTopic { .. } => "rename_topic",
|
||||
Command::StopExecution => "stop_execution",
|
||||
Command::ListMemories => "list_memories",
|
||||
Command::CreateMemory { .. } => "create_memory",
|
||||
|
||||
@ -13,6 +13,7 @@ use crate::command::handlers::get_current::GetCurrentSessionCommandHandler;
|
||||
use crate::command::handlers::help::HelpCommandHandler;
|
||||
use crate::command::handlers::list_sessions::ListSessionsCommandHandler;
|
||||
use crate::command::handlers::load_topic::LoadTopicCommandHandler;
|
||||
use crate::command::handlers::rename_topic::RenameTopicCommandHandler;
|
||||
use crate::command::handlers::save_session::SaveSessionCommandHandler;
|
||||
use crate::command::handlers::save_topic::SaveTopicCommandHandler;
|
||||
use crate::command::handlers::session::SessionCommandHandler;
|
||||
@ -104,6 +105,9 @@ impl InboundProcessor {
|
||||
.with_session_manager(session_manager.clone()),
|
||||
));
|
||||
|
||||
// 注册 rename_topic 处理器
|
||||
command_router.register(Box::new(RenameTopicCommandHandler::new(store.clone())));
|
||||
|
||||
// 注册 help 处理器(最后注册,获取所有已注册命令的元数据)
|
||||
let metadata = command_router.metadata_arc();
|
||||
command_router.register(Box::new(HelpCommandHandler::new(metadata)));
|
||||
|
||||
@ -20,6 +20,7 @@ use crate::command::handlers::list_topics::ListTopicsCommandHandler;
|
||||
use crate::command::handlers::load_chat_messages::LoadChatMessagesCommandHandler;
|
||||
use crate::command::handlers::load_task_messages::LoadTaskMessagesCommandHandler;
|
||||
use crate::command::handlers::load_topic::LoadTopicCommandHandler;
|
||||
use crate::command::handlers::rename_topic::RenameTopicCommandHandler;
|
||||
use crate::command::handlers::save_session::SaveSessionCommandHandler;
|
||||
use crate::command::handlers::save_topic::SaveTopicCommandHandler;
|
||||
use crate::command::handlers::session::SessionCommandHandler;
|
||||
@ -453,6 +454,8 @@ async fn handle_inbound(
|
||||
DeleteTopicCommandHandler::new(store.clone())
|
||||
.with_session_manager(state.session_manager.clone()),
|
||||
));
|
||||
// 注册 rename_topic 处理器
|
||||
router.register(Box::new(RenameTopicCommandHandler::new(store.clone())));
|
||||
// 注册 help 处理器
|
||||
let metadata = router.metadata_arc();
|
||||
router.register(Box::new(HelpCommandHandler::new(metadata)));
|
||||
|
||||
@ -248,6 +248,13 @@ pub enum WsOutbound {
|
||||
topics: Vec<TopicSummary>,
|
||||
session_id: String,
|
||||
},
|
||||
#[serde(rename = "topic_renamed")]
|
||||
TopicRenamed {
|
||||
topic_id: String,
|
||||
title: String,
|
||||
topics: Vec<TopicSummary>,
|
||||
session_id: String,
|
||||
},
|
||||
#[serde(rename = "session_loaded")]
|
||||
SessionLoaded {
|
||||
session_id: String,
|
||||
|
||||
@ -3,7 +3,7 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use r2d2::Pool;
|
||||
use r2d2_sqlite::SqliteConnectionManager;
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params};
|
||||
|
||||
use crate::bus::ChatMessage;
|
||||
|
||||
@ -62,7 +62,7 @@ impl SessionStore {
|
||||
/// The connection is used for schema initialization only; the pool
|
||||
/// manages subsequent connections using the same file path.
|
||||
fn from_connection(conn: Connection, db_uri: &str) -> Result<Self, StorageError> {
|
||||
conn.busy_timeout(std::time::Duration::from_secs(5))?;
|
||||
conn.busy_timeout(std::time::Duration::from_secs(30))?;
|
||||
conn.execute_batch(
|
||||
"
|
||||
PRAGMA journal_mode = WAL;
|
||||
@ -230,7 +230,7 @@ impl SessionStore {
|
||||
|
||||
let manager = SqliteConnectionManager::file(db_uri)
|
||||
.with_init(|c| {
|
||||
c.busy_timeout(std::time::Duration::from_secs(5))?;
|
||||
c.busy_timeout(std::time::Duration::from_secs(30))?;
|
||||
Ok(())
|
||||
});
|
||||
let pool = Pool::builder()
|
||||
@ -575,8 +575,8 @@ impl SessionStore {
|
||||
topic_id: Option<&str>,
|
||||
message: &ChatMessage,
|
||||
) -> Result<(), StorageError> {
|
||||
let conn = self.pool.get()?;
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
let mut conn = self.pool.get()?;
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
|
||||
let seq: i64 = tx.query_row(
|
||||
"SELECT COALESCE(MAX(seq), 0) + 1 FROM messages WHERE session_id = ?1",
|
||||
@ -651,8 +651,8 @@ impl SessionStore {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let conn = self.pool.get()?;
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
let mut conn = self.pool.get()?;
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
|
||||
let mut seq: i64 = tx.query_row(
|
||||
"SELECT COALESCE(MAX(seq), 0) + 1 FROM messages WHERE session_id = ?1",
|
||||
@ -736,8 +736,8 @@ impl SessionStore {
|
||||
summary_message: &ChatMessage,
|
||||
preserved_messages: &[ChatMessage],
|
||||
) -> Result<bool, StorageError> {
|
||||
let conn = self.pool.get()?;
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
let mut conn = self.pool.get()?;
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
|
||||
let current_max_seq: i64 = tx.query_row(
|
||||
"SELECT COALESCE(MAX(seq), 0) FROM messages WHERE session_id = ?1",
|
||||
@ -833,8 +833,8 @@ impl SessionStore {
|
||||
session_id: &str,
|
||||
messages: &[ChatMessage],
|
||||
) -> Result<(), StorageError> {
|
||||
let conn = self.pool.get()?;
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
let mut conn = self.pool.get()?;
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
let now = current_timestamp();
|
||||
|
||||
// Delete all existing messages for this session
|
||||
@ -892,8 +892,8 @@ impl SessionStore {
|
||||
topic_id: &str,
|
||||
messages: &[ChatMessage],
|
||||
) -> Result<(), StorageError> {
|
||||
let conn = self.pool.get()?;
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
let mut conn = self.pool.get()?;
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
let now = current_timestamp();
|
||||
|
||||
// Delete only messages belonging to this topic — other topics'
|
||||
@ -1023,8 +1023,8 @@ impl SessionStore {
|
||||
|
||||
pub fn put_memory(&self, input: &MemoryUpsert) -> Result<MemoryRecord, StorageError> {
|
||||
let now = current_timestamp();
|
||||
let conn = self.pool.get()?;
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
let mut conn = self.pool.get()?;
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
|
||||
let existing: Option<(String, i64)> = tx
|
||||
.query_row(
|
||||
@ -1611,10 +1611,11 @@ impl SessionStore {
|
||||
items: &[TodoRecord],
|
||||
) -> Result<Vec<TodoRecord>, StorageError> {
|
||||
let mut conn = self.pool.get()?;
|
||||
// 用 transaction()(非 unchecked_transaction)保证严格事务语义:
|
||||
// 用 BEGIN IMMEDIATE 事务保证严格语义:写锁在事务开始时获取,
|
||||
// 避免并发写事务在提交时死锁导致 "database is locked"。
|
||||
// 用户数据替换需保证原子性——中途失败必须回滚,避免 DELETE 后 INSERT
|
||||
// 异常导致 todos 列表丢失且无法恢复。
|
||||
let tx = conn.transaction()?;
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
let now = current_timestamp();
|
||||
|
||||
// Delete existing todos for this scope_key
|
||||
|
||||
@ -84,6 +84,7 @@ function App() {
|
||||
createTopic,
|
||||
switchTopic,
|
||||
deleteTopic,
|
||||
renameTopic,
|
||||
requestSessionList,
|
||||
requestTopicList,
|
||||
topicRefreshTrigger,
|
||||
@ -348,6 +349,15 @@ function App() {
|
||||
[sendMessage, handleCommand, deleteTopic, selectedTopic, selectTopic, clearMessages]
|
||||
)
|
||||
|
||||
const handleRenameTopic = useCallback(
|
||||
(topicId: string, title: string) => {
|
||||
const cmd = renameTopic(topicId, title)
|
||||
handleCommand(cmd)
|
||||
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
|
||||
},
|
||||
[sendMessage, handleCommand, renameTopic]
|
||||
)
|
||||
|
||||
const handleNavigateToSubAgent = useCallback(
|
||||
(taskId: string, description: string, subagentType?: string) => {
|
||||
const cmd = enterSubAgentView(taskId, description, subagentType)
|
||||
@ -690,6 +700,7 @@ function App() {
|
||||
onRefresh={handleRefreshTopics}
|
||||
onSwitchTopic={handleSwitchTopic}
|
||||
onDeleteTopic={handleDeleteTopic}
|
||||
onRenameTopic={handleRenameTopic}
|
||||
/>
|
||||
) : (
|
||||
<SchedulerJobList
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
|
||||
import { Plus, MessageSquare, Layers, Hash, Clock, RefreshCw, Trash2, Check, X, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { Plus, MessageSquare, Layers, Hash, Clock, RefreshCw, Trash2, Check, X, ChevronLeft, ChevronRight, Edit2 } from 'lucide-react'
|
||||
import type { Topic } from '../../types/protocol'
|
||||
|
||||
interface TopicListProps {
|
||||
@ -11,6 +11,7 @@ interface TopicListProps {
|
||||
onRefresh: () => void
|
||||
onSwitchTopic: (topicId: string) => void
|
||||
onDeleteTopic: (topicId: string) => void
|
||||
onRenameTopic: (topicId: string, title: string) => void
|
||||
}
|
||||
|
||||
function formatTime(timestamp: number): string {
|
||||
@ -38,8 +39,42 @@ export function TopicList({
|
||||
onRefresh,
|
||||
onSwitchTopic,
|
||||
onDeleteTopic,
|
||||
onRenameTopic,
|
||||
}: TopicListProps) {
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
|
||||
const [editingTopicId, setEditingTopicId] = useState<string | null>(null)
|
||||
const [editingTitle, setEditingTitle] = useState('')
|
||||
const editInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// 进入编辑模式时自动聚焦 input
|
||||
useEffect(() => {
|
||||
if (editingTopicId && editInputRef.current) {
|
||||
editInputRef.current.focus()
|
||||
editInputRef.current.select()
|
||||
}
|
||||
}, [editingTopicId])
|
||||
|
||||
const startEdit = useCallback((topic: Topic) => {
|
||||
setConfirmDeleteId(null)
|
||||
setEditingTopicId(topic.id)
|
||||
setEditingTitle(topic.title)
|
||||
}, [])
|
||||
|
||||
const cancelEdit = useCallback(() => {
|
||||
setEditingTopicId(null)
|
||||
setEditingTitle('')
|
||||
}, [])
|
||||
|
||||
const commitEdit = useCallback(() => {
|
||||
const trimmed = editingTitle.trim()
|
||||
if (!trimmed || !editingTopicId) {
|
||||
cancelEdit()
|
||||
return
|
||||
}
|
||||
onRenameTopic(editingTopicId, trimmed)
|
||||
setEditingTopicId(null)
|
||||
setEditingTitle('')
|
||||
}, [editingTitle, editingTopicId, onRenameTopic, cancelEdit])
|
||||
|
||||
// Pagination — dynamically sized to fill one screen without scrolling
|
||||
const ESTIMATED_ITEM_HEIGHT = 64 // py-3(24px) + title(20px) + mt-1.5(6px) + meta(14px)
|
||||
@ -138,6 +173,51 @@ export function TopicList({
|
||||
<div className="space-y-1">
|
||||
{pagedTopics.map((topic, index) => (
|
||||
<div key={topic.id} className="group relative">
|
||||
{editingTopicId === topic.id ? (
|
||||
// 编辑模式:内联输入框 + 提交/取消按钮
|
||||
// 按钮使用 onMouseDown preventDefault 防止 input blur 提前触发
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
commitEdit()
|
||||
}}
|
||||
className="w-full rounded-xl pl-3 pr-1.5 py-2 flex items-center gap-2 bg-[var(--bg-tertiary)] border border-[var(--accent-cyan)]/40"
|
||||
>
|
||||
<input
|
||||
ref={editInputRef}
|
||||
value={editingTitle}
|
||||
onChange={(e) => setEditingTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelEdit()
|
||||
}
|
||||
}}
|
||||
onBlur={cancelEdit}
|
||||
className="flex-1 min-w-0 bg-transparent text-sm text-[var(--text-primary)] outline-none border-none focus:ring-0 placeholder:text-[var(--text-muted)]"
|
||||
placeholder="话题标题"
|
||||
maxLength={120}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
className="flex items-center justify-center h-6 w-6 rounded-md bg-emerald-500/20 text-emerald-400 hover:bg-emerald-500/30 transition-colors shrink-0"
|
||||
title="确认 (Enter)"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={cancelEdit}
|
||||
className="flex items-center justify-center h-6 w-6 rounded-md bg-zinc-500/20 text-zinc-400 hover:bg-zinc-500/30 transition-colors shrink-0"
|
||||
title="取消 (Esc)"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => onSwitchTopic(topic.id)}
|
||||
className={`w-full rounded-xl pl-3 pr-8 py-3 text-left text-sm transition-all ${
|
||||
@ -173,7 +253,7 @@ export function TopicList({
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Delete button — visible on group hover */}
|
||||
{/* 编辑/删除按钮 — 悬停可见 */}
|
||||
<div className="absolute top-2.5 right-2.5">
|
||||
{confirmDeleteId === topic.id ? (
|
||||
<span className="flex items-center gap-1.5 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border-color)] px-2 py-1 shadow-lg animate-scale-in">
|
||||
@ -201,18 +281,32 @@ export function TopicList({
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
startEdit(topic)
|
||||
}}
|
||||
className="flex items-center justify-center h-6 w-6 rounded-md text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/10 transition-colors"
|
||||
title="重命名话题"
|
||||
>
|
||||
<Edit2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setConfirmDeleteId(topic.id)
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center h-6 w-6 rounded-md text-[var(--text-muted)] hover:text-red-400 hover:bg-red-500/10"
|
||||
className="flex items-center justify-center h-6 w-6 rounded-md text-[var(--text-muted)] hover:text-red-400 hover:bg-red-500/10 transition-colors"
|
||||
title="删除话题"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState, useCallback, useRef, useEffect, type Dispatch, type SetStateAction, type MutableRefObject } from 'react'
|
||||
import type { Topic, TopicList, TopicSummary, Command } from '../../types/protocol'
|
||||
import type { Topic, TopicList, TopicRenamed, TopicSummary, Command } from '../../types/protocol'
|
||||
|
||||
export interface UseTopicsReturn {
|
||||
topics: Topic[]
|
||||
@ -13,12 +13,28 @@ export interface UseTopicsReturn {
|
||||
pendingNewTopicRef: MutableRefObject<boolean>
|
||||
/** 处理 topic_list 消息:映射格式并按 pendingNewTopic 自动聚焦,返回是否自动聚焦了新话题 */
|
||||
handleTopicList: (msg: TopicList) => boolean
|
||||
/** 处理 topic_renamed 消息:用刷新后的列表替换本地状态(不改 selectedTopic) */
|
||||
handleTopicRenamed: (msg: TopicRenamed) => void
|
||||
createTopic: (title?: string) => Command
|
||||
switchTopic: (topicId: string) => Command
|
||||
deleteTopic: (topicId: string) => Command
|
||||
renameTopic: (topicId: string, title: string) => Command
|
||||
requestTopicList: (sessionId: string | null) => Command | null
|
||||
}
|
||||
|
||||
/** 将后端 TopicSummary[] 映射为前端 Topic[] */
|
||||
function mapTopicSummaries(summaries: TopicSummary[]): Topic[] {
|
||||
return summaries.map(t => ({
|
||||
id: t.topic_id,
|
||||
session_id: t.session_id,
|
||||
title: t.title,
|
||||
description: t.description || undefined,
|
||||
message_count: Number(t.message_count),
|
||||
created_at: t.created_at,
|
||||
updated_at: t.last_active_at,
|
||||
}))
|
||||
}
|
||||
|
||||
export function useTopics(): UseTopicsReturn {
|
||||
const [topics, setTopics] = useState<Topic[]>([])
|
||||
const [selectedTopic, setSelectedTopic] = useState<string | null>(null)
|
||||
@ -42,15 +58,7 @@ export function useTopics(): UseTopicsReturn {
|
||||
}, [])
|
||||
|
||||
const handleTopicList = useCallback((msg: TopicList): boolean => {
|
||||
const newTopics: Topic[] = msg.topics.map((t: TopicSummary) => ({
|
||||
id: t.topic_id,
|
||||
session_id: t.session_id,
|
||||
title: t.title,
|
||||
description: t.description || undefined,
|
||||
message_count: Number(t.message_count),
|
||||
created_at: t.created_at,
|
||||
updated_at: t.last_active_at,
|
||||
}))
|
||||
const newTopics = mapTopicSummaries(msg.topics)
|
||||
setTopics(newTopics)
|
||||
|
||||
// 新建话题后自动聚焦到新话题(列表按 last_active_at DESC 排序,第一个即最新)
|
||||
@ -64,6 +72,11 @@ export function useTopics(): UseTopicsReturn {
|
||||
return false
|
||||
}, [])
|
||||
|
||||
const handleTopicRenamed = useCallback((msg: TopicRenamed): void => {
|
||||
// 后端返回刷新后的完整列表,直接替换;selectedTopic 基于 id 不变,无需调整
|
||||
setTopics(mapTopicSummaries(msg.topics))
|
||||
}, [])
|
||||
|
||||
const createTopic = useCallback((title?: string): Command => {
|
||||
pendingNewTopicRef.current = true
|
||||
return {
|
||||
@ -80,6 +93,10 @@ export function useTopics(): UseTopicsReturn {
|
||||
return { type: 'delete_topic', topic_id: topicId }
|
||||
}, [])
|
||||
|
||||
const renameTopic = useCallback((topicId: string, title: string): Command => {
|
||||
return { type: 'rename_topic', topic_id: topicId, title }
|
||||
}, [])
|
||||
|
||||
const requestTopicList = useCallback((sessionId: string | null): Command | null => {
|
||||
if (!sessionId) return null
|
||||
return { type: 'list_topics', session_id: sessionId }
|
||||
@ -96,9 +113,11 @@ export function useTopics(): UseTopicsReturn {
|
||||
selectedTopicRef,
|
||||
pendingNewTopicRef,
|
||||
handleTopicList,
|
||||
handleTopicRenamed,
|
||||
createTopic,
|
||||
switchTopic,
|
||||
deleteTopic,
|
||||
renameTopic,
|
||||
requestTopicList,
|
||||
}
|
||||
}
|
||||
|
||||
@ -67,6 +67,7 @@ interface UseChatReturn {
|
||||
createTopic: (title?: string) => Command
|
||||
switchTopic: (topicId: string) => Command
|
||||
deleteTopic: (topicId: string) => Command
|
||||
renameTopic: (topicId: string, title: string) => Command
|
||||
|
||||
// 初始化方法
|
||||
requestSessionList: () => Command
|
||||
@ -181,6 +182,10 @@ export function useChat(): UseChatReturn {
|
||||
return
|
||||
}
|
||||
|
||||
case 'topic_renamed':
|
||||
topics.handleTopicRenamed(message)
|
||||
return
|
||||
|
||||
case 'scheduler_job_list':
|
||||
scheduler.setSchedulerJobs(message.jobs)
|
||||
return
|
||||
@ -326,6 +331,7 @@ export function useChat(): UseChatReturn {
|
||||
createTopic: topics.createTopic,
|
||||
switchTopic: topics.switchTopic,
|
||||
deleteTopic: topics.deleteTopic,
|
||||
renameTopic: topics.renameTopic,
|
||||
requestSessionList,
|
||||
requestTopicList,
|
||||
topicRefreshTrigger: topics.topicRefreshTrigger,
|
||||
|
||||
@ -165,6 +165,14 @@ export interface TopicList {
|
||||
session_id: string
|
||||
}
|
||||
|
||||
export interface TopicRenamed {
|
||||
type: 'topic_renamed'
|
||||
topic_id: string
|
||||
title: string
|
||||
topics: TopicSummary[]
|
||||
session_id: string
|
||||
}
|
||||
|
||||
export interface Channel {
|
||||
id: string
|
||||
name: string
|
||||
@ -303,6 +311,7 @@ export type WsOutbound =
|
||||
| SessionLoaded
|
||||
| SessionSaved
|
||||
| TopicList
|
||||
| TopicRenamed
|
||||
| ChannelList
|
||||
| TaskMessagesLoaded
|
||||
| SchedulerJobList
|
||||
@ -392,6 +401,12 @@ export interface DeleteTopicCommand {
|
||||
topic_id: string
|
||||
}
|
||||
|
||||
export interface RenameTopicCommand {
|
||||
type: 'rename_topic'
|
||||
topic_id: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export interface StopExecutionCommand {
|
||||
type: 'stop_execution'
|
||||
}
|
||||
@ -443,6 +458,7 @@ export type Command =
|
||||
| ListSchedulerJobsCommand
|
||||
| LoadChatMessagesCommand
|
||||
| DeleteTopicCommand
|
||||
| RenameTopicCommand
|
||||
| StopExecutionCommand
|
||||
| ListMemoriesCommand
|
||||
| CreateMemoryCommand
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user