diff --git a/app/api/agents/route.ts b/app/api/agents/route.ts new file mode 100644 index 0000000..4160baa --- /dev/null +++ b/app/api/agents/route.ts @@ -0,0 +1,13 @@ +// app/api/agents/route.ts +import { NextResponse } from 'next/server'; +import { getAgents } from '@/lib/agents'; + +export async function GET() { + try { + const agents = getAgents(); + return NextResponse.json({ agents }); + } catch (error) { + console.error('Failed to get agents:', error); + return NextResponse.json({ agents: [], error: 'Failed to fetch agents' }, { status: 500 }); + } +} diff --git a/app/api/daily/route.ts b/app/api/daily/route.ts new file mode 100644 index 0000000..b0b82cb --- /dev/null +++ b/app/api/daily/route.ts @@ -0,0 +1,16 @@ +// app/api/daily/route.ts +import { NextResponse } from 'next/server'; +import { getTodayItems } from '@/lib/daily'; + +export async function GET() { + try { + const items = getTodayItems(); + return NextResponse.json({ + items, + date: new Date().toISOString().split('T')[0] + }); + } catch (error) { + console.error('Failed to get daily items:', error); + return NextResponse.json({ items: [], date: new Date().toISOString().split('T')[0] }, { status: 500 }); + } +} diff --git a/app/daily/page.tsx b/app/daily/page.tsx new file mode 100644 index 0000000..1c0f4bf --- /dev/null +++ b/app/daily/page.tsx @@ -0,0 +1,179 @@ +// app/daily/page.tsx +'use client'; + +import { useEffect, useState } from 'react'; + +interface DailyItem { + time: string; + content: string; + status: 'completed' | 'in-progress' | 'pending'; +} + +function DailyBoard() { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [speaking, setSpeaking] = useState(false); + const [utterance, setUtterance] = useState(null); + + useEffect(() => { + async function fetchDaily() { + try { + const res = await fetch('/api/daily'); + const data = await res.json(); + setItems(data.items || []); + } catch (error) { + console.error('Failed to fetch daily:', error); + } finally { + setLoading(false); + } + } + + fetchDaily(); + }, []); + + const completedItems = items.filter(i => i.status === 'completed'); + const inProgressItems = items.filter(i => i.status === 'in-progress'); + + const reportText = completedItems.length > 0 + ? `今日共完成 ${completedItems.length} 项任务。${completedItems.map(i => i.content).join('。')}` + : '今日暂无完成记录'; + + const speak = () => { + if (!('speechSynthesis' in window)) { + alert('您的浏览器不支持语音合成'); + return; + } + + if (speaking && utterance) { + window.speechSynthesis.cancel(); + setSpeaking(false); + return; + } + + const u = new SpeechSynthesisUtterance(reportText); + u.lang = 'zh-CN'; + u.rate = 1.0; + + u.onstart = () => setSpeaking(true); + u.onend = () => setSpeaking(false); + u.onerror = () => setSpeaking(false); + + setUtterance(u); + window.speechSynthesis.speak(u); + }; + + const stop = () => { + if ('speechSynthesis' in window) { + window.speechSynthesis.cancel(); + } + setSpeaking(false); + }; + + const today = new Date().toLocaleDateString('zh-CN', { + year: 'numeric', + month: 'long', + day: 'numeric', + weekday: 'long', + }); + + return ( +
+ {/* 头部 */} +
+
+

📅 今日播报板

+

{today}

+
+
+ +
+
+ + {/* 统计 */} +
+
+
{completedItems.length}
+
已完成
+
+
+
{inProgressItems.length}
+
进行中
+
+
+
+ {items.length - completedItems.length - inProgressItems.length} +
+
待开始
+
+
+ + {/* 播报列表 */} +
+ {loading ? ( +
加载中...
+ ) : items.length === 0 ? ( +
+ 今日暂无记录 +
+ ) : ( +
+ {items.map((item, index) => ( +
+ + {item.status === 'completed' ? '✅' : + item.status === 'in-progress' ? '🔄' : '⏳'} + +
+

+ {item.content} +

+ {item.time && ( +

{item.time}

+ )} +
+
+ ))} +
+ )} +
+ + {/* 提示 */} + {speaking && ( +
+ 🎤 正在语音播报今日完成事项... +
+ )} +
+ ); +} + +export default function DailyPage() { + return ; +} diff --git a/app/sidebar.tsx b/app/sidebar.tsx index 367056b..421559a 100644 --- a/app/sidebar.tsx +++ b/app/sidebar.tsx @@ -10,7 +10,7 @@ const BUGS_ENABLED_KEY = "pixel-office-bugs-enabled"; const BUGS_COUNT_KEY = "pixel-office-bugs-count"; const BUGS_MAX = 400; -type NavIconName = "agents" | "pixelOffice" | "models" | "sessions" | "stats" | "alerts" | "skills"; +type NavIconName = "agents" | "pixelOffice" | "models" | "sessions" | "stats" | "alerts" | "skills" | "workspace" | "daily"; type PixelTone = "base" | "shade" | "light"; type PixelRect = { x: number; y: number; w?: number; h?: number; tone?: PixelTone; opacity?: number }; type PixelPalette = { base: string; shade: string; light: string }; @@ -176,6 +176,38 @@ function NavPixelIcon({ name, active }: { name: NavIconName; active: boolean }) ]} /> ); + case "workspace": + return ( + + ); + case "daily": + return ( + + ); } } @@ -205,6 +237,8 @@ const NAV_ITEMS: { group: string; items: { href: string; icon: NavIconName; labe items: [ { href: "/", icon: "agents", labelKey: "nav.agents" }, { href: "/pixel-office", icon: "pixelOffice", labelKey: "nav.pixelOffice" }, + { href: "/workspace", icon: "workspace", labelKey: "nav.workspace" }, + { href: "/daily", icon: "daily", labelKey: "nav.daily" }, { href: "/models", icon: "models", labelKey: "nav.models" }, ], }, diff --git a/app/workspace/page.tsx b/app/workspace/page.tsx new file mode 100644 index 0000000..843c56a --- /dev/null +++ b/app/workspace/page.tsx @@ -0,0 +1,179 @@ +// app/workspace/page.tsx +'use client'; + +import { useEffect, useState } from 'react'; + +interface AgentState { + agentId: string; + state: 'working' | 'online' | 'idle' | 'offline'; + lastActive: number | null; +} + +interface Skill { + name: string; + description: string; + version: string; + status: string; +} + +const STATE_CONFIG = { + working: { label: '工作中', emoji: '🔥', color: 'bg-green-500' }, + online: { label: '在线', emoji: '🟢', color: 'bg-blue-500' }, + idle: { label: '空闲', emoji: '😴', color: 'bg-yellow-500' }, + offline: { label: '离线', emoji: '⚫', color: 'bg-gray-500' }, +}; + +function AgentCard({ agent }: { agent: AgentState }) { + const config = STATE_CONFIG[agent.state] || STATE_CONFIG.offline; + const lastActiveText = agent.lastActive + ? new Date(agent.lastActive).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) + : '无记录'; + + return ( +
+
+
+ {config.emoji} +
+

{agent.agentId}

+

{config.label}

+
+
+
+ 最后活跃: {lastActiveText} +
+
+ ); +} + +function SkillCard({ skill }: { skill: Skill }) { + return ( +
+
+

{skill.name}

+ + {skill.status === 'running' ? '运行中' : '已禁用'} + +
+

+ {skill.description || '无描述'} +

+

+ v{skill.version} +

+
+ ); +} + +export default function WorkspacePage() { + const [agents, setAgents] = useState([]); + const [skills, setSkills] = useState([]); + const [loading, setLoading] = useState(true); + const [lastRefresh, setLastRefresh] = useState(new Date()); + + useEffect(() => { + async function fetchData() { + try { + const [agentRes, skillsRes] = await Promise.all([ + fetch('/api/agent-status'), + fetch('/api/skills'), + ]); + const agentData = await agentRes.json(); + const skillsData = await skillsRes.json(); + + // Handle both old and new skill format + if (Array.isArray(skillsData.skills)) { + setSkills(skillsData.skills.map((s: any) => ({ + name: s.name || s.id || '未知', + description: s.description || '', + version: s.version || 'unknown', + status: 'running', + }))); + } + + setAgents(agentData.statuses || []); + } catch (error) { + console.error('Failed to fetch data:', error); + } finally { + setLoading(false); + setLastRefresh(new Date()); + } + } + + fetchData(); + const interval = setInterval(fetchData, 15000); // 15秒刷新 + return () => clearInterval(interval); + }, []); + + const workingCount = agents.filter(a => a.state === 'working').length; + const onlineCount = agents.filter(a => a.state === 'online' || a.state === 'working').length; + + return ( +
+ {/* 头部 */} +
+
+

🤖 Agent 工作台

+

+ 实时监控所有 Agent 状态 · {lastRefresh.toLocaleTimeString('zh-CN')} 更新 +

+
+
+
+ 在线: + {onlineCount}/{agents.length} +
+
+ 工作中: + {workingCount} +
+
+
+ + {/* Agent 网格 */} +
+

📊 Agent 状态

+ {loading ? ( +
加载中...
+ ) : agents.length === 0 ? ( +
+ 暂无 Agent 数据 +
+ ) : ( +
+ {agents.map((agent) => ( + + ))} +
+ )} +
+ + {/* 技能列表 */} +
+
+

📦 已安装技能

+ + 共 {skills.length} 个技能 + +
+ {loading ? ( +
加载中...
+ ) : skills.length === 0 ? ( +
+ 暂无技能 +
+ ) : ( +
+ {skills.map((skill) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/components/agent-card.tsx b/components/agent-card.tsx new file mode 100644 index 0000000..0f33ad0 --- /dev/null +++ b/components/agent-card.tsx @@ -0,0 +1,57 @@ +// components/agent-card.tsx +'use client'; + +import { AgentStatus } from '@/lib/agents'; + +interface AgentCardProps { + agent: AgentStatus; +} + +const STATUS_LABELS: Record = { + working: '工作中', + idle: '空闲', + thinking: '思考中', + waiting: '等待指令', +}; + +const MOOD_EMOJIS: Record = { + happy: '😊', + neutral: '😐', + busy: '🔥', + tired: '😴', + thinking: '🤯', +}; + +export function AgentCard({ agent }: AgentCardProps) { + return ( +
+
+ {agent.emoji} +
+

{agent.name}

+ {agent.id} +
+
+ +
+
+ 状态: + + {STATUS_LABELS[agent.status] || agent.status} + +
+ +
+ 心情: + {MOOD_EMOJIS[agent.mood] || '😐'} +
+ +
+

+ 当前:{agent.currentTask} +

+
+
+
+ ); +} diff --git a/components/agent-grid.tsx b/components/agent-grid.tsx new file mode 100644 index 0000000..ab666a1 --- /dev/null +++ b/components/agent-grid.tsx @@ -0,0 +1,50 @@ +// components/agent-grid.tsx +'use client'; + +import { useEffect, useState } from 'react'; +import { AgentCard } from './agent-card'; +import { AgentStatus } from '@/lib/agents'; + +export function AgentGrid() { + const [agents, setAgents] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + async function fetchAgents() { + try { + const res = await fetch('/api/agents'); + const data = await res.json(); + setAgents(data.agents || []); + } catch (error) { + console.error('Failed to fetch agents:', error); + } finally { + setLoading(false); + } + } + + fetchAgents(); + // 每 10 秒刷新一次 + const interval = setInterval(fetchAgents, 10000); + return () => clearInterval(interval); + }, []); + + if (loading) { + return
加载中...
; + } + + if (agents.length === 0) { + return ( +
+ 暂无 Agent 数据 +
+ ); + } + + return ( +
+ {agents.map((agent) => ( + + ))} +
+ ); +} diff --git a/lib/agents.ts b/lib/agents.ts new file mode 100644 index 0000000..eec3c54 --- /dev/null +++ b/lib/agents.ts @@ -0,0 +1,43 @@ +// lib/agents.ts +import { readFileSync, existsSync, readdirSync } from 'fs'; +import { join } from 'path'; + +export interface AgentStatus { + id: string; + name: string; + emoji: string; + status: 'working' | 'idle' | 'thinking' | 'waiting'; + mood: 'happy' | 'neutral' | 'busy' | 'tired' | 'thinking'; + currentTask: string; + uptime: number; + lastActive: string; +} + +export function getAgents(): AgentStatus[] { + const configPath = join(process.env.HOME || '', '.openclaw/openclaw.json'); + + if (!existsSync(configPath)) { + return []; + } + + let config: any = {}; + try { + config = JSON.parse(readFileSync(configPath, 'utf-8')); + } catch { + return []; + } + + // 从配置中提取 Agent 信息 + const agents = Object.entries(config.agents || {}).map(([id, agent]: [string, any]) => ({ + id, + name: agent.name || id, + emoji: agent.emoji || '🤖', + status: 'idle' as const, + mood: 'neutral' as const, + currentTask: '等待指令', + uptime: 0, + lastActive: new Date().toISOString(), + })); + + return agents; +} diff --git a/lib/daily.ts b/lib/daily.ts new file mode 100644 index 0000000..3d9f4e4 --- /dev/null +++ b/lib/daily.ts @@ -0,0 +1,52 @@ +// lib/daily.ts +import { readFileSync, existsSync } from 'fs'; +import { join } from 'path'; + +export interface DailyItem { + time: string; + content: string; + status: 'completed' | 'in-progress' | 'pending'; + agent?: string; +} + +export function getTodayItems(): DailyItem[] { + const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD + const memoryDir = join(process.env.HOME || '', '.openclaw/workspace/memory'); + const dailyPath = join(memoryDir, `${today}.md`); + + if (!existsSync(dailyPath)) { + return []; + } + + let content = ''; + try { + content = readFileSync(dailyPath, 'utf-8'); + } catch { + return []; + } + + const items: DailyItem[] = []; + const lines = content.split('\n'); + + for (const line of lines) { + // 匹配 - [x] 或 - [ ] 格式 + const match = line.match(/^-\s+\[([ x])\]\s+(.+)/); + if (!match) continue; + + const status = match[1] === 'x' ? 'completed' : 'pending'; + const text = match[2]; + + // 提取时间(如果存在) + const timeMatch = text.match(/\[([\d: -]+)\]/); + const time = timeMatch?.[1] || ''; + const contentText = text.replace(/\[[\d: -]+\]\s*/, '').trim(); + + items.push({ + time, + content: contentText, + status, + }); + } + + return items; +} diff --git a/lib/i18n.tsx b/lib/i18n.tsx index 0ccb732..cead4dc 100644 --- a/lib/i18n.tsx +++ b/lib/i18n.tsx @@ -301,6 +301,8 @@ const translations: Record> = { // nav sidebar "nav.overview": "总览", "nav.agents": "机器人", + "nav.workspace": "Agent 工作台", + "nav.daily": "今日播报", "nav.models": "模型列表", "nav.monitor": "监控", "nav.sessions": "会话列表", @@ -589,6 +591,8 @@ const translations: Record> = { // nav sidebar "nav.overview": "Overview", "nav.agents": "Bots", + "nav.workspace": "Agent Workspace", + "nav.daily": "Daily Report", "nav.models": "Models", "nav.monitor": "Monitor", "nav.sessions": "Sessions", diff --git a/lib/skills.ts b/lib/skills.ts new file mode 100644 index 0000000..7ef5103 --- /dev/null +++ b/lib/skills.ts @@ -0,0 +1,60 @@ +// lib/skills.ts +import { readdirSync, existsSync, readFileSync } from 'fs'; +import { join } from 'path'; + +export interface SkillInfo { + name: string; + path: string; + version: string; + description: string; + status: 'running' | 'disabled' | 'error'; + lastRun: string | null; +} + +export function scanSkills(skillsDir: string): SkillInfo[] { + if (!existsSync(skillsDir)) { + return []; + } + + let entries: { name: string; isDirectory: () => boolean }[] = []; + try { + entries = readdirSync(skillsDir, { withFileTypes: true }); + } catch { + return []; + } + + const skills: SkillInfo[] = []; + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const skillPath = join(skillsDir, entry.name); + const skillMdPath = join(skillPath, 'SKILL.md'); + + let description = '无描述'; + let version = 'unknown'; + + if (existsSync(skillMdPath)) { + try { + const content = readFileSync(skillMdPath, 'utf-8'); + const titleMatch = content.match(/^#\s+(.+)/m); + const versionMatch = content.match(/version:\s*(.+)/i); + description = titleMatch?.[1] || entry.name; + version = versionMatch?.[1] || 'unknown'; + } catch { + description = entry.name; + } + } + + skills.push({ + name: entry.name, + path: skillPath, + version, + description, + status: 'running', + lastRun: null, + }); + } + + return skills; +}