Compare commits
4 Commits
2fbe5bdde1
...
3f9bb22097
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f9bb22097 | ||
|
|
f470affb2f | ||
|
|
4c2a2ebf28 | ||
|
|
ef274e0387 |
64
src/command/handlers/list_skills.rs
Normal file
64
src/command/handlers/list_skills.rs
Normal file
@ -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<SkillRuntime>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ListSkillsCommandHandler {
|
||||||
|
pub fn new(skills: Arc<SkillRuntime>) -> Self {
|
||||||
|
Self { skills }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl CommandHandler for ListSkillsCommandHandler {
|
||||||
|
fn can_handle(&self, cmd: &Command) -> bool {
|
||||||
|
matches!(cmd, Command::ListSkills)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn metadata(&self) -> Option<CommandMetadata> {
|
||||||
|
Some(CommandMetadata {
|
||||||
|
name: "list_skills",
|
||||||
|
description: "列出所有技能",
|
||||||
|
usage: "/list_skills",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle(
|
||||||
|
&self,
|
||||||
|
_cmd: Command,
|
||||||
|
ctx: CommandContext,
|
||||||
|
) -> Result<CommandResponse, CommandError> {
|
||||||
|
let skill_list = self.skills.list_skills();
|
||||||
|
|
||||||
|
let summaries: Vec<SkillSummary> = 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ pub mod help;
|
|||||||
pub mod list_channels;
|
pub mod list_channels;
|
||||||
pub mod list_memories;
|
pub mod list_memories;
|
||||||
pub mod list_scheduler_jobs;
|
pub mod list_scheduler_jobs;
|
||||||
|
pub mod list_skills;
|
||||||
pub mod memory_crud;
|
pub mod memory_crud;
|
||||||
pub mod list_sessions;
|
pub mod list_sessions;
|
||||||
pub mod list_sessions_by_channel;
|
pub mod list_sessions_by_channel;
|
||||||
|
|||||||
@ -71,6 +71,8 @@ pub enum Command {
|
|||||||
},
|
},
|
||||||
/// 删除记忆
|
/// 删除记忆
|
||||||
DeleteMemory { id: String },
|
DeleteMemory { id: String },
|
||||||
|
/// 列出所有技能
|
||||||
|
ListSkills,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Command {
|
impl Command {
|
||||||
@ -97,6 +99,7 @@ impl Command {
|
|||||||
Command::CreateMemory { .. } => "create_memory",
|
Command::CreateMemory { .. } => "create_memory",
|
||||||
Command::UpdateMemory { .. } => "update_memory",
|
Command::UpdateMemory { .. } => "update_memory",
|
||||||
Command::DeleteMemory { .. } => "delete_memory",
|
Command::DeleteMemory { .. } => "delete_memory",
|
||||||
|
Command::ListSkills => "list_skills",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ use crate::command::handlers::get_current::GetCurrentSessionCommandHandler;
|
|||||||
use crate::command::handlers::help::HelpCommandHandler;
|
use crate::command::handlers::help::HelpCommandHandler;
|
||||||
use crate::command::handlers::list_channels::ListChannelsCommandHandler;
|
use crate::command::handlers::list_channels::ListChannelsCommandHandler;
|
||||||
use crate::command::handlers::list_memories::ListMemoriesCommandHandler;
|
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::list_scheduler_jobs::ListSchedulerJobsCommandHandler;
|
||||||
use crate::command::handlers::memory_crud::MemoryCrudCommandHandler;
|
use crate::command::handlers::memory_crud::MemoryCrudCommandHandler;
|
||||||
use crate::command::handlers::list_sessions::ListSessionsCommandHandler;
|
use crate::command::handlers::list_sessions::ListSessionsCommandHandler;
|
||||||
@ -362,6 +363,7 @@ async fn handle_inbound(
|
|||||||
let _cli_sessions = state.session_manager.cli_sessions();
|
let _cli_sessions = state.session_manager.cli_sessions();
|
||||||
let store = state.session_manager.store();
|
let store = state.session_manager.store();
|
||||||
let skills = state.session_manager.skills();
|
let skills = state.session_manager.skills();
|
||||||
|
let skills_for_handler = skills.clone();
|
||||||
let provider_config = state.config.get_provider_config("default")
|
let provider_config = state.config.get_provider_config("default")
|
||||||
.map_err(|e| AgentError::Other(e.to_string()))?;
|
.map_err(|e| AgentError::Other(e.to_string()))?;
|
||||||
let prompt_repository = state.session_manager.store().clone();
|
let prompt_repository = state.session_manager.store().clone();
|
||||||
@ -418,6 +420,8 @@ async fn handle_inbound(
|
|||||||
router.register(Box::new(ListSchedulerJobsCommandHandler::new(store.clone())));
|
router.register(Box::new(ListSchedulerJobsCommandHandler::new(store.clone())));
|
||||||
// 注册 list_memories 处理器
|
// 注册 list_memories 处理器
|
||||||
router.register(Box::new(ListMemoriesCommandHandler::new(store.clone())));
|
router.register(Box::new(ListMemoriesCommandHandler::new(store.clone())));
|
||||||
|
// 注册 list_skills 处理器
|
||||||
|
router.register(Box::new(ListSkillsCommandHandler::new(skills_for_handler)));
|
||||||
// 注册 memory_crud 处理器
|
// 注册 memory_crud 处理器
|
||||||
router.register(Box::new(MemoryCrudCommandHandler::new(store.clone())));
|
router.register(Box::new(MemoryCrudCommandHandler::new(store.clone())));
|
||||||
// 注册 load_chat_messages 处理器
|
// 注册 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::<Vec<crate::protocol::SkillSummary>>(skills_json) {
|
||||||
|
let _ = sender.send(WsOutbound::SkillList { skills }).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 处理记忆列表
|
// 处理记忆列表
|
||||||
if let Some(memories_json) = response.metadata.get("memories") {
|
if let Some(memories_json) = response.metadata.get("memories") {
|
||||||
if let Ok(memories) = serde_json::from_str::<Vec<crate::protocol::MemorySummary>>(memories_json) {
|
if let Ok(memories) = serde_json::from_str::<Vec<crate::protocol::MemorySummary>>(memories_json) {
|
||||||
|
|||||||
@ -74,6 +74,14 @@ pub struct MemorySummary {
|
|||||||
pub updated_at: i64,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct SchedulerJobSummary {
|
pub struct SchedulerJobSummary {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@ -237,6 +245,10 @@ pub enum WsOutbound {
|
|||||||
MemoryList {
|
MemoryList {
|
||||||
memories: Vec<MemorySummary>,
|
memories: Vec<MemorySummary>,
|
||||||
},
|
},
|
||||||
|
#[serde(rename = "skill_list")]
|
||||||
|
SkillList {
|
||||||
|
skills: Vec<SkillSummary>,
|
||||||
|
},
|
||||||
#[serde(rename = "execution_cancelled")]
|
#[serde(rename = "execution_cancelled")]
|
||||||
ExecutionCancelled { message: String },
|
ExecutionCancelled { message: String },
|
||||||
#[serde(rename = "pong")]
|
#[serde(rename = "pong")]
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>PicoBot</title>
|
<title>PicoBot</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
3
web/public/favicon.svg
Normal file
3
web/public/favicon.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#22d3ee" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 247 B |
@ -1,9 +1,10 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
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 { ChatContainer } from './components/Chat/ChatContainer'
|
||||||
import { TopicList } from './components/Sidebar/TopicList'
|
import { TopicList } from './components/Sidebar/TopicList'
|
||||||
import { SchedulerJobList } from './components/Sidebar/SchedulerJobList'
|
import { SchedulerJobList } from './components/Sidebar/SchedulerJobList'
|
||||||
import { MemoryPanel } from './components/Panel/MemoryPanel'
|
import { MemoryPanel } from './components/Panel/MemoryPanel'
|
||||||
|
import { SkillList } from './components/Panel/SkillList'
|
||||||
import { ConnectionStatus } from './components/ConnectionStatus'
|
import { ConnectionStatus } from './components/ConnectionStatus'
|
||||||
import { ChannelSelector } from './components/Header/ChannelSelector'
|
import { ChannelSelector } from './components/Header/ChannelSelector'
|
||||||
import { SessionSelector } from './components/Header/SessionSelector'
|
import { SessionSelector } from './components/Header/SessionSelector'
|
||||||
@ -38,6 +39,9 @@ function App() {
|
|||||||
createMemory,
|
createMemory,
|
||||||
updateMemory,
|
updateMemory,
|
||||||
deleteMemory,
|
deleteMemory,
|
||||||
|
// 技能
|
||||||
|
skills,
|
||||||
|
requestSkillList,
|
||||||
// 定时任务
|
// 定时任务
|
||||||
schedulerJobs,
|
schedulerJobs,
|
||||||
sidebarTab,
|
sidebarTab,
|
||||||
@ -92,6 +96,8 @@ function App() {
|
|||||||
localStorage.setItem('picobot-memory-panel-open', String(open))
|
localStorage.setItem('picobot-memory-panel-open', String(open))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const [rightPanelTab, setRightPanelTab] = useState<'memory' | 'skill'>('memory')
|
||||||
|
|
||||||
const [theme, setTheme] = useState<'dark' | 'light'>(() => {
|
const [theme, setTheme] = useState<'dark' | 'light'>(() => {
|
||||||
const saved = localStorage.getItem('picobot-theme')
|
const saved = localStorage.getItem('picobot-theme')
|
||||||
return saved === 'light' ? 'light' : 'dark'
|
return saved === 'light' ? 'light' : 'dark'
|
||||||
@ -302,14 +308,17 @@ function App() {
|
|||||||
}
|
}
|
||||||
}, [sidebarTab, status, handleCommand, sendMessage, requestSchedulerJobList])
|
}, [sidebarTab, status, handleCommand, sendMessage, requestSchedulerJobList])
|
||||||
|
|
||||||
// 连接就绪时自动拉取记忆列表
|
// 连接就绪时自动拉取记忆和技能列表
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status === 'connected') {
|
if (status === 'connected') {
|
||||||
const cmd = requestMemoryList()
|
const memCmd = requestMemoryList()
|
||||||
handleCommand(cmd)
|
handleCommand(memCmd)
|
||||||
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
|
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 handleRefreshMemories = useCallback(() => {
|
||||||
const cmd = requestMemoryList()
|
const cmd = requestMemoryList()
|
||||||
@ -317,6 +326,12 @@ function App() {
|
|||||||
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
|
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
|
||||||
}, [handleCommand, sendMessage, requestMemoryList])
|
}, [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) => {
|
const sendMemoryCommand = useCallback((cmd: Command) => {
|
||||||
handleCommand(cmd)
|
handleCommand(cmd)
|
||||||
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
|
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
|
||||||
@ -573,18 +588,53 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Sidebar - Memory Panel (collapsible) */}
|
{/* Right Sidebar - Memory & Skill Panel (collapsible, tabbed) */}
|
||||||
<div className={`shrink-0 border-l border-[var(--border-color)] bg-[var(--bg-secondary)]/50 transition-all duration-300 ease-out overflow-hidden ${memoryPanelOpen ? 'w-80' : 'w-0 border-l-0'}`}>
|
<div className={`shrink-0 border-l border-[var(--border-color)] bg-[var(--bg-secondary)]/50 transition-all duration-300 ease-out overflow-hidden ${memoryPanelOpen ? 'w-80' : 'w-0 border-l-0'}`}>
|
||||||
<div className={`w-80 ${memoryPanelOpen ? '' : 'invisible'}`}>
|
<div className={`w-80 h-full flex flex-col ${memoryPanelOpen ? '' : 'invisible'}`}>
|
||||||
<MemoryPanel
|
{/* Tab 栏 */}
|
||||||
memories={memories}
|
<div className="shrink-0 flex border-b border-[var(--border-color)]">
|
||||||
onRefresh={handleRefreshMemories}
|
<button
|
||||||
onClose={() => toggleMemoryPanel(false)}
|
onClick={() => setRightPanelTab('memory')}
|
||||||
onCreateMemory={createMemory}
|
className={`flex-1 py-2.5 text-sm font-medium text-center transition-colors ${
|
||||||
onUpdateMemory={updateMemory}
|
rightPanelTab === 'memory'
|
||||||
onDeleteMemory={deleteMemory}
|
? 'text-[var(--accent-cyan)] border-b-2 border-[var(--accent-cyan)]'
|
||||||
sendCommand={sendMemoryCommand}
|
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'
|
||||||
/>
|
}`}
|
||||||
|
>
|
||||||
|
记忆
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setRightPanelTab('skill')}
|
||||||
|
className={`flex-1 py-2.5 text-sm font-medium text-center transition-colors ${
|
||||||
|
rightPanelTab === 'skill'
|
||||||
|
? 'text-[var(--accent-cyan)] border-b-2 border-[var(--accent-cyan)]'
|
||||||
|
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
技能
|
||||||
|
</button>
|
||||||
|
<button onClick={() => toggleMemoryPanel(false)} className="px-2 py-2.5 text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors" title="收起">
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* Panel content */}
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
|
{rightPanelTab === 'memory' ? (
|
||||||
|
<MemoryPanel
|
||||||
|
memories={memories}
|
||||||
|
onRefresh={handleRefreshMemories}
|
||||||
|
onCreateMemory={createMemory}
|
||||||
|
onUpdateMemory={updateMemory}
|
||||||
|
onDeleteMemory={deleteMemory}
|
||||||
|
sendCommand={sendMemoryCommand}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<SkillList
|
||||||
|
skills={skills}
|
||||||
|
onRefresh={handleRefreshSkills}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -15,6 +15,8 @@ export function MessageList({ messages, onNavigateToSubAgent }: MessageListProps
|
|||||||
const prevShowBottomRef = useRef(false)
|
const prevShowBottomRef = useRef(false)
|
||||||
const prevShowTopRef = useRef(false)
|
const prevShowTopRef = useRef(false)
|
||||||
const lastMessageCountRef = useRef(0)
|
const lastMessageCountRef = useRef(0)
|
||||||
|
const prevMessageCountRef = useRef(0)
|
||||||
|
const stickyLockUntilRef = useRef(0)
|
||||||
const isProgrammaticScrollRef = useRef(false)
|
const isProgrammaticScrollRef = useRef(false)
|
||||||
const scrollTimerRef = useRef<number>(0)
|
const scrollTimerRef = useRef<number>(0)
|
||||||
|
|
||||||
@ -28,6 +30,12 @@ export function MessageList({ messages, onNavigateToSubAgent }: MessageListProps
|
|||||||
const el = containerRef.current
|
const el = containerRef.current
|
||||||
if (!el) return
|
if (!el) return
|
||||||
|
|
||||||
|
// sticky lock 期间:防止中间 DOM 状态的 scroll 事件打断历史加载
|
||||||
|
if (Date.now() < stickyLockUntilRef.current) {
|
||||||
|
isStickyRef.current = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight
|
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight
|
||||||
const nearBottom = distanceFromBottom < 120
|
const nearBottom = distanceFromBottom < 120
|
||||||
|
|
||||||
@ -81,13 +89,31 @@ export function MessageList({ messages, onNavigateToSubAgent }: MessageListProps
|
|||||||
// ---- auto-scroll effect ----
|
// ---- auto-scroll effect ----
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (messages.length === 0) return
|
// 消息清空 → 重置所有追踪状态(话题/会话切换)
|
||||||
|
if (messages.length === 0) {
|
||||||
|
prevMessageCountRef.current = 0
|
||||||
|
lastMessageCountRef.current = 0
|
||||||
|
isStickyRef.current = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const lastMessage = messages[messages.length - 1]
|
const lastMessage = messages[messages.length - 1]
|
||||||
|
const isFreshLoad = prevMessageCountRef.current === 0 && messages.length > 0
|
||||||
|
|
||||||
// 用户自己发的消息 → 始终滚到底部
|
// 用户自己发的消息 → 始终滚到底部
|
||||||
if (lastMessage.role === 'user') {
|
if (lastMessage.role === 'user') {
|
||||||
scrollToBottom('instant')
|
scrollToBottom('instant')
|
||||||
|
prevMessageCountRef.current = messages.length
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新加载(话题切换后首次收到消息)→ 强制滚到底部
|
||||||
|
if (isFreshLoad) {
|
||||||
|
isStickyRef.current = true
|
||||||
|
lastMessageCountRef.current = 0
|
||||||
|
stickyLockUntilRef.current = Date.now() + 1500
|
||||||
|
scrollToBottom('instant')
|
||||||
|
prevMessageCountRef.current = messages.length
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -102,6 +128,7 @@ export function MessageList({ messages, onNavigateToSubAgent }: MessageListProps
|
|||||||
}
|
}
|
||||||
|
|
||||||
lastMessageCountRef.current = messages.length
|
lastMessageCountRef.current = messages.length
|
||||||
|
prevMessageCountRef.current = messages.length
|
||||||
}, [messages, scrollToBottom])
|
}, [messages, scrollToBottom])
|
||||||
|
|
||||||
// ---- ResizeObserver: 窗口大小变化时保持底部对齐 ----
|
// ---- ResizeObserver: 窗口大小变化时保持底部对齐 ----
|
||||||
@ -119,6 +146,15 @@ export function MessageList({ messages, onNavigateToSubAgent }: MessageListProps
|
|||||||
return () => observer.disconnect()
|
return () => observer.disconnect()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// ---- 组件挂载时滚动到底部 ----
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (messages.length > 0) {
|
||||||
|
scrollToBottom('instant')
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
// ---- 清理定时器 ----
|
// ---- 清理定时器 ----
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -139,7 +139,7 @@ function SectionHeader({ config, count, isCollapsed, onClick }:
|
|||||||
{ config: NamespaceConfig; count: number; isCollapsed: boolean; onClick: () => void }) {
|
{ config: NamespaceConfig; count: number; isCollapsed: boolean; onClick: () => void }) {
|
||||||
const Icon = config.icon
|
const Icon = config.icon
|
||||||
return (
|
return (
|
||||||
<button onClick={onClick} className="group flex items-center gap-2 w-full py-1.5 rounded-lg transition-colors hover:bg-[var(--overlay-hover)]">
|
<button onClick={onClick} className="sticky top-0 z-10 group flex items-center gap-2 w-full py-1.5 rounded-lg transition-colors hover:bg-[var(--overlay-hover)] bg-[var(--bg-secondary)]/90 backdrop-blur-sm -mx-3 px-3">
|
||||||
{isCollapsed ? <ChevronRight className="h-3 w-3 text-[var(--text-muted)]" /> : <ChevronDown className="h-3 w-3 text-[var(--text-muted)]" />}
|
{isCollapsed ? <ChevronRight className="h-3 w-3 text-[var(--text-muted)]" /> : <ChevronDown className="h-3 w-3 text-[var(--text-muted)]" />}
|
||||||
<div className={`flex items-center justify-center w-5 h-5 rounded-md bg-[var(--overlay-hover)] ${config.accent}`}>
|
<div className={`flex items-center justify-center w-5 h-5 rounded-md bg-[var(--overlay-hover)] ${config.accent}`}>
|
||||||
<Icon className="h-3 w-3" />
|
<Icon className="h-3 w-3" />
|
||||||
@ -224,7 +224,7 @@ export function MemoryPanel({ memories, onRefresh, onClose, onCreateMemory, onUp
|
|||||||
|
|
||||||
{/* list */}
|
{/* list */}
|
||||||
{memories.length > 0 && (
|
{memories.length > 0 && (
|
||||||
<div className="flex-1 overflow-y-auto px-3 py-2 space-y-3">
|
<div className="flex-1 overflow-y-auto px-3 pt-0 pb-2 space-y-3">
|
||||||
{sorted.map(ns => {
|
{sorted.map(ns => {
|
||||||
const c = cfg(ns)
|
const c = cfg(ns)
|
||||||
const items = grouped.get(ns)!
|
const items = grouped.get(ns)!
|
||||||
|
|||||||
142
web/src/components/Panel/SkillList.tsx
Normal file
142
web/src/components/Panel/SkillList.tsx
Normal file
@ -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<string, SourceConfig> = {
|
||||||
|
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 (
|
||||||
|
<button onClick={onClick} className="sticky top-0 z-10 group flex items-center gap-2 w-full py-1.5 rounded-lg transition-colors hover:bg-[var(--overlay-hover)] bg-[var(--bg-secondary)]/90 backdrop-blur-sm -mx-3 px-3">
|
||||||
|
{isCollapsed ? <ChevronRight className="h-3 w-3 text-[var(--text-muted)]" /> : <ChevronDown className="h-3 w-3 text-[var(--text-muted)]" />}
|
||||||
|
<div className={`flex items-center justify-center w-5 h-5 rounded-md bg-[var(--overlay-hover)] ${config.accent}`}>
|
||||||
|
<Icon className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-semibold text-[var(--text-primary)] tracking-tight">{config.label}</span>
|
||||||
|
<span className="text-[10px] text-[var(--text-muted)] font-mono tabular-nums ml-auto">{count}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Skill card ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function SkillCard({ skill, config }: { skill: SkillSummary; config: SourceConfig }) {
|
||||||
|
return (
|
||||||
|
<div className="group rounded-lg bg-[var(--overlay-hover)] border border-[var(--border-color)] overflow-hidden transition-all duration-200 hover:border-[var(--border-accent)]">
|
||||||
|
<div className="px-3 py-2.5">
|
||||||
|
<span className={`text-[10px] font-mono uppercase tracking-wider ${config.accent}`}>
|
||||||
|
{skill.name}
|
||||||
|
</span>
|
||||||
|
<p className="text-sm text-[var(--text-secondary)] leading-relaxed mt-1">{skill.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── main component ────────────────────────────────────── */
|
||||||
|
|
||||||
|
export function SkillList({ skills, onRefresh }: SkillListProps) {
|
||||||
|
const [collapsed, setCollapsed] = useState<Set<string>>(() => {
|
||||||
|
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<string, SkillSummary[]>()
|
||||||
|
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 (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{/* title bar */}
|
||||||
|
<div className="shrink-0 border-b border-[var(--border-color)] px-4 py-3 flex items-center gap-2.5">
|
||||||
|
<div className="flex items-center justify-center w-6 h-6 rounded-lg bg-[var(--accent-cyan)]/10">
|
||||||
|
<BookOpen className="h-3.5 w-3.5 text-[var(--accent-cyan)]" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-bold text-[var(--text-primary)] tracking-tight">技能</span>
|
||||||
|
{skills.length > 0 && <span className="text-[11px] font-mono text-[var(--text-muted)] tabular-nums ml-0.5">{skills.length}</span>}
|
||||||
|
<div className="ml-auto flex items-center gap-0.5">
|
||||||
|
<button onClick={onRefresh} className="p-1.5 rounded-lg hover:bg-[var(--overlay-hover)] text-[var(--text-muted)] hover:text-[var(--accent-cyan)] transition-colors" title="刷新">
|
||||||
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* empty */}
|
||||||
|
{skills.length === 0 && (
|
||||||
|
<div className="flex flex-1 items-center justify-center p-8">
|
||||||
|
<div className="text-center select-none">
|
||||||
|
<div className="relative inline-block mb-5">
|
||||||
|
<div className="absolute inset-0 rounded-full bg-[var(--accent-cyan)]/10 blur-xl animate-pulse" />
|
||||||
|
<BookOpen className="relative h-12 w-12 text-[var(--accent-cyan)]/25" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[var(--text-muted)] leading-relaxed">暂无可用技能<br />在 .picobot/skills/ 目录下添加 SKILL.md 文件</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* list */}
|
||||||
|
{skills.length > 0 && (
|
||||||
|
<div className="flex-1 overflow-y-auto px-3 pt-0 pb-2 space-y-3">
|
||||||
|
{sorted.map(source => {
|
||||||
|
const cfg = sourceConfig(source)
|
||||||
|
const items = grouped.get(source)!
|
||||||
|
const closed = collapsed.has(source)
|
||||||
|
return (
|
||||||
|
<div key={source}>
|
||||||
|
<SectionHeader config={cfg} count={items.length} isCollapsed={closed} onClick={() => toggle(source)} />
|
||||||
|
{!closed && (
|
||||||
|
<div className="mt-1.5 space-y-1.5">
|
||||||
|
{items.map(s => <SkillCard key={s.name} skill={s} config={cfg} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -18,6 +18,8 @@ import type {
|
|||||||
Attachment,
|
Attachment,
|
||||||
MemorySummary,
|
MemorySummary,
|
||||||
MemoryList,
|
MemoryList,
|
||||||
|
SkillSummary,
|
||||||
|
SkillList,
|
||||||
SchedulerJobList,
|
SchedulerJobList,
|
||||||
SchedulerJobSummary,
|
SchedulerJobSummary,
|
||||||
SchedulerJobSessionLookup,
|
SchedulerJobSessionLookup,
|
||||||
@ -86,6 +88,10 @@ interface UseChatReturn {
|
|||||||
updateMemory: (id: string, content: string) => Command
|
updateMemory: (id: string, content: string) => Command
|
||||||
deleteMemory: (id: string) => Command
|
deleteMemory: (id: string) => Command
|
||||||
|
|
||||||
|
// 技能状态
|
||||||
|
skills: SkillSummary[]
|
||||||
|
requestSkillList: () => Command
|
||||||
|
|
||||||
// 定时任务状态
|
// 定时任务状态
|
||||||
schedulerJobs: SchedulerJobSummary[]
|
schedulerJobs: SchedulerJobSummary[]
|
||||||
sidebarTab: 'topics' | 'scheduler'
|
sidebarTab: 'topics' | 'scheduler'
|
||||||
@ -133,6 +139,7 @@ export function useChat(): UseChatReturn {
|
|||||||
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null)
|
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null)
|
||||||
const [subAgentView, setSubAgentView] = useState<SubAgentView | null>(null)
|
const [subAgentView, setSubAgentView] = useState<SubAgentView | null>(null)
|
||||||
const [memories, setMemories] = useState<MemorySummary[]>([])
|
const [memories, setMemories] = useState<MemorySummary[]>([])
|
||||||
|
const [skills, setSkills] = useState<SkillSummary[]>([])
|
||||||
const [schedulerJobs, setSchedulerJobs] = useState<SchedulerJobSummary[]>([])
|
const [schedulerJobs, setSchedulerJobs] = useState<SchedulerJobSummary[]>([])
|
||||||
const [sidebarTab, setSidebarTab] = useState<'topics' | 'scheduler'>('topics')
|
const [sidebarTab, setSidebarTab] = useState<'topics' | 'scheduler'>('topics')
|
||||||
const [schedulerView, setSchedulerView] = useState<SchedulerJobView | null>(null)
|
const [schedulerView, setSchedulerView] = useState<SchedulerJobView | null>(null)
|
||||||
@ -527,6 +534,11 @@ export function useChat(): UseChatReturn {
|
|||||||
setMemories(msg.memories)
|
setMemories(msg.memories)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
case 'skill_list': {
|
||||||
|
const msg = message as SkillList
|
||||||
|
setSkills(msg.skills)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
case 'channel_list': {
|
case 'channel_list': {
|
||||||
const msg = message as ChannelList
|
const msg = message as ChannelList
|
||||||
@ -697,6 +709,10 @@ export function useChat(): UseChatReturn {
|
|||||||
return { type: 'delete_memory', id }
|
return { type: 'delete_memory', id }
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const requestSkillList = useCallback((): Command => {
|
||||||
|
return { type: 'list_skills' }
|
||||||
|
}, [])
|
||||||
|
|
||||||
// 定时任务方法
|
// 定时任务方法
|
||||||
const requestSchedulerJobList = useCallback((): Command => {
|
const requestSchedulerJobList = useCallback((): Command => {
|
||||||
return { type: 'list_scheduler_jobs' }
|
return { type: 'list_scheduler_jobs' }
|
||||||
@ -783,6 +799,8 @@ export function useChat(): UseChatReturn {
|
|||||||
createMemory,
|
createMemory,
|
||||||
updateMemory,
|
updateMemory,
|
||||||
deleteMemory,
|
deleteMemory,
|
||||||
|
skills,
|
||||||
|
requestSkillList,
|
||||||
schedulerJobs,
|
schedulerJobs,
|
||||||
sidebarTab,
|
sidebarTab,
|
||||||
setSidebarTab,
|
setSidebarTab,
|
||||||
|
|||||||
@ -187,6 +187,17 @@ export interface MemoryList {
|
|||||||
memories: MemorySummary[]
|
memories: MemorySummary[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SkillSummary {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
source: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillList {
|
||||||
|
type: 'skill_list'
|
||||||
|
skills: SkillSummary[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface SchedulerJobSessionLookup {
|
export interface SchedulerJobSessionLookup {
|
||||||
channel: string
|
channel: string
|
||||||
chat_id: string
|
chat_id: string
|
||||||
@ -244,6 +255,7 @@ export type WsOutbound =
|
|||||||
| TaskMessagesLoaded
|
| TaskMessagesLoaded
|
||||||
| SchedulerJobList
|
| SchedulerJobList
|
||||||
| MemoryList
|
| MemoryList
|
||||||
|
| SkillList
|
||||||
| ExecutionCancelled
|
| ExecutionCancelled
|
||||||
| Pong
|
| Pong
|
||||||
|
|
||||||
@ -353,6 +365,10 @@ export interface DeleteMemoryCommand {
|
|||||||
id: string
|
id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ListSkillsCommand {
|
||||||
|
type: 'list_skills'
|
||||||
|
}
|
||||||
|
|
||||||
export type Command =
|
export type Command =
|
||||||
| CreateSessionCommand
|
| CreateSessionCommand
|
||||||
| ListSessionsCommand
|
| ListSessionsCommand
|
||||||
@ -374,6 +390,7 @@ export type Command =
|
|||||||
| CreateMemoryCommand
|
| CreateMemoryCommand
|
||||||
| UpdateMemoryCommand
|
| UpdateMemoryCommand
|
||||||
| DeleteMemoryCommand
|
| DeleteMemoryCommand
|
||||||
|
| ListSkillsCommand
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// UI Types
|
// UI Types
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user