feat(gateway): 支持网关平滑重启功能

- 在 Gateway 运行逻辑中增加重启循环,可根据重启信号自动重启服务
- 添加取消管理器功能,支持取消所有运行中的 Agent 以便平滑重启
- 在 HTTP API 新增 /api/restart 接口,实现基于任务活动检测的安全重启
- GatewayState 增加重启信号通道,支持异步触发重启流程
- 修改主运行逻辑支持重启等待机制,实现优雅关闭或重启
- 日志初始化修复防止重复初始化导致的问题
- 技能和子代理加载支持自定义绝对路径来源,增加灵活性和扩展性
- 调整技能与子代理来源枚举,增加 Custom 选项支持动态路径
- 优化工具与任务运行时代码,完善路径处理和克隆逻辑
- 前端配置页增加时区选择下拉菜单,提供常用时区选项
- 新增来源路径编辑组件,支持启用/禁用已知来源及添加自定义绝对路径
- 配置页新增保存配置后重启提示,支持用户确认立即重启网关
- 实现重启操作的前端调用及重启状态展示,包括任务冲突提示与重启轮询恢复
- 频道绑定 Agent 字段由输入框改为下拉选择,提升配置体验和正确性
This commit is contained in:
ooodc 2026-06-19 10:40:00 +08:00
parent 18ad891a51
commit d802534abe
10 changed files with 420 additions and 53 deletions

View File

@ -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 {

View File

@ -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(),
}))
}

View File

@ -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)
} }

View File

@ -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

View File

@ -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(())

View File

@ -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");

View 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<_>>()
}) })

View File

@ -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);

View File

@ -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),
} }
/// 子代理完整定义 /// 子代理完整定义

View File

@ -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>
) )