feat(gateway): 支持网关平滑重启功能
- 在 Gateway 运行逻辑中增加重启循环,可根据重启信号自动重启服务 - 添加取消管理器功能,支持取消所有运行中的 Agent 以便平滑重启 - 在 HTTP API 新增 /api/restart 接口,实现基于任务活动检测的安全重启 - GatewayState 增加重启信号通道,支持异步触发重启流程 - 修改主运行逻辑支持重启等待机制,实现优雅关闭或重启 - 日志初始化修复防止重复初始化导致的问题 - 技能和子代理加载支持自定义绝对路径来源,增加灵活性和扩展性 - 调整技能与子代理来源枚举,增加 Custom 选项支持动态路径 - 优化工具与任务运行时代码,完善路径处理和克隆逻辑 - 前端配置页增加时区选择下拉菜单,提供常用时区选项 - 新增来源路径编辑组件,支持启用/禁用已知来源及添加自定义绝对路径 - 配置页新增保存配置后重启提示,支持用户确认立即重启网关 - 实现重启操作的前端调用及重启状态展示,包括任务冲突提示与重启轮询恢复 - 频道绑定 Agent 字段由输入框改为下拉选择,提升配置体验和正确性
This commit is contained in:
parent
18ad891a51
commit
d802534abe
@ -52,6 +52,21 @@ impl CancelManager {
|
|||||||
pub async fn remove_by_topic(&self, topic_id: &str) {
|
pub async fn remove_by_topic(&self, topic_id: &str) {
|
||||||
self.tokens.lock().await.remove(topic_id);
|
self.tokens.lock().await.remove(topic_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 返回当前正在运行的 Agent 数量。
|
||||||
|
pub async fn active_count(&self) -> usize {
|
||||||
|
self.tokens.lock().await.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取消所有正在运行的 Agent 并清空注册表。
|
||||||
|
///
|
||||||
|
/// 用于 graceful shutdown / restart 场景。
|
||||||
|
pub async fn cancel_all(&self) {
|
||||||
|
let mut tokens = self.tokens.lock().await;
|
||||||
|
for (_, tx) in tokens.drain() {
|
||||||
|
let _ = tx.send(());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for CancelManager {
|
impl Default for CancelManager {
|
||||||
|
|||||||
@ -118,3 +118,36 @@ pub async fn save_config(
|
|||||||
config_path: config_path.to_string_lossy().to_string(),
|
config_path: config_path.to_string_lossy().to_string(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct RestartResponse {
|
||||||
|
pub success: bool,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/restart — Restart the gateway to apply config changes
|
||||||
|
pub async fn restart(
|
||||||
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
) -> Result<Json<RestartResponse>, (StatusCode, Json<RestartResponse>)> {
|
||||||
|
let active = state.cancel_manager.active_count().await;
|
||||||
|
if active > 0 {
|
||||||
|
return Err((
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
Json(RestartResponse {
|
||||||
|
success: false,
|
||||||
|
message: format!("当前有 {} 个任务正在运行,请等待完成后再重启", active),
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let restart_tx = state.restart_tx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||||
|
let _ = restart_tx.send(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Json(RestartResponse {
|
||||||
|
success: true,
|
||||||
|
message: "服务正在重启...".to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|||||||
@ -52,6 +52,8 @@ use session_message_sender::BusSessionMessageSender;
|
|||||||
use session::SessionManager;
|
use session::SessionManager;
|
||||||
use static_files::static_handler;
|
use static_files::static_handler;
|
||||||
|
|
||||||
|
use tokio::sync::watch;
|
||||||
|
|
||||||
pub struct GatewayState {
|
pub struct GatewayState {
|
||||||
pub config: Config,
|
pub config: Config,
|
||||||
pub session_manager: SessionManager,
|
pub session_manager: SessionManager,
|
||||||
@ -59,10 +61,11 @@ pub struct GatewayState {
|
|||||||
pub bus: Arc<MessageBus>,
|
pub bus: Arc<MessageBus>,
|
||||||
pub task_repository: Arc<dyn TaskRepository>,
|
pub task_repository: Arc<dyn TaskRepository>,
|
||||||
pub cancel_manager: CancelManager,
|
pub cancel_manager: CancelManager,
|
||||||
|
pub restart_tx: watch::Sender<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GatewayState {
|
impl GatewayState {
|
||||||
pub fn from_config(config: Config) -> Result<Self, Box<dyn std::error::Error>> {
|
pub fn from_config(config: Config, restart_tx: watch::Sender<bool>) -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
// Get provider config for SessionManager
|
// Get provider config for SessionManager
|
||||||
let provider_config = config.get_provider_config("default")?;
|
let provider_config = config.get_provider_config("default")?;
|
||||||
let mut provider_configs = HashMap::<String, LLMProviderConfig>::new();
|
let mut provider_configs = HashMap::<String, LLMProviderConfig>::new();
|
||||||
@ -109,6 +112,7 @@ impl GatewayState {
|
|||||||
bus,
|
bus,
|
||||||
task_repository,
|
task_repository,
|
||||||
cancel_manager,
|
cancel_manager,
|
||||||
|
restart_tx,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -150,7 +154,7 @@ impl GatewayState {
|
|||||||
pub async fn run(
|
pub async fn run(
|
||||||
host: Option<String>,
|
host: Option<String>,
|
||||||
port: Option<u16>,
|
port: Option<u16>,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||||
let config = Config::load_default()?;
|
let config = Config::load_default()?;
|
||||||
let timezone = config.time.parse_timezone()?;
|
let timezone = config.time.parse_timezone()?;
|
||||||
|
|
||||||
@ -158,7 +162,10 @@ pub async fn run(
|
|||||||
logging::init_logging(timezone);
|
logging::init_logging(timezone);
|
||||||
tracing::info!("Starting PicoBot Gateway");
|
tracing::info!("Starting PicoBot Gateway");
|
||||||
|
|
||||||
let state = Arc::new(GatewayState::from_config(config)?);
|
// Restart signal channel
|
||||||
|
let (restart_tx, mut restart_rx) = watch::channel(false);
|
||||||
|
|
||||||
|
let state = Arc::new(GatewayState::from_config(config, restart_tx)?);
|
||||||
|
|
||||||
// Get provider config for channels
|
// Get provider config for channels
|
||||||
let provider_config = state.config.get_provider_config("default")?;
|
let provider_config = state.config.get_provider_config("default")?;
|
||||||
@ -201,6 +208,7 @@ pub async fn run(
|
|||||||
Router::new()
|
Router::new()
|
||||||
.route("/health", routing::get(http::health))
|
.route("/health", routing::get(http::health))
|
||||||
.route("/api/config", routing::get(http::get_config).put(http::save_config))
|
.route("/api/config", routing::get(http::get_config).put(http::save_config))
|
||||||
|
.route("/api/restart", routing::post(http::restart))
|
||||||
.route("/ws", routing::get(ws::ws_handler))
|
.route("/ws", routing::get(ws::ws_handler))
|
||||||
.fallback(static_handler)
|
.fallback(static_handler)
|
||||||
.with_state(state.clone())
|
.with_state(state.clone())
|
||||||
@ -209,6 +217,7 @@ pub async fn run(
|
|||||||
Router::new()
|
Router::new()
|
||||||
.route("/health", routing::get(http::health))
|
.route("/health", routing::get(http::health))
|
||||||
.route("/api/config", routing::get(http::get_config).put(http::save_config))
|
.route("/api/config", routing::get(http::get_config).put(http::save_config))
|
||||||
|
.route("/api/restart", routing::post(http::restart))
|
||||||
.route("/ws", routing::get(ws::ws_handler))
|
.route("/ws", routing::get(ws::ws_handler))
|
||||||
.fallback_service(ServeDir::new(&static_dir))
|
.fallback_service(ServeDir::new(&static_dir))
|
||||||
.with_state(state.clone())
|
.with_state(state.clone())
|
||||||
@ -218,25 +227,48 @@ pub async fn run(
|
|||||||
let listener = TcpListener::bind(&addr).await?;
|
let listener = TcpListener::bind(&addr).await?;
|
||||||
tracing::info!(address = %addr, "Gateway listening");
|
tracing::info!(address = %addr, "Gateway listening");
|
||||||
|
|
||||||
// Graceful shutdown using oneshot channel
|
// Graceful shutdown / restart signal
|
||||||
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
|
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
|
||||||
|
// Side channel to communicate whether this was a restart or shutdown
|
||||||
|
let (result_tx, result_rx) = tokio::sync::oneshot::channel::<bool>();
|
||||||
let channel_manager = state.channel_manager.clone();
|
let channel_manager = state.channel_manager.clone();
|
||||||
|
let cancel_manager = state.cancel_manager.clone();
|
||||||
|
|
||||||
// Spawn ctrl_c handler
|
// Spawn ctrl_c / restart handler
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
tokio::signal::ctrl_c().await.ok();
|
tokio::select! {
|
||||||
|
_ = tokio::signal::ctrl_c() => {
|
||||||
tracing::info!("Shutdown signal received");
|
tracing::info!("Shutdown signal received");
|
||||||
|
cancel_manager.cancel_all().await;
|
||||||
let _ = scheduler_shutdown_tx.send(true);
|
let _ = scheduler_shutdown_tx.send(true);
|
||||||
let _ = channel_manager.stop_all().await;
|
let _ = channel_manager.stop_all().await;
|
||||||
|
let _ = result_tx.send(false);
|
||||||
let _ = shutdown_tx.send(());
|
let _ = shutdown_tx.send(());
|
||||||
|
}
|
||||||
|
_ = restart_rx.changed() => {
|
||||||
|
if *restart_rx.borrow() {
|
||||||
|
tracing::info!("Restart signal received");
|
||||||
|
cancel_manager.cancel_all().await;
|
||||||
|
let _ = scheduler_shutdown_tx.send(true);
|
||||||
|
let _ = channel_manager.stop_all().await;
|
||||||
|
let _ = result_tx.send(true);
|
||||||
|
let _ = shutdown_tx.send(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Serve with graceful shutdown
|
// Serve with graceful shutdown
|
||||||
axum::serve(listener, app)
|
axum::serve(listener, app)
|
||||||
.with_graceful_shutdown(async {
|
.with_graceful_shutdown(async {
|
||||||
shutdown_rx.await.ok();
|
let _ = shutdown_rx.await;
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(())
|
// Wait briefly for in-flight requests to complete
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||||
|
|
||||||
|
// Check if this was a restart
|
||||||
|
let should_restart = result_rx.await.unwrap_or(false);
|
||||||
|
Ok(should_restart)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,6 +41,16 @@ pub fn get_default_config_path() -> PathBuf {
|
|||||||
/// Initialize logging with file appender
|
/// Initialize logging with file appender
|
||||||
/// Logs are written to ~/.picobot/logs/ with daily rotation
|
/// Logs are written to ~/.picobot/logs/ with daily rotation
|
||||||
pub fn init_logging(timezone: Tz) {
|
pub fn init_logging(timezone: Tz) {
|
||||||
|
use std::sync::Once;
|
||||||
|
static INIT: Once = Once::new();
|
||||||
|
|
||||||
|
let mut initialized = false;
|
||||||
|
INIT.call_once(|| { initialized = true; });
|
||||||
|
if !initialized {
|
||||||
|
// Already initialized (e.g. after gateway restart), skip
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let log_dir = get_default_log_dir();
|
let log_dir = get_default_log_dir();
|
||||||
|
|
||||||
// Create log directory if it doesn't exist
|
// Create log directory if it doesn't exist
|
||||||
|
|||||||
@ -56,7 +56,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
picobot::client::run(&url).await?;
|
picobot::client::run(&url).await?;
|
||||||
}
|
}
|
||||||
Command::Gateway { host, port } => {
|
Command::Gateway { host, port } => {
|
||||||
picobot::gateway::run(host, port).await?;
|
loop {
|
||||||
|
let should_restart = picobot::gateway::run(host.clone(), port).await?;
|
||||||
|
if !should_restart {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tracing::info!("Gateway restarting...");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@ -25,7 +25,7 @@ pub struct Skill {
|
|||||||
pub path: PathBuf,
|
pub path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum SkillSource {
|
pub enum SkillSource {
|
||||||
User,
|
User,
|
||||||
UserAgent,
|
UserAgent,
|
||||||
@ -33,6 +33,7 @@ pub enum SkillSource {
|
|||||||
Project,
|
Project,
|
||||||
ProjectAgent,
|
ProjectAgent,
|
||||||
ProjectOpenclaw,
|
ProjectOpenclaw,
|
||||||
|
Custom(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
@ -326,7 +327,7 @@ impl crate::agent::SkillProvider for SkillRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SkillSource {
|
impl SkillSource {
|
||||||
fn as_str(&self) -> &'static str {
|
pub fn as_str(&self) -> &str {
|
||||||
match self {
|
match self {
|
||||||
SkillSource::User => "user",
|
SkillSource::User => "user",
|
||||||
SkillSource::UserAgent => "user_agent",
|
SkillSource::UserAgent => "user_agent",
|
||||||
@ -334,6 +335,7 @@ impl SkillSource {
|
|||||||
SkillSource::Project => "project",
|
SkillSource::Project => "project",
|
||||||
SkillSource::ProjectAgent => "project_agent",
|
SkillSource::ProjectAgent => "project_agent",
|
||||||
SkillSource::ProjectOpenclaw => "project_openclaw",
|
SkillSource::ProjectOpenclaw => "project_openclaw",
|
||||||
|
SkillSource::Custom(path) => path.as_str(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -387,10 +389,10 @@ impl SkillCatalog {
|
|||||||
// Load from least specific to most specific so later sources win on conflicts.
|
// Load from least specific to most specific so later sources win on conflicts.
|
||||||
for source in source_order(&config.sources) {
|
for source in source_order(&config.sources) {
|
||||||
sources_seen += 1;
|
sources_seen += 1;
|
||||||
let root = source_root(source, &cwd);
|
let root = source_root(&source, &cwd);
|
||||||
|
|
||||||
let Some(root) = root else { continue };
|
let Some(root) = root else { continue };
|
||||||
for skill in load_skills_from_root(&root, source) {
|
for skill in load_skills_from_root(&root, source.clone()) {
|
||||||
if let Some(existing) = merged.get(&skill.name) {
|
if let Some(existing) = merged.get(&skill.name) {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
skill = %skill.name,
|
skill = %skill.name,
|
||||||
@ -595,7 +597,10 @@ fn source_order(sources: &[String]) -> Vec<SkillSource> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
unknown => {
|
unknown => {
|
||||||
tracing::warn!(source = %unknown, "Unknown skills source ignored");
|
let custom = SkillSource::Custom(unknown.to_string());
|
||||||
|
if !result.contains(&custom) {
|
||||||
|
result.push(custom);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -658,7 +663,7 @@ fn user_openclaw_skills_root() -> Option<PathBuf> {
|
|||||||
platform_home_dir().map(|p| p.join(".openclaw").join("skills"))
|
platform_home_dir().map(|p| p.join(".openclaw").join("skills"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn source_root(source: SkillSource, cwd: &Path) -> Option<PathBuf> {
|
fn source_root(source: &SkillSource, cwd: &Path) -> Option<PathBuf> {
|
||||||
match source {
|
match source {
|
||||||
SkillSource::User => user_skills_root(),
|
SkillSource::User => user_skills_root(),
|
||||||
SkillSource::UserAgent => user_agent_skills_root(),
|
SkillSource::UserAgent => user_agent_skills_root(),
|
||||||
@ -666,6 +671,15 @@ fn source_root(source: SkillSource, cwd: &Path) -> Option<PathBuf> {
|
|||||||
SkillSource::Project => Some(cwd.join(".picobot").join("skills")),
|
SkillSource::Project => Some(cwd.join(".picobot").join("skills")),
|
||||||
SkillSource::ProjectAgent => Some(project_agent_skills_root(cwd)),
|
SkillSource::ProjectAgent => Some(project_agent_skills_root(cwd)),
|
||||||
SkillSource::ProjectOpenclaw => Some(project_openclaw_skills_root(cwd)),
|
SkillSource::ProjectOpenclaw => Some(project_openclaw_skills_root(cwd)),
|
||||||
|
SkillSource::Custom(path) => {
|
||||||
|
let p = std::path::PathBuf::from(path);
|
||||||
|
if p.is_absolute() {
|
||||||
|
Some(p)
|
||||||
|
} else {
|
||||||
|
tracing::warn!(path = %path, "Custom skills source must be an absolute path, skipping");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -819,7 +833,7 @@ fn load_skills_from_root(root: &Path, source: SkillSource) -> Vec<Skill> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
match parse_skill_file(&skill_md, source) {
|
match parse_skill_file(&skill_md, source.clone()) {
|
||||||
Ok(skill) => out.push(skill),
|
Ok(skill) => out.push(skill),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
tracing::warn!(path = %skill_md.display(), error = %err, "Skipping invalid skill file");
|
tracing::warn!(path = %skill_md.display(), error = %err, "Skipping invalid skill file");
|
||||||
|
|||||||
@ -117,14 +117,7 @@ impl Tool for SkillManageTool {
|
|||||||
"name": skill.name,
|
"name": skill.name,
|
||||||
"description": skill.description,
|
"description": skill.description,
|
||||||
"body": skill.body,
|
"body": skill.body,
|
||||||
"source": match skill.source {
|
"source": skill.source.as_str(),
|
||||||
crate::skills::SkillSource::User => "user",
|
|
||||||
crate::skills::SkillSource::UserAgent => "user_agent",
|
|
||||||
crate::skills::SkillSource::UserOpenclaw => "user_openclaw",
|
|
||||||
crate::skills::SkillSource::Project => "project",
|
|
||||||
crate::skills::SkillSource::ProjectAgent => "project_agent",
|
|
||||||
crate::skills::SkillSource::ProjectOpenclaw => "project_openclaw",
|
|
||||||
},
|
|
||||||
"path": skill.path.display().to_string(),
|
"path": skill.path.display().to_string(),
|
||||||
}),
|
}),
|
||||||
None => return Ok(error_result(&format!("skill '{}' not found", name))),
|
None => return Ok(error_result(&format!("skill '{}' not found", name))),
|
||||||
@ -276,14 +269,7 @@ fn list_skills_payload(skills: &Arc<SkillRuntime>) -> serde_json::Value {
|
|||||||
"skills": skills.into_iter().map(|skill| json!({
|
"skills": skills.into_iter().map(|skill| json!({
|
||||||
"name": skill.name,
|
"name": skill.name,
|
||||||
"description": skill.description,
|
"description": skill.description,
|
||||||
"source": match skill.source {
|
"source": skill.source.as_str(),
|
||||||
crate::skills::SkillSource::User => "user",
|
|
||||||
crate::skills::SkillSource::UserAgent => "user_agent",
|
|
||||||
crate::skills::SkillSource::UserOpenclaw => "user_openclaw",
|
|
||||||
crate::skills::SkillSource::Project => "project",
|
|
||||||
crate::skills::SkillSource::ProjectAgent => "project_agent",
|
|
||||||
crate::skills::SkillSource::ProjectOpenclaw => "project_openclaw",
|
|
||||||
},
|
|
||||||
"path": skill.path.display().to_string(),
|
"path": skill.path.display().to_string(),
|
||||||
})).collect::<Vec<_>>()
|
})).collect::<Vec<_>>()
|
||||||
})
|
})
|
||||||
|
|||||||
@ -737,7 +737,7 @@ impl SubagentCatalog {
|
|||||||
// 按配置顺序扫描源目录
|
// 按配置顺序扫描源目录
|
||||||
if config.enabled {
|
if config.enabled {
|
||||||
for source in source_order(&config.sources) {
|
for source in source_order(&config.sources) {
|
||||||
let root = source_root(source, cwd);
|
let root = source_root(&source, cwd);
|
||||||
tracing::debug!(source = ?source, root = ?root.as_ref().map(|p| p.display().to_string()), "Checking subagent source");
|
tracing::debug!(source = ?source, root = ?root.as_ref().map(|p| p.display().to_string()), "Checking subagent source");
|
||||||
if let Some(root) = root {
|
if let Some(root) = root {
|
||||||
if root.exists() {
|
if root.exists() {
|
||||||
@ -745,7 +745,7 @@ impl SubagentCatalog {
|
|||||||
} else {
|
} else {
|
||||||
tracing::debug!(path = %root.display(), "Subagents directory does not exist, skipping");
|
tracing::debug!(path = %root.display(), "Subagents directory does not exist, skipping");
|
||||||
}
|
}
|
||||||
for def in load_subagents_from_root(&root, source) {
|
for def in load_subagents_from_root(&root, source.clone()) {
|
||||||
if let Some(existing) = merged.get(&def.name) {
|
if let Some(existing) = merged.get(&def.name) {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
subagent = %def.name,
|
subagent = %def.name,
|
||||||
@ -849,7 +849,10 @@ fn source_order(sources: &[String]) -> Vec<SubagentSource> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
unknown => {
|
unknown => {
|
||||||
tracing::warn!(source = %unknown, "Unknown subagents source ignored");
|
let custom = SubagentSource::Custom(unknown.to_string());
|
||||||
|
if !result.contains(&custom) {
|
||||||
|
result.push(custom);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -863,11 +866,20 @@ fn source_order(sources: &[String]) -> Vec<SubagentSource> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 获取源目录根路径
|
/// 获取源目录根路径
|
||||||
fn source_root(source: SubagentSource, cwd: &Path) -> Option<std::path::PathBuf> {
|
fn source_root(source: &SubagentSource, cwd: &Path) -> Option<std::path::PathBuf> {
|
||||||
match source {
|
match source {
|
||||||
SubagentSource::User => dirs::home_dir().map(|p| p.join(".picobot").join("subagents")),
|
SubagentSource::User => dirs::home_dir().map(|p| p.join(".picobot").join("subagents")),
|
||||||
SubagentSource::Project => Some(cwd.join(".picobot").join("subagents")),
|
SubagentSource::Project => Some(cwd.join(".picobot").join("subagents")),
|
||||||
SubagentSource::Builtin => None,
|
SubagentSource::Builtin => None,
|
||||||
|
SubagentSource::Custom(path) => {
|
||||||
|
let p = std::path::PathBuf::from(path);
|
||||||
|
if p.is_absolute() {
|
||||||
|
Some(p)
|
||||||
|
} else {
|
||||||
|
tracing::warn!(path = %path, "Custom subagents source must be an absolute path, skipping");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -921,7 +933,7 @@ fn load_subagents_from_root(root: &Path, source: SubagentSource) -> Vec<Subagent
|
|||||||
}
|
}
|
||||||
found_files += 1;
|
found_files += 1;
|
||||||
|
|
||||||
match parse_subagent_file(&subagent_md, source) {
|
match parse_subagent_file(&subagent_md, source.clone()) {
|
||||||
Ok(def) => {
|
Ok(def) => {
|
||||||
tracing::info!(name = %def.name, path = %subagent_md.display(), "Loaded subagent");
|
tracing::info!(name = %def.name, path = %subagent_md.display(), "Loaded subagent");
|
||||||
out.push(def);
|
out.push(def);
|
||||||
|
|||||||
@ -23,7 +23,7 @@ impl Default for TaskSessionState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 子代理来源
|
/// 子代理来源
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum SubagentSource {
|
pub enum SubagentSource {
|
||||||
/// 内置定义
|
/// 内置定义
|
||||||
@ -32,6 +32,8 @@ pub enum SubagentSource {
|
|||||||
User,
|
User,
|
||||||
/// 项目级自定义 (./.picobot/subagents/)
|
/// 项目级自定义 (./.picobot/subagents/)
|
||||||
Project,
|
Project,
|
||||||
|
/// 自定义绝对路径
|
||||||
|
Custom(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 子代理完整定义
|
/// 子代理完整定义
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, type ReactNode } from 'react'
|
|||||||
import {
|
import {
|
||||||
Settings, Server, Cpu, Bot, Clock, Calendar, Wrench, Brain, Image,
|
Settings, Server, Cpu, Bot, Clock, Calendar, Wrench, Brain, Image,
|
||||||
Users, Save, X, Plus, Trash2, AlertTriangle, Loader2, Wifi,
|
Users, Save, X, Plus, Trash2, AlertTriangle, Loader2, Wifi,
|
||||||
CheckCircle, Plug, Radio,
|
CheckCircle, Plug, Radio, RefreshCw,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────
|
// ── Types ──────────────────────────────────────────────
|
||||||
@ -84,6 +84,29 @@ function Field({ label, children, hint }: { label: string; children: ReactNode;
|
|||||||
const inputCls = "w-full px-3 py-2 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border-color)] text-[var(--text-primary)] text-sm placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--accent-cyan)] focus:ring-1 focus:ring-[var(--focus-ring)] transition-colors"
|
const inputCls = "w-full px-3 py-2 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border-color)] text-[var(--text-primary)] text-sm placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--accent-cyan)] focus:ring-1 focus:ring-[var(--focus-ring)] transition-colors"
|
||||||
const selectCls = inputCls
|
const selectCls = inputCls
|
||||||
|
|
||||||
|
const TIMEZONE_OPTIONS: { value: string; label: string }[] = [
|
||||||
|
{ value: 'Asia/Shanghai', label: 'Asia/Shanghai (中国标准时间, UTC+8)' },
|
||||||
|
{ value: 'Asia/Tokyo', label: 'Asia/Tokyo (日本标准时间, UTC+9)' },
|
||||||
|
{ value: 'Asia/Seoul', label: 'Asia/Seoul (韩国标准时间, UTC+9)' },
|
||||||
|
{ value: 'Asia/Singapore', label: 'Asia/Singapore (新加坡时间, UTC+8)' },
|
||||||
|
{ value: 'Asia/Hong_Kong', label: 'Asia/Hong_Kong (香港时间, UTC+8)' },
|
||||||
|
{ value: 'Asia/Taipei', label: 'Asia/Taipei (台北时间, UTC+8)' },
|
||||||
|
{ value: 'Asia/Bangkok', label: 'Asia/Bangkok (曼谷时间, UTC+7)' },
|
||||||
|
{ value: 'Asia/Kolkata', label: 'Asia/Kolkata (印度标准时间, UTC+5:30)' },
|
||||||
|
{ value: 'Asia/Dubai', label: 'Asia/Dubai (海湾标准时间, UTC+4)' },
|
||||||
|
{ value: 'Europe/London', label: 'Europe/London (格林威治时间, UTC+0)' },
|
||||||
|
{ value: 'Europe/Paris', label: 'Europe/Paris (中欧时间, UTC+1)' },
|
||||||
|
{ value: 'Europe/Berlin', label: 'Europe/Berlin (中欧时间, UTC+1)' },
|
||||||
|
{ value: 'Europe/Moscow', label: 'Europe/Moscow (莫斯科时间, UTC+3)' },
|
||||||
|
{ value: 'America/New_York', label: 'America/New_York (美东时间, UTC-5)' },
|
||||||
|
{ value: 'America/Chicago', label: 'America/Chicago (美中时间, UTC-6)' },
|
||||||
|
{ value: 'America/Denver', label: 'America/Denver (美山地时间, UTC-7)' },
|
||||||
|
{ value: 'America/Los_Angeles', label: 'America/Los_Angeles (美太平洋时间, UTC-8)' },
|
||||||
|
{ value: 'Pacific/Auckland', label: 'Pacific/Auckland (新西兰时间, UTC+12)' },
|
||||||
|
{ value: 'Australia/Sydney', label: 'Australia/Sydney (澳东时间, UTC+10)' },
|
||||||
|
{ value: 'UTC', label: 'UTC (协调世界时)' },
|
||||||
|
]
|
||||||
|
|
||||||
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@ -91,7 +114,7 @@ function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean
|
|||||||
onClick={() => onChange(!checked)}
|
onClick={() => onChange(!checked)}
|
||||||
className={`relative inline-flex h-6 w-11 shrink-0 rounded-full transition-colors duration-200 ${checked ? 'bg-[var(--accent-cyan)]' : 'bg-[var(--bg-hover)]'}`}
|
className={`relative inline-flex h-6 w-11 shrink-0 rounded-full transition-colors duration-200 ${checked ? 'bg-[var(--accent-cyan)]' : 'bg-[var(--bg-hover)]'}`}
|
||||||
>
|
>
|
||||||
<span className={`inline-block h-5 w-5 rounded-full bg-white shadow-sm transition-transform duration-200 mt-0.5 ${checked ? 'translate-x-5.5 ml-[22px]' : 'translate-x-0.5 ml-[2px]'}`} />
|
<span className={`absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white shadow-sm transition-transform duration-200 ${checked ? 'translate-x-5' : 'translate-x-0'}`} />
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -130,6 +153,101 @@ function SectionCard({ title, children }: { title: string; children: ReactNode }
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface KnownSource {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function SourceEditor({
|
||||||
|
sources,
|
||||||
|
onChange,
|
||||||
|
knownSources,
|
||||||
|
examplePaths,
|
||||||
|
showCustom = true,
|
||||||
|
}: {
|
||||||
|
sources: string[]
|
||||||
|
onChange: (s: string[]) => void
|
||||||
|
knownSources: KnownSource[]
|
||||||
|
examplePaths?: string[]
|
||||||
|
showCustom?: boolean
|
||||||
|
}) {
|
||||||
|
const [customInput, setCustomInput] = useState('')
|
||||||
|
const knownKeys = new Set(knownSources.map(k => k.key))
|
||||||
|
const customPaths = sources.filter(s => !knownKeys.has(s))
|
||||||
|
|
||||||
|
const toggleKnown = (key: string) => {
|
||||||
|
if (sources.includes(key)) {
|
||||||
|
onChange(sources.filter(s => s !== key))
|
||||||
|
} else {
|
||||||
|
onChange([...sources, key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addCustom = () => {
|
||||||
|
const v = customInput.trim()
|
||||||
|
if (v && !sources.includes(v)) {
|
||||||
|
onChange([...sources, v])
|
||||||
|
setCustomInput('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeCustom = (path: string) => {
|
||||||
|
onChange(sources.filter(s => s !== path))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Known sources as toggles */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{knownSources.map(src => (
|
||||||
|
<div key={src.key} className="flex items-center justify-between py-1.5">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm text-[var(--text-primary)]">{src.label}</div>
|
||||||
|
<div className="text-xs text-[var(--text-muted)] font-mono">{src.description}</div>
|
||||||
|
</div>
|
||||||
|
<Toggle checked={sources.includes(src.key)} onChange={() => toggleKnown(src.key)} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom paths (only shown when showCustom is true) */}
|
||||||
|
{showCustom && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs font-medium text-[var(--text-muted)] uppercase tracking-wider">自定义路径</div>
|
||||||
|
{customPaths.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{customPaths.map((p, i) => (
|
||||||
|
<span key={i} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-[var(--accent-cyan)]/10 border border-[var(--accent-cyan)]/20 text-xs text-[var(--accent-cyan)] font-mono">
|
||||||
|
{p}
|
||||||
|
<button onClick={() => removeCustom(p)} className="hover:text-white transition-colors"><X className="h-3 w-3" /></button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
value={customInput}
|
||||||
|
onChange={e => setCustomInput(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), addCustom())}
|
||||||
|
placeholder="输入绝对路径,如 D:\my-skills"
|
||||||
|
className={inputCls + ' !text-xs font-mono'}
|
||||||
|
/>
|
||||||
|
<button onClick={addCustom} className="px-2 py-1 rounded-lg bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/20 transition-colors text-xs shrink-0">
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{examplePaths && (
|
||||||
|
<p className="text-xs text-[var(--text-muted)]">
|
||||||
|
示例: {examplePaths.join('、')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function MapEntryHeader({ name, onDelete, onRename }: { name: string; onDelete: () => void; onRename?: (n: string) => void }) {
|
function MapEntryHeader({ name, onDelete, onRename }: { name: string; onDelete: () => void; onRename?: (n: string) => void }) {
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false)
|
||||||
const [val, setVal] = useState(name)
|
const [val, setVal] = useState(name)
|
||||||
@ -164,6 +282,8 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
|
|||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [toast, setToast] = useState('')
|
const [toast, setToast] = useState('')
|
||||||
const [dirty, setDirty] = useState(false)
|
const [dirty, setDirty] = useState(false)
|
||||||
|
const [showRestartDialog, setShowRestartDialog] = useState(false)
|
||||||
|
const [restarting, setRestarting] = useState(false)
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
if (dirty && !confirm('有未保存的更改,确定要关闭吗?')) return
|
if (dirty && !confirm('有未保存的更改,确定要关闭吗?')) return
|
||||||
@ -204,9 +324,9 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
|
|||||||
// Reload config from server to get masked values
|
// Reload config from server to get masked values
|
||||||
const refreshed = await fetch('/api/config').then(r => r.json())
|
const refreshed = await fetch('/api/config').then(r => r.json())
|
||||||
setConfig(refreshed)
|
setConfig(refreshed)
|
||||||
setToast(data.message || '配置已保存,需要重启服务才能生效')
|
|
||||||
setDirty(false)
|
setDirty(false)
|
||||||
setTimeout(() => setToast(''), 5000)
|
// Show restart confirmation dialog
|
||||||
|
setShowRestartDialog(true)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e.message || '保存失败')
|
setError(e.message || '保存失败')
|
||||||
} finally {
|
} finally {
|
||||||
@ -214,6 +334,47 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleRestart = async () => {
|
||||||
|
setShowRestartDialog(false)
|
||||||
|
setRestarting(true)
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/restart', { method: 'POST' })
|
||||||
|
const data = await resp.json()
|
||||||
|
if (resp.status === 409) {
|
||||||
|
setToast(data.message || '有任务运行中,请等待完成后再试')
|
||||||
|
setRestarting(false)
|
||||||
|
setTimeout(() => setToast(''), 5000)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!resp.ok) throw new Error(data.message || '重启失败')
|
||||||
|
setToast('服务正在重启,页面将自动重连...')
|
||||||
|
// Poll /health until gateway is back
|
||||||
|
const poll = async () => {
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
await new Promise(r => setTimeout(r, 1000))
|
||||||
|
try {
|
||||||
|
const r = await fetch('/health')
|
||||||
|
if (r.ok) {
|
||||||
|
const refreshed = await fetch('/api/config').then(r => r.json())
|
||||||
|
setConfig(refreshed)
|
||||||
|
setToast('服务已重启,配置已生效')
|
||||||
|
setRestarting(false)
|
||||||
|
setTimeout(() => setToast(''), 3000)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch { /* gateway not ready yet */ }
|
||||||
|
}
|
||||||
|
setToast('重启超时,请手动刷新页面')
|
||||||
|
setRestarting(false)
|
||||||
|
setTimeout(() => setToast(''), 5000)
|
||||||
|
}
|
||||||
|
poll()
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message || '重启失败')
|
||||||
|
setRestarting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Render sections ──────────────────────────────────
|
// ── Render sections ──────────────────────────────────
|
||||||
if (loading) return (
|
if (loading) return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
|
||||||
@ -373,7 +534,17 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
|
|||||||
|
|
||||||
const renderTime = () => (
|
const renderTime = () => (
|
||||||
<SectionCard title="时区设置">
|
<SectionCard title="时区设置">
|
||||||
<Field label="时区" hint="IANA 格式,如 Asia/Shanghai"><input value={config.time.timezone} onChange={e => update('time', { timezone: e.target.value })} className={inputCls} placeholder="Asia/Shanghai" /></Field>
|
<Field label="时区" hint="IANA 格式">
|
||||||
|
<select
|
||||||
|
value={config.time.timezone}
|
||||||
|
onChange={e => update('time', { timezone: e.target.value })}
|
||||||
|
className={inputCls}
|
||||||
|
>
|
||||||
|
{TIMEZONE_OPTIONS.map(tz => (
|
||||||
|
<option key={tz.value} value={tz.value}>{tz.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -388,6 +559,20 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const SKILL_KNOWN_SOURCES: KnownSource[] = [
|
||||||
|
{ key: 'user', label: '用户技能', description: '~/.picobot/skills' },
|
||||||
|
{ key: 'user_agent', label: '用户 Agent 技能', description: '~/.agents/skills' },
|
||||||
|
{ key: 'user_openclaw', label: '用户 OpenClaw 技能', description: '~/.openclaw/skills' },
|
||||||
|
{ key: 'project', label: '项目技能', description: '.picobot/skills' },
|
||||||
|
{ key: 'project_agent', label: '项目 Agent 技能', description: '.agents/skills' },
|
||||||
|
{ key: 'project_openclaw', label: '项目 OpenClaw 技能', description: '.openclaw/skills' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const SUBAGENT_KNOWN_SOURCES: KnownSource[] = [
|
||||||
|
{ key: 'user', label: '用户子代理', description: '~/.picobot/subagents' },
|
||||||
|
{ key: 'project', label: '项目子代理', description: '.picobot/subagents' },
|
||||||
|
]
|
||||||
|
|
||||||
const renderSkills = () => (
|
const renderSkills = () => (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<SectionCard title="技能系统">
|
<SectionCard title="技能系统">
|
||||||
@ -396,11 +581,31 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
|
|||||||
<Field label="最大展示技能数"><input type="number" value={config.skills.max_listed_skills} onChange={e => update('skills', { ...config.skills, max_listed_skills: +e.target.value })} className={inputCls} /></Field>
|
<Field label="最大展示技能数"><input type="number" value={config.skills.max_listed_skills} onChange={e => update('skills', { ...config.skills, max_listed_skills: +e.target.value })} className={inputCls} /></Field>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
<SectionCard title="来源目录">
|
<SectionCard title="来源目录">
|
||||||
<TagEditor tags={config.skills.sources} onChange={v => update('skills', { ...config.skills, sources: v })} />
|
<SourceEditor
|
||||||
|
sources={config.skills.sources}
|
||||||
|
onChange={v => update('skills', { ...config.skills, sources: v })}
|
||||||
|
knownSources={SKILL_KNOWN_SOURCES}
|
||||||
|
examplePaths={['D:\\my-skills', '/home/user/shared-skills']}
|
||||||
|
/>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const TASK_KNOWN_TOOLS: KnownSource[] = [
|
||||||
|
{ key: 'read', label: 'Read', description: '读取文件' },
|
||||||
|
{ key: 'edit', label: 'Edit', description: '编辑文件' },
|
||||||
|
{ key: 'write', label: 'Write', description: '写入文件' },
|
||||||
|
{ key: 'bash', label: 'Bash', description: '执行 Shell 命令' },
|
||||||
|
{ key: 'http_request', label: 'HTTP Request', description: '发送 HTTP 请求' },
|
||||||
|
{ key: 'web_fetch', label: 'Web Fetch', description: '抓取网页内容' },
|
||||||
|
{ key: 'memory_search', label: 'Memory Search', description: '搜索记忆' },
|
||||||
|
{ key: 'get_time', label: 'Get Time', description: '获取当前时间' },
|
||||||
|
{ key: 'calculator', label: 'Calculator', description: '计算器' },
|
||||||
|
{ key: 'skill_activate', label: 'Skill Activate', description: '激活技能' },
|
||||||
|
{ key: 'skill_list', label: 'Skill List', description: '列出技能' },
|
||||||
|
{ key: 'send_session_message', label: 'Send Session Message', description: '发送会话消息' },
|
||||||
|
]
|
||||||
|
|
||||||
const renderTools = () => (
|
const renderTools = () => (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<SectionCard title="禁用工具列表">
|
<SectionCard title="禁用工具列表">
|
||||||
@ -413,7 +618,12 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
|
|||||||
<Field label="TTL (小时)"><input type="number" value={config.tools.task.ttl_hours} onChange={e => update('tools', { ...config.tools, task: { ...config.tools.task, ttl_hours: +e.target.value } })} className={inputCls} /></Field>
|
<Field label="TTL (小时)"><input type="number" value={config.tools.task.ttl_hours} onChange={e => update('tools', { ...config.tools, task: { ...config.tools.task, ttl_hours: +e.target.value } })} className={inputCls} /></Field>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
<SectionCard title="允许的工具列表">
|
<SectionCard title="允许的工具列表">
|
||||||
<TagEditor tags={config.tools.task.allowed_tools} onChange={v => update('tools', { ...config.tools, task: { ...config.tools.task, allowed_tools: v } })} />
|
<SourceEditor
|
||||||
|
sources={config.tools.task.allowed_tools}
|
||||||
|
onChange={v => update('tools', { ...config.tools, task: { ...config.tools.task, allowed_tools: v } })}
|
||||||
|
knownSources={TASK_KNOWN_TOOLS}
|
||||||
|
showCustom={false}
|
||||||
|
/>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -439,7 +649,12 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
|
|||||||
<div className="flex items-center justify-between"><span className="text-sm text-[var(--text-secondary)]">启用子代理发现</span><Toggle checked={config.subagents.enabled} onChange={v => update('subagents', { ...config.subagents, enabled: v })} /></div>
|
<div className="flex items-center justify-between"><span className="text-sm text-[var(--text-secondary)]">启用子代理发现</span><Toggle checked={config.subagents.enabled} onChange={v => update('subagents', { ...config.subagents, enabled: v })} /></div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
<SectionCard title="来源目录">
|
<SectionCard title="来源目录">
|
||||||
<TagEditor tags={config.subagents.sources} onChange={v => update('subagents', { ...config.subagents, sources: v })} />
|
<SourceEditor
|
||||||
|
sources={config.subagents.sources}
|
||||||
|
onChange={v => update('subagents', { ...config.subagents, sources: v })}
|
||||||
|
knownSources={SUBAGENT_KNOWN_SOURCES}
|
||||||
|
examplePaths={['D:\\my-subagents', '/home/user/shared-agents']}
|
||||||
|
/>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -550,7 +765,12 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
|
|||||||
<>
|
<>
|
||||||
<Field label="App ID"><input value={ch.app_id ?? ''} onChange={e => updChannel(name, { app_id: e.target.value })} className={inputCls} /></Field>
|
<Field label="App ID"><input value={ch.app_id ?? ''} onChange={e => updChannel(name, { app_id: e.target.value })} className={inputCls} /></Field>
|
||||||
<Field label="App Secret"><input type="password" value={ch.app_secret ?? ''} onChange={e => updChannel(name, { app_secret: e.target.value })} className={inputCls} /></Field>
|
<Field label="App Secret"><input type="password" value={ch.app_secret ?? ''} onChange={e => updChannel(name, { app_secret: e.target.value })} className={inputCls} /></Field>
|
||||||
<Field label="绑定 Agent" hint="留空使用 default"><input value={ch.agent ?? ''} onChange={e => updChannel(name, { agent: e.target.value })} className={inputCls} placeholder="default" /></Field>
|
<Field label="绑定 Agent" hint="留空使用 default">
|
||||||
|
<select value={ch.agent ?? ''} onChange={e => updChannel(name, { agent: e.target.value })} className={selectCls}>
|
||||||
|
<option value="">default</option>
|
||||||
|
{Object.keys(config.agents).map(a => <option key={a} value={a}>{a}</option>)}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
<Field label="最大消息字符数"><input type="number" value={ch.max_message_chars ?? 20000} onChange={e => updChannel(name, { max_message_chars: +e.target.value })} className={inputCls} /></Field>
|
<Field label="最大消息字符数"><input type="number" value={ch.max_message_chars ?? 20000} onChange={e => updChannel(name, { max_message_chars: +e.target.value })} className={inputCls} /></Field>
|
||||||
<Field label="回复上下文最大字符数"><input type="number" value={ch.reply_context_max_chars ?? 20000} onChange={e => updChannel(name, { reply_context_max_chars: +e.target.value })} className={inputCls} /></Field>
|
<Field label="回复上下文最大字符数"><input type="number" value={ch.reply_context_max_chars ?? 20000} onChange={e => updChannel(name, { reply_context_max_chars: +e.target.value })} className={inputCls} /></Field>
|
||||||
</>
|
</>
|
||||||
@ -559,7 +779,12 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
|
|||||||
<>
|
<>
|
||||||
<Field label="凭证文件路径"><input value={ch.cred_path ?? ''} onChange={e => updChannel(name, { cred_path: e.target.value })} className={inputCls} placeholder="~/.picobot/wechat/credentials.json" /></Field>
|
<Field label="凭证文件路径"><input value={ch.cred_path ?? ''} onChange={e => updChannel(name, { cred_path: e.target.value })} className={inputCls} placeholder="~/.picobot/wechat/credentials.json" /></Field>
|
||||||
<Field label="Base URL"><input value={ch.base_url ?? 'https://ilinkai.weixin.qq.com'} onChange={e => updChannel(name, { base_url: e.target.value })} className={inputCls} /></Field>
|
<Field label="Base URL"><input value={ch.base_url ?? 'https://ilinkai.weixin.qq.com'} onChange={e => updChannel(name, { base_url: e.target.value })} className={inputCls} /></Field>
|
||||||
<Field label="绑定 Agent" hint="留空使用 default"><input value={ch.agent ?? ''} onChange={e => updChannel(name, { agent: e.target.value })} className={inputCls} placeholder="default" /></Field>
|
<Field label="绑定 Agent" hint="留空使用 default">
|
||||||
|
<select value={ch.agent ?? ''} onChange={e => updChannel(name, { agent: e.target.value })} className={selectCls}>
|
||||||
|
<option value="">default</option>
|
||||||
|
{Object.keys(config.agents).map(a => <option key={a} value={a}>{a}</option>)}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
<div className="flex items-center justify-between"><span className="text-sm text-[var(--text-secondary)]">强制重新登录</span><Toggle checked={!!ch.force_login} onChange={v => updChannel(name, { force_login: v })} /></div>
|
<div className="flex items-center justify-between"><span className="text-sm text-[var(--text-secondary)]">强制重新登录</span><Toggle checked={!!ch.force_login} onChange={v => updChannel(name, { force_login: v })} /></div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -661,10 +886,42 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
|
|||||||
{/* Toast */}
|
{/* Toast */}
|
||||||
{toast && (
|
{toast && (
|
||||||
<div className="absolute top-20 left-1/2 -translate-x-1/2 z-10 flex items-center gap-2 px-5 py-3 rounded-xl bg-emerald-500/15 border border-emerald-500/30 text-emerald-400 text-sm shadow-lg backdrop-blur-md animate-[fadeIn_0.2s_ease-out]">
|
<div className="absolute top-20 left-1/2 -translate-x-1/2 z-10 flex items-center gap-2 px-5 py-3 rounded-xl bg-emerald-500/15 border border-emerald-500/30 text-emerald-400 text-sm shadow-lg backdrop-blur-md animate-[fadeIn_0.2s_ease-out]">
|
||||||
<CheckCircle className="h-4 w-4" />
|
{restarting ? <Loader2 className="h-4 w-4 animate-spin" /> : <CheckCircle className="h-4 w-4" />}
|
||||||
{toast}
|
{toast}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Restart confirmation dialog */}
|
||||||
|
{showRestartDialog && (
|
||||||
|
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/50 backdrop-blur-sm rounded-2xl">
|
||||||
|
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-xl p-6 max-w-sm mx-4 shadow-2xl animate-[scaleIn_0.2s_ease-out]">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-[var(--accent-cyan)]/10">
|
||||||
|
<RefreshCw className="h-5 w-5 text-[var(--accent-cyan)]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--text-primary)]">配置已保存</h3>
|
||||||
|
<p className="text-xs text-[var(--text-muted)]">是否立即重启服务使配置生效?</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowRestartDialog(false)}
|
||||||
|
className="px-4 py-2 rounded-lg text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)] transition-colors"
|
||||||
|
>
|
||||||
|
稍后
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleRestart}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white bg-[var(--accent-cyan)]/20 border border-[var(--accent-cyan)]/30 hover:bg-[var(--accent-cyan)]/30 hover:border-[var(--accent-cyan)]/50 transition-all"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
立即重启
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user