Commit ed87748033f8e993da77f5a4a62f2ec62b6b1473
1 parent
c5693e36
feat: Agent action controls - chat, skills panel, train trigger
- Agent names are clickable links to chatbot page - Action button group per agent: heartbeat ▶/⏹, 💬 Chat, 📋 Skills, 🎓 Train - Expandable skills panel shows agent's loaded skills - Train button sends instruction to CEO to arrange Innovator training - Backend: GET /api/company/:id/agents/:agentId/skills API
Showing
2 changed files
with
169 additions
and
42 deletions
server/routes/company.js
| ... | ... | @@ -285,6 +285,32 @@ router.post('/:companyId/agents/:agentId/heartbeat/stop', (req, res) => { |
| 285 | 285 | }); |
| 286 | 286 | |
| 287 | 287 | // ═══════════════════════════════════════ |
| 288 | +// Agent 技能查询 | |
| 289 | +// ═══════════════════════════════════════ | |
| 290 | + | |
| 291 | +// GET /api/company/:companyId/agents/:agentId/skills — 获取 Agent 技能列表 | |
| 292 | +router.get('/:companyId/agents/:agentId/skills', (req, res) => { | |
| 293 | + try { | |
| 294 | + const path = require('path'); | |
| 295 | + const fs = require('fs'); | |
| 296 | + const DATA_DIR = path.resolve(__dirname, '../../data'); | |
| 297 | + const skillsDir = path.join(DATA_DIR, req.params.companyId, 'agents', req.params.agentId, 'skills'); | |
| 298 | + | |
| 299 | + if (!fs.existsSync(skillsDir)) { | |
| 300 | + return res.json([]); | |
| 301 | + } | |
| 302 | + | |
| 303 | + const skills = fs.readdirSync(skillsDir, { withFileTypes: true }) | |
| 304 | + .filter(d => d.isDirectory()) | |
| 305 | + .map(d => d.name); | |
| 306 | + | |
| 307 | + res.json(skills); | |
| 308 | + } catch (err) { | |
| 309 | + res.status(500).json({ error: err.message }); | |
| 310 | + } | |
| 311 | +}); | |
| 312 | + | |
| 313 | +// ═══════════════════════════════════════ | |
| 288 | 314 | // Agent 招聘 |
| 289 | 315 | // ═══════════════════════════════════════ |
| 290 | 316 | ... | ... |
web/src/views/CompanyDashboard.vue
| ... | ... | @@ -123,48 +123,68 @@ |
| 123 | 123 | <span class="col-heartbeat">心跳</span> |
| 124 | 124 | <span class="col-actions">操作</span> |
| 125 | 125 | </div> |
| 126 | - <div v-for="agent in companyStatus.agents" :key="agent.id" class="table-row"> | |
| 127 | - <span class="col-name"> | |
| 128 | - <span class="agent-dot" :class="agent.heartbeatActive ? 'active' : 'inactive'"></span> | |
| 129 | - {{ agent.name }} | |
| 130 | - </span> | |
| 131 | - <span class="col-type"> | |
| 132 | - <span class="type-badge">{{ agent.type }}</span> | |
| 133 | - </span> | |
| 134 | - <span class="col-status"> | |
| 135 | - <span class="card-status" :class="agent.status">{{ agent.status }}</span> | |
| 136 | - </span> | |
| 137 | - <span class="col-interval"> | |
| 138 | - <select | |
| 139 | - class="interval-select" | |
| 140 | - :value="agent.heartbeatInterval" | |
| 141 | - @change="updateAgentInterval(agent.id, $event.target.value)" | |
| 142 | - > | |
| 143 | - <option :value="1">1 分钟</option> | |
| 144 | - <option :value="5">5 分钟</option> | |
| 145 | - <option :value="10">10 分钟</option> | |
| 146 | - <option :value="15">15 分钟</option> | |
| 147 | - <option :value="30">30 分钟</option> | |
| 148 | - <option :value="60">60 分钟</option> | |
| 149 | - </select> | |
| 150 | - </span> | |
| 151 | - <span class="col-heartbeat"> | |
| 152 | - <span v-if="agent.heartbeatActive" class="hb-active">💓 运行中</span> | |
| 153 | - <span v-else class="hb-inactive">⏸️ 停止</span> | |
| 154 | - </span> | |
| 155 | - <span class="col-actions"> | |
| 156 | - <button | |
| 157 | - v-if="!agent.heartbeatActive" | |
| 158 | - class="btn-agent-start" | |
| 159 | - @click="startAgentHeartbeat(agent.id)" | |
| 160 | - >▶ 启动</button> | |
| 161 | - <button | |
| 162 | - v-else | |
| 163 | - class="btn-agent-stop" | |
| 164 | - @click="stopAgentHeartbeat(agent.id)" | |
| 165 | - >⏹ 停止</button> | |
| 166 | - </span> | |
| 167 | - </div> | |
| 126 | + <template v-for="agent in companyStatus.agents" :key="agent.id"> | |
| 127 | + <div class="table-row"> | |
| 128 | + <span class="col-name"> | |
| 129 | + <span class="agent-dot" :class="agent.heartbeatActive ? 'active' : 'inactive'"></span> | |
| 130 | + <a class="agent-name-link" @click="goToAgentChat(agent.id)">{{ agent.name }}</a> | |
| 131 | + </span> | |
| 132 | + <span class="col-type"> | |
| 133 | + <span class="type-badge">{{ agent.type }}</span> | |
| 134 | + </span> | |
| 135 | + <span class="col-status"> | |
| 136 | + <span class="card-status" :class="agent.status">{{ agent.status }}</span> | |
| 137 | + </span> | |
| 138 | + <span class="col-interval"> | |
| 139 | + <select | |
| 140 | + class="interval-select" | |
| 141 | + :value="agent.heartbeatInterval" | |
| 142 | + @change="updateAgentInterval(agent.id, $event.target.value)" | |
| 143 | + > | |
| 144 | + <option :value="1">1 分钟</option> | |
| 145 | + <option :value="5">5 分钟</option> | |
| 146 | + <option :value="10">10 分钟</option> | |
| 147 | + <option :value="15">15 分钟</option> | |
| 148 | + <option :value="30">30 分钟</option> | |
| 149 | + <option :value="60">60 分钟</option> | |
| 150 | + </select> | |
| 151 | + </span> | |
| 152 | + <span class="col-heartbeat"> | |
| 153 | + <span v-if="agent.heartbeatActive" class="hb-active">💓 运行中</span> | |
| 154 | + <span v-else class="hb-inactive">⏸️ 停止</span> | |
| 155 | + </span> | |
| 156 | + <span class="col-actions"> | |
| 157 | + <div class="action-group"> | |
| 158 | + <button | |
| 159 | + v-if="!agent.heartbeatActive" | |
| 160 | + class="btn-agent-start" | |
| 161 | + @click="startAgentHeartbeat(agent.id)" | |
| 162 | + title="启动心跳" | |
| 163 | + >▶</button> | |
| 164 | + <button | |
| 165 | + v-else | |
| 166 | + class="btn-agent-stop" | |
| 167 | + @click="stopAgentHeartbeat(agent.id)" | |
| 168 | + title="停止心跳" | |
| 169 | + >⏹</button> | |
| 170 | + <button class="btn-action-chat" @click="goToAgentChat(agent.id)" title="聊天">💬</button> | |
| 171 | + <button class="btn-action-skills" @click="toggleAgentSkills(agent.id)" title="技能">📋</button> | |
| 172 | + <button class="btn-action-train" @click="triggerTrain(agent.id)" title="训练">🎓</button> | |
| 173 | + </div> | |
| 174 | + </span> | |
| 175 | + </div> | |
| 176 | + <!-- Expandable Skills Panel --> | |
| 177 | + <div v-if="expandedAgent === agent.id" class="agent-expand-panel"> | |
| 178 | + <div class="expand-header"> | |
| 179 | + <h4>📋 {{ agent.name }} 的技能列表</h4> | |
| 180 | + <button class="btn-close-expand" @click="expandedAgent = null">✕</button> | |
| 181 | + </div> | |
| 182 | + <div v-if="agentSkills.length > 0" class="skills-grid"> | |
| 183 | + <div v-for="skill in agentSkills" :key="skill" class="skill-chip">⚡ {{ skill }}</div> | |
| 184 | + </div> | |
| 185 | + <div v-else class="empty-mini" style="padding:12px;">暂无技能(可通过 Innovator 训练获得)</div> | |
| 186 | + </div> | |
| 187 | + </template> | |
| 168 | 188 | </div> |
| 169 | 189 | <div v-else class="empty-mini">暂无 Agent</div> |
| 170 | 190 | </div> |
| ... | ... | @@ -312,6 +332,8 @@ const templates = ref([]) |
| 312 | 332 | const activeTab = ref('overview') |
| 313 | 333 | const showTemplates = ref(false) |
| 314 | 334 | const showDropdown = ref(false) |
| 335 | +const expandedAgent = ref(null) | |
| 336 | +const agentSkills = ref([]) | |
| 315 | 337 | |
| 316 | 338 | function statusLabel(status) { |
| 317 | 339 | const map = { creating: '创建中', running: '运行中', paused: '已暂停', shutdown: '已关停' } |
| ... | ... | @@ -482,6 +504,41 @@ async function stopAgentHeartbeat(agentId) { |
| 482 | 504 | } catch (e) { console.error(e) } |
| 483 | 505 | } |
| 484 | 506 | |
| 507 | +function goToAgentChat(agentId) { | |
| 508 | + if (!selectedCompany.value) return | |
| 509 | + router.push(`/${selectedCompany.value.id}/agents/${agentId}`) | |
| 510 | +} | |
| 511 | + | |
| 512 | +async function toggleAgentSkills(agentId) { | |
| 513 | + if (expandedAgent.value === agentId) { | |
| 514 | + expandedAgent.value = null | |
| 515 | + return | |
| 516 | + } | |
| 517 | + // Fetch skills from backend | |
| 518 | + try { | |
| 519 | + const { data } = await axios.get(`/api/company/${selectedCompany.value.id}/agents/${agentId}/skills`) | |
| 520 | + agentSkills.value = data | |
| 521 | + } catch { | |
| 522 | + agentSkills.value = [] | |
| 523 | + } | |
| 524 | + expandedAgent.value = agentId | |
| 525 | +} | |
| 526 | + | |
| 527 | +async function triggerTrain(agentId) { | |
| 528 | + if (!selectedCompany.value) return | |
| 529 | + const namespacedId = `${selectedCompany.value.id}:${agentId}` | |
| 530 | + if (!confirm(`确定要对 ${agentId} 发起 Innovator 训练?这将触发 agent-creator 重新定义并由 Innovator 训练该 Agent。`)) return | |
| 531 | + try { | |
| 532 | + await axios.post(`/api/agent/${selectedCompany.value.id}:ceo/chat`, { | |
| 533 | + message: `请安排 Innovator 对 ${namespacedId} 进行一次完整的技能训练/再训练。要求:1)评估当前技能缺口 2)使用 Innovator 进行 FDE 训练 3)训练完成后汇报结果`, | |
| 534 | + conversationId: `train-${agentId}-${Date.now().toString(36)}`, | |
| 535 | + }) | |
| 536 | + alert(`✅ 训练指令已发送给 CEO,CEO 将安排 Innovator 训练 ${agentId}`) | |
| 537 | + } catch (e) { | |
| 538 | + alert(`❌ 发送训练指令失败: ${e.message}`) | |
| 539 | + } | |
| 540 | +} | |
| 541 | + | |
| 485 | 542 | // switchTab removed, handled by goToTab and router watcher |
| 486 | 543 | |
| 487 | 544 | onMounted(() => { |
| ... | ... | @@ -963,4 +1020,48 @@ function closeDropdown() { |
| 963 | 1020 | |
| 964 | 1021 | .hb-active { color: var(--accent-light); font-size: 12px; } |
| 965 | 1022 | .hb-inactive { color: var(--text-muted); font-size: 12px; } |
| 1023 | + | |
| 1024 | +/* Agent Name Link */ | |
| 1025 | +.agent-name-link { | |
| 1026 | + color: var(--text-primary); cursor: pointer; text-decoration: none; | |
| 1027 | + transition: color 0.15s; | |
| 1028 | +} | |
| 1029 | +.agent-name-link:hover { color: var(--accent-light); text-decoration: underline; } | |
| 1030 | + | |
| 1031 | +/* Action Button Group */ | |
| 1032 | +.action-group { display: flex; gap: 4px; align-items: center; } | |
| 1033 | +.action-group button { | |
| 1034 | + width: 28px; height: 28px; border-radius: 6px; border: 1px solid var(--border-primary); | |
| 1035 | + background: var(--bg-primary); cursor: pointer; font-size: 13px; | |
| 1036 | + display: flex; align-items: center; justify-content: center; transition: all 0.15s; | |
| 1037 | + padding: 0; | |
| 1038 | +} | |
| 1039 | +.action-group button:hover { background: var(--bg-surface-hover); border-color: var(--border-active); transform: scale(1.1); } | |
| 1040 | + | |
| 1041 | +.btn-action-chat { color: var(--accent-light); } | |
| 1042 | +.btn-action-skills { color: var(--blue-text, #60a5fa); } | |
| 1043 | +.btn-action-train { color: var(--purple-text, #a78bfa); } | |
| 1044 | + | |
| 1045 | +/* Expandable Skills Panel */ | |
| 1046 | +.agent-expand-panel { | |
| 1047 | + padding: 16px 20px; background: var(--bg-primary); | |
| 1048 | + border-bottom: 1px solid var(--border-primary); | |
| 1049 | + animation: fadeIn 0.2s ease; | |
| 1050 | +} | |
| 1051 | +.expand-header { | |
| 1052 | + display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; | |
| 1053 | +} | |
| 1054 | +.expand-header h4 { font-size: 13px; font-weight: 700; margin: 0; } | |
| 1055 | +.btn-close-expand { | |
| 1056 | + background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 14px; | |
| 1057 | + padding: 4px 8px; border-radius: 4px; transition: all 0.15s; | |
| 1058 | +} | |
| 1059 | +.btn-close-expand:hover { background: var(--bg-surface-hover); color: var(--text-primary); } | |
| 1060 | + | |
| 1061 | +.skills-grid { display: flex; flex-wrap: wrap; gap: 8px; } | |
| 1062 | +.skill-chip { | |
| 1063 | + padding: 4px 12px; border-radius: 8px; font-size: 12px; font-weight: 600; | |
| 1064 | + background: rgba(139,92,246,0.08); color: var(--purple-text, #a78bfa); | |
| 1065 | + border: 1px solid rgba(139,92,246,0.15); | |
| 1066 | +} | |
| 966 | 1067 | </style> | ... | ... |