Compare commits
4 Commits
1309fa28da
...
cb0d3d932d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb0d3d932d | ||
|
|
4766f838a2 | ||
|
|
994db87f11 | ||
|
|
76abdbd1de |
@ -425,6 +425,8 @@ pub enum OutboundEventKind {
|
||||
StreamDelta,
|
||||
/// 流式结束信号
|
||||
StreamEnd,
|
||||
/// 智能体执行完全结束(不再有后续工具调用或 LLM 迭代)
|
||||
ExecutionCompleted,
|
||||
}
|
||||
|
||||
impl OutboundMessage {
|
||||
@ -629,7 +631,32 @@ impl OutboundMessage {
|
||||
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(
|
||||
channel: &str,
|
||||
chat_id: &str,
|
||||
|
||||
@ -2461,7 +2461,7 @@ impl Channel for FeishuChannel {
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
return Ok(());
|
||||
|
||||
@ -315,6 +315,7 @@ impl Channel for WechatChannel {
|
||||
| OutboundEventKind::ToolCall
|
||||
| OutboundEventKind::StreamDelta
|
||||
| OutboundEventKind::StreamEnd
|
||||
| OutboundEventKind::ExecutionCompleted
|
||||
) || msg.metadata.get("is_subagent_event").map(|v| v == "true").unwrap_or(false)
|
||||
{
|
||||
return Ok(());
|
||||
|
||||
@ -387,6 +387,25 @@ impl InboundProcessor {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
//! - Dynamically registers MCP tools via the Tool trait adapter
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use rmcp::{
|
||||
@ -18,10 +18,13 @@ use rmcp::{
|
||||
transport::streamable_http_client::{StreamableHttpClientTransport, StreamableHttpClientTransportConfig},
|
||||
};
|
||||
use http::{HeaderName, HeaderValue};
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::mcp::config::{McpServerConfig, McpTransportConfig};
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Stdio;
|
||||
|
||||
/// Resolve ${ENV_VAR} placeholders in a value 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()
|
||||
}
|
||||
|
||||
/// 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
|
||||
pub type McpClient = RunningService<RoleClient, ()>;
|
||||
|
||||
@ -183,7 +245,7 @@ impl McpClientManager {
|
||||
let transport = config.transport().map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
let client = match transport {
|
||||
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 } => {
|
||||
self.connect_http(&url, &headers).await?
|
||||
@ -219,12 +281,54 @@ impl McpClientManager {
|
||||
/// Connect via stdio transport (spawn child process)
|
||||
async fn connect_stdio(
|
||||
&self,
|
||||
server_key: &str,
|
||||
command: &str,
|
||||
args: &[String],
|
||||
env: &HashMap<String, String>,
|
||||
_cwd: &Option<std::path::PathBuf>,
|
||||
cwd: &Option<PathBuf>,
|
||||
) -> 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);
|
||||
|
||||
// Set environment variables
|
||||
@ -232,10 +336,70 @@ impl McpClientManager {
|
||||
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)
|
||||
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
|
||||
self.stdio_client_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
@ -598,4 +762,57 @@ impl McpInitializer {
|
||||
}
|
||||
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")]
|
||||
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")]
|
||||
TodoList {
|
||||
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(),
|
||||
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
|
||||
WHERE channel_name = ?1
|
||||
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 { 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 { TopicList } from './components/Sidebar/TopicList'
|
||||
import { SchedulerJobList } from './components/Sidebar/SchedulerJobList'
|
||||
@ -103,22 +103,25 @@ function App() {
|
||||
setSendMessage(sendMessage)
|
||||
}, [setSendMessage, sendMessage])
|
||||
|
||||
// ---- 主题状态 ----
|
||||
// ---- 右边栏状态(与左边栏对称的折叠/展开逻辑) ----
|
||||
|
||||
const [memoryPanelOpen, setMemoryPanelOpen] = useState(() => {
|
||||
const [rightSidebarCollapsed, setRightSidebarCollapsed] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem('picobot-memory-panel-open') !== 'false'
|
||||
return localStorage.getItem('picobot-right-sidebar-collapsed') === 'true'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
const toggleMemoryPanel = useCallback((open: boolean) => {
|
||||
setMemoryPanelOpen(open)
|
||||
localStorage.setItem('picobot-memory-panel-open', String(open))
|
||||
const toggleRightSidebar = useCallback(() => {
|
||||
setRightSidebarCollapsed(prev => {
|
||||
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(() => {
|
||||
try {
|
||||
@ -417,15 +420,25 @@ function App() {
|
||||
|
||||
// 点击待办项后滚动到对应消息
|
||||
const handleTodoClick = useCallback((todo: TodoItemSummary) => {
|
||||
if (todo.created_by_message_id) {
|
||||
// 先清再设,确保同一 todo 重复点击也能触发 useEffect
|
||||
setHighlightedMessageId(null)
|
||||
const msgId = todo.created_by_message_id
|
||||
setTimeout(() => setHighlightedMessageId(msgId), 0)
|
||||
} else {
|
||||
if (!todo.created_by_message_id) {
|
||||
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 cmd = requestSchedulerJobList()
|
||||
@ -780,80 +793,90 @@ function App() {
|
||||
showThinking={showThinking}
|
||||
viewKey={viewKey}
|
||||
highlightedMessageId={highlightedMessageId}
|
||||
todoPanel={
|
||||
<TodoPanel
|
||||
todos={todos}
|
||||
requestTodoList={refreshTodoList}
|
||||
sendCommand={sendMemoryCommand}
|
||||
onTodoClick={handleTodoClick}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Sidebar - 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 className={`w-80 h-full flex flex-col ${memoryPanelOpen ? '' : 'invisible'}`}>
|
||||
{/* Tab 栏 */}
|
||||
<div className="shrink-0 flex border-b border-[var(--border-color)]">
|
||||
<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={() => 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">
|
||||
{/* Right Sidebar - Todo / Memory / Skill Panel (collapsible, tabbed) */}
|
||||
<div
|
||||
className={`shrink-0 border-l border-[var(--border-color)] bg-[var(--bg-secondary)]/50 flex flex-col overflow-hidden ${rightSidebarCollapsed ? 'w-11' : 'w-80'}`}
|
||||
style={{ transition: 'width 200ms ease-out', willChange: 'width' }}
|
||||
>
|
||||
{rightSidebarCollapsed ? (
|
||||
// 折叠态:窄条 + 展开按钮
|
||||
<button
|
||||
onClick={() => toggleMemoryPanel(true)}
|
||||
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"
|
||||
title="展开记忆面板"
|
||||
onClick={toggleRightSidebar}
|
||||
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="展开侧栏"
|
||||
>
|
||||
<PanelRightOpen className="h-4 w-4" />
|
||||
<PanelLeftOpen className="h-4 w-4 rotate-180" />
|
||||
</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>
|
||||
|
||||
{/* 系统配置页面 */}
|
||||
|
||||
@ -11,8 +11,6 @@ interface ChatContainerProps {
|
||||
onNavigateToSubAgent?: (taskId: string, description: string, subagentType?: string) => void
|
||||
onStop?: () => void
|
||||
showThinking?: boolean
|
||||
/** 浮动待办面板,绝对定位在消息区域上方 */
|
||||
todoPanel?: React.ReactNode
|
||||
/** 视图标识,用于保存/恢复滚动位置 */
|
||||
viewKey?: string
|
||||
/** 高亮的消息 ID */
|
||||
@ -28,7 +26,6 @@ export function ChatContainer({
|
||||
onNavigateToSubAgent,
|
||||
onStop,
|
||||
showThinking = true,
|
||||
todoPanel,
|
||||
viewKey,
|
||||
highlightedMessageId,
|
||||
}: ChatContainerProps) {
|
||||
@ -36,7 +33,6 @@ export function ChatContainer({
|
||||
<div className="flex h-full flex-col relative">
|
||||
<div className="flex-1 overflow-hidden relative">
|
||||
<MessageList messages={messages} onNavigateToSubAgent={onNavigateToSubAgent} showThinking={showThinking} viewKey={viewKey} highlightedMessageId={highlightedMessageId} />
|
||||
{todoPanel}
|
||||
</div>
|
||||
<MessageInput
|
||||
onSend={onSendMessage}
|
||||
|
||||
@ -376,7 +376,7 @@ export function MessageBubble({ message, onNavigateToSubAgent, showThinking = tr
|
||||
} as const
|
||||
|
||||
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 ${
|
||||
isTaskTool ? 'bg-violet-500/20' : statusConfig.avatarBg
|
||||
}`}>
|
||||
|
||||
@ -123,18 +123,14 @@ export function MessageList({ messages, onNavigateToSubAgent, showThinking = tru
|
||||
|
||||
useEffect(() => {
|
||||
if (!highlightedMessageId) return
|
||||
|
||||
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
// 查找目标消息元素
|
||||
const targetElement = container.querySelector(`[data-message-id="${highlightedMessageId}"]`)
|
||||
if (!targetElement) return
|
||||
|
||||
// 滚动到目标位置
|
||||
targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
|
||||
// 添加高亮样式
|
||||
targetElement.classList.add('todo-highlight')
|
||||
setTimeout(() => {
|
||||
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 ────────────────────────────────────────── */
|
||||
|
||||
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 [pos, setPos] = useState(loadPos)
|
||||
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(() => {
|
||||
const newIds = new Set(todos.map(t => t.id))
|
||||
if (todos.length === 0) {
|
||||
setExpanded(false)
|
||||
} else {
|
||||
if (todos.length > 0) {
|
||||
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
|
||||
}, [todos])
|
||||
@ -104,169 +83,78 @@ export function TodoPanel({ todos, requestTodoList, sendCommand, onTodoClick }:
|
||||
|
||||
const handleRefresh = useCallback(() => sendCommand(requestTodoList()), [sendCommand, requestTodoList])
|
||||
|
||||
/* ── drag handling ──────────────────────────────────── */
|
||||
|
||||
const handleDragStart = useCallback((e: React.MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest('button')) return
|
||||
e.preventDefault()
|
||||
|
||||
const startX = e.clientX
|
||||
const startY = e.clientY
|
||||
const startPos = { ...pos }
|
||||
dragRef.current = { startX, startY, startPos, moved: false }
|
||||
|
||||
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" />
|
||||
)}
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* title bar */}
|
||||
<div className="shrink-0 flex items-center gap-2 px-4 py-2.5 border-b border-[var(--border-color)]/50">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── expanded: full card ────────────────────────────── */
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute z-30"
|
||||
style={{ top: `${16 + pos.y}px`, right: `${16 + pos.x}px` }}
|
||||
>
|
||||
<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>
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{GROUP_ORDER.map(status => {
|
||||
const items = grouped.get(status)
|
||||
if (!items || items.length === 0) return null
|
||||
{GROUP_ORDER.map(status => {
|
||||
const items = grouped.get(status)
|
||||
if (!items || items.length === 0) return null
|
||||
|
||||
const cfg = statusCfg(status)
|
||||
const isCollapsed = collapsedGroups.has(status)
|
||||
const cfg = statusCfg(status)
|
||||
const isCollapsed = collapsedGroups.has(status)
|
||||
|
||||
return (
|
||||
<div key={status} className="mt-1 first:mt-0">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<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-[10px] ${cfg.color} opacity-50 tabular-nums ml-0.5`}>{items.length}</span>
|
||||
<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'}`} />
|
||||
</span>
|
||||
</button>
|
||||
return (
|
||||
<div key={status} className="mt-1 first:mt-0">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<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-[10px] ${cfg.color} opacity-50 tabular-nums ml-0.5`}>{items.length}</span>
|
||||
<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'}`} />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<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">
|
||||
{items.map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
{item.content}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<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">
|
||||
{items.map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
{item.content}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -26,6 +26,7 @@ interface McpServerConfig {
|
||||
command?: string
|
||||
args?: string[]
|
||||
env?: Record<string, string>
|
||||
cwd?: string
|
||||
base_url?: string
|
||||
headers?: Record<string, 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>
|
||||
{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="工作目录 (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,每行一个">
|
||||
<textarea
|
||||
value={Object.entries(s.env ?? {}).map(([k, v]) => `${k}=${v}`).join('\n')}
|
||||
|
||||
@ -29,6 +29,7 @@ import type {
|
||||
ChannelList,
|
||||
StreamDelta,
|
||||
StreamEnd,
|
||||
ExecutionCompleted,
|
||||
WsInbound,
|
||||
} 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)
|
||||
break
|
||||
}
|
||||
@ -668,6 +669,15 @@ export function useChat(): UseChatReturn {
|
||||
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': {
|
||||
const msg = message as AssistantResponse
|
||||
// 按 topic_id 隔离:如果消息属于其他话题则丢弃
|
||||
@ -692,7 +702,7 @@ export function useChat(): UseChatReturn {
|
||||
}
|
||||
return [...prev, newMsg]
|
||||
})
|
||||
setIsLoading(false)
|
||||
// 注意:assistant_response 不设置 isLoading=false,智能体可能还会调用工具继续迭代
|
||||
|
||||
// 当前话题无描述时,可能刚触发了异步生成,标记需要刷新
|
||||
const currentTopic = topicsRef.current.find(t => t.id === selectedTopicRef.current)
|
||||
|
||||
@ -278,6 +278,12 @@ export interface StreamEnd {
|
||||
topic_id?: string
|
||||
}
|
||||
|
||||
export interface ExecutionCompleted {
|
||||
type: 'execution_completed'
|
||||
topic_id?: string
|
||||
timestamp?: number
|
||||
}
|
||||
|
||||
export type WsOutbound =
|
||||
| AssistantResponse
|
||||
| ToolCall
|
||||
@ -287,6 +293,7 @@ export type WsOutbound =
|
||||
| TaskStarted
|
||||
| StreamDelta
|
||||
| StreamEnd
|
||||
| ExecutionCompleted
|
||||
| SessionEstablished
|
||||
| SessionCreated
|
||||
| SessionList
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user