- 新增 CLIPluginAdapter 统一接口 (backend/app/core/agent_adapter.py) - 新增 LLM 服务层,支持 Anthropic/OpenAI/DeepSeek/Ollama (backend/app/services/llm_service.py) - 新增 Agent 执行引擎,支持文件锁自动管理 (backend/app/services/agent_executor.py) - 新增 NativeLLMAgent 原生 LLM 适配器 (backend/app/adapters/native_llm_agent.py) - 新增进程管理器 (backend/app/services/process_manager.py) - 新增 Agent 控制 API (backend/app/routers/agents_control.py) - 新增 WebSocket 实时通信 (backend/app/routers/websocket.py) - 更新前端 AgentsPage,支持启动/停止 Agent - 测试通过:Agent 启动、批量操作、栅栏同步 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
363 lines
16 KiB
TypeScript
363 lines
16 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { HardDrive, Lock, Unlock, Activity, Clock, FileText, RefreshCw } from 'lucide-react';
|
|
import { api } from '../lib/api';
|
|
import type { FileLock, Heartbeat, AgentResourceStatus } from '../types';
|
|
|
|
export function ResourcesPage() {
|
|
const [locks, setLocks] = useState<FileLock[]>([]);
|
|
const [heartbeats, setHeartbeats] = useState<Record<string, Heartbeat>>({});
|
|
const [statuses, setStatuses] = useState<AgentResourceStatus[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [activeTab, setActiveTab] = useState<'locks' | 'heartbeats' | 'status'>('locks');
|
|
|
|
const loadData = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const [locksRes, heartbeatsRes, statusRes] = await Promise.all([
|
|
api.lock.list().catch(() => ({ locks: [] })),
|
|
api.heartbeat.list().catch(() => ({ heartbeats: {} })),
|
|
api.resource.getAllStatus().catch(() => ({ agents: [] })),
|
|
]);
|
|
setLocks(locksRes.locks);
|
|
setHeartbeats(heartbeatsRes.heartbeats);
|
|
setStatuses(statusRes.agents);
|
|
setError(null);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : '加载失败');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
const interval = setInterval(loadData, 5000); // 5秒刷新一次
|
|
return () => clearInterval(interval);
|
|
}, []);
|
|
|
|
const handleReleaseLock = async (filePath: string, agentId: string) => {
|
|
if (!confirm(`确定要释放 ${filePath} 的锁吗?`)) return;
|
|
try {
|
|
await api.lock.release(filePath, agentId);
|
|
loadData();
|
|
} catch (err) {
|
|
alert('释放失败: ' + (err instanceof Error ? err.message : '未知错误'));
|
|
}
|
|
};
|
|
|
|
const getStatusColor = (status: string) => {
|
|
switch (status) {
|
|
case 'working':
|
|
return '#00ff9d';
|
|
case 'idle':
|
|
return '#00f0ff';
|
|
case 'waiting':
|
|
return '#ff9500';
|
|
case 'error':
|
|
return '#ff006e';
|
|
default:
|
|
return '#666';
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
{/* Header */}
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
|
<div>
|
|
<h1 style={{ fontSize: 28, fontWeight: 700, color: '#fff', margin: 0 }}>资源监控</h1>
|
|
<p style={{ fontSize: 14, color: 'rgba(255, 255, 255, 0.5)', margin: '8px 0 0 0' }}>
|
|
监控文件锁、Agent 心跳和系统状态
|
|
</p>
|
|
</div>
|
|
{loading && (
|
|
<span style={{ fontSize: 12, color: '#00f0ff', marginRight: 12 }}>加载中...</span>
|
|
)}
|
|
<button
|
|
onClick={loadData}
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 8,
|
|
padding: '10px 16px',
|
|
background: 'rgba(255, 255, 255, 0.05)',
|
|
border: '1px solid rgba(255, 255, 255, 0.1)',
|
|
borderRadius: 8,
|
|
color: 'rgba(255, 255, 255, 0.7)',
|
|
fontSize: 14,
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
<RefreshCw size={16} />
|
|
刷新
|
|
</button>
|
|
</div>
|
|
|
|
{error && (
|
|
<div
|
|
style={{
|
|
padding: '12px 16px',
|
|
background: '#ff006e20',
|
|
border: '1px solid #ff006e50',
|
|
borderRadius: 8,
|
|
color: '#ff006e',
|
|
marginBottom: 24,
|
|
}}
|
|
>
|
|
连接后端失败: {error}
|
|
</div>
|
|
)}
|
|
|
|
{/* Tabs */}
|
|
<div style={{ display: 'flex', gap: 8, marginBottom: 24 }}>
|
|
{[
|
|
{ id: 'locks', label: '文件锁', icon: Lock, count: locks.length },
|
|
{ id: 'heartbeats', label: '心跳状态', icon: Activity, count: Object.keys(heartbeats).length },
|
|
{ id: 'status', label: 'Agent 状态', icon: HardDrive, count: statuses.length },
|
|
].map((tab) => (
|
|
<button
|
|
key={tab.id}
|
|
onClick={() => setActiveTab(tab.id as typeof activeTab)}
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 8,
|
|
padding: '12px 20px',
|
|
background: activeTab === tab.id ? 'rgba(0, 240, 255, 0.1)' : 'rgba(255, 255, 255, 0.05)',
|
|
border: `1px solid ${activeTab === tab.id ? 'rgba(0, 240, 255, 0.3)' : 'rgba(255, 255, 255, 0.1)'}`,
|
|
borderRadius: 8,
|
|
color: activeTab === tab.id ? '#00f0ff' : 'rgba(255, 255, 255, 0.7)',
|
|
fontSize: 14,
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
<tab.icon size={16} />
|
|
{tab.label}
|
|
<span
|
|
style={{
|
|
marginLeft: 4,
|
|
padding: '2px 8px',
|
|
background: activeTab === tab.id ? 'rgba(0, 240, 255, 0.2)' : 'rgba(255, 255, 255, 0.1)',
|
|
borderRadius: 10,
|
|
fontSize: 12,
|
|
}}
|
|
>
|
|
{tab.count}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Content */}
|
|
{activeTab === 'locks' && (
|
|
<div style={{ background: 'rgba(17, 24, 39, 0.7)', borderRadius: 12, border: '1px solid rgba(0, 240, 255, 0.1)' }}>
|
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
|
<thead>
|
|
<tr style={{ borderBottom: '1px solid rgba(0, 240, 255, 0.1)' }}>
|
|
<th style={{ padding: '16px 20px', textAlign: 'left', fontSize: 12, color: 'rgba(255, 255, 255, 0.5)', fontWeight: 500 }}>文件路径</th>
|
|
<th style={{ padding: '16px 20px', textAlign: 'left', fontSize: 12, color: 'rgba(255, 255, 255, 0.5)', fontWeight: 500 }}>锁定者</th>
|
|
<th style={{ padding: '16px 20px', textAlign: 'left', fontSize: 12, color: 'rgba(255, 255, 255, 0.5)', fontWeight: 500 }}>锁定时间</th>
|
|
<th style={{ padding: '16px 20px', textAlign: 'center', fontSize: 12, color: 'rgba(255, 255, 255, 0.5)', fontWeight: 500 }}>操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{locks.map((lock, index) => (
|
|
<tr key={index} style={{ borderBottom: '1px solid rgba(255, 255, 255, 0.05)' }}>
|
|
<td style={{ padding: '14px 20px' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
<FileText size={16} color="#00f0ff" />
|
|
<span style={{ fontSize: 14, color: '#fff', fontFamily: 'monospace' }}>{lock.file_path}</span>
|
|
</div>
|
|
</td>
|
|
<td style={{ padding: '14px 20px' }}>
|
|
<span style={{ fontSize: 14, color: '#fff' }}>{lock.agent_name}</span>
|
|
<span style={{ fontSize: 12, color: 'rgba(255, 255, 255, 0.4)', marginLeft: 8 }}>({lock.agent_id})</span>
|
|
</td>
|
|
<td style={{ padding: '14px 20px' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
<Clock size={14} color="rgba(255, 255, 255, 0.4)" />
|
|
<span style={{ fontSize: 13, color: 'rgba(255, 255, 255, 0.7)' }}>{lock.elapsed_display}</span>
|
|
</div>
|
|
</td>
|
|
<td style={{ padding: '14px 20px', textAlign: 'center' }}>
|
|
<button
|
|
onClick={() => handleReleaseLock(lock.file_path, lock.agent_id)}
|
|
style={{
|
|
padding: '6px 12px',
|
|
background: '#ff006e20',
|
|
border: '1px solid #ff006e50',
|
|
borderRadius: 6,
|
|
color: '#ff006e',
|
|
fontSize: 12,
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
释放
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
{locks.length === 0 && (
|
|
<div style={{ textAlign: 'center', padding: 60 }}>
|
|
<Unlock size={48} color="rgba(255, 255, 255, 0.2)" style={{ marginBottom: 16 }} />
|
|
<p style={{ color: 'rgba(255, 255, 255, 0.5)', margin: 0 }}>暂无文件锁</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'heartbeats' && (
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: 16 }}>
|
|
{Object.entries(heartbeats).map(([agentId, hb]) => (
|
|
<div
|
|
key={agentId}
|
|
style={{
|
|
padding: 20,
|
|
background: 'rgba(17, 24, 39, 0.7)',
|
|
borderRadius: 12,
|
|
border: `1px solid ${hb.is_timeout ? '#ff006e50' : 'rgba(0, 240, 255, 0.1)'}`,
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
|
<span style={{ fontSize: 14, fontWeight: 600, color: '#fff' }}>{agentId}</span>
|
|
{hb.is_timeout ? (
|
|
<span style={{ padding: '4px 10px', borderRadius: 12, background: '#ff006e20', color: '#ff006e', fontSize: 11 }}>
|
|
超时
|
|
</span>
|
|
) : (
|
|
<span style={{ padding: '4px 10px', borderRadius: 12, background: '#00ff9d20', color: '#00ff9d', fontSize: 11 }}>
|
|
正常
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
|
<span style={{ fontSize: 12, color: 'rgba(255, 255, 255, 0.5)' }}>状态</span>
|
|
<span style={{ fontSize: 12, color: getStatusColor(hb.status) }}>{hb.status}</span>
|
|
</div>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
|
<span style={{ fontSize: 12, color: 'rgba(255, 255, 255, 0.5)' }}>当前任务</span>
|
|
<span style={{ fontSize: 12, color: '#fff' }}>{hb.current_task || '-'}</span>
|
|
</div>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
|
<span style={{ fontSize: 12, color: 'rgba(255, 255, 255, 0.5)' }}>进度</span>
|
|
<span style={{ fontSize: 12, color: '#00f0ff' }}>{hb.progress}%</span>
|
|
</div>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
|
<span style={{ fontSize: 12, color: 'rgba(255, 255, 255, 0.5)' }}>运行时间</span>
|
|
<span style={{ fontSize: 12, color: 'rgba(255, 255, 255, 0.7)' }}>{hb.elapsed_display}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid rgba(255, 255, 255, 0.05)' }}>
|
|
<div
|
|
style={{
|
|
height: 4,
|
|
background: 'rgba(255, 255, 255, 0.1)',
|
|
borderRadius: 2,
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
width: `${hb.progress}%`,
|
|
height: '100%',
|
|
background: hb.is_timeout ? '#ff006e' : '#00ff9d',
|
|
borderRadius: 2,
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
{Object.keys(heartbeats).length === 0 && (
|
|
<div style={{ textAlign: 'center', padding: 60, gridColumn: '1 / -1' }}>
|
|
<Activity size={48} color="rgba(255, 255, 255, 0.2)" style={{ marginBottom: 16 }} />
|
|
<p style={{ color: 'rgba(255, 255, 255, 0.5)', margin: 0 }}>暂无心跳数据</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'status' && (
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(360px, 1fr))', gap: 16 }}>
|
|
{statuses.map((status) => (
|
|
<div
|
|
key={status.agent_id}
|
|
style={{
|
|
padding: 20,
|
|
background: 'rgba(17, 24, 39, 0.7)',
|
|
borderRadius: 12,
|
|
border: '1px solid rgba(0, 240, 255, 0.1)',
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
|
<div>
|
|
<h4 style={{ fontSize: 16, fontWeight: 600, color: '#fff', margin: 0 }}>{status.info.name}</h4>
|
|
<p style={{ fontSize: 12, color: 'rgba(255, 255, 255, 0.4)', margin: '4px 0 0 0' }}>{status.agent_id}</p>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
<span
|
|
style={{
|
|
padding: '4px 10px',
|
|
borderRadius: 12,
|
|
background: `${getStatusColor(status.heartbeat.status)}20`,
|
|
color: getStatusColor(status.heartbeat.status),
|
|
fontSize: 11,
|
|
}}
|
|
>
|
|
{status.heartbeat.status}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
|
<div style={{ padding: 12, background: 'rgba(0, 0, 0, 0.2)', borderRadius: 8 }}>
|
|
<p style={{ fontSize: 11, color: 'rgba(255, 255, 255, 0.5)', margin: '0 0 4px 0' }}>角色</p>
|
|
<p style={{ fontSize: 13, color: '#fff', margin: 0 }}>{status.info.role}</p>
|
|
</div>
|
|
<div style={{ padding: 12, background: 'rgba(0, 0, 0, 0.2)', borderRadius: 8 }}>
|
|
<p style={{ fontSize: 11, color: 'rgba(255, 255, 255, 0.5)', margin: '0 0 4px 0' }}>模型</p>
|
|
<p style={{ fontSize: 13, color: '#fff', margin: 0 }}>{status.info.model}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ marginTop: 12, padding: 12, background: 'rgba(0, 0, 0, 0.2)', borderRadius: 8 }}>
|
|
<p style={{ fontSize: 11, color: 'rgba(255, 255, 255, 0.5)', margin: '0 0 4px 0' }}>当前任务</p>
|
|
<p style={{ fontSize: 13, color: '#00f0ff', margin: 0 }}>{status.state.task || '-'}</p>
|
|
</div>
|
|
|
|
{status.locks.length > 0 && (
|
|
<div style={{ marginTop: 12 }}>
|
|
<p style={{ fontSize: 11, color: 'rgba(255, 255, 255, 0.5)', margin: '0 0 8px 0' }}>
|
|
文件锁 ({status.locks.length})
|
|
</p>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
{status.locks.map((lock, i) => (
|
|
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<Lock size={12} color="#ff9500" />
|
|
<span style={{ fontSize: 12, color: '#ff9500', fontFamily: 'monospace' }}>{lock.file}</span>
|
|
<span style={{ fontSize: 11, color: 'rgba(255, 255, 255, 0.4)' }}>({lock.elapsed})</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
{statuses.length === 0 && (
|
|
<div style={{ textAlign: 'center', padding: 60, gridColumn: '1 / -1' }}>
|
|
<HardDrive size={48} color="rgba(255, 255, 255, 0.2)" style={{ marginBottom: 16 }} />
|
|
<p style={{ color: 'rgba(255, 255, 255, 0.5)', margin: 0 }}>暂无 Agent 状态数据</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|