Compare commits
4 Commits
1309fa28da
...
cb0d3d932d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb0d3d932d | ||
|
|
4766f838a2 | ||
|
|
994db87f11 | ||
|
|
76abdbd1de |
@ -425,6 +425,8 @@ pub enum OutboundEventKind {
|
|||||||
StreamDelta,
|
StreamDelta,
|
||||||
/// 流式结束信号
|
/// 流式结束信号
|
||||||
StreamEnd,
|
StreamEnd,
|
||||||
|
/// 智能体执行完全结束(不再有后续工具调用或 LLM 迭代)
|
||||||
|
ExecutionCompleted,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OutboundMessage {
|
impl OutboundMessage {
|
||||||
@ -630,6 +632,31 @@ impl OutboundMessage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 构造执行完成信号
|
||||||
|
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,
|
||||||
|
|||||||
@ -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(());
|
||||||
|
|||||||
@ -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(());
|
||||||
|
|||||||
@ -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(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
@ -599,3 +763,56 @@ 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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>,
|
||||||
|
|||||||
@ -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()),
|
||||||
|
}],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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:%'
|
||||||
",
|
",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
187
web/src/App.tsx
187
web/src/App.tsx
@ -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>
|
||||||
|
|
||||||
{/* 系统配置页面 */}
|
{/* 系统配置页面 */}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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
|
||||||
}`}>
|
}`}>
|
||||||
|
|||||||
@ -127,14 +127,10 @@ export function MessageList({ messages, onNavigateToSubAgent, showThinking = tru
|
|||||||
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')
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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')}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user