Compare commits

...

4 Commits

Author SHA1 Message Date
oudecheng
cb0d3d932d feat: TodoPanel 改为常驻右边栏并支持点击跳转
- TodoPanel 移除浮动定位与拖动逻辑,改为常驻右边栏
- 右边栏与左边栏对称的折叠/展开逻辑,localStorage 持久化
- 待办/记忆/技能三 Tab 切换
- 点击已完成 todo 滚动并高亮对应 tool 消息
- 修复 MessageBubble isMergedTool 分支缺失 data-message-id 导致跳转失效
- ChatContainer 移除 todoPanel prop
2026-07-03 19:32:46 +08:00
oudecheng
4766f838a2 fix: 过滤 subagent session 避免污染会话列表
subagent session(id 形如 sub:...)存入 DB 时 channel_name=websocket,会被 list_sessions 查出并显示在前端右上角。SQL 添加 AND id NOT LIKE 'sub:%' 过滤。
2026-07-03 19:32:28 +08:00
oudecheng
994db87f11 feat: 新增 ExecutionCompleted 信号修复发送按钮状态
- 后端 OutboundEventKind/WsOutbound 新增 ExecutionCompleted 变体
- processor 在 handle_message 完成后发送该信号
- 飞书/微信通道过滤该信号(不发送)
- 前端 useChat 移除 stream_delta/assistant_response 的 isLoading=false
- 改由 execution_completed 事件统一设置 isLoading=false,确保智能体迭代期间按钮保持停止态
2026-07-03 19:32:14 +08:00
oudecheng
76abdbd1de feat: MCP stdio 命令解析与错误诊断增强
- 新增 resolve_command_path:Windows 上搜索 .exe/.cmd/.bat 后缀
- 启动失败时给出友好错误信息(含 PATH 和建议)
- 捕获子进程 stderr 并在连接失败时回显
- ConfigPage 新增 cwd 工作目录字段
- 添加单元测试覆盖路径解析逻辑
2026-07-03 19:31:52 +08:00
16 changed files with 483 additions and 285 deletions

View File

@ -425,6 +425,8 @@ pub enum OutboundEventKind {
StreamDelta, StreamDelta,
/// 流式结束信号 /// 流式结束信号
StreamEnd, StreamEnd,
/// 智能体执行完全结束(不再有后续工具调用或 LLM 迭代)
ExecutionCompleted,
} }
impl OutboundMessage { impl OutboundMessage {
@ -629,7 +631,32 @@ impl OutboundMessage {
message_id: None, message_id: None,
} }
} }
/// 构造执行完成信号
pub fn execution_completed(
channel: impl Into<String>,
chat_id: impl Into<String>,
session_id: Option<String>,
metadata: HashMap<String, String>,
) -> Self {
Self {
channel: channel.into(),
chat_id: chat_id.into(),
session_id,
content: String::new(),
reply_to: None,
media: Vec::new(),
metadata,
event_kind: OutboundEventKind::ExecutionCompleted,
role: "assistant".to_string(),
tool_call_id: None,
tool_name: None,
tool_arguments: None,
reasoning_content: None,
message_id: None,
}
}
pub fn from_chat_message( pub fn from_chat_message(
channel: &str, channel: &str,
chat_id: &str, chat_id: &str,

View File

@ -2461,7 +2461,7 @@ impl Channel for FeishuChannel {
} }
async fn send(&self, msg: OutboundMessage) -> Result<(), ChannelError> { async fn send(&self, msg: OutboundMessage) -> Result<(), ChannelError> {
if matches!(msg.event_kind, OutboundEventKind::ToolResult | OutboundEventKind::ToolPending | OutboundEventKind::StreamDelta | OutboundEventKind::StreamEnd) if matches!(msg.event_kind, OutboundEventKind::ToolResult | OutboundEventKind::ToolPending | OutboundEventKind::StreamDelta | OutboundEventKind::StreamEnd | OutboundEventKind::ExecutionCompleted)
|| msg.metadata.get("is_subagent_event").map(|v| v == "true").unwrap_or(false) || msg.metadata.get("is_subagent_event").map(|v| v == "true").unwrap_or(false)
{ {
return Ok(()); return Ok(());

View File

@ -315,6 +315,7 @@ impl Channel for WechatChannel {
| OutboundEventKind::ToolCall | OutboundEventKind::ToolCall
| OutboundEventKind::StreamDelta | OutboundEventKind::StreamDelta
| OutboundEventKind::StreamEnd | OutboundEventKind::StreamEnd
| OutboundEventKind::ExecutionCompleted
) || msg.metadata.get("is_subagent_event").map(|v| v == "true").unwrap_or(false) ) || msg.metadata.get("is_subagent_event").map(|v| v == "true").unwrap_or(false)
{ {
return Ok(()); return Ok(());

View File

@ -387,6 +387,25 @@ impl InboundProcessor {
self.cancel_manager.remove_by_topic(topic_id).await; self.cancel_manager.remove_by_topic(topic_id).await;
} }
// 发送执行完成信号,通知前端可以停止 loading 状态
// 无论成功还是失败都发送,确保前端状态正确
let mut completion_metadata = inbound.forwarded_metadata.clone();
if let Some(ref topic_id) = current_topic {
completion_metadata.insert("topic_id".to_string(), topic_id.clone());
}
if let Err(error) = self
.bus
.publish_outbound(OutboundMessage::execution_completed(
channel,
chat_id,
Some(session_id),
completion_metadata,
))
.await
{
tracing::error!(error = %error, "Failed to publish execution_completed");
}
Ok(()) Ok(())
} }
} }

View File

@ -7,7 +7,7 @@
//! - Dynamically registers MCP tools via the Tool trait adapter //! - Dynamically registers MCP tools via the Tool trait adapter
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::{Arc, Mutex};
use tokio::sync::RwLock; use tokio::sync::RwLock;
use rmcp::{ use rmcp::{
@ -18,10 +18,13 @@ use rmcp::{
transport::streamable_http_client::{StreamableHttpClientTransport, StreamableHttpClientTransportConfig}, transport::streamable_http_client::{StreamableHttpClientTransport, StreamableHttpClientTransportConfig},
}; };
use http::{HeaderName, HeaderValue}; use http::{HeaderName, HeaderValue};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command; use tokio::process::Command;
use crate::mcp::config::{McpServerConfig, McpTransportConfig}; use crate::mcp::config::{McpServerConfig, McpTransportConfig};
use std::env; use std::env;
use std::path::PathBuf;
use std::process::Stdio;
/// Resolve ${ENV_VAR} placeholders in a value string /// Resolve ${ENV_VAR} placeholders in a value string
fn resolve_env_placeholders_in_value(value: &str) -> String { fn resolve_env_placeholders_in_value(value: &str) -> String {
@ -33,6 +36,65 @@ fn resolve_env_placeholders_in_value(value: &str) -> String {
.to_string() .to_string()
} }
/// Resolve a command name to an executable path.
///
/// On Windows, if the command has no file extension, searches PATH for
/// `.exe`, `.cmd`, `.bat` variants (Rust's `Command::new` only finds `.exe`).
/// On non-Windows platforms, returns `None` (defers to OS default behavior).
fn resolve_command_path(command: &str) -> Option<PathBuf> {
#[cfg(windows)]
{
let path = PathBuf::from(command);
// If already an absolute path with extension, use directly
if path.is_absolute() {
if path.is_file() {
return Some(path);
}
// Absolute path but doesn't exist — try appending extensions if no ext
if path.extension().is_none() {
for ext in &["exe", "cmd", "bat"] {
let candidate = path.with_extension(ext);
if candidate.is_file() {
return Some(candidate);
}
}
}
return None;
}
// If it has a path separator (relative path), don't search PATH
if path.parent().map(|p| !p.as_os_str().is_empty()).unwrap_or(false) {
return None;
}
// Bare command name — search PATH with extensions
let extensions: &[&str] = if path.extension().is_some() {
&[""] // already has extension, try as-is
} else {
&[".exe", ".cmd", ".bat"]
};
let path_env = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path_env) {
for ext in extensions {
let candidate = dir.join(format!("{}{}", command, ext));
if candidate.is_file() {
return Some(candidate);
}
}
}
None
}
#[cfg(not(windows))]
{
// On non-Windows, defer to OS default behavior
let _ = command;
None
}
}
/// Type alias for the MCP client service /// Type alias for the MCP client service
pub type McpClient = RunningService<RoleClient, ()>; pub type McpClient = RunningService<RoleClient, ()>;
@ -183,7 +245,7 @@ impl McpClientManager {
let transport = config.transport().map_err(|e| anyhow::anyhow!("{}", e))?; let transport = config.transport().map_err(|e| anyhow::anyhow!("{}", e))?;
let client = match transport { let client = match transport {
McpTransportConfig::Stdio { command, args, env, cwd } => { McpTransportConfig::Stdio { command, args, env, cwd } => {
self.connect_stdio(&command, &args, &env, &cwd).await? self.connect_stdio(key, &command, &args, &env, &cwd).await?
} }
McpTransportConfig::Http { url, headers } => { McpTransportConfig::Http { url, headers } => {
self.connect_http(&url, &headers).await? self.connect_http(&url, &headers).await?
@ -219,12 +281,54 @@ impl McpClientManager {
/// Connect via stdio transport (spawn child process) /// Connect via stdio transport (spawn child process)
async fn connect_stdio( async fn connect_stdio(
&self, &self,
server_key: &str,
command: &str, command: &str,
args: &[String], args: &[String],
env: &HashMap<String, String>, env: &HashMap<String, String>,
_cwd: &Option<std::path::PathBuf>, cwd: &Option<PathBuf>,
) -> anyhow::Result<McpClient> { ) -> anyhow::Result<McpClient> {
let mut cmd = Command::new(command); // Resolve command path (Windows extension handling)
let resolved_command = resolve_command_path(command);
let effective_command: PathBuf = match &resolved_command {
Some(p) => p.clone(),
None => PathBuf::from(command),
};
// Pre-flight check on Windows: if command is bare name and not resolved, give friendly error
#[cfg(windows)]
if resolved_command.is_none() {
let path = std::path::Path::new(command);
let is_absolute = path.is_absolute();
let has_separator = path.parent().map(|p| !p.as_os_str().is_empty()).unwrap_or(false);
if !is_absolute && !has_separator && path.extension().is_none() {
// Bare name not found on Windows
let path_env = std::env::var("PATH").unwrap_or_default();
return Err(anyhow::anyhow!(
"Command '{}' not found in PATH (tried .exe, .cmd, .bat). \
Current PATH: {}. \
Suggestion: use the full absolute path to the executable, \
or ensure the tool is installed and its directory is in PATH.",
command, path_env
));
}
}
let env_keys: Vec<&String> = env.keys().collect();
let cwd_display = cwd
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "<inherit>".to_string());
tracing::info!(
server_key = %server_key,
command = %effective_command.display(),
args = ?args,
env_keys = ?env_keys, // only keys, NOT values (avoid leaking secrets)
cwd = %cwd_display,
"Spawning MCP stdio child process"
);
let mut cmd = Command::new(&effective_command);
cmd.args(args); cmd.args(args);
// Set environment variables // Set environment variables
@ -232,10 +336,70 @@ impl McpClientManager {
cmd.env(key, value); cmd.env(key, value);
} }
let transport = TokioChildProcess::new(cmd)?; // Set working directory if specified
if let Some(cwd_path) = cwd {
cmd.current_dir(cwd_path);
}
// Shared buffer to collect stderr lines for error reporting
let stderr_lines: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let stderr_lines_for_task = stderr_lines.clone();
// Use builder to capture stderr (default is inherit)
let (transport, stderr_opt) = TokioChildProcess::builder(cmd)
.stderr(Stdio::piped())
.spawn()
.map_err(|e| {
anyhow::anyhow!(
"Failed to spawn MCP stdio child process '{}': {}",
effective_command.display(),
e
)
})?;
// Spawn a background task to read stderr and log it
if let Some(child_stderr) = stderr_opt {
let server_key_owned = server_key.to_string();
tokio::spawn(async move {
let reader = BufReader::new(child_stderr);
let mut lines = reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
tracing::warn!(
server_key = %server_key_owned,
stderr = %line,
"MCP child process stderr"
);
// Also collect into the shared buffer (cap at 50 lines)
if let Ok(mut buf) = stderr_lines_for_task.lock() {
if buf.len() < 50 {
buf.push(line);
}
}
}
});
}
// Use default client handler (empty tuple) // Use default client handler (empty tuple)
let client = ().serve(transport).await?; let client = ().serve(transport).await.map_err(|e| {
// Include stderr summary in error if available
let stderr_summary = stderr_lines
.lock()
.ok()
.map(|buf| {
if buf.is_empty() {
String::new()
} else {
format!("\nstderr:\n {}", buf.join("\n "))
}
})
.unwrap_or_default();
anyhow::anyhow!(
"Failed to establish MCP stdio connection '{}': {}{}",
effective_command.display(),
e,
stderr_summary
)
})?;
// Track that we have a stdio (child process) connection // Track that we have a stdio (child process) connection
self.stdio_client_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); self.stdio_client_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
@ -598,4 +762,57 @@ impl McpInitializer {
} }
Ok(()) Ok(())
} }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_resolve_command_path_returns_none_for_nonexistent_bare_name() {
// A bare name that definitely doesn't exist
let result = resolve_command_path("this_binary_definitely_does_not_exist_xyz123");
// On all platforms, this should return None (either because it's not found,
// or because non-Windows always returns None)
#[cfg(windows)]
assert!(result.is_none(), "Expected None for nonexistent command on Windows");
#[cfg(not(windows))]
assert!(result.is_none(), "Expected None on non-Windows (always returns None)");
}
#[test]
fn test_resolve_command_path_absolute_path_existing() {
// Use an absolute path to a known existing executable
#[cfg(windows)]
let cmd = "C:\\Windows\\System32\\cmd.exe";
#[cfg(not(windows))]
let cmd = "/bin/ls";
let result = resolve_command_path(cmd);
#[cfg(windows)]
assert!(result.is_some(), "Expected to find {} on Windows", cmd);
#[cfg(not(windows))]
assert!(result.is_none(), "Expected None on non-Windows for absolute path");
}
#[test]
fn test_resolve_command_path_absolute_path_nonexistent() {
let result = resolve_command_path("/definitely/not/a/real/path/binary123");
assert!(result.is_none());
}
#[test]
#[cfg(windows)]
fn test_resolve_command_path_finds_known_windows_binary() {
// cmd.exe should always be in C:\Windows\System32 which is in PATH
let result = resolve_command_path("cmd");
assert!(result.is_some(), "Expected to find cmd.exe via PATH on Windows");
if let Some(p) = result {
assert!(
p.to_string_lossy().to_lowercase().ends_with("cmd.exe"),
"Expected resolved path to end with cmd.exe, got: {}",
p.display()
);
}
}
} }

View File

@ -295,6 +295,13 @@ pub enum WsOutbound {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
topic_id: Option<String>, topic_id: Option<String>,
}, },
#[serde(rename = "execution_completed")]
ExecutionCompleted {
#[serde(default, skip_serializing_if = "Option::is_none")]
topic_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
timestamp: Option<i64>,
},
#[serde(rename = "todo_list")] #[serde(rename = "todo_list")]
TodoList { TodoList {
todos: Vec<TodoItemSummary>, todos: Vec<TodoItemSummary>,

View File

@ -194,6 +194,10 @@ pub(crate) fn ws_outbound_from_outbound_message(message: &OutboundMessage) -> Ve
subagent_task_id: message.metadata.get("subagent_task_id").cloned(), subagent_task_id: message.metadata.get("subagent_task_id").cloned(),
topic_id: message.metadata.get("topic_id").cloned(), topic_id: message.metadata.get("topic_id").cloned(),
}], }],
OutboundEventKind::ExecutionCompleted => vec![WsOutbound::ExecutionCompleted {
topic_id: message.metadata.get("topic_id").cloned(),
timestamp: Some(crate::protocol::now_timestamp()),
}],
} }
} }

View File

@ -376,6 +376,7 @@ impl SessionStore {
FROM sessions FROM sessions
WHERE channel_name = ?1 WHERE channel_name = ?1
AND deleted_at IS NULL AND deleted_at IS NULL
AND id NOT LIKE 'sub:%'
", ",
); );

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Zap, ArrowLeft, Bot, Clock, Sun, Moon, PanelRightOpen, PanelLeftClose, PanelLeftOpen, X, Brain, Settings as SettingsIcon, ChevronRight } from 'lucide-react' import { Zap, ArrowLeft, Bot, Clock, Sun, Moon, PanelLeftClose, PanelLeftOpen, Brain, Settings as SettingsIcon, ChevronRight } from 'lucide-react'
import { ChatContainer } from './components/Chat/ChatContainer' import { ChatContainer } from './components/Chat/ChatContainer'
import { TopicList } from './components/Sidebar/TopicList' import { TopicList } from './components/Sidebar/TopicList'
import { SchedulerJobList } from './components/Sidebar/SchedulerJobList' import { SchedulerJobList } from './components/Sidebar/SchedulerJobList'
@ -103,22 +103,25 @@ function App() {
setSendMessage(sendMessage) setSendMessage(sendMessage)
}, [setSendMessage, sendMessage]) }, [setSendMessage, sendMessage])
// ---- 主题状态 ---- // ---- 右边栏状态(与左边栏对称的折叠/展开逻辑) ----
const [memoryPanelOpen, setMemoryPanelOpen] = useState(() => { const [rightSidebarCollapsed, setRightSidebarCollapsed] = useState(() => {
try { try {
return localStorage.getItem('picobot-memory-panel-open') !== 'false' return localStorage.getItem('picobot-right-sidebar-collapsed') === 'true'
} catch { } catch {
return false return false
} }
}) })
const toggleMemoryPanel = useCallback((open: boolean) => { const toggleRightSidebar = useCallback(() => {
setMemoryPanelOpen(open) setRightSidebarCollapsed(prev => {
localStorage.setItem('picobot-memory-panel-open', String(open)) const next = !prev
localStorage.setItem('picobot-right-sidebar-collapsed', String(next))
return next
})
}, []) }, [])
const [rightPanelTab, setRightPanelTab] = useState<'memory' | 'skill'>('memory') const [rightPanelTab, setRightPanelTab] = useState<'todo' | 'memory' | 'skill'>('todo')
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => { const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
try { try {
@ -417,15 +420,25 @@ function App() {
// 点击待办项后滚动到对应消息 // 点击待办项后滚动到对应消息
const handleTodoClick = useCallback((todo: TodoItemSummary) => { const handleTodoClick = useCallback((todo: TodoItemSummary) => {
if (todo.created_by_message_id) { if (!todo.created_by_message_id) {
// 先清再设,确保同一 todo 重复点击也能触发 useEffect
setHighlightedMessageId(null)
const msgId = todo.created_by_message_id
setTimeout(() => setHighlightedMessageId(msgId), 0)
} else {
alert('该待办的完成记录无法定位,可能是历史数据') alert('该待办的完成记录无法定位,可能是历史数据')
return
} }
}, [setHighlightedMessageId]) // 若处于子智能体视图或定时任务视图,先退出回到主会话视图
if (subAgentStack.length > 0) {
navigateToSubAgentLevel(-1)
}
if (schedulerView) {
exitSchedulerJobView()
}
// 先清再设,确保同一 todo 重复点击也能触发 useEffect
const msgId = todo.created_by_message_id
setHighlightedMessageId(null)
// 延迟一帧,等视图切换后消息列表渲染完成再滚动
setTimeout(() => {
setHighlightedMessageId(msgId)
}, 50)
}, [setHighlightedMessageId, subAgentStack.length, schedulerView, navigateToSubAgentLevel, exitSchedulerJobView])
const handleRefreshSchedulerJobs = useCallback(() => { const handleRefreshSchedulerJobs = useCallback(() => {
const cmd = requestSchedulerJobList() const cmd = requestSchedulerJobList()
@ -780,80 +793,90 @@ function App() {
showThinking={showThinking} showThinking={showThinking}
viewKey={viewKey} viewKey={viewKey}
highlightedMessageId={highlightedMessageId} highlightedMessageId={highlightedMessageId}
todoPanel={
<TodoPanel
todos={todos}
requestTodoList={refreshTodoList}
sendCommand={sendMemoryCommand}
onTodoClick={handleTodoClick}
/>
}
/> />
</div> </div>
</div> </div>
{/* Right Sidebar - Memory & Skill Panel (collapsible, tabbed) */} {/* Right Sidebar - Todo / Memory / Skill Panel (collapsible, tabbed) */}
<div className={`shrink-0 border-l border-[var(--border-color)] bg-[var(--bg-secondary)]/50 transition-all duration-300 ease-out overflow-hidden ${memoryPanelOpen ? 'w-80' : 'w-0 border-l-0'}`}> <div
<div className={`w-80 h-full flex flex-col ${memoryPanelOpen ? '' : 'invisible'}`}> className={`shrink-0 border-l border-[var(--border-color)] bg-[var(--bg-secondary)]/50 flex flex-col overflow-hidden ${rightSidebarCollapsed ? 'w-11' : 'w-80'}`}
{/* Tab 栏 */} style={{ transition: 'width 200ms ease-out', willChange: 'width' }}
<div className="shrink-0 flex border-b border-[var(--border-color)]"> >
<button {rightSidebarCollapsed ? (
onClick={() => setRightPanelTab('memory')} // 折叠态:窄条 + 展开按钮
className={`flex-1 py-2.5 text-sm font-medium text-center transition-colors ${
rightPanelTab === 'memory'
? 'text-[var(--accent-cyan)] border-b-2 border-[var(--accent-cyan)]'
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'
}`}
>
</button>
<button
onClick={() => setRightPanelTab('skill')}
className={`flex-1 py-2.5 text-sm font-medium text-center transition-colors ${
rightPanelTab === 'skill'
? 'text-[var(--accent-cyan)] border-b-2 border-[var(--accent-cyan)]'
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'
}`}
>
</button>
<button onClick={() => toggleMemoryPanel(false)} className="px-2 py-2.5 text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors" title="收起">
<X className="h-3.5 w-3.5" />
</button>
</div>
{/* Panel content */}
<div className="flex-1 min-h-0">
{rightPanelTab === 'memory' ? (
<MemoryPanel
memories={memories}
onRefresh={handleRefreshMemories}
onCreateMemory={createMemory}
onUpdateMemory={updateMemory}
onDeleteMemory={deleteMemory}
sendCommand={sendMemoryCommand}
/>
) : (
<SkillList
skills={skills}
onRefresh={handleRefreshSkills}
/>
)}
</div>
</div>
</div>
{/* Reopen button — visible when panel is collapsed */}
{!memoryPanelOpen && (
<div className="absolute right-0 top-1/2 -translate-y-1/2 z-10">
<button <button
onClick={() => toggleMemoryPanel(true)} onClick={toggleRightSidebar}
className="flex items-center gap-1.5 px-2 py-4 rounded-l-xl bg-[var(--bg-secondary)]/80 backdrop-blur-sm border border-r-0 border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:border-[var(--accent-cyan)]/30 transition-all duration-300 shadow-lg" className="flex items-center justify-center w-full py-4 text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:bg-[var(--overlay-hover)] transition-colors"
title="展开记忆面板" title="展开侧栏"
> >
<PanelRightOpen className="h-4 w-4" /> <PanelLeftOpen className="h-4 w-4 rotate-180" />
</button> </button>
</div> ) : (
)} <div className="w-80 h-full flex flex-col">
{/* Tab 栏 */}
<div className="shrink-0 flex border-b border-[var(--border-color)]">
<button
onClick={() => setRightPanelTab('todo')}
className={`flex-1 py-2.5 text-sm font-medium text-center transition-colors ${
rightPanelTab === 'todo'
? 'text-[var(--accent-cyan)] border-b-2 border-[var(--accent-cyan)]'
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'
}`}
>
</button>
<button
onClick={() => setRightPanelTab('memory')}
className={`flex-1 py-2.5 text-sm font-medium text-center transition-colors ${
rightPanelTab === 'memory'
? 'text-[var(--accent-cyan)] border-b-2 border-[var(--accent-cyan)]'
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'
}`}
>
</button>
<button
onClick={() => setRightPanelTab('skill')}
className={`flex-1 py-2.5 text-sm font-medium text-center transition-colors ${
rightPanelTab === 'skill'
? 'text-[var(--accent-cyan)] border-b-2 border-[var(--accent-cyan)]'
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'
}`}
>
</button>
<button onClick={toggleRightSidebar} className="px-2 py-2.5 text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors shrink-0" title="收起">
<PanelLeftClose className="h-3.5 w-3.5 rotate-180" />
</button>
</div>
{/* Panel content */}
<div className="flex-1 min-h-0">
{rightPanelTab === 'todo' ? (
<TodoPanel
todos={todos}
requestTodoList={refreshTodoList}
sendCommand={sendMemoryCommand}
onTodoClick={handleTodoClick}
/>
) : rightPanelTab === 'memory' ? (
<MemoryPanel
memories={memories}
onRefresh={handleRefreshMemories}
onCreateMemory={createMemory}
onUpdateMemory={updateMemory}
onDeleteMemory={deleteMemory}
sendCommand={sendMemoryCommand}
/>
) : (
<SkillList
skills={skills}
onRefresh={handleRefreshSkills}
/>
)}
</div>
</div>
)}
</div>
</div> </div>
{/* 系统配置页面 */} {/* 系统配置页面 */}

View File

@ -11,8 +11,6 @@ interface ChatContainerProps {
onNavigateToSubAgent?: (taskId: string, description: string, subagentType?: string) => void onNavigateToSubAgent?: (taskId: string, description: string, subagentType?: string) => void
onStop?: () => void onStop?: () => void
showThinking?: boolean showThinking?: boolean
/** 浮动待办面板,绝对定位在消息区域上方 */
todoPanel?: React.ReactNode
/** 视图标识,用于保存/恢复滚动位置 */ /** 视图标识,用于保存/恢复滚动位置 */
viewKey?: string viewKey?: string
/** 高亮的消息 ID */ /** 高亮的消息 ID */
@ -28,7 +26,6 @@ export function ChatContainer({
onNavigateToSubAgent, onNavigateToSubAgent,
onStop, onStop,
showThinking = true, showThinking = true,
todoPanel,
viewKey, viewKey,
highlightedMessageId, highlightedMessageId,
}: ChatContainerProps) { }: ChatContainerProps) {
@ -36,7 +33,6 @@ export function ChatContainer({
<div className="flex h-full flex-col relative"> <div className="flex h-full flex-col relative">
<div className="flex-1 overflow-hidden relative"> <div className="flex-1 overflow-hidden relative">
<MessageList messages={messages} onNavigateToSubAgent={onNavigateToSubAgent} showThinking={showThinking} viewKey={viewKey} highlightedMessageId={highlightedMessageId} /> <MessageList messages={messages} onNavigateToSubAgent={onNavigateToSubAgent} showThinking={showThinking} viewKey={viewKey} highlightedMessageId={highlightedMessageId} />
{todoPanel}
</div> </div>
<MessageInput <MessageInput
onSend={onSendMessage} onSend={onSendMessage}

View File

@ -376,7 +376,7 @@ export function MessageBubble({ message, onNavigateToSubAgent, showThinking = tr
} as const } as const
return ( return (
<div className="flex gap-3 animate-slide-in"> <div data-message-id={message.id} className="flex gap-3 animate-slide-in">
<div className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-full mt-0.5 ${ <div className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-full mt-0.5 ${
isTaskTool ? 'bg-violet-500/20' : statusConfig.avatarBg isTaskTool ? 'bg-violet-500/20' : statusConfig.avatarBg
}`}> }`}>

View File

@ -123,18 +123,14 @@ export function MessageList({ messages, onNavigateToSubAgent, showThinking = tru
useEffect(() => { useEffect(() => {
if (!highlightedMessageId) return if (!highlightedMessageId) return
const container = containerRef.current const container = containerRef.current
if (!container) return if (!container) return
// 查找目标消息元素
const targetElement = container.querySelector(`[data-message-id="${highlightedMessageId}"]`) const targetElement = container.querySelector(`[data-message-id="${highlightedMessageId}"]`)
if (!targetElement) return if (!targetElement) return
// 滚动到目标位置
targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' }) targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
// 添加高亮样式
targetElement.classList.add('todo-highlight') targetElement.classList.add('todo-highlight')
setTimeout(() => { setTimeout(() => {
targetElement.classList.remove('todo-highlight') targetElement.classList.remove('todo-highlight')

View File

@ -47,45 +47,24 @@ function PulseDot() {
) )
} }
/* ── position persistence ─────────────────────────────── */
const POS_KEY = 'picobot-todo-pos'
function loadPos(): { x: number; y: number } {
try {
const raw = localStorage.getItem(POS_KEY)
if (raw) return JSON.parse(raw)
} catch { /* ignore */ }
return { x: 0, y: 0 }
}
function savePos(pos: { x: number; y: number }) {
try { localStorage.setItem(POS_KEY, JSON.stringify(pos)) } catch { /* ignore */ }
}
/* ── TodoPanel ────────────────────────────────────────── */ /* ── TodoPanel ────────────────────────────────────────── */
export function TodoPanel({ todos, requestTodoList, sendCommand, onTodoClick }: TodoPanelProps) { export function TodoPanel({ todos, requestTodoList, sendCommand, onTodoClick }: TodoPanelProps) {
const [expanded, setExpanded] = useState(() => {
try { return localStorage.getItem('picobot-todo-expanded') === 'true' } catch { return false }
})
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(() => new Set(['completed', 'cancelled'])) const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(() => new Set(['completed', 'cancelled']))
const [pos, setPos] = useState(loadPos)
const prevTodoIdsRef = useRef<Set<string>>(new Set()) const prevTodoIdsRef = useRef<Set<string>>(new Set())
const dragRef = useRef<{ startX: number; startY: number; startPos: { x: number; y: number }; moved: boolean } | null>(null)
useEffect(() => { // 新增待办时自动展开"进行中"分组
localStorage.setItem('picobot-todo-expanded', String(expanded))
}, [expanded])
// auto-expand on new items, auto-collapse when empty
useEffect(() => { useEffect(() => {
const newIds = new Set(todos.map(t => t.id)) const newIds = new Set(todos.map(t => t.id))
if (todos.length === 0) { if (todos.length > 0) {
setExpanded(false)
} else {
const hasNewItems = todos.some(t => !prevTodoIdsRef.current.has(t.id)) const hasNewItems = todos.some(t => !prevTodoIdsRef.current.has(t.id))
if (hasNewItems) setExpanded(true) if (hasNewItems) {
setCollapsedGroups(prev => {
const next = new Set(prev)
next.delete('in_progress')
return next
})
}
} }
prevTodoIdsRef.current = newIds prevTodoIdsRef.current = newIds
}, [todos]) }, [todos])
@ -104,169 +83,78 @@ export function TodoPanel({ todos, requestTodoList, sendCommand, onTodoClick }:
const handleRefresh = useCallback(() => sendCommand(requestTodoList()), [sendCommand, requestTodoList]) const handleRefresh = useCallback(() => sendCommand(requestTodoList()), [sendCommand, requestTodoList])
/* ── drag handling ──────────────────────────────────── */ return (
<div className="flex h-full flex-col">
const handleDragStart = useCallback((e: React.MouseEvent) => { {/* title bar */}
if ((e.target as HTMLElement).closest('button')) return <div className="shrink-0 flex items-center gap-2 px-4 py-2.5 border-b border-[var(--border-color)]/50">
e.preventDefault() <ClipboardList className="h-4 w-4 text-[var(--accent-cyan)]/80" />
<span className="text-[13px] font-semibold text-[var(--text-primary)] tracking-tight"></span>
const startX = e.clientX <span className="text-[11px] text-[var(--text-muted)]/80 tabular-nums">{totalCount}</span>
const startY = e.clientY {inProgressCount > 0 && <PulseDot />}
const startPos = { ...pos } <div className="ml-auto flex items-center gap-0.5">
dragRef.current = { startX, startY, startPos, moved: false } <button onClick={handleRefresh} className="p-1.5 rounded-lg text-[var(--text-muted)]/50 hover:text-[var(--accent-cyan)] hover:bg-[var(--overlay-hover)] transition-colors" title="刷新">
<RefreshCw className="h-3.5 w-3.5" />
const handleMove = (ev: MouseEvent) => {
if (!dragRef.current) return
const dx = ev.clientX - dragRef.current.startX
const dy = ev.clientY - dragRef.current.startY
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) dragRef.current.moved = true
setPos({
x: Math.max(0, dragRef.current.startPos.x - dx),
y: Math.max(0, dragRef.current.startPos.y + dy),
})
}
const handleUp = () => {
document.removeEventListener('mousemove', handleMove)
document.removeEventListener('mouseup', handleUp)
document.body.style.userSelect = ''
document.body.style.cursor = ''
if (dragRef.current) {
setPos(prev => { savePos(prev); return prev })
dragRef.current = null
}
}
document.addEventListener('mousemove', handleMove)
document.addEventListener('mouseup', handleUp)
document.body.style.userSelect = 'none'
document.body.style.cursor = 'grabbing'
}, [pos])
/* ── minimized: circle button ───────────────────────── */
if (!expanded) {
return (
<div
className="absolute z-30"
style={{ top: `${16 + pos.y}px`, right: `${16 + pos.x}px` }}
>
<div
className="relative cursor-grab active:cursor-grabbing select-none"
onMouseDown={handleDragStart}
>
<button
onClick={() => { if (totalCount > 0) setExpanded(true); else handleRefresh() }}
className="relative w-14 h-14 rounded-full bg-[var(--bg-tertiary)]/90 backdrop-blur-xl border border-[var(--border-color)] hover:border-[var(--accent-cyan)]/50 shadow-[0_4px_24px_rgba(0,240,255,0.08)] hover:shadow-[0_4px_32px_var(--shadow-glow-sm)] transition-all duration-300 group"
title="待办"
>
<div className="flex items-center justify-center">
<ClipboardList className="h-5 w-5 text-[var(--text-muted)] group-hover:text-[var(--accent-cyan)] transition-colors" />
</div>
{totalCount > 0 && (
<span className={`absolute -top-1.5 -right-1.5 min-w-[20px] h-5 px-1 rounded-full flex items-center justify-center text-[11px] font-bold leading-tight ${
inProgressCount > 0
? 'bg-amber-400 text-black shadow-[0_0_12px_rgba(245,158,11,0.4)]'
: 'bg-[var(--bg-secondary)] text-[var(--text-secondary)] border border-[var(--border-color)]'
}`}>
{totalCount}
</span>
)}
{inProgressCount > 0 && (
<span className="absolute -inset-[3px] rounded-full animate-todo-ring-pulse" />
)}
</button> </button>
</div> </div>
</div> </div>
)
}
/* ── expanded: full card ────────────────────────────── */ {/* list */}
<div className="flex-1 overflow-y-auto scrollbar-hide px-4 py-3 space-y-2">
return ( {totalCount === 0 && (
<div <div className="flex flex-col items-center justify-center py-8 px-4 text-center select-none">
className="absolute z-30" <div className="relative mb-4">
style={{ top: `${16 + pos.y}px`, right: `${16 + pos.x}px` }} <div className="absolute inset-0 rounded-full bg-[var(--accent-cyan)]/10 blur-xl animate-pulse" />
> <ClipboardList className="relative h-10 w-10 text-[var(--accent-cyan)]/25" />
<div className="w-80 max-h-[55vh] flex flex-col rounded-2xl bg-[var(--bg-tertiary)]/95 backdrop-blur-md border border-[var(--border-color)] shadow-2xl hover:shadow-[0_8px_40px_var(--shadow-glow-sm)] overflow-hidden animate-todo-card-in">
{/* title bar (drag handle) */}
<div
className="shrink-0 flex items-center gap-2 px-4 py-2.5 border-b border-[var(--border-color)]/50 cursor-grab active:cursor-grabbing select-none rounded-t-2xl"
onMouseDown={handleDragStart}
>
<span className="text-[var(--text-muted)]/40 text-sm tracking-[0.15em] leading-none select-none group-hover:text-[var(--accent-cyan)]/60 transition-colors"></span>
<ClipboardList className="h-4 w-4 text-[var(--accent-cyan)]/80" />
<span className="text-[13px] font-semibold text-[var(--text-primary)] tracking-tight"></span>
<span className="text-[11px] text-[var(--text-muted)]/80 tabular-nums">{totalCount}</span>
{inProgressCount > 0 && <PulseDot />}
<div className="ml-auto flex items-center gap-0.5">
<button onClick={handleRefresh} className="p-1.5 rounded-lg text-[var(--text-muted)]/50 hover:text-[var(--accent-cyan)] hover:bg-[var(--overlay-hover)] transition-colors" title="刷新">
<RefreshCw className="h-3.5 w-3.5" />
</button>
<button onClick={() => setExpanded(false)} className="p-1.5 rounded-lg text-[var(--text-muted)]/50 hover:text-[var(--text-secondary)] hover:bg-[var(--overlay-hover)] transition-colors" title="缩小">
<ChevronDown className="h-4 w-4" />
</button>
</div>
</div>
{/* list */}
<div className="flex-1 overflow-y-auto scrollbar-hide px-4 py-3 space-y-2">
{totalCount === 0 && (
<div className="flex flex-col items-center justify-center py-8 px-4 text-center select-none">
<div className="relative mb-4">
<div className="absolute inset-0 rounded-full bg-[var(--accent-cyan)]/10 blur-xl animate-pulse" />
<ClipboardList className="relative h-10 w-10 text-[var(--accent-cyan)]/25" />
</div>
<p className="text-[12px] text-[var(--text-muted)] leading-relaxed mb-1">
</p>
<p className="text-[10px] text-[var(--text-muted)]/60 leading-relaxed">
AI
</p>
</div> </div>
)} <p className="text-[12px] text-[var(--text-muted)] leading-relaxed mb-1">
</p>
<p className="text-[10px] text-[var(--text-muted)]/60 leading-relaxed">
AI
</p>
</div>
)}
{GROUP_ORDER.map(status => { {GROUP_ORDER.map(status => {
const items = grouped.get(status) const items = grouped.get(status)
if (!items || items.length === 0) return null if (!items || items.length === 0) return null
const cfg = statusCfg(status) const cfg = statusCfg(status)
const isCollapsed = collapsedGroups.has(status) const isCollapsed = collapsedGroups.has(status)
return ( return (
<div key={status} className="mt-1 first:mt-0"> <div key={status} className="mt-1 first:mt-0">
<button <button
onClick={() => toggleGroup(status)} onClick={() => toggleGroup(status)}
className="sticky top-0 z-10 flex items-center gap-2 w-full py-1.5 rounded-lg transition-colors hover:bg-[var(--overlay-hover)] bg-[var(--bg-tertiary)]/95 backdrop-blur-sm" className="sticky top-0 z-10 flex items-center gap-2 w-full py-1.5 rounded-lg transition-colors hover:bg-[var(--overlay-hover)] bg-[var(--bg-tertiary)]/95 backdrop-blur-sm"
> >
<span className={`h-2 w-2 rounded-full ${cfg.dot} shrink-0`} /> <span className={`h-2 w-2 rounded-full ${cfg.dot} shrink-0`} />
<span className={`text-[12px] font-semibold ${cfg.color}`}>{cfg.label}</span> <span className={`text-[12px] font-semibold ${cfg.color}`}>{cfg.label}</span>
<span className={`text-[10px] ${cfg.color} opacity-50 tabular-nums ml-0.5`}>{items.length}</span> <span className={`text-[10px] ${cfg.color} opacity-50 tabular-nums ml-0.5`}>{items.length}</span>
<span className="ml-auto text-[var(--text-muted)]/50"> <span className="ml-auto text-[var(--text-muted)]/50">
<ChevronDown className={`h-3.5 w-3.5 transition-transform duration-200 ${isCollapsed ? '-rotate-90' : 'rotate-0'}`} /> <ChevronDown className={`h-3.5 w-3.5 transition-transform duration-200 ${isCollapsed ? '-rotate-90' : 'rotate-0'}`} />
</span> </span>
</button> </button>
<div className={`todo-group-body ${isCollapsed ? 'todo-group-body-closed' : 'todo-group-body-open'}`}> <div className={`todo-group-body ${isCollapsed ? 'todo-group-body-closed' : 'todo-group-body-open'}`}>
<div className="ml-[7px] border-l-2 border-[var(--border-color)]/60 pl-3 mt-1.5 space-y-0.5"> <div className="ml-[7px] border-l-2 border-[var(--border-color)]/60 pl-3 mt-1.5 space-y-0.5">
{items.map(item => ( {items.map(item => (
<button <button
key={item.id} key={item.id}
onClick={() => onTodoClick?.(item)} onClick={() => onTodoClick?.(item)}
className="group/item w-full text-left py-1.5 px-2 -mx-2 rounded-md transition-colors duration-150 hover:bg-[var(--overlay-hover)] flex items-start gap-1.5 cursor-pointer" className="group/item w-full text-left py-1.5 px-2 -mx-2 rounded-md transition-colors duration-150 hover:bg-[var(--overlay-hover)] flex items-start gap-1.5 cursor-pointer"
> >
<span className={`h-2 w-2 rounded-full ${cfg.dot} shrink-0 mt-1.5`} /> <span className={`h-2 w-2 rounded-full ${cfg.dot} shrink-0 mt-1.5`} />
<span className="text-[13px] leading-relaxed text-[var(--text-primary)]/85 group-hover/item:text-[var(--text-primary)] transition-colors break-words"> <span className="text-[13px] leading-relaxed text-[var(--text-primary)]/85 group-hover/item:text-[var(--text-primary)] transition-colors break-words">
{item.content} {item.content}
</span> </span>
</button> </button>
))} ))}
</div>
</div> </div>
</div> </div>
) </div>
})} )
</div> })}
</div> </div>
</div> </div>
) )

View File

@ -26,6 +26,7 @@ interface McpServerConfig {
command?: string command?: string
args?: string[] args?: string[]
env?: Record<string, string> env?: Record<string, string>
cwd?: string
base_url?: string base_url?: string
headers?: Record<string, string> headers?: Record<string, string>
description?: string description?: string
@ -746,8 +747,9 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
<Field label="描述"><input value={s.description ?? ''} onChange={e => updMcp(name, { description: e.target.value || undefined })} className={inputCls} placeholder="可选描述" /></Field> <Field label="描述"><input value={s.description ?? ''} onChange={e => updMcp(name, { description: e.target.value || undefined })} className={inputCls} placeholder="可选描述" /></Field>
{s.type === 'stdio' && ( {s.type === 'stdio' && (
<> <>
<Field label="命令" hint="如 npx, node, cargo"><input value={s.command ?? ''} onChange={e => updMcp(name, { command: e.target.value })} className={inputCls} placeholder="npx" /></Field> <Field label="命令" hint="如 npx, node, cargo, uv"><input value={s.command ?? ''} onChange={e => updMcp(name, { command: e.target.value })} className={inputCls} placeholder="npx" /></Field>
<Field label="参数" hint="空格分隔"><input value={(s.args ?? []).join(' ')} onChange={e => updMcp(name, { args: e.target.value ? e.target.value.split(/\s+/) : [] })} className={inputCls} placeholder="-y @modelcontextprotocol/server-filesystem /tmp" /></Field> <Field label="参数" hint="空格分隔"><input value={(s.args ?? []).join(' ')} onChange={e => updMcp(name, { args: e.target.value ? e.target.value.split(/\s+/) : [] })} className={inputCls} placeholder="-y @modelcontextprotocol/server-filesystem /tmp" /></Field>
<Field label="工作目录 (cwd)" hint="可选。子进程运行目录,常用于 uv/python 项目解析 pyproject.toml 或 venv"><input value={s.cwd ?? ''} onChange={e => updMcp(name, { cwd: e.target.value || undefined })} className={inputCls} placeholder="E:\code_project\my-mcp-server" /></Field>
<Field label="环境变量" hint="KEY=VALUE每行一个"> <Field label="环境变量" hint="KEY=VALUE每行一个">
<textarea <textarea
value={Object.entries(s.env ?? {}).map(([k, v]) => `${k}=${v}`).join('\n')} value={Object.entries(s.env ?? {}).map(([k, v]) => `${k}=${v}`).join('\n')}

View File

@ -29,6 +29,7 @@ import type {
ChannelList, ChannelList,
StreamDelta, StreamDelta,
StreamEnd, StreamEnd,
ExecutionCompleted,
WsInbound, WsInbound,
} from '../types/protocol' } from '../types/protocol'
@ -658,7 +659,7 @@ export function useChat(): UseChatReturn {
}, },
] ]
}) })
setIsLoading(false) // 注意stream_delta 期间不设置 isLoading=false智能体仍在生成
if (msg.user_message_id) applyUserMessageId(msg.user_message_id) if (msg.user_message_id) applyUserMessageId(msg.user_message_id)
break break
} }
@ -668,6 +669,15 @@ export function useChat(): UseChatReturn {
break break
} }
case 'execution_completed': {
// 智能体执行完全结束(不再有后续工具调用或 LLM 迭代)
const msg = message as ExecutionCompleted
// 按 topic_id 隔离:只处理当前话题的完成信号
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return
setIsLoading(false)
break
}
case 'assistant_response': { case 'assistant_response': {
const msg = message as AssistantResponse const msg = message as AssistantResponse
// 按 topic_id 隔离:如果消息属于其他话题则丢弃 // 按 topic_id 隔离:如果消息属于其他话题则丢弃
@ -692,7 +702,7 @@ export function useChat(): UseChatReturn {
} }
return [...prev, newMsg] return [...prev, newMsg]
}) })
setIsLoading(false) // 注意assistant_response 不设置 isLoading=false智能体可能还会调用工具继续迭代
// 当前话题无描述时,可能刚触发了异步生成,标记需要刷新 // 当前话题无描述时,可能刚触发了异步生成,标记需要刷新
const currentTopic = topicsRef.current.find(t => t.id === selectedTopicRef.current) const currentTopic = topicsRef.current.find(t => t.id === selectedTopicRef.current)

View File

@ -278,6 +278,12 @@ export interface StreamEnd {
topic_id?: string topic_id?: string
} }
export interface ExecutionCompleted {
type: 'execution_completed'
topic_id?: string
timestamp?: number
}
export type WsOutbound = export type WsOutbound =
| AssistantResponse | AssistantResponse
| ToolCall | ToolCall
@ -287,6 +293,7 @@ export type WsOutbound =
| TaskStarted | TaskStarted
| StreamDelta | StreamDelta
| StreamEnd | StreamEnd
| ExecutionCompleted
| SessionEstablished | SessionEstablished
| SessionCreated | SessionCreated
| SessionList | SessionList