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..."
|
@echo "Starting frontend dev server..."
|
||||||
cd web && npm run dev
|
cd web && npm run dev
|
||||||
|
|
||||||
# Build for production
|
# Build for production (frontend is built automatically by cargo via build.rs)
|
||||||
build:
|
build:
|
||||||
@echo "Building PicoBot Web UI..."
|
@echo "Building PicoBot Web UI..."
|
||||||
cd web && npm run build
|
|
||||||
cargo build --release
|
cargo build --release
|
||||||
@echo "Build complete!"
|
@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
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Step 1: Building frontend..."
|
echo "Building PicoBot (frontend is built automatically by cargo via build.rs)..."
|
||||||
echo "----------------------------------------"
|
|
||||||
cd web
|
|
||||||
npm install
|
|
||||||
npm run build
|
|
||||||
cd ..
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Step 2: Building Rust backend..."
|
|
||||||
echo "----------------------------------------"
|
echo "----------------------------------------"
|
||||||
cargo build --release
|
cargo build --release
|
||||||
|
|
||||||
|
|||||||
@ -14,18 +14,8 @@ $DistDir = "$Root\dist\$PackageName"
|
|||||||
|
|
||||||
Write-Host "=== PicoBot v$Version Build ===" -ForegroundColor Cyan
|
Write-Host "=== PicoBot v$Version Build ===" -ForegroundColor Cyan
|
||||||
|
|
||||||
# Step 1: build frontend
|
# Build Rust backend (frontend is built automatically via build.rs)
|
||||||
Write-Host "[1/4] Building web frontend..." -ForegroundColor Yellow
|
Write-Host "[1/3] Building PicoBot (frontend auto-built by cargo)..." -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
|
|
||||||
Push-Location $Root
|
Push-Location $Root
|
||||||
try {
|
try {
|
||||||
cargo build --release
|
cargo build --release
|
||||||
@ -34,16 +24,16 @@ try {
|
|||||||
Pop-Location
|
Pop-Location
|
||||||
}
|
}
|
||||||
|
|
||||||
# Step 3: assemble dist directory
|
# Assemble dist directory
|
||||||
Write-Host "[3/4] Assembling package..." -ForegroundColor Yellow
|
Write-Host "[2/3] Assembling package..." -ForegroundColor Yellow
|
||||||
New-Item -ItemType Directory -Force -Path $DistDir | Out-Null
|
New-Item -ItemType Directory -Force -Path $DistDir | Out-Null
|
||||||
|
|
||||||
# exe + package contents
|
# exe + package contents
|
||||||
Copy-Item "$Root\target\release\picobot.exe" $DistDir
|
Copy-Item "$Root\target\release\picobot.exe" $DistDir
|
||||||
Copy-Item "$Root\package\*" $DistDir
|
Copy-Item "$Root\package\*" $DistDir
|
||||||
|
|
||||||
# Step 4: zip
|
# Create zip
|
||||||
Write-Host "[4/4] Creating zip..." -ForegroundColor Yellow
|
Write-Host "[3/3] Creating zip..." -ForegroundColor Yellow
|
||||||
$ZipPath = "$Root\dist\$PackageName.zip"
|
$ZipPath = "$Root\dist\$PackageName.zip"
|
||||||
if (Test-Path $ZipPath) { Remove-Item $ZipPath }
|
if (Test-Path $ZipPath) { Remove-Item $ZipPath }
|
||||||
Compress-Archive -Path $DistDir -DestinationPath $ZipPath
|
Compress-Archive -Path $DistDir -DestinationPath $ZipPath
|
||||||
|
|||||||
@ -84,7 +84,33 @@ impl OutputAdapter for WebSocketOutputAdapter {
|
|||||||
},
|
},
|
||||||
MessageKind::Notification => {
|
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
|
// Topic 列表响应 - 优先检查 topics
|
||||||
match serde_json::from_str::<Vec<crate::protocol::TopicSummary>>(topics_json) {
|
match serde_json::from_str::<Vec<crate::protocol::TopicSummary>>(topics_json) {
|
||||||
Ok(topics) => {
|
Ok(topics) => {
|
||||||
|
|||||||
@ -13,6 +13,7 @@ pub mod list_topics;
|
|||||||
pub mod load_chat_messages;
|
pub mod load_chat_messages;
|
||||||
pub mod load_task_messages;
|
pub mod load_task_messages;
|
||||||
pub mod load_topic;
|
pub mod load_topic;
|
||||||
|
pub mod rename_topic;
|
||||||
pub mod save_session;
|
pub mod save_session;
|
||||||
pub mod save_topic;
|
pub mod save_topic;
|
||||||
pub mod session;
|
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 },
|
DeleteTopic { topic_id: String },
|
||||||
|
/// 重命名指定话题
|
||||||
|
RenameTopic { topic_id: String, title: String },
|
||||||
/// 停止当前正在执行的 Agent
|
/// 停止当前正在执行的 Agent
|
||||||
StopExecution,
|
StopExecution,
|
||||||
/// 列出所有记忆
|
/// 列出所有记忆
|
||||||
@ -100,6 +102,7 @@ impl Command {
|
|||||||
Command::ListSchedulerJobs => "list_scheduler_jobs",
|
Command::ListSchedulerJobs => "list_scheduler_jobs",
|
||||||
Command::LoadChatMessages { .. } => "load_chat_messages",
|
Command::LoadChatMessages { .. } => "load_chat_messages",
|
||||||
Command::DeleteTopic { .. } => "delete_topic",
|
Command::DeleteTopic { .. } => "delete_topic",
|
||||||
|
Command::RenameTopic { .. } => "rename_topic",
|
||||||
Command::StopExecution => "stop_execution",
|
Command::StopExecution => "stop_execution",
|
||||||
Command::ListMemories => "list_memories",
|
Command::ListMemories => "list_memories",
|
||||||
Command::CreateMemory { .. } => "create_memory",
|
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::help::HelpCommandHandler;
|
||||||
use crate::command::handlers::list_sessions::ListSessionsCommandHandler;
|
use crate::command::handlers::list_sessions::ListSessionsCommandHandler;
|
||||||
use crate::command::handlers::load_topic::LoadTopicCommandHandler;
|
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_session::SaveSessionCommandHandler;
|
||||||
use crate::command::handlers::save_topic::SaveTopicCommandHandler;
|
use crate::command::handlers::save_topic::SaveTopicCommandHandler;
|
||||||
use crate::command::handlers::session::SessionCommandHandler;
|
use crate::command::handlers::session::SessionCommandHandler;
|
||||||
@ -104,6 +105,9 @@ impl InboundProcessor {
|
|||||||
.with_session_manager(session_manager.clone()),
|
.with_session_manager(session_manager.clone()),
|
||||||
));
|
));
|
||||||
|
|
||||||
|
// 注册 rename_topic 处理器
|
||||||
|
command_router.register(Box::new(RenameTopicCommandHandler::new(store.clone())));
|
||||||
|
|
||||||
// 注册 help 处理器(最后注册,获取所有已注册命令的元数据)
|
// 注册 help 处理器(最后注册,获取所有已注册命令的元数据)
|
||||||
let metadata = command_router.metadata_arc();
|
let metadata = command_router.metadata_arc();
|
||||||
command_router.register(Box::new(HelpCommandHandler::new(metadata)));
|
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_chat_messages::LoadChatMessagesCommandHandler;
|
||||||
use crate::command::handlers::load_task_messages::LoadTaskMessagesCommandHandler;
|
use crate::command::handlers::load_task_messages::LoadTaskMessagesCommandHandler;
|
||||||
use crate::command::handlers::load_topic::LoadTopicCommandHandler;
|
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_session::SaveSessionCommandHandler;
|
||||||
use crate::command::handlers::save_topic::SaveTopicCommandHandler;
|
use crate::command::handlers::save_topic::SaveTopicCommandHandler;
|
||||||
use crate::command::handlers::session::SessionCommandHandler;
|
use crate::command::handlers::session::SessionCommandHandler;
|
||||||
@ -453,6 +454,8 @@ async fn handle_inbound(
|
|||||||
DeleteTopicCommandHandler::new(store.clone())
|
DeleteTopicCommandHandler::new(store.clone())
|
||||||
.with_session_manager(state.session_manager.clone()),
|
.with_session_manager(state.session_manager.clone()),
|
||||||
));
|
));
|
||||||
|
// 注册 rename_topic 处理器
|
||||||
|
router.register(Box::new(RenameTopicCommandHandler::new(store.clone())));
|
||||||
// 注册 help 处理器
|
// 注册 help 处理器
|
||||||
let metadata = router.metadata_arc();
|
let metadata = router.metadata_arc();
|
||||||
router.register(Box::new(HelpCommandHandler::new(metadata)));
|
router.register(Box::new(HelpCommandHandler::new(metadata)));
|
||||||
|
|||||||
@ -248,6 +248,13 @@ pub enum WsOutbound {
|
|||||||
topics: Vec<TopicSummary>,
|
topics: Vec<TopicSummary>,
|
||||||
session_id: String,
|
session_id: String,
|
||||||
},
|
},
|
||||||
|
#[serde(rename = "topic_renamed")]
|
||||||
|
TopicRenamed {
|
||||||
|
topic_id: String,
|
||||||
|
title: String,
|
||||||
|
topics: Vec<TopicSummary>,
|
||||||
|
session_id: String,
|
||||||
|
},
|
||||||
#[serde(rename = "session_loaded")]
|
#[serde(rename = "session_loaded")]
|
||||||
SessionLoaded {
|
SessionLoaded {
|
||||||
session_id: String,
|
session_id: String,
|
||||||
|
|||||||
@ -3,7 +3,7 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
use r2d2::Pool;
|
use r2d2::Pool;
|
||||||
use r2d2_sqlite::SqliteConnectionManager;
|
use r2d2_sqlite::SqliteConnectionManager;
|
||||||
use rusqlite::{Connection, OptionalExtension, params};
|
use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params};
|
||||||
|
|
||||||
use crate::bus::ChatMessage;
|
use crate::bus::ChatMessage;
|
||||||
|
|
||||||
@ -62,7 +62,7 @@ impl SessionStore {
|
|||||||
/// The connection is used for schema initialization only; the pool
|
/// The connection is used for schema initialization only; the pool
|
||||||
/// manages subsequent connections using the same file path.
|
/// manages subsequent connections using the same file path.
|
||||||
fn from_connection(conn: Connection, db_uri: &str) -> Result<Self, StorageError> {
|
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(
|
conn.execute_batch(
|
||||||
"
|
"
|
||||||
PRAGMA journal_mode = WAL;
|
PRAGMA journal_mode = WAL;
|
||||||
@ -230,7 +230,7 @@ impl SessionStore {
|
|||||||
|
|
||||||
let manager = SqliteConnectionManager::file(db_uri)
|
let manager = SqliteConnectionManager::file(db_uri)
|
||||||
.with_init(|c| {
|
.with_init(|c| {
|
||||||
c.busy_timeout(std::time::Duration::from_secs(5))?;
|
c.busy_timeout(std::time::Duration::from_secs(30))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
let pool = Pool::builder()
|
let pool = Pool::builder()
|
||||||
@ -575,8 +575,8 @@ impl SessionStore {
|
|||||||
topic_id: Option<&str>,
|
topic_id: Option<&str>,
|
||||||
message: &ChatMessage,
|
message: &ChatMessage,
|
||||||
) -> Result<(), StorageError> {
|
) -> Result<(), StorageError> {
|
||||||
let conn = self.pool.get()?;
|
let mut conn = self.pool.get()?;
|
||||||
let tx = conn.unchecked_transaction()?;
|
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||||
|
|
||||||
let seq: i64 = tx.query_row(
|
let seq: i64 = tx.query_row(
|
||||||
"SELECT COALESCE(MAX(seq), 0) + 1 FROM messages WHERE session_id = ?1",
|
"SELECT COALESCE(MAX(seq), 0) + 1 FROM messages WHERE session_id = ?1",
|
||||||
@ -651,8 +651,8 @@ impl SessionStore {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let conn = self.pool.get()?;
|
let mut conn = self.pool.get()?;
|
||||||
let tx = conn.unchecked_transaction()?;
|
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||||
|
|
||||||
let mut seq: i64 = tx.query_row(
|
let mut seq: i64 = tx.query_row(
|
||||||
"SELECT COALESCE(MAX(seq), 0) + 1 FROM messages WHERE session_id = ?1",
|
"SELECT COALESCE(MAX(seq), 0) + 1 FROM messages WHERE session_id = ?1",
|
||||||
@ -736,8 +736,8 @@ impl SessionStore {
|
|||||||
summary_message: &ChatMessage,
|
summary_message: &ChatMessage,
|
||||||
preserved_messages: &[ChatMessage],
|
preserved_messages: &[ChatMessage],
|
||||||
) -> Result<bool, StorageError> {
|
) -> Result<bool, StorageError> {
|
||||||
let conn = self.pool.get()?;
|
let mut conn = self.pool.get()?;
|
||||||
let tx = conn.unchecked_transaction()?;
|
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||||
|
|
||||||
let current_max_seq: i64 = tx.query_row(
|
let current_max_seq: i64 = tx.query_row(
|
||||||
"SELECT COALESCE(MAX(seq), 0) FROM messages WHERE session_id = ?1",
|
"SELECT COALESCE(MAX(seq), 0) FROM messages WHERE session_id = ?1",
|
||||||
@ -833,8 +833,8 @@ impl SessionStore {
|
|||||||
session_id: &str,
|
session_id: &str,
|
||||||
messages: &[ChatMessage],
|
messages: &[ChatMessage],
|
||||||
) -> Result<(), StorageError> {
|
) -> Result<(), StorageError> {
|
||||||
let conn = self.pool.get()?;
|
let mut conn = self.pool.get()?;
|
||||||
let tx = conn.unchecked_transaction()?;
|
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||||
let now = current_timestamp();
|
let now = current_timestamp();
|
||||||
|
|
||||||
// Delete all existing messages for this session
|
// Delete all existing messages for this session
|
||||||
@ -892,8 +892,8 @@ impl SessionStore {
|
|||||||
topic_id: &str,
|
topic_id: &str,
|
||||||
messages: &[ChatMessage],
|
messages: &[ChatMessage],
|
||||||
) -> Result<(), StorageError> {
|
) -> Result<(), StorageError> {
|
||||||
let conn = self.pool.get()?;
|
let mut conn = self.pool.get()?;
|
||||||
let tx = conn.unchecked_transaction()?;
|
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||||
let now = current_timestamp();
|
let now = current_timestamp();
|
||||||
|
|
||||||
// Delete only messages belonging to this topic — other topics'
|
// 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> {
|
pub fn put_memory(&self, input: &MemoryUpsert) -> Result<MemoryRecord, StorageError> {
|
||||||
let now = current_timestamp();
|
let now = current_timestamp();
|
||||||
let conn = self.pool.get()?;
|
let mut conn = self.pool.get()?;
|
||||||
let tx = conn.unchecked_transaction()?;
|
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||||
|
|
||||||
let existing: Option<(String, i64)> = tx
|
let existing: Option<(String, i64)> = tx
|
||||||
.query_row(
|
.query_row(
|
||||||
@ -1611,10 +1611,11 @@ impl SessionStore {
|
|||||||
items: &[TodoRecord],
|
items: &[TodoRecord],
|
||||||
) -> Result<Vec<TodoRecord>, StorageError> {
|
) -> Result<Vec<TodoRecord>, StorageError> {
|
||||||
let mut conn = self.pool.get()?;
|
let mut conn = self.pool.get()?;
|
||||||
// 用 transaction()(非 unchecked_transaction)保证严格事务语义:
|
// 用 BEGIN IMMEDIATE 事务保证严格语义:写锁在事务开始时获取,
|
||||||
|
// 避免并发写事务在提交时死锁导致 "database is locked"。
|
||||||
// 用户数据替换需保证原子性——中途失败必须回滚,避免 DELETE 后 INSERT
|
// 用户数据替换需保证原子性——中途失败必须回滚,避免 DELETE 后 INSERT
|
||||||
// 异常导致 todos 列表丢失且无法恢复。
|
// 异常导致 todos 列表丢失且无法恢复。
|
||||||
let tx = conn.transaction()?;
|
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||||
let now = current_timestamp();
|
let now = current_timestamp();
|
||||||
|
|
||||||
// Delete existing todos for this scope_key
|
// Delete existing todos for this scope_key
|
||||||
|
|||||||
@ -84,6 +84,7 @@ function App() {
|
|||||||
createTopic,
|
createTopic,
|
||||||
switchTopic,
|
switchTopic,
|
||||||
deleteTopic,
|
deleteTopic,
|
||||||
|
renameTopic,
|
||||||
requestSessionList,
|
requestSessionList,
|
||||||
requestTopicList,
|
requestTopicList,
|
||||||
topicRefreshTrigger,
|
topicRefreshTrigger,
|
||||||
@ -348,6 +349,15 @@ function App() {
|
|||||||
[sendMessage, handleCommand, deleteTopic, selectedTopic, selectTopic, clearMessages]
|
[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(
|
const handleNavigateToSubAgent = useCallback(
|
||||||
(taskId: string, description: string, subagentType?: string) => {
|
(taskId: string, description: string, subagentType?: string) => {
|
||||||
const cmd = enterSubAgentView(taskId, description, subagentType)
|
const cmd = enterSubAgentView(taskId, description, subagentType)
|
||||||
@ -690,6 +700,7 @@ function App() {
|
|||||||
onRefresh={handleRefreshTopics}
|
onRefresh={handleRefreshTopics}
|
||||||
onSwitchTopic={handleSwitchTopic}
|
onSwitchTopic={handleSwitchTopic}
|
||||||
onDeleteTopic={handleDeleteTopic}
|
onDeleteTopic={handleDeleteTopic}
|
||||||
|
onRenameTopic={handleRenameTopic}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<SchedulerJobList
|
<SchedulerJobList
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
|
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'
|
import type { Topic } from '../../types/protocol'
|
||||||
|
|
||||||
interface TopicListProps {
|
interface TopicListProps {
|
||||||
@ -11,6 +11,7 @@ interface TopicListProps {
|
|||||||
onRefresh: () => void
|
onRefresh: () => void
|
||||||
onSwitchTopic: (topicId: string) => void
|
onSwitchTopic: (topicId: string) => void
|
||||||
onDeleteTopic: (topicId: string) => void
|
onDeleteTopic: (topicId: string) => void
|
||||||
|
onRenameTopic: (topicId: string, title: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(timestamp: number): string {
|
function formatTime(timestamp: number): string {
|
||||||
@ -38,8 +39,42 @@ export function TopicList({
|
|||||||
onRefresh,
|
onRefresh,
|
||||||
onSwitchTopic,
|
onSwitchTopic,
|
||||||
onDeleteTopic,
|
onDeleteTopic,
|
||||||
|
onRenameTopic,
|
||||||
}: TopicListProps) {
|
}: TopicListProps) {
|
||||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
|
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
|
// 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)
|
const ESTIMATED_ITEM_HEIGHT = 64 // py-3(24px) + title(20px) + mt-1.5(6px) + meta(14px)
|
||||||
@ -138,81 +173,140 @@ export function TopicList({
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{pagedTopics.map((topic, index) => (
|
{pagedTopics.map((topic, index) => (
|
||||||
<div key={topic.id} className="group relative">
|
<div key={topic.id} className="group relative">
|
||||||
<button
|
{editingTopicId === topic.id ? (
|
||||||
onClick={() => onSwitchTopic(topic.id)}
|
// 编辑模式:内联输入框 + 提交/取消按钮
|
||||||
className={`w-full rounded-xl pl-3 pr-8 py-3 text-left text-sm transition-all ${
|
// 按钮使用 onMouseDown preventDefault 防止 input blur 提前触发
|
||||||
topic.id === currentTopicId
|
<form
|
||||||
? 'bg-gradient-to-r from-[var(--accent-cyan)]/20 to-transparent border border-[var(--accent-cyan)]/30'
|
onSubmit={(e) => {
|
||||||
: 'hover:bg-[var(--overlay-hover)] border border-transparent'
|
e.preventDefault()
|
||||||
}`}
|
commitEdit()
|
||||||
>
|
}}
|
||||||
<div className="flex items-start gap-3">
|
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"
|
||||||
<span className="mt-0.5 text-xs text-[var(--text-muted)] font-mono w-4">
|
>
|
||||||
{currentPage * pageSize + index + 1}
|
<input
|
||||||
</span>
|
ref={editInputRef}
|
||||||
<div className="min-w-0 flex-1">
|
value={editingTitle}
|
||||||
<div className={`truncate font-medium ${
|
onChange={(e) => setEditingTitle(e.target.value)}
|
||||||
topic.id === currentTopicId ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-secondary)]'
|
onKeyDown={(e) => {
|
||||||
}`}>
|
if (e.key === 'Escape') {
|
||||||
{topic.description || topic.title}
|
e.preventDefault()
|
||||||
</div>
|
cancelEdit()
|
||||||
<div className="flex items-center gap-3 mt-1.5">
|
}
|
||||||
<span className="text-xs text-[var(--text-muted)] flex items-center gap-1">
|
|
||||||
<Hash className="h-3 w-3" />
|
|
||||||
{topic.message_count} 条消息
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-[var(--text-muted)] flex items-center gap-1">
|
|
||||||
<Clock className="h-3 w-3" />
|
|
||||||
{formatTime(topic.updated_at)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{topic.id === currentTopicId && (
|
|
||||||
<span className="inline-block h-2 w-2 rounded-full bg-[var(--accent-cyan)] shadow-lg shadow-[var(--shadow-glow-soft)] mt-1.5" />
|
|
||||||
)}
|
|
||||||
</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">
|
|
||||||
<span className="text-xs text-red-400 whitespace-nowrap">确认删除?</span>
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
onDeleteTopic(topic.id)
|
|
||||||
setConfirmDeleteId(null)
|
|
||||||
}}
|
|
||||||
className="flex items-center justify-center h-5 w-5 rounded bg-emerald-500/20 text-emerald-400 hover:bg-emerald-500/30 transition-colors"
|
|
||||||
title="确认"
|
|
||||||
>
|
|
||||||
<Check className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
setConfirmDeleteId(null)
|
|
||||||
}}
|
|
||||||
className="flex items-center justify-center h-5 w-5 rounded bg-zinc-500/20 text-zinc-400 hover:bg-zinc-500/30 transition-colors"
|
|
||||||
title="取消"
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<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"
|
onBlur={cancelEdit}
|
||||||
title="删除话题"
|
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)"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Check className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
<button
|
||||||
</div>
|
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 ${
|
||||||
|
topic.id === currentTopicId
|
||||||
|
? 'bg-gradient-to-r from-[var(--accent-cyan)]/20 to-transparent border border-[var(--accent-cyan)]/30'
|
||||||
|
: 'hover:bg-[var(--overlay-hover)] border border-transparent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="mt-0.5 text-xs text-[var(--text-muted)] font-mono w-4">
|
||||||
|
{currentPage * pageSize + index + 1}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className={`truncate font-medium ${
|
||||||
|
topic.id === currentTopicId ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-secondary)]'
|
||||||
|
}`}>
|
||||||
|
{topic.description || topic.title}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 mt-1.5">
|
||||||
|
<span className="text-xs text-[var(--text-muted)] flex items-center gap-1">
|
||||||
|
<Hash className="h-3 w-3" />
|
||||||
|
{topic.message_count} 条消息
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-[var(--text-muted)] flex items-center gap-1">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{formatTime(topic.updated_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{topic.id === currentTopicId && (
|
||||||
|
<span className="inline-block h-2 w-2 rounded-full bg-[var(--accent-cyan)] shadow-lg shadow-[var(--shadow-glow-soft)] mt-1.5" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 编辑/删除按钮 — 悬停可见 */}
|
||||||
|
<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">
|
||||||
|
<span className="text-xs text-red-400 whitespace-nowrap">确认删除?</span>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onDeleteTopic(topic.id)
|
||||||
|
setConfirmDeleteId(null)
|
||||||
|
}}
|
||||||
|
className="flex items-center justify-center h-5 w-5 rounded bg-emerald-500/20 text-emerald-400 hover:bg-emerald-500/30 transition-colors"
|
||||||
|
title="确认"
|
||||||
|
>
|
||||||
|
<Check className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setConfirmDeleteId(null)
|
||||||
|
}}
|
||||||
|
className="flex items-center justify-center h-5 w-5 rounded bg-zinc-500/20 text-zinc-400 hover:bg-zinc-500/30 transition-colors"
|
||||||
|
title="取消"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</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="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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useCallback, useRef, useEffect, type Dispatch, type SetStateAction, type MutableRefObject } from 'react'
|
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 {
|
export interface UseTopicsReturn {
|
||||||
topics: Topic[]
|
topics: Topic[]
|
||||||
@ -13,12 +13,28 @@ export interface UseTopicsReturn {
|
|||||||
pendingNewTopicRef: MutableRefObject<boolean>
|
pendingNewTopicRef: MutableRefObject<boolean>
|
||||||
/** 处理 topic_list 消息:映射格式并按 pendingNewTopic 自动聚焦,返回是否自动聚焦了新话题 */
|
/** 处理 topic_list 消息:映射格式并按 pendingNewTopic 自动聚焦,返回是否自动聚焦了新话题 */
|
||||||
handleTopicList: (msg: TopicList) => boolean
|
handleTopicList: (msg: TopicList) => boolean
|
||||||
|
/** 处理 topic_renamed 消息:用刷新后的列表替换本地状态(不改 selectedTopic) */
|
||||||
|
handleTopicRenamed: (msg: TopicRenamed) => void
|
||||||
createTopic: (title?: string) => Command
|
createTopic: (title?: string) => Command
|
||||||
switchTopic: (topicId: string) => Command
|
switchTopic: (topicId: string) => Command
|
||||||
deleteTopic: (topicId: string) => Command
|
deleteTopic: (topicId: string) => Command
|
||||||
|
renameTopic: (topicId: string, title: string) => Command
|
||||||
requestTopicList: (sessionId: string | null) => Command | null
|
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 {
|
export function useTopics(): UseTopicsReturn {
|
||||||
const [topics, setTopics] = useState<Topic[]>([])
|
const [topics, setTopics] = useState<Topic[]>([])
|
||||||
const [selectedTopic, setSelectedTopic] = useState<string | null>(null)
|
const [selectedTopic, setSelectedTopic] = useState<string | null>(null)
|
||||||
@ -42,15 +58,7 @@ export function useTopics(): UseTopicsReturn {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleTopicList = useCallback((msg: TopicList): boolean => {
|
const handleTopicList = useCallback((msg: TopicList): boolean => {
|
||||||
const newTopics: Topic[] = msg.topics.map((t: TopicSummary) => ({
|
const newTopics = mapTopicSummaries(msg.topics)
|
||||||
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,
|
|
||||||
}))
|
|
||||||
setTopics(newTopics)
|
setTopics(newTopics)
|
||||||
|
|
||||||
// 新建话题后自动聚焦到新话题(列表按 last_active_at DESC 排序,第一个即最新)
|
// 新建话题后自动聚焦到新话题(列表按 last_active_at DESC 排序,第一个即最新)
|
||||||
@ -64,6 +72,11 @@ export function useTopics(): UseTopicsReturn {
|
|||||||
return false
|
return false
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const handleTopicRenamed = useCallback((msg: TopicRenamed): void => {
|
||||||
|
// 后端返回刷新后的完整列表,直接替换;selectedTopic 基于 id 不变,无需调整
|
||||||
|
setTopics(mapTopicSummaries(msg.topics))
|
||||||
|
}, [])
|
||||||
|
|
||||||
const createTopic = useCallback((title?: string): Command => {
|
const createTopic = useCallback((title?: string): Command => {
|
||||||
pendingNewTopicRef.current = true
|
pendingNewTopicRef.current = true
|
||||||
return {
|
return {
|
||||||
@ -80,6 +93,10 @@ export function useTopics(): UseTopicsReturn {
|
|||||||
return { type: 'delete_topic', topic_id: topicId }
|
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 => {
|
const requestTopicList = useCallback((sessionId: string | null): Command | null => {
|
||||||
if (!sessionId) return null
|
if (!sessionId) return null
|
||||||
return { type: 'list_topics', session_id: sessionId }
|
return { type: 'list_topics', session_id: sessionId }
|
||||||
@ -96,9 +113,11 @@ export function useTopics(): UseTopicsReturn {
|
|||||||
selectedTopicRef,
|
selectedTopicRef,
|
||||||
pendingNewTopicRef,
|
pendingNewTopicRef,
|
||||||
handleTopicList,
|
handleTopicList,
|
||||||
|
handleTopicRenamed,
|
||||||
createTopic,
|
createTopic,
|
||||||
switchTopic,
|
switchTopic,
|
||||||
deleteTopic,
|
deleteTopic,
|
||||||
|
renameTopic,
|
||||||
requestTopicList,
|
requestTopicList,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -67,6 +67,7 @@ interface UseChatReturn {
|
|||||||
createTopic: (title?: string) => Command
|
createTopic: (title?: string) => Command
|
||||||
switchTopic: (topicId: string) => Command
|
switchTopic: (topicId: string) => Command
|
||||||
deleteTopic: (topicId: string) => Command
|
deleteTopic: (topicId: string) => Command
|
||||||
|
renameTopic: (topicId: string, title: string) => Command
|
||||||
|
|
||||||
// 初始化方法
|
// 初始化方法
|
||||||
requestSessionList: () => Command
|
requestSessionList: () => Command
|
||||||
@ -181,6 +182,10 @@ export function useChat(): UseChatReturn {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'topic_renamed':
|
||||||
|
topics.handleTopicRenamed(message)
|
||||||
|
return
|
||||||
|
|
||||||
case 'scheduler_job_list':
|
case 'scheduler_job_list':
|
||||||
scheduler.setSchedulerJobs(message.jobs)
|
scheduler.setSchedulerJobs(message.jobs)
|
||||||
return
|
return
|
||||||
@ -326,6 +331,7 @@ export function useChat(): UseChatReturn {
|
|||||||
createTopic: topics.createTopic,
|
createTopic: topics.createTopic,
|
||||||
switchTopic: topics.switchTopic,
|
switchTopic: topics.switchTopic,
|
||||||
deleteTopic: topics.deleteTopic,
|
deleteTopic: topics.deleteTopic,
|
||||||
|
renameTopic: topics.renameTopic,
|
||||||
requestSessionList,
|
requestSessionList,
|
||||||
requestTopicList,
|
requestTopicList,
|
||||||
topicRefreshTrigger: topics.topicRefreshTrigger,
|
topicRefreshTrigger: topics.topicRefreshTrigger,
|
||||||
|
|||||||
@ -165,6 +165,14 @@ export interface TopicList {
|
|||||||
session_id: string
|
session_id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TopicRenamed {
|
||||||
|
type: 'topic_renamed'
|
||||||
|
topic_id: string
|
||||||
|
title: string
|
||||||
|
topics: TopicSummary[]
|
||||||
|
session_id: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface Channel {
|
export interface Channel {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@ -303,6 +311,7 @@ export type WsOutbound =
|
|||||||
| SessionLoaded
|
| SessionLoaded
|
||||||
| SessionSaved
|
| SessionSaved
|
||||||
| TopicList
|
| TopicList
|
||||||
|
| TopicRenamed
|
||||||
| ChannelList
|
| ChannelList
|
||||||
| TaskMessagesLoaded
|
| TaskMessagesLoaded
|
||||||
| SchedulerJobList
|
| SchedulerJobList
|
||||||
@ -392,6 +401,12 @@ export interface DeleteTopicCommand {
|
|||||||
topic_id: string
|
topic_id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RenameTopicCommand {
|
||||||
|
type: 'rename_topic'
|
||||||
|
topic_id: string
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface StopExecutionCommand {
|
export interface StopExecutionCommand {
|
||||||
type: 'stop_execution'
|
type: 'stop_execution'
|
||||||
}
|
}
|
||||||
@ -443,6 +458,7 @@ export type Command =
|
|||||||
| ListSchedulerJobsCommand
|
| ListSchedulerJobsCommand
|
||||||
| LoadChatMessagesCommand
|
| LoadChatMessagesCommand
|
||||||
| DeleteTopicCommand
|
| DeleteTopicCommand
|
||||||
|
| RenameTopicCommand
|
||||||
| StopExecutionCommand
|
| StopExecutionCommand
|
||||||
| ListMemoriesCommand
|
| ListMemoriesCommand
|
||||||
| CreateMemoryCommand
|
| CreateMemoryCommand
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user