feat: 添加 Todo 面板,支持待办事项的展示与管理
This commit is contained in:
parent
ce6dce81f4
commit
3f32079f92
@ -5,6 +5,7 @@ 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 { SkillList } from './components/Panel/SkillList'
|
||||||
|
import { TodoPanel } from './components/Panel/TodoPanel'
|
||||||
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'
|
||||||
@ -42,6 +43,8 @@ function App() {
|
|||||||
// 技能
|
// 技能
|
||||||
skills,
|
skills,
|
||||||
requestSkillList,
|
requestSkillList,
|
||||||
|
todos,
|
||||||
|
requestTodoList,
|
||||||
// 定时任务
|
// 定时任务
|
||||||
schedulerJobs,
|
schedulerJobs,
|
||||||
sidebarTab,
|
sidebarTab,
|
||||||
@ -677,6 +680,13 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 悬浮 Todo 面板 */}
|
||||||
|
<TodoPanel
|
||||||
|
todos={todos}
|
||||||
|
requestTodoList={requestTodoList}
|
||||||
|
sendCommand={sendMemoryCommand}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
223
web/src/components/Panel/TodoPanel.tsx
Normal file
223
web/src/components/Panel/TodoPanel.tsx
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||||
|
import { ClipboardList, ChevronUp, ChevronDown, Circle } from 'lucide-react'
|
||||||
|
import type { TodoItemSummary, Command } from '../../types/protocol'
|
||||||
|
|
||||||
|
interface TodoPanelProps {
|
||||||
|
todos: TodoItemSummary[]
|
||||||
|
requestTodoList: () => Command
|
||||||
|
sendCommand: (cmd: Command) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── status helpers ───────────────────────────────────── */
|
||||||
|
|
||||||
|
interface StatusStyle { label: string; border: string; dot: string; text: string; icon: string }
|
||||||
|
|
||||||
|
const STATUS: Record<string, StatusStyle> = {
|
||||||
|
in_progress: { label: '进行中', border: 'border-amber-400/60', dot: 'bg-amber-400 shadow-[0_0_6px_#fbbf24]', text: 'text-amber-300', icon: '●' },
|
||||||
|
pending: { label: '待处理', border: 'border-slate-500/50', dot: 'bg-slate-500', text: 'text-slate-400', icon: '○' },
|
||||||
|
completed: { label: '已完成', border: 'border-emerald-400/40', dot: 'bg-emerald-400', text: 'text-emerald-400', icon: '✓' },
|
||||||
|
cancelled: { label: '已取消', border: 'border-red-400/40', dot: 'bg-red-400', text: 'text-red-400', icon: '✕' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusStyle(s: string): StatusStyle {
|
||||||
|
return STATUS[s] ?? { label: s, border: 'border-[var(--border-color)]', dot: 'bg-[var(--text-muted)]', text: 'text-[var(--text-muted)]', icon: '?' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRIORITY: Record<string, string> = {
|
||||||
|
high: 'text-rose-400',
|
||||||
|
medium: 'text-amber-400',
|
||||||
|
low: 'text-slate-400',
|
||||||
|
}
|
||||||
|
|
||||||
|
function priorityDot(p: string) { return PRIORITY[p] ?? 'text-slate-400' }
|
||||||
|
|
||||||
|
/* ── group helpers ────────────────────────────────────── */
|
||||||
|
|
||||||
|
const GROUP_ORDER = ['in_progress', 'pending', 'completed', 'cancelled']
|
||||||
|
const COLLAPSED_DEFAULT = new Set(['completed', 'cancelled'])
|
||||||
|
|
||||||
|
function groupTodos(todos: TodoItemSummary[]): Map<string, TodoItemSummary[]> {
|
||||||
|
const map = new Map<string, TodoItemSummary[]>()
|
||||||
|
for (const t of todos) {
|
||||||
|
const list = map.get(t.status) ?? []
|
||||||
|
list.push(t)
|
||||||
|
map.set(t.status, list)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── pulse dot ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function PulseDot() {
|
||||||
|
return (
|
||||||
|
<span className="relative flex h-2 w-2">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75" />
|
||||||
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-amber-400 shadow-[0_0_6px_#fbbf24]" />
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── TodoPanel ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export function TodoPanel({ todos, requestTodoList, sendCommand }: TodoPanelProps) {
|
||||||
|
const [expanded, setExpanded] = useState(() => {
|
||||||
|
try { return localStorage.getItem('picobot-todo-panel-open') === 'true' } catch { return false }
|
||||||
|
})
|
||||||
|
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(() => new Set(COLLAPSED_DEFAULT))
|
||||||
|
const prevTodoIdsRef = useRef<Set<string>>(new Set())
|
||||||
|
|
||||||
|
// 持久化展开状态
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('picobot-todo-panel-open', String(expanded))
|
||||||
|
}, [expanded])
|
||||||
|
|
||||||
|
// 当有新 todo 出现时自动展开
|
||||||
|
useEffect(() => {
|
||||||
|
const newIds = new Set(todos.map(t => t.id))
|
||||||
|
const hasNewItems = todos.some(t => !prevTodoIdsRef.current.has(t.id))
|
||||||
|
if (hasNewItems && todos.length > 0) {
|
||||||
|
setExpanded(true)
|
||||||
|
}
|
||||||
|
prevTodoIdsRef.current = newIds
|
||||||
|
}, [todos])
|
||||||
|
|
||||||
|
const grouped = groupTodos(todos)
|
||||||
|
const inProgressCount = grouped.get('in_progress')?.length ?? 0
|
||||||
|
const totalCount = todos.length
|
||||||
|
|
||||||
|
const toggleGroup = useCallback((status: string) => {
|
||||||
|
setCollapsedGroups(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(status)) next.delete(status); else next.add(status)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// ── 空状态 ──
|
||||||
|
if (totalCount === 0) {
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 right-4 z-50">
|
||||||
|
<button
|
||||||
|
onClick={() => sendCommand(requestTodoList())}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 rounded-xl bg-[var(--bg-tertiary)]/70 backdrop-blur-sm border border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:border-[var(--border-accent)] transition-all duration-300 shadow-lg text-xs"
|
||||||
|
title="刷新待办"
|
||||||
|
>
|
||||||
|
<ClipboardList className="h-3.5 w-3.5" />
|
||||||
|
<span>待办</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 折叠态 ──
|
||||||
|
if (!expanded) {
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 right-4 z-50">
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(true)}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 rounded-xl bg-[var(--bg-tertiary)]/80 backdrop-blur-sm border border-[var(--border-color)] hover:border-[var(--accent-cyan)]/30 transition-all duration-300 shadow-lg group"
|
||||||
|
>
|
||||||
|
{inProgressCount > 0 ? <PulseDot /> : <ClipboardList className="h-3.5 w-3.5 text-[var(--text-muted)]" />}
|
||||||
|
<span className="text-sm font-medium text-[var(--text-secondary)] group-hover:text-[var(--accent-cyan)] transition-colors">
|
||||||
|
待办 ({totalCount})
|
||||||
|
</span>
|
||||||
|
<ChevronUp className="h-3.5 w-3.5 text-[var(--text-muted)]" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 展开态 ──
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 right-4 z-50 w-72 max-h-[60vh] flex flex-col rounded-xl border border-[var(--border-color)] bg-[var(--bg-tertiary)]/95 backdrop-blur-md shadow-2xl overflow-hidden transition-all duration-300">
|
||||||
|
{/* 标题栏 */}
|
||||||
|
<div className="shrink-0 flex items-center justify-between px-3 py-2.5 border-b border-[var(--border-color)]">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ClipboardList className="h-4 w-4 text-[var(--accent-cyan)]" />
|
||||||
|
<span className="text-sm font-semibold text-[var(--text-primary)]">待办事项</span>
|
||||||
|
<span className="text-[11px] text-[var(--text-muted)] tabular-nums">共 {totalCount} 项</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(false)}
|
||||||
|
className="p-1 rounded hover:bg-[var(--overlay-subtle)] text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 列表区 */}
|
||||||
|
<div className="flex-1 overflow-y-auto scrollbar-hide px-2 py-2 space-y-2">
|
||||||
|
{GROUP_ORDER.map(status => {
|
||||||
|
const items = grouped.get(status)
|
||||||
|
if (!items || items.length === 0) return null
|
||||||
|
|
||||||
|
const style = statusStyle(status)
|
||||||
|
const isCollapsed = collapsedGroups.has(status)
|
||||||
|
const isTerminal = status === 'completed' || status === 'cancelled'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={status}>
|
||||||
|
{/* 分组标题 */}
|
||||||
|
<button
|
||||||
|
onClick={() => toggleGroup(status)}
|
||||||
|
className="flex items-center gap-1.5 w-full px-1 py-1 text-xs text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors"
|
||||||
|
>
|
||||||
|
{isCollapsed ? (
|
||||||
|
<ChevronUp className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
<span className={style.text}>{style.icon}</span>
|
||||||
|
<span>{style.label}</span>
|
||||||
|
<span className="tabular-nums">({items.length})</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 卡片列表 */}
|
||||||
|
{!isCollapsed && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{items.map(item => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className={`rounded-lg border-l-2 ${style.border} border border-[var(--border-color)] bg-[var(--overlay-hover)]/50 px-2.5 py-1.5 transition-colors hover:border-[var(--border-accent)]`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className={`mt-0.5 shrink-0 ${priorityDot(item.priority)}`}>
|
||||||
|
<Circle className="h-2 w-2 fill-current" />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className={`text-[13px] leading-snug text-[var(--text-primary)] break-words ${
|
||||||
|
isTerminal ? 'line-through opacity-50' : ''
|
||||||
|
}`}>
|
||||||
|
{item.content}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
<span className={`text-[10px] ${priorityDot(item.priority)}`}>
|
||||||
|
{item.priority}
|
||||||
|
</span>
|
||||||
|
<span className={`text-[10px] ${style.text}`}>
|
||||||
|
{style.icon} {style.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部刷新 */}
|
||||||
|
<div className="shrink-0 border-t border-[var(--border-color)] px-3 py-1.5">
|
||||||
|
<button
|
||||||
|
onClick={() => sendCommand(requestTodoList())}
|
||||||
|
className="text-[10px] text-[var(--text-muted)] hover:text-[var(--accent-cyan)] transition-colors"
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -20,6 +20,8 @@ import type {
|
|||||||
MemoryList,
|
MemoryList,
|
||||||
SkillSummary,
|
SkillSummary,
|
||||||
SkillList,
|
SkillList,
|
||||||
|
TodoItemSummary,
|
||||||
|
TodoList,
|
||||||
SchedulerJobList,
|
SchedulerJobList,
|
||||||
SchedulerJobSummary,
|
SchedulerJobSummary,
|
||||||
SchedulerJobSessionLookup,
|
SchedulerJobSessionLookup,
|
||||||
@ -92,6 +94,10 @@ interface UseChatReturn {
|
|||||||
skills: SkillSummary[]
|
skills: SkillSummary[]
|
||||||
requestSkillList: () => Command
|
requestSkillList: () => Command
|
||||||
|
|
||||||
|
// Todo 状态
|
||||||
|
todos: TodoItemSummary[]
|
||||||
|
requestTodoList: () => Command
|
||||||
|
|
||||||
// 定时任务状态
|
// 定时任务状态
|
||||||
schedulerJobs: SchedulerJobSummary[]
|
schedulerJobs: SchedulerJobSummary[]
|
||||||
sidebarTab: 'topics' | 'scheduler'
|
sidebarTab: 'topics' | 'scheduler'
|
||||||
@ -140,6 +146,7 @@ export function useChat(): UseChatReturn {
|
|||||||
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 [skills, setSkills] = useState<SkillSummary[]>([])
|
||||||
|
const [todos, setTodos] = useState<TodoItemSummary[]>([])
|
||||||
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)
|
||||||
@ -572,6 +579,11 @@ export function useChat(): UseChatReturn {
|
|||||||
setSkills(msg.skills)
|
setSkills(msg.skills)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
case 'todo_list': {
|
||||||
|
const msg = message as TodoList
|
||||||
|
setTodos(msg.todos)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
case 'channel_list': {
|
case 'channel_list': {
|
||||||
const msg = message as ChannelList
|
const msg = message as ChannelList
|
||||||
@ -750,6 +762,10 @@ export function useChat(): UseChatReturn {
|
|||||||
return { type: 'list_skills' }
|
return { type: 'list_skills' }
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const requestTodoList = useCallback((): Command => {
|
||||||
|
return { type: 'list_todos' }
|
||||||
|
}, [])
|
||||||
|
|
||||||
// 定时任务方法
|
// 定时任务方法
|
||||||
const requestSchedulerJobList = useCallback((): Command => {
|
const requestSchedulerJobList = useCallback((): Command => {
|
||||||
return { type: 'list_scheduler_jobs' }
|
return { type: 'list_scheduler_jobs' }
|
||||||
@ -838,6 +854,8 @@ export function useChat(): UseChatReturn {
|
|||||||
deleteMemory,
|
deleteMemory,
|
||||||
skills,
|
skills,
|
||||||
requestSkillList,
|
requestSkillList,
|
||||||
|
todos,
|
||||||
|
requestTodoList,
|
||||||
schedulerJobs,
|
schedulerJobs,
|
||||||
sidebarTab,
|
sidebarTab,
|
||||||
setSidebarTab,
|
setSidebarTab,
|
||||||
|
|||||||
@ -201,6 +201,21 @@ export interface SkillList {
|
|||||||
skills: SkillSummary[]
|
skills: SkillSummary[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TodoItemSummary {
|
||||||
|
id: string
|
||||||
|
content: string
|
||||||
|
status: string
|
||||||
|
priority: string
|
||||||
|
created_at: number
|
||||||
|
updated_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TodoList {
|
||||||
|
type: 'todo_list'
|
||||||
|
todos: TodoItemSummary[]
|
||||||
|
scope_key: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface SchedulerJobSessionLookup {
|
export interface SchedulerJobSessionLookup {
|
||||||
channel: string
|
channel: string
|
||||||
chat_id: string
|
chat_id: string
|
||||||
@ -259,6 +274,7 @@ export type WsOutbound =
|
|||||||
| SchedulerJobList
|
| SchedulerJobList
|
||||||
| MemoryList
|
| MemoryList
|
||||||
| SkillList
|
| SkillList
|
||||||
|
| TodoList
|
||||||
| ExecutionCancelled
|
| ExecutionCancelled
|
||||||
| Pong
|
| Pong
|
||||||
|
|
||||||
@ -372,6 +388,10 @@ export interface ListSkillsCommand {
|
|||||||
type: 'list_skills'
|
type: 'list_skills'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ListTodosCommand {
|
||||||
|
type: 'list_todos'
|
||||||
|
}
|
||||||
|
|
||||||
export type Command =
|
export type Command =
|
||||||
| CreateSessionCommand
|
| CreateSessionCommand
|
||||||
| ListSessionsCommand
|
| ListSessionsCommand
|
||||||
@ -394,6 +414,7 @@ export type Command =
|
|||||||
| UpdateMemoryCommand
|
| UpdateMemoryCommand
|
||||||
| DeleteMemoryCommand
|
| DeleteMemoryCommand
|
||||||
| ListSkillsCommand
|
| ListSkillsCommand
|
||||||
|
| ListTodosCommand
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// UI Types
|
// UI Types
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user