diff --git a/src/command/handlers/list_skills.rs b/src/command/handlers/list_skills.rs new file mode 100644 index 0000000..f8ce5a3 --- /dev/null +++ b/src/command/handlers/list_skills.rs @@ -0,0 +1,64 @@ +use crate::command::context::CommandContext; +use crate::command::handler::{CommandHandler, CommandMetadata}; +use crate::command::response::{CommandError, CommandResponse}; +use crate::command::Command; +use crate::skills::SkillRuntime; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +/// Skill 摘要信息(发送给前端) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SkillSummary { + pub name: String, + pub description: String, + pub source: String, +} + +pub struct ListSkillsCommandHandler { + skills: Arc, +} + +impl ListSkillsCommandHandler { + pub fn new(skills: Arc) -> Self { + Self { skills } + } +} + +#[async_trait] +impl CommandHandler for ListSkillsCommandHandler { + fn can_handle(&self, cmd: &Command) -> bool { + matches!(cmd, Command::ListSkills) + } + + fn metadata(&self) -> Option { + Some(CommandMetadata { + name: "list_skills", + description: "列出所有技能", + usage: "/list_skills", + }) + } + + async fn handle( + &self, + _cmd: Command, + ctx: CommandContext, + ) -> Result { + let skill_list = self.skills.list_skills(); + + let summaries: Vec = skill_list + .into_iter() + .map(|s| SkillSummary { + name: s.name, + description: s.description, + source: format!("{:?}", s.source).to_lowercase(), + }) + .collect(); + + let skills_json = serde_json::to_string(&summaries) + .map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?; + + Ok(CommandResponse::success(ctx.request_id) + .with_metadata("skills", &skills_json)) + } +} diff --git a/src/command/handlers/mod.rs b/src/command/handlers/mod.rs index d6dbe5a..37d90e2 100644 --- a/src/command/handlers/mod.rs +++ b/src/command/handlers/mod.rs @@ -4,6 +4,7 @@ pub mod help; pub mod list_channels; pub mod list_memories; pub mod list_scheduler_jobs; +pub mod list_skills; pub mod memory_crud; pub mod list_sessions; pub mod list_sessions_by_channel; diff --git a/src/command/mod.rs b/src/command/mod.rs index 6578617..7ed4d00 100644 --- a/src/command/mod.rs +++ b/src/command/mod.rs @@ -71,6 +71,8 @@ pub enum Command { }, /// 删除记忆 DeleteMemory { id: String }, + /// 列出所有技能 + ListSkills, } impl Command { @@ -97,6 +99,7 @@ impl Command { Command::CreateMemory { .. } => "create_memory", Command::UpdateMemory { .. } => "update_memory", Command::DeleteMemory { .. } => "delete_memory", + Command::ListSkills => "list_skills", } } } diff --git a/src/gateway/ws.rs b/src/gateway/ws.rs index 5ed0799..93fac45 100644 --- a/src/gateway/ws.rs +++ b/src/gateway/ws.rs @@ -10,6 +10,7 @@ use crate::command::handlers::get_current::GetCurrentSessionCommandHandler; use crate::command::handlers::help::HelpCommandHandler; use crate::command::handlers::list_channels::ListChannelsCommandHandler; use crate::command::handlers::list_memories::ListMemoriesCommandHandler; +use crate::command::handlers::list_skills::ListSkillsCommandHandler; use crate::command::handlers::list_scheduler_jobs::ListSchedulerJobsCommandHandler; use crate::command::handlers::memory_crud::MemoryCrudCommandHandler; use crate::command::handlers::list_sessions::ListSessionsCommandHandler; @@ -362,6 +363,7 @@ async fn handle_inbound( let _cli_sessions = state.session_manager.cli_sessions(); let store = state.session_manager.store(); let skills = state.session_manager.skills(); + let skills_for_handler = skills.clone(); let provider_config = state.config.get_provider_config("default") .map_err(|e| AgentError::Other(e.to_string()))?; let prompt_repository = state.session_manager.store().clone(); @@ -418,6 +420,8 @@ async fn handle_inbound( router.register(Box::new(ListSchedulerJobsCommandHandler::new(store.clone()))); // 注册 list_memories 处理器 router.register(Box::new(ListMemoriesCommandHandler::new(store.clone()))); + // 注册 list_skills 处理器 + router.register(Box::new(ListSkillsCommandHandler::new(skills_for_handler))); // 注册 memory_crud 处理器 router.register(Box::new(MemoryCrudCommandHandler::new(store.clone()))); // 注册 load_chat_messages 处理器 @@ -508,6 +512,13 @@ async fn handle_inbound( } } + // 处理技能列表 + if let Some(skills_json) = response.metadata.get("skills") { + if let Ok(skills) = serde_json::from_str::>(skills_json) { + let _ = sender.send(WsOutbound::SkillList { skills }).await; + } + } + // 处理记忆列表 if let Some(memories_json) = response.metadata.get("memories") { if let Ok(memories) = serde_json::from_str::>(memories_json) { diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index 764cc95..d8eb43f 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -74,6 +74,14 @@ pub struct MemorySummary { pub updated_at: i64, } +/// Skill 摘要(发送给前端) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SkillSummary { + pub name: String, + pub description: String, + pub source: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SchedulerJobSummary { pub id: String, @@ -237,6 +245,10 @@ pub enum WsOutbound { MemoryList { memories: Vec, }, + #[serde(rename = "skill_list")] + SkillList { + skills: Vec, + }, #[serde(rename = "execution_cancelled")] ExecutionCancelled { message: String }, #[serde(rename = "pong")] diff --git a/web/src/App.tsx b/web/src/App.tsx index f6a2591..9c14f06 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,9 +1,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Zap, ArrowLeft, Bot, Clock, Sun, Moon, PanelRightOpen } from 'lucide-react' +import { Zap, ArrowLeft, Bot, Clock, Sun, Moon, PanelRightOpen, X } from 'lucide-react' import { ChatContainer } from './components/Chat/ChatContainer' import { TopicList } from './components/Sidebar/TopicList' import { SchedulerJobList } from './components/Sidebar/SchedulerJobList' import { MemoryPanel } from './components/Panel/MemoryPanel' +import { SkillList } from './components/Panel/SkillList' import { ConnectionStatus } from './components/ConnectionStatus' import { ChannelSelector } from './components/Header/ChannelSelector' import { SessionSelector } from './components/Header/SessionSelector' @@ -38,6 +39,9 @@ function App() { createMemory, updateMemory, deleteMemory, + // 技能 + skills, + requestSkillList, // 定时任务 schedulerJobs, sidebarTab, @@ -92,6 +96,8 @@ function App() { localStorage.setItem('picobot-memory-panel-open', String(open)) }, []) + const [rightPanelTab, setRightPanelTab] = useState<'memory' | 'skill'>('memory') + const [theme, setTheme] = useState<'dark' | 'light'>(() => { const saved = localStorage.getItem('picobot-theme') return saved === 'light' ? 'light' : 'dark' @@ -302,14 +308,17 @@ function App() { } }, [sidebarTab, status, handleCommand, sendMessage, requestSchedulerJobList]) - // 连接就绪时自动拉取记忆列表 + // 连接就绪时自动拉取记忆和技能列表 useEffect(() => { if (status === 'connected') { - const cmd = requestMemoryList() - handleCommand(cmd) - sendMessage({ type: 'command', payload: JSON.stringify(cmd) }) + const memCmd = requestMemoryList() + handleCommand(memCmd) + sendMessage({ type: 'command', payload: JSON.stringify(memCmd) }) + const skillCmd = requestSkillList() + handleCommand(skillCmd) + sendMessage({ type: 'command', payload: JSON.stringify(skillCmd) }) } - }, [status, handleCommand, sendMessage, requestMemoryList]) + }, [status, handleCommand, sendMessage, requestMemoryList, requestSkillList]) const handleRefreshMemories = useCallback(() => { const cmd = requestMemoryList() @@ -317,6 +326,12 @@ function App() { sendMessage({ type: 'command', payload: JSON.stringify(cmd) }) }, [handleCommand, sendMessage, requestMemoryList]) + const handleRefreshSkills = useCallback(() => { + const cmd = requestSkillList() + handleCommand(cmd) + sendMessage({ type: 'command', payload: JSON.stringify(cmd) }) + }, [handleCommand, sendMessage, requestSkillList]) + const sendMemoryCommand = useCallback((cmd: Command) => { handleCommand(cmd) sendMessage({ type: 'command', payload: JSON.stringify(cmd) }) @@ -573,18 +588,53 @@ function App() { - {/* Right Sidebar - Memory Panel (collapsible) */} + {/* Right Sidebar - Memory & Skill Panel (collapsible, tabbed) */}
-
- toggleMemoryPanel(false)} - onCreateMemory={createMemory} - onUpdateMemory={updateMemory} - onDeleteMemory={deleteMemory} - sendCommand={sendMemoryCommand} - /> +
+ {/* Tab 栏 */} +
+ + + +
+ {/* Panel content */} +
+ {rightPanelTab === 'memory' ? ( + + ) : ( + + )} +
diff --git a/web/src/components/Panel/SkillList.tsx b/web/src/components/Panel/SkillList.tsx new file mode 100644 index 0000000..45490a9 --- /dev/null +++ b/web/src/components/Panel/SkillList.tsx @@ -0,0 +1,142 @@ +import { useState } from 'react' +import { Package, User, Folder, RefreshCw, ChevronDown, ChevronRight, BookOpen } from 'lucide-react' +import type { SkillSummary } from '../../types/protocol' + +/* ── types ────────────────────────────────────────────── */ + +interface SkillListProps { + skills: SkillSummary[] + onRefresh: () => void +} + +interface SourceConfig { + label: string + icon: typeof Package + accent: string +} + +const SOURCE_CONFIG: Record = { + user: { label: '用户技能', icon: User, accent: 'text-cyan-400' }, + useragent: { label: '用户 Agent', icon: User, accent: 'text-cyan-400' }, + useropenclaw:{ label: '用户 OpenClaw', icon: User, accent: 'text-cyan-400' }, + project: { label: '项目技能', icon: Folder, accent: 'text-amber-400' }, + projectagent:{ label: '项目 Agent', icon: Folder, accent: 'text-amber-400' }, + projectopenclaw: { label: '项目 OpenClaw', icon: Folder, accent: 'text-amber-400' }, +} + +function sourceConfig(source: string): SourceConfig { + return SOURCE_CONFIG[source] ?? { label: source, icon: Package, accent: 'text-[var(--text-secondary)]' } +} + +/* ── Section header ────────────────────────────────────── */ + +function SectionHeader({ config, count, isCollapsed, onClick }: + { config: SourceConfig; count: number; isCollapsed: boolean; onClick: () => void }) { + const Icon = config.icon + return ( + + ) +} + +/* ── Skill card ────────────────────────────────────────── */ + +function SkillCard({ skill, config }: { skill: SkillSummary; config: SourceConfig }) { + return ( +
+
+ + {skill.name} + +

{skill.description}

+
+
+ ) +} + +/* ── main component ────────────────────────────────────── */ + +export function SkillList({ skills, onRefresh }: SkillListProps) { + const [collapsed, setCollapsed] = useState>(() => { + try { const s = localStorage.getItem('picobot-skill-collapsed'); return s ? new Set(JSON.parse(s)) : new Set() } + catch (_) { return new Set() } + }) + + const toggle = (source: string) => { + setCollapsed(prev => { + const next = new Set(prev) + if (next.has(source)) { next.delete(source) } else { next.add(source) } + localStorage.setItem('picobot-skill-collapsed', JSON.stringify([...next])) + return next + }) + } + + const grouped = new Map() + for (const s of skills) { const l = grouped.get(s.source) || []; l.push(s); grouped.set(s.source, l) } + + const order = ['user', 'useragent', 'useropenclaw', 'project', 'projectagent', 'projectopenclaw'] + const sorted = Array.from(grouped.keys()).sort((a, b) => { + const ai = order.indexOf(a); const bi = order.indexOf(b) + if (ai !== -1 && bi !== -1) return ai - bi + if (ai !== -1) return -1; if (bi !== -1) return 1 + return a.localeCompare(b) + }) + + return ( +
+ {/* title bar */} +
+
+ +
+ 技能 + {skills.length > 0 && {skills.length}} +
+ +
+
+ + {/* empty */} + {skills.length === 0 && ( +
+
+
+
+ +
+

暂无可用技能
在 .picobot/skills/ 目录下添加 SKILL.md 文件

+
+
+ )} + + {/* list */} + {skills.length > 0 && ( +
+ {sorted.map(source => { + const cfg = sourceConfig(source) + const items = grouped.get(source)! + const closed = collapsed.has(source) + return ( +
+ toggle(source)} /> + {!closed && ( +
+ {items.map(s => )} +
+ )} +
+ ) + })} +
+ )} +
+ ) +} diff --git a/web/src/hooks/useChat.ts b/web/src/hooks/useChat.ts index 2d90152..93b0b6e 100644 --- a/web/src/hooks/useChat.ts +++ b/web/src/hooks/useChat.ts @@ -18,6 +18,8 @@ import type { Attachment, MemorySummary, MemoryList, + SkillSummary, + SkillList, SchedulerJobList, SchedulerJobSummary, SchedulerJobSessionLookup, @@ -86,6 +88,10 @@ interface UseChatReturn { updateMemory: (id: string, content: string) => Command deleteMemory: (id: string) => Command + // 技能状态 + skills: SkillSummary[] + requestSkillList: () => Command + // 定时任务状态 schedulerJobs: SchedulerJobSummary[] sidebarTab: 'topics' | 'scheduler' @@ -133,6 +139,7 @@ export function useChat(): UseChatReturn { const [selectedSessionId, setSelectedSessionId] = useState(null) const [subAgentView, setSubAgentView] = useState(null) const [memories, setMemories] = useState([]) + const [skills, setSkills] = useState([]) const [schedulerJobs, setSchedulerJobs] = useState([]) const [sidebarTab, setSidebarTab] = useState<'topics' | 'scheduler'>('topics') const [schedulerView, setSchedulerView] = useState(null) @@ -527,6 +534,11 @@ export function useChat(): UseChatReturn { setMemories(msg.memories) break } + case 'skill_list': { + const msg = message as SkillList + setSkills(msg.skills) + break + } case 'channel_list': { const msg = message as ChannelList @@ -697,6 +709,10 @@ export function useChat(): UseChatReturn { return { type: 'delete_memory', id } }, []) + const requestSkillList = useCallback((): Command => { + return { type: 'list_skills' } + }, []) + // 定时任务方法 const requestSchedulerJobList = useCallback((): Command => { return { type: 'list_scheduler_jobs' } @@ -783,6 +799,8 @@ export function useChat(): UseChatReturn { createMemory, updateMemory, deleteMemory, + skills, + requestSkillList, schedulerJobs, sidebarTab, setSidebarTab, diff --git a/web/src/types/protocol.ts b/web/src/types/protocol.ts index 06892e4..fc83e60 100644 --- a/web/src/types/protocol.ts +++ b/web/src/types/protocol.ts @@ -187,6 +187,17 @@ export interface MemoryList { memories: MemorySummary[] } +export interface SkillSummary { + name: string + description: string + source: string +} + +export interface SkillList { + type: 'skill_list' + skills: SkillSummary[] +} + export interface SchedulerJobSessionLookup { channel: string chat_id: string @@ -244,6 +255,7 @@ export type WsOutbound = | TaskMessagesLoaded | SchedulerJobList | MemoryList + | SkillList | ExecutionCancelled | Pong @@ -353,6 +365,10 @@ export interface DeleteMemoryCommand { id: string } +export interface ListSkillsCommand { + type: 'list_skills' +} + export type Command = | CreateSessionCommand | ListSessionsCommand @@ -374,6 +390,7 @@ export type Command = | CreateMemoryCommand | UpdateMemoryCommand | DeleteMemoryCommand + | ListSkillsCommand // ============================================================================ // UI Types