diff --git a/src/gateway/cancel_manager.rs b/src/gateway/cancel_manager.rs index e056bbe..ad7a3c0 100644 --- a/src/gateway/cancel_manager.rs +++ b/src/gateway/cancel_manager.rs @@ -52,6 +52,21 @@ impl CancelManager { pub async fn remove_by_topic(&self, topic_id: &str) { 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 { diff --git a/src/gateway/http.rs b/src/gateway/http.rs index c9fa1b3..1a35c59 100644 --- a/src/gateway/http.rs +++ b/src/gateway/http.rs @@ -118,3 +118,36 @@ pub async fn save_config( 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>, +) -> Result, (StatusCode, Json)> { + 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(), + })) +} diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index 2670b33..b77229b 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -52,6 +52,8 @@ use session_message_sender::BusSessionMessageSender; use session::SessionManager; use static_files::static_handler; +use tokio::sync::watch; + pub struct GatewayState { pub config: Config, pub session_manager: SessionManager, @@ -59,10 +61,11 @@ pub struct GatewayState { pub bus: Arc, pub task_repository: Arc, pub cancel_manager: CancelManager, + pub restart_tx: watch::Sender, } impl GatewayState { - pub fn from_config(config: Config) -> Result> { + pub fn from_config(config: Config, restart_tx: watch::Sender) -> Result> { // Get provider config for SessionManager let provider_config = config.get_provider_config("default")?; let mut provider_configs = HashMap::::new(); @@ -109,6 +112,7 @@ impl GatewayState { bus, task_repository, cancel_manager, + restart_tx, }) } @@ -150,7 +154,7 @@ impl GatewayState { pub async fn run( host: Option, port: Option, -) -> Result<(), Box> { +) -> Result> { let config = Config::load_default()?; let timezone = config.time.parse_timezone()?; @@ -158,7 +162,10 @@ pub async fn run( logging::init_logging(timezone); 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 let provider_config = state.config.get_provider_config("default")?; @@ -201,6 +208,7 @@ pub async fn run( Router::new() .route("/health", routing::get(http::health)) .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)) .fallback(static_handler) .with_state(state.clone()) @@ -209,6 +217,7 @@ pub async fn run( Router::new() .route("/health", routing::get(http::health)) .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)) .fallback_service(ServeDir::new(&static_dir)) .with_state(state.clone()) @@ -218,25 +227,48 @@ pub async fn run( let listener = TcpListener::bind(&addr).await?; tracing::info!(address = %addr, "Gateway listening"); - // Graceful shutdown using oneshot channel + // Graceful shutdown / restart signal 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::(); 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::signal::ctrl_c().await.ok(); - tracing::info!("Shutdown signal received"); - let _ = scheduler_shutdown_tx.send(true); - let _ = channel_manager.stop_all().await; - let _ = shutdown_tx.send(()); + tokio::select! { + _ = tokio::signal::ctrl_c() => { + tracing::info!("Shutdown signal received"); + cancel_manager.cancel_all().await; + let _ = scheduler_shutdown_tx.send(true); + let _ = channel_manager.stop_all().await; + let _ = result_tx.send(false); + 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 axum::serve(listener, app) .with_graceful_shutdown(async { - shutdown_rx.await.ok(); + let _ = shutdown_rx.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) } diff --git a/src/logging.rs b/src/logging.rs index 57bfc8d..4eb23d3 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -41,6 +41,16 @@ pub fn get_default_config_path() -> PathBuf { /// Initialize logging with file appender /// Logs are written to ~/.picobot/logs/ with daily rotation 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(); // Create log directory if it doesn't exist diff --git a/src/main.rs b/src/main.rs index 756199d..fd5da21 100644 --- a/src/main.rs +++ b/src/main.rs @@ -56,7 +56,13 @@ async fn main() -> Result<(), Box> { picobot::client::run(&url).await?; } 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(()) diff --git a/src/skills/mod.rs b/src/skills/mod.rs index 6d1a9d4..3d556dd 100644 --- a/src/skills/mod.rs +++ b/src/skills/mod.rs @@ -25,7 +25,7 @@ pub struct Skill { pub path: PathBuf, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum SkillSource { User, UserAgent, @@ -33,6 +33,7 @@ pub enum SkillSource { Project, ProjectAgent, ProjectOpenclaw, + Custom(String), } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -326,7 +327,7 @@ impl crate::agent::SkillProvider for SkillRuntime { } impl SkillSource { - fn as_str(&self) -> &'static str { + pub fn as_str(&self) -> &str { match self { SkillSource::User => "user", SkillSource::UserAgent => "user_agent", @@ -334,6 +335,7 @@ impl SkillSource { SkillSource::Project => "project", SkillSource::ProjectAgent => "project_agent", 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. for source in source_order(&config.sources) { sources_seen += 1; - let root = source_root(source, &cwd); + let root = source_root(&source, &cwd); 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) { tracing::warn!( skill = %skill.name, @@ -595,7 +597,10 @@ fn source_order(sources: &[String]) -> Vec { } } 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 { platform_home_dir().map(|p| p.join(".openclaw").join("skills")) } -fn source_root(source: SkillSource, cwd: &Path) -> Option { +fn source_root(source: &SkillSource, cwd: &Path) -> Option { match source { SkillSource::User => user_skills_root(), SkillSource::UserAgent => user_agent_skills_root(), @@ -666,6 +671,15 @@ fn source_root(source: SkillSource, cwd: &Path) -> Option { SkillSource::Project => Some(cwd.join(".picobot").join("skills")), SkillSource::ProjectAgent => Some(project_agent_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 { continue; } - match parse_skill_file(&skill_md, source) { + match parse_skill_file(&skill_md, source.clone()) { Ok(skill) => out.push(skill), Err(err) => { tracing::warn!(path = %skill_md.display(), error = %err, "Skipping invalid skill file"); diff --git a/src/tools/skill_manage.rs b/src/tools/skill_manage.rs index 28a57b6..4bcbd6d 100644 --- a/src/tools/skill_manage.rs +++ b/src/tools/skill_manage.rs @@ -117,14 +117,7 @@ impl Tool for SkillManageTool { "name": skill.name, "description": skill.description, "body": skill.body, - "source": match skill.source { - 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", - }, + "source": skill.source.as_str(), "path": skill.path.display().to_string(), }), None => return Ok(error_result(&format!("skill '{}' not found", name))), @@ -276,14 +269,7 @@ fn list_skills_payload(skills: &Arc) -> serde_json::Value { "skills": skills.into_iter().map(|skill| json!({ "name": skill.name, "description": skill.description, - "source": match skill.source { - 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", - }, + "source": skill.source.as_str(), "path": skill.path.display().to_string(), })).collect::>() }) diff --git a/src/tools/task/runtime.rs b/src/tools/task/runtime.rs index eeab6bc..5269b4f 100644 --- a/src/tools/task/runtime.rs +++ b/src/tools/task/runtime.rs @@ -737,7 +737,7 @@ impl SubagentCatalog { // 按配置顺序扫描源目录 if config.enabled { 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"); if let Some(root) = root { if root.exists() { @@ -745,7 +745,7 @@ impl SubagentCatalog { } else { 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) { tracing::warn!( subagent = %def.name, @@ -849,7 +849,10 @@ fn source_order(sources: &[String]) -> Vec { } } 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 { } /// 获取源目录根路径 -fn source_root(source: SubagentSource, cwd: &Path) -> Option { +fn source_root(source: &SubagentSource, cwd: &Path) -> Option { match source { SubagentSource::User => dirs::home_dir().map(|p| p.join(".picobot").join("subagents")), SubagentSource::Project => Some(cwd.join(".picobot").join("subagents")), 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 { tracing::info!(name = %def.name, path = %subagent_md.display(), "Loaded subagent"); out.push(def); diff --git a/src/tools/task/types.rs b/src/tools/task/types.rs index 281a7b4..029baa0 100644 --- a/src/tools/task/types.rs +++ b/src/tools/task/types.rs @@ -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")] pub enum SubagentSource { /// 内置定义 @@ -32,6 +32,8 @@ pub enum SubagentSource { User, /// 项目级自定义 (./.picobot/subagents/) Project, + /// 自定义绝对路径 + Custom(String), } /// 子代理完整定义 diff --git a/web/src/components/Settings/ConfigPage.tsx b/web/src/components/Settings/ConfigPage.tsx index 43793a1..3489c9f 100644 --- a/web/src/components/Settings/ConfigPage.tsx +++ b/web/src/components/Settings/ConfigPage.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, type ReactNode } from 'react' import { Settings, Server, Cpu, Bot, Clock, Calendar, Wrench, Brain, Image, Users, Save, X, Plus, Trash2, AlertTriangle, Loader2, Wifi, - CheckCircle, Plug, Radio, + CheckCircle, Plug, Radio, RefreshCw, } from 'lucide-react' // ── 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 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 }) { return ( ) } @@ -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 ( +
+ {/* Known sources as toggles */} +
+ {knownSources.map(src => ( +
+
+
{src.label}
+
{src.description}
+
+ toggleKnown(src.key)} /> +
+ ))} +
+ + {/* Custom paths (only shown when showCustom is true) */} + {showCustom && ( +
+
自定义路径
+ {customPaths.length > 0 && ( +
+ {customPaths.map((p, i) => ( + + {p} + + + ))} +
+ )} +
+ setCustomInput(e.target.value)} + onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), addCustom())} + placeholder="输入绝对路径,如 D:\my-skills" + className={inputCls + ' !text-xs font-mono'} + /> + +
+ {examplePaths && ( +

+ 示例: {examplePaths.join('、')} +

+ )} +
+ )} +
+ ) +} + function MapEntryHeader({ name, onDelete, onRename }: { name: string; onDelete: () => void; onRename?: (n: string) => void }) { const [editing, setEditing] = useState(false) const [val, setVal] = useState(name) @@ -164,6 +282,8 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) { const [error, setError] = useState('') const [toast, setToast] = useState('') const [dirty, setDirty] = useState(false) + const [showRestartDialog, setShowRestartDialog] = useState(false) + const [restarting, setRestarting] = useState(false) const handleClose = useCallback(() => { if (dirty && !confirm('有未保存的更改,确定要关闭吗?')) return @@ -204,9 +324,9 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) { // Reload config from server to get masked values const refreshed = await fetch('/api/config').then(r => r.json()) setConfig(refreshed) - setToast(data.message || '配置已保存,需要重启服务才能生效') setDirty(false) - setTimeout(() => setToast(''), 5000) + // Show restart confirmation dialog + setShowRestartDialog(true) } catch (e: any) { setError(e.message || '保存失败') } 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 ────────────────────────────────── if (loading) return (
@@ -373,7 +534,17 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) { const renderTime = () => ( - update('time', { timezone: e.target.value })} className={inputCls} placeholder="Asia/Shanghai" /> + + + ) @@ -388,6 +559,20 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
) + 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 = () => (
@@ -396,11 +581,31 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) { update('skills', { ...config.skills, max_listed_skills: +e.target.value })} className={inputCls} /> - update('skills', { ...config.skills, sources: v })} /> + update('skills', { ...config.skills, sources: v })} + knownSources={SKILL_KNOWN_SOURCES} + examplePaths={['D:\\my-skills', '/home/user/shared-skills']} + />
) + 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 = () => (
@@ -413,7 +618,12 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) { update('tools', { ...config.tools, task: { ...config.tools.task, ttl_hours: +e.target.value } })} className={inputCls} /> - update('tools', { ...config.tools, task: { ...config.tools.task, allowed_tools: v } })} /> + update('tools', { ...config.tools, task: { ...config.tools.task, allowed_tools: v } })} + knownSources={TASK_KNOWN_TOOLS} + showCustom={false} + />
) @@ -439,7 +649,12 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
启用子代理发现 update('subagents', { ...config.subagents, enabled: v })} />
- update('subagents', { ...config.subagents, sources: v })} /> + update('subagents', { ...config.subagents, sources: v })} + knownSources={SUBAGENT_KNOWN_SOURCES} + examplePaths={['D:\\my-subagents', '/home/user/shared-agents']} + /> ) @@ -550,7 +765,12 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) { <> updChannel(name, { app_id: e.target.value })} className={inputCls} /> updChannel(name, { app_secret: e.target.value })} className={inputCls} /> - updChannel(name, { agent: e.target.value })} className={inputCls} placeholder="default" /> + + + updChannel(name, { max_message_chars: +e.target.value })} className={inputCls} /> updChannel(name, { reply_context_max_chars: +e.target.value })} className={inputCls} /> @@ -559,7 +779,12 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) { <> updChannel(name, { cred_path: e.target.value })} className={inputCls} placeholder="~/.picobot/wechat/credentials.json" /> updChannel(name, { base_url: e.target.value })} className={inputCls} /> - updChannel(name, { agent: e.target.value })} className={inputCls} placeholder="default" /> + + +
强制重新登录 updChannel(name, { force_login: v })} />
)} @@ -661,10 +886,42 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) { {/* Toast */} {toast && (
- + {restarting ? : } {toast}
)} + + {/* Restart confirmation dialog */} + {showRestartDialog && ( +
+
+
+
+ +
+
+

配置已保存

+

是否立即重启服务使配置生效?

+
+
+
+ + +
+
+
+ )} )