Commit 67b54882b12abf31747e0ab14da1cc2efa71c05a
1 parent
e3921011
feat(p1): 工作日历系统 + 告警通道 + CEO Pipeline设计 + Master晨会日报
Phase 2 改进: 1. 工作日历系统 (ScheduleService): - 新增 schedule-service.js: 公司级日程管理 - 支持按小时调度、按周几过滤、时区感知 - 心跳触发时自动检查到期 slots 并触发 Agent - 执行去重(同日同 slot 不重复触发) - CRUD API: GET/POST/PUT/DELETE /api/company/:id/schedule/slots - CLI: schedule add / schedule list 2. 告警通道 (Alert Channel): - Pipeline 步骤超时自动告警 Master Agent - 心跳异常自动告警 Master Agent - _sendAlertToMaster() 统一告警接口 3. CEO → Pipeline 自动生成: - CEO 模板从 3 步升级为 5 步工作流 - 新增 Step 2: Pipeline JSON 设计(含 loop/cron/steps 结构) - 新增 Step 4: OKR 创建 - Pipeline 设计输出为 CTO 搭建流水线的蓝图 4. Master 晨会日报: - 每天 08:00 自动生成全公司运营日报 - 包含公司状态、Pipeline 运行、OKR 进度、Agent 活跃度 - 自动推送给 Master Agent - 防重复机制(每天只生成一次) 5. Pipeline 驳回信息注入: - 驳回步骤被心跳重新触发时,__rejection 信息注入 prompt - 让 Agent 知道被驳回的原因和重做次数 - 替换原有 curl 汇报指令为 task report CLI
Showing
5 changed files
with
489 additions
and
19 deletions
bin/lemon.js
| ... | ... | @@ -148,6 +148,25 @@ async function run() { |
| 148 | 148 | }); |
| 149 | 149 | console.log(JSON.stringify(res.data, null, 2)); |
| 150 | 150 | } |
| 151 | + else if (command === 'schedule add') { | |
| 152 | + const { values } = parseArgs({ args: args.slice(2), options: { | |
| 153 | + companyId: { type: 'string' }, time: { type: 'string' }, | |
| 154 | + agent: { type: 'string' }, task: { type: 'string' }, | |
| 155 | + type: { type: 'string', default: 'task' } | |
| 156 | + }}); | |
| 157 | + const res = await request('POST', `/company/${values.companyId}/schedule/slots`, { | |
| 158 | + time: values.time, agent: values.agent, | |
| 159 | + task: values.task, type: values.type | |
| 160 | + }); | |
| 161 | + console.log(JSON.stringify(res.data, null, 2)); | |
| 162 | + } | |
| 163 | + else if (command === 'schedule list') { | |
| 164 | + const { values } = parseArgs({ args: args.slice(2), options: { | |
| 165 | + companyId: { type: 'string' } | |
| 166 | + }}); | |
| 167 | + const res = await request('GET', `/company/${values.companyId}/schedule`); | |
| 168 | + console.log(JSON.stringify(res.data, null, 2)); | |
| 169 | + } | |
| 151 | 170 | else { |
| 152 | 171 | console.error('Unknown command:', command); |
| 153 | 172 | process.exit(1); | ... | ... |
data/templates/ceo/agent.md
| ... | ... | @@ -11,28 +11,60 @@ |
| 11 | 11 | - `coo`:负责 7x24 小时督促流水线,不合格的产出打回重做。 |
| 12 | 12 | 3. **顶层目标管理 (OKR Owner)**:使用 `lemon goal create` 创建顶层 OKR。 |
| 13 | 13 | |
| 14 | -## ⚡ 强制工作流(同一轮对话内必须连贯完成全部三步!) | |
| 14 | +## ⚡ 强制工作流(同一轮对话内必须连贯完成全部五步!) | |
| 15 | 15 | |
| 16 | -> **🚨 这是你的最高原则:你必须在收到战略任务后的同一轮对话里,一口气完成以下全部三步。 | |
| 16 | +> **🚨 这是你的最高原则:你必须在收到战略任务后的同一轮对话里,一口气完成以下全部五步。 | |
| 17 | 17 | > 绝对禁止在做完第一步后就结束对话、汇报"我做完了"然后等 Master 来推进第二步!** |
| 18 | 18 | |
| 19 | 19 | ### Step 1: 战略规划(撰写商业蓝图) |
| 20 | 20 | 使用 Write 工具撰写 `workspace/<公司名>_strategy.md`,把商业模式讲透。 |
| 21 | 21 | |
| 22 | -### Step 2: 当场招人(不要等!紧接着执行!) | |
| 23 | -战略写完后,**在同一轮对话中立刻执行**: | |
| 22 | +### Step 2: 设计 SOP 流水线(撰写 Pipeline JSON) | |
| 23 | +基于战略蓝图,设计完整的业务流水线并写入 JSON 文件。例子: | |
| 24 | +```bash | |
| 25 | +# 将以下 JSON 写入 workspace/pipeline_design.json | |
| 26 | +``` | |
| 27 | +JSON 格式要求: | |
| 28 | +```json | |
| 29 | +{ | |
| 30 | + "name": "每日内容生产", | |
| 31 | + "description": "从热点抓取到内容发布的完整流水线", | |
| 32 | + "loop": true, | |
| 33 | + "cron": { "intervalHours": 24 }, | |
| 34 | + "steps": [ | |
| 35 | + { "name": "热点抓取", "agent": "trend-hunter", "prompt": "抓取今日热点话题", "timeout": 30 }, | |
| 36 | + { "name": "选题决策", "agent": "topic-selector", "prompt": "从热点中筛选最有价值的选题", "timeout": 15 }, | |
| 37 | + { "name": "内容创作", "agent": "writer", "prompt": "根据选题撰写高质量文章", "timeout": 60 }, | |
| 38 | + { "name": "COO审核", "agent": "coo", "prompt": "审核文章质量,评分并决定通过或驳回", "timeout": 20 }, | |
| 39 | + { "name": "发布上线", "agent": "publisher", "prompt": "将通过审核的文章发布到目标平台", "timeout": 15 } | |
| 40 | + ] | |
| 41 | +} | |
| 42 | +``` | |
| 43 | +**Pipeline 设计要点**: | |
| 44 | +- 必须设置 `"loop": true` 实现每日循环执行 | |
| 45 | +- 必须确保关键质量节点有 COO 审核步骤 | |
| 46 | +- 每一步必须有明确的 `agent`(对应 CTO 将要招聘的角色) | |
| 47 | +- timeout 单位是分钟 | |
| 48 | + | |
| 49 | +### Step 3: 当场招人(不要等!紧接着执行!) | |
| 50 | +战略和 Pipeline 设计完后,**在同一轮对话中立刻执行**: | |
| 24 | 51 | ```bash |
| 25 | 52 | node bin/lemon.js company hire --id <你的公司ID> --role cto --type cxo --name "技术与班底组建总监" |
| 26 | 53 | node bin/lemon.js company hire --id <你的公司ID> --role coo --type cxo --name "首席运营与品控官" |
| 27 | 54 | ``` |
| 28 | 55 | |
| 29 | -### Step 3: 当场委派(不要等!紧接着执行!) | |
| 56 | +### Step 4: 设定 OKR(不要等!紧接着执行!) | |
| 57 | +```bash | |
| 58 | +node bin/lemon.js goal create --title "Q1 业务目标" --desc "..." --owner <你的公司ID>:ceo | |
| 59 | +``` | |
| 60 | + | |
| 61 | +### Step 5: 当场委派(不要等!紧接着执行!) | |
| 30 | 62 | 招完人后,**在同一轮对话中立刻执行**: |
| 31 | 63 | ```bash |
| 32 | -node bin/lemon.js task dispatch --from <你自己,如 wechatai:ceo> --to <公司ID>:cto --task "我已经写好商业蓝图在 workspace 下,请立刻设计技术架构、搭建底层 Agent 流水线并规定数据流向,完成后移交给 COO 运营。" --priority P0 | |
| 64 | +node bin/lemon.js task dispatch --from <你自己,如 wechatai:ceo> --to <公司ID>:cto --task "我已经写好商业蓝图和 Pipeline 设计在 workspace/ 下。请立刻:1) 按照 pipeline_design.json 的角色需求招聘执行层 Agent;2) 将 pipeline_design.json 注册为正式 Pipeline;3) 搭建数据流向;4) 完成后移交给 COO 运营。" --priority P0 | |
| 33 | 65 | ``` |
| 34 | 66 | |
| 35 | -**只有当上面三步全部做完,你才可以结束本轮对话。** | |
| 67 | +**只有当上面五步全部做完,你才可以结束本轮对话。** | |
| 36 | 68 | |
| 37 | 69 | ## 🔄 心跳自检规则(每 5 分钟系统会唤醒你) |
| 38 | 70 | 当你被心跳唤醒时,执行以下检查: |
| ... | ... | @@ -44,17 +76,18 @@ node bin/lemon.js task dispatch --from <你自己,如 wechatai:ceo> --to <公司 |
| 44 | 76 | |
| 45 | 77 | ## ⚠️ 绝对不要(Never Rules) |
| 46 | 78 | - 🚫 **不要写完战略就停下来等 Master 催你进行下一步** — 这是致命错误! |
| 47 | -- 🚫 **不要只汇报"我做完了"然后休眠** — 你必须在同一轮对话里连贯完成三步! | |
| 79 | +- 🚫 **不要只汇报"我做完了"然后休眠** — 你必须在同一轮对话里连贯完成五步! | |
| 48 | 80 | - 🚫 **不要亲自搭建底层员工**(如"画师"、"文案")— 全部交给 CTO ! |
| 49 | 81 | - 🚫 **不要使用旧的 curl API** — 强制使用 `bin/lemon.js` 命令。 |
| 82 | +- 🚫 **不要忘记设计 Pipeline JSON** — 这是 CTO 搭建流水线的蓝图! | |
| 50 | 83 | |
| 51 | 84 | ## 强制思维隔离 (`<thought>`) |
| 52 | 85 | 在执行任何工具命令前,自省分析: |
| 53 | 86 | ```xml |
| 54 | 87 | <thought> |
| 55 | -1. 我的公司叫什么?我目前在第几步(战略/招人/委派)? | |
| 56 | -2. 我是不是准备"结束对话"了?如果三步还没全做完,绝对不能停! | |
| 57 | -3. 我的工具命令参数正确吗?公司前缀加对了吗? | |
| 88 | +1. 我的公司叫什么?我目前在第几步(战略/Pipeline设计/招人/OKR/委派)? | |
| 89 | +2. 我是不是准备"结束对话"了?如果五步还没全做完,绝对不能停! | |
| 90 | +3. Pipeline 设计中是否包含了所有必要的角色和质量审核环节? | |
| 91 | +4. 我的工具命令参数正确吗?公司前缀加对了吗? | |
| 58 | 92 | </thought> |
| 59 | 93 | ``` |
| 60 | - | ... | ... |
server/routes/company.js
| ... | ... | @@ -7,8 +7,10 @@ |
| 7 | 7 | const express = require('express'); |
| 8 | 8 | const router = express.Router(); |
| 9 | 9 | const { CompanyService } = require('../services/company-service'); |
| 10 | +const { ScheduleService } = require('../services/schedule-service'); | |
| 10 | 11 | |
| 11 | 12 | const companyService = new CompanyService(); |
| 13 | +const scheduleService = new ScheduleService(); | |
| 12 | 14 | |
| 13 | 15 | // ═══════════════════════════════════════ |
| 14 | 16 | // Company CRUD |
| ... | ... | @@ -305,4 +307,36 @@ router.post('/:companyId/pipelines/runs/:runId/reject', (req, res) => { |
| 305 | 307 | } |
| 306 | 308 | }); |
| 307 | 309 | |
| 310 | +// ═══════════════════════════════════════ | |
| 311 | +// Schedule (日程管理) | |
| 312 | +// ═══════════════════════════════════════ | |
| 313 | + | |
| 314 | +// GET /api/company/:companyId/schedule — 获取日程 | |
| 315 | +router.get('/:companyId/schedule', (req, res) => { | |
| 316 | + res.json(scheduleService.getSchedule(req.params.companyId)); | |
| 317 | +}); | |
| 318 | + | |
| 319 | +// POST /api/company/:companyId/schedule/slots — 添加日程 | |
| 320 | +router.post('/:companyId/schedule/slots', (req, res) => { | |
| 321 | + try { | |
| 322 | + const slot = scheduleService.addSlot(req.params.companyId, req.body); | |
| 323 | + res.json(slot); | |
| 324 | + } catch (err) { | |
| 325 | + res.status(400).json({ error: err.message }); | |
| 326 | + } | |
| 327 | +}); | |
| 328 | + | |
| 329 | +// PUT /api/company/:companyId/schedule/slots/:slotId — 更新日程 | |
| 330 | +router.put('/:companyId/schedule/slots/:slotId', (req, res) => { | |
| 331 | + const slot = scheduleService.updateSlot(req.params.companyId, req.params.slotId, req.body); | |
| 332 | + if (!slot) return res.status(404).json({ error: '日程不存在' }); | |
| 333 | + res.json(slot); | |
| 334 | +}); | |
| 335 | + | |
| 336 | +// DELETE /api/company/:companyId/schedule/slots/:slotId — 删除日程 | |
| 337 | +router.delete('/:companyId/schedule/slots/:slotId', (req, res) => { | |
| 338 | + scheduleService.removeSlot(req.params.companyId, req.params.slotId); | |
| 339 | + res.json({ success: true }); | |
| 340 | +}); | |
| 341 | + | |
| 308 | 342 | module.exports = router; | ... | ... |
server/services/company-heartbeat-service.js
| ... | ... | @@ -16,6 +16,7 @@ const path = require('path'); |
| 16 | 16 | const http = require('http'); |
| 17 | 17 | const { CompanyService } = require('./company-service'); |
| 18 | 18 | const { PipelineService } = require('./pipeline-service'); |
| 19 | +const { ScheduleService } = require('./schedule-service'); | |
| 19 | 20 | |
| 20 | 21 | const DATA_DIR = path.resolve(__dirname, '../../data'); |
| 21 | 22 | |
| ... | ... | @@ -24,6 +25,7 @@ class CompanyHeartbeatService { |
| 24 | 25 | constructor() { |
| 25 | 26 | this.timers = new Map(); // companyId → NodeJS.Timeout |
| 26 | 27 | this.companyService = new CompanyService(); |
| 28 | + this.scheduleService = new ScheduleService(); | |
| 27 | 29 | this.port = 3000; |
| 28 | 30 | this._started = false; |
| 29 | 31 | } |
| ... | ... | @@ -126,12 +128,20 @@ class CompanyHeartbeatService { |
| 126 | 128 | // 2. 驱动运行中的 Pipeline Run |
| 127 | 129 | await this._driveActiveRuns(companyId); |
| 128 | 130 | |
| 129 | - // 3. 触发 CEO 心跳 (如果有 CEO 在公司内) | |
| 131 | + // 3. 执行日程驱动的任务 (Schedule) | |
| 132 | + await this._executeScheduledTasks(companyId); | |
| 133 | + | |
| 134 | + // 4. 触发 CEO 心跳 (如果有 CEO 在公司内) | |
| 130 | 135 | if (company.ceo) { |
| 131 | 136 | this._triggerCeoHeartbeat(companyId, company.ceo); |
| 132 | 137 | } |
| 138 | + | |
| 139 | + // 5. 检查是否需要生成 Master 晨会日报 (每天 08:00) | |
| 140 | + this._checkDailyReport(companyId); | |
| 133 | 141 | } catch (err) { |
| 134 | 142 | console.error(`[CompanyHB] ❌ ${companyId} 心跳执行失败:`, err.message); |
| 143 | + // 告警通道: 心跳本身异常也通知 Master | |
| 144 | + this._sendAlertToMaster(companyId, `公司心跳执行异常: ${err.message}`); | |
| 135 | 145 | } |
| 136 | 146 | } |
| 137 | 147 | |
| ... | ... | @@ -208,6 +218,10 @@ class CompanyHeartbeatService { |
| 208 | 218 | status: 'skipped', |
| 209 | 219 | output: { reason: '超时跳过' }, |
| 210 | 220 | }); |
| 221 | + // 🚨 告警通道: 超时通知 Master | |
| 222 | + this._sendAlertToMaster(companyId, | |
| 223 | + `Pipeline "${run.pipelineName}" 步骤 ${task.stepIndex + 1} (${task.stepName}) 执行超时 (${Math.round(elapsed/60000)}分钟),已自动跳过。Agent: ${task.agent}` | |
| 224 | + ); | |
| 211 | 225 | } |
| 212 | 226 | } |
| 213 | 227 | continue; |
| ... | ... | @@ -222,17 +236,21 @@ class CompanyHeartbeatService { |
| 222 | 236 | const stepDef = pipeline?.steps?.[task.stepIndex]; |
| 223 | 237 | const inputStr = task.input ? `\n上一步的输出(作为你的输入):\n${JSON.stringify(task.input, null, 2)}` : ''; |
| 224 | 238 | |
| 239 | + // 检查是否有驳回信息 | |
| 240 | + const rejectionInfo = task.input?.__rejection | |
| 241 | + ? `\n⚠️ ${task.input.__rejection.instruction}\n这是第 ${task.input.__rejection.attempt} 次重做。\n` | |
| 242 | + : ''; | |
| 243 | + | |
| 225 | 244 | const taskMessage = [ |
| 226 | 245 | `[PIPELINE TASK]`, |
| 227 | 246 | `流水线: ${run.pipelineName}`, |
| 228 | 247 | `步骤 ${task.stepIndex + 1}/${run.steps.length}: ${task.stepName}`, |
| 229 | 248 | `指令: ${stepDef?.prompt || '请执行你的职责'}`, |
| 249 | + rejectionInfo, | |
| 230 | 250 | inputStr, |
| 231 | 251 | '', |
| 232 | - `完成后请调用以下接口汇报结果:`, | |
| 233 | - `curl -X POST http://localhost:${this.port}/api/company/${companyId}/pipelines/runs/${runMeta.runId}/advance \\`, | |
| 234 | - ` -H "Content-Type: application/json" \\`, | |
| 235 | - ` -d '{"stepIndex": ${task.stepIndex}, "output": {"你的输出key": "你的输出value"}}'`, | |
| 252 | + `完成后请调用以下命令汇报结果:`, | |
| 253 | + `node bin/lemon.js task report --taskId pipeline-${runMeta.runId}-step-${task.stepIndex} --from ${companyId}:${task.agent} --to ${companyId}:coo --status done --summary "(你的完成摘要)"`, | |
| 236 | 254 | ].join('\n'); |
| 237 | 255 | |
| 238 | 256 | // 通过 HTTP 触发 Agent Chat (异步,不阻塞心跳) |
| ... | ... | @@ -261,10 +279,194 @@ class CompanyHeartbeatService { |
| 261 | 279 | this._sendAgentChat(companyId, ceoId, msg, `heartbeat-${Date.now().toString(36)}`); |
| 262 | 280 | } |
| 263 | 281 | |
| 282 | + // ═══════════════════════════════════════ | |
| 283 | + // 日程驱动执行 (Schedule) | |
| 284 | + // ═══════════════════════════════════════ | |
| 285 | + | |
| 286 | + /** | |
| 287 | + * 执行当前时间到期的日程任务 | |
| 288 | + */ | |
| 289 | + async _executeScheduledTasks(companyId) { | |
| 290 | + const dueSlots = this.scheduleService.getDueSlots(companyId); | |
| 291 | + if (dueSlots.length === 0) return; | |
| 292 | + | |
| 293 | + for (const slot of dueSlots) { | |
| 294 | + const agentId = `${companyId}:${slot.agent}`; | |
| 295 | + const typeLabel = slot.type === 'meeting' ? '📋 会议' : slot.type === 'review' ? '🔍 复盘' : '📌 任务'; | |
| 296 | + const msg = [ | |
| 297 | + `[SCHEDULED TASK]`, | |
| 298 | + `${typeLabel}: ${slot.task}`, | |
| 299 | + `日程时间: ${slot.time}`, | |
| 300 | + ``, | |
| 301 | + `这是你的日程中预定的任务,请立即执行。`, | |
| 302 | + ].join('\n'); | |
| 303 | + | |
| 304 | + console.log(`[Schedule] 📅 ${companyId} 触发日程: ${slot.time} → ${slot.agent} (${slot.task})`); | |
| 305 | + this._sendAgentChat(companyId, agentId, msg, `schedule-${slot.id}-${Date.now().toString(36)}`); | |
| 306 | + } | |
| 307 | + } | |
| 308 | + | |
| 309 | + // ═══════════════════════════════════════ | |
| 310 | + // 告警通道 (Alert Channel) | |
| 311 | + // ═══════════════════════════════════════ | |
| 312 | + | |
| 313 | + /** | |
| 314 | + * 向 Master Agent 发送告警消息 | |
| 315 | + */ | |
| 316 | + _sendAlertToMaster(companyId, alertMessage) { | |
| 317 | + const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19); | |
| 318 | + const msg = [ | |
| 319 | + `[🚨 COMPANY ALERT]`, | |
| 320 | + `公司: ${companyId}`, | |
| 321 | + `时间: ${timestamp}`, | |
| 322 | + ``, | |
| 323 | + `告警内容:`, | |
| 324 | + alertMessage, | |
| 325 | + ``, | |
| 326 | + `请评估是否需要介入处理。`, | |
| 327 | + ].join('\n'); | |
| 328 | + | |
| 329 | + // 发给 Master Agent | |
| 330 | + this._sendAgentChat(null, 'master', msg, `alert-${companyId}-${Date.now().toString(36)}`); | |
| 331 | + console.log(`[Alert] 🚨 已向 Master 发送告警: ${companyId} - ${alertMessage.substring(0, 80)}`); | |
| 332 | + } | |
| 333 | + | |
| 334 | + // ═══════════════════════════════════════ | |
| 335 | + // Master 晨会日报 (Daily Report) | |
| 336 | + // ═══════════════════════════════════════ | |
| 337 | + | |
| 338 | + /** | |
| 339 | + * 检查是否需要生成每日晨会报告 (08:00 触发) | |
| 340 | + */ | |
| 341 | + _checkDailyReport(companyId) { | |
| 342 | + const now = new Date(); | |
| 343 | + const tz = 'Asia/Shanghai'; | |
| 344 | + const localTime = new Date(now.toLocaleString('en-US', { timeZone: tz })); | |
| 345 | + const hour = localTime.getHours(); | |
| 346 | + const minute = localTime.getMinutes(); | |
| 347 | + | |
| 348 | + // 仅在 08:00~08:14 范围内触发 | |
| 349 | + if (hour !== 8 || minute >= 15) return; | |
| 350 | + | |
| 351 | + // 防重复: 检查今天是否已生成 | |
| 352 | + const today = localTime.toISOString().slice(0, 10); | |
| 353 | + const flagPath = path.join(DATA_DIR, companyId, '.daily-report-flag'); | |
| 354 | + try { | |
| 355 | + const lastDate = fs.readFileSync(flagPath, 'utf-8').trim(); | |
| 356 | + if (lastDate === today) return; | |
| 357 | + } catch { /* first time */ } | |
| 358 | + | |
| 359 | + // 生成日报 | |
| 360 | + const report = this._generateDailyReport(companyId); | |
| 361 | + if (!report) return; | |
| 362 | + | |
| 363 | + // 写入标记 | |
| 364 | + fs.writeFileSync(flagPath, today, 'utf-8'); | |
| 365 | + | |
| 366 | + // 发给 Master | |
| 367 | + const msg = [ | |
| 368 | + `[📊 DAILY COMPANY REPORT]`, | |
| 369 | + `公司: ${companyId}`, | |
| 370 | + `日期: ${today}`, | |
| 371 | + ``, | |
| 372 | + report, | |
| 373 | + ``, | |
| 374 | + `请查看以上日报。如需调整策略或设定新目标,请下发指令给 CEO。`, | |
| 375 | + ].join('\n'); | |
| 376 | + | |
| 377 | + this._sendAgentChat(null, 'master', msg, `daily-report-${companyId}-${today}`); | |
| 378 | + console.log(`[DailyReport] 📊 ${companyId} 晨会日报已发送给 Master`); | |
| 379 | + } | |
| 380 | + | |
| 381 | + /** | |
| 382 | + * 生成公司日报内容 | |
| 383 | + */ | |
| 384 | + _generateDailyReport(companyId) { | |
| 385 | + try { | |
| 386 | + const company = this.companyService.get(companyId); | |
| 387 | + if (!company) return null; | |
| 388 | + | |
| 389 | + const lines = [`# ${company.name} 运营日报`, '']; | |
| 390 | + | |
| 391 | + // 1. 公司状态 | |
| 392 | + lines.push(`## 公司状态`); | |
| 393 | + lines.push(`- 状态: ${company.status}`); | |
| 394 | + lines.push(`- CEO: ${company.ceo || '(未任命)'}`); | |
| 395 | + lines.push(`- Agent 数量: ${company.agents?.length || 0}`); | |
| 396 | + lines.push(''); | |
| 397 | + | |
| 398 | + // 2. Pipeline 运行状况 | |
| 399 | + const pSvc = new PipelineService(companyId); | |
| 400 | + const pipelines = pSvc.listPipelines(); | |
| 401 | + const runs = pSvc.listRuns(); | |
| 402 | + const activeRuns = runs.filter(r => r.status === 'running'); | |
| 403 | + const completedToday = runs.filter(r => { | |
| 404 | + const created = new Date(r.createdAt); | |
| 405 | + const today = new Date(); | |
| 406 | + return r.status === 'completed' && created.toDateString() === today.toDateString(); | |
| 407 | + }); | |
| 408 | + | |
| 409 | + lines.push(`## Pipeline 状况`); | |
| 410 | + lines.push(`- 已定义流水线: ${pipelines.length} 条`); | |
| 411 | + lines.push(`- 运行中: ${activeRuns.length} 个`); | |
| 412 | + lines.push(`- 今日完成: ${completedToday.length} 个`); | |
| 413 | + if (activeRuns.length > 0) { | |
| 414 | + lines.push(`- 活跃详情:`); | |
| 415 | + for (const r of activeRuns) { | |
| 416 | + lines.push(` - ${r.pipelineName}: 步骤 ${r.currentStep + 1}/${r.totalSteps}`); | |
| 417 | + } | |
| 418 | + } | |
| 419 | + lines.push(''); | |
| 420 | + | |
| 421 | + // 3. OKR 进度 | |
| 422 | + try { | |
| 423 | + const { GoalsService } = require('./goals'); | |
| 424 | + const goals = new GoalsService(companyId); | |
| 425 | + const goalList = goals.list(); | |
| 426 | + if (goalList.length > 0) { | |
| 427 | + lines.push(`## OKR 进度`); | |
| 428 | + for (const g of goalList) { | |
| 429 | + const pct = Math.round((g.progress || 0) * 100); | |
| 430 | + lines.push(`- ${g.id} ${g.title}: ${pct}% (${g.doneCount || 0}/${g.totalCount || 0})`); | |
| 431 | + } | |
| 432 | + lines.push(''); | |
| 433 | + } | |
| 434 | + } catch { /* no goals */ } | |
| 435 | + | |
| 436 | + // 4. Agent 活跃度 | |
| 437 | + const agentsDir = path.join(DATA_DIR, companyId, 'agents'); | |
| 438 | + if (fs.existsSync(agentsDir)) { | |
| 439 | + lines.push(`## Agent 活跃度`); | |
| 440 | + const agents = fs.readdirSync(agentsDir, { withFileTypes: true }) | |
| 441 | + .filter(d => d.isDirectory()); | |
| 442 | + for (const a of agents) { | |
| 443 | + const convDir = path.join(agentsDir, a.name, 'conversations'); | |
| 444 | + let lastActive = '(无记录)'; | |
| 445 | + if (fs.existsSync(convDir)) { | |
| 446 | + const convs = fs.readdirSync(convDir).sort().reverse(); | |
| 447 | + if (convs.length > 0) { | |
| 448 | + const metaPath = path.join(convDir, convs[0], 'meta.json'); | |
| 449 | + try { | |
| 450 | + const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8')); | |
| 451 | + lastActive = meta.updatedAt?.substring(0, 16) || '(未知)'; | |
| 452 | + } catch {} | |
| 453 | + } | |
| 454 | + } | |
| 455 | + lines.push(`- ${a.name}: 最近活跃 ${lastActive}`); | |
| 456 | + } | |
| 457 | + lines.push(''); | |
| 458 | + } | |
| 459 | + | |
| 460 | + return lines.join('\n'); | |
| 461 | + } catch (err) { | |
| 462 | + console.error(`[DailyReport] ❌ 生成日报失败:`, err.message); | |
| 463 | + return null; | |
| 464 | + } | |
| 465 | + } | |
| 466 | + | |
| 264 | 467 | /** |
| 265 | 468 | * 通过 HTTP 向 Agent 发送消息 (异步, 不阻塞) |
| 266 | - * 公司内 Agent 通过 /api/agent/{agentId}/chat 触发 | |
| 267 | - * TODO: 未来改为公司级路由 /api/company/{companyId}/agent/{agentId}/chat | |
| 469 | + * companyId 为 null 时发给全局 Agent (如 master) | |
| 268 | 470 | */ |
| 269 | 471 | _sendAgentChat(companyId, agentId, message, conversationId) { |
| 270 | 472 | const postData = JSON.stringify({ | ... | ... |
server/services/schedule-service.js
0 → 100644
| 1 | +/** | |
| 2 | + * ScheduleService — 公司级工作日历系统 | |
| 3 | + * | |
| 4 | + * 数据目录: data/{companyId}/schedule.json | |
| 5 | + * | |
| 6 | + * 核心概念: | |
| 7 | + * Schedule = 公司的每日时间表 | |
| 8 | + * TimeSlot = 某个Agent在某个时间点执行的固定任务 | |
| 9 | + * | |
| 10 | + * 与 HeartbeatService 集成: | |
| 11 | + * 心跳触发时,ScheduleService 检查当前时间是否有需要执行的日程 | |
| 12 | + * 匹配到的日程自动触发对应 Agent 的 Chat | |
| 13 | + */ | |
| 14 | + | |
| 15 | +const fs = require('fs'); | |
| 16 | +const path = require('path'); | |
| 17 | + | |
| 18 | +const DATA_DIR = path.resolve(__dirname, '../../data'); | |
| 19 | + | |
| 20 | +class ScheduleService { | |
| 21 | + | |
| 22 | + /** | |
| 23 | + * 获取公司的日程配置 | |
| 24 | + * @param {string} companyId | |
| 25 | + * @returns {object} schedule config | |
| 26 | + */ | |
| 27 | + getSchedule(companyId) { | |
| 28 | + const fp = path.join(DATA_DIR, companyId, 'schedule.json'); | |
| 29 | + if (!fs.existsSync(fp)) return { timezone: 'Asia/Shanghai', slots: [] }; | |
| 30 | + try { | |
| 31 | + return JSON.parse(fs.readFileSync(fp, 'utf-8')); | |
| 32 | + } catch { | |
| 33 | + return { timezone: 'Asia/Shanghai', slots: [] }; | |
| 34 | + } | |
| 35 | + } | |
| 36 | + | |
| 37 | + /** | |
| 38 | + * 保存公司日程配置 | |
| 39 | + * @param {string} companyId | |
| 40 | + * @param {object} schedule | |
| 41 | + */ | |
| 42 | + saveSchedule(companyId, schedule) { | |
| 43 | + const fp = path.join(DATA_DIR, companyId, 'schedule.json'); | |
| 44 | + fs.writeFileSync(fp, JSON.stringify(schedule, null, 2), 'utf-8'); | |
| 45 | + } | |
| 46 | + | |
| 47 | + /** | |
| 48 | + * 添加日程时间槽 | |
| 49 | + * @param {string} companyId | |
| 50 | + * @param {object} slot - { time, agent, task, days?, enabled? } | |
| 51 | + * time: "09:00" (24小时制) | |
| 52 | + * agent: "trend-hunter" (Agent ID) | |
| 53 | + * task: "执行每日热点抓取" (任务描述) | |
| 54 | + * days: ["mon","tue","wed","thu","fri","sat","sun"] (可选,默认每天) | |
| 55 | + * enabled: true (可选) | |
| 56 | + */ | |
| 57 | + addSlot(companyId, slot) { | |
| 58 | + const schedule = this.getSchedule(companyId); | |
| 59 | + const newSlot = { | |
| 60 | + id: `S-${Date.now().toString(36)}`, | |
| 61 | + time: slot.time, | |
| 62 | + agent: slot.agent, | |
| 63 | + task: slot.task, | |
| 64 | + days: slot.days || ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'], | |
| 65 | + enabled: slot.enabled !== false, | |
| 66 | + type: slot.type || 'task', // task | meeting | review | |
| 67 | + createdAt: new Date().toISOString(), | |
| 68 | + }; | |
| 69 | + schedule.slots.push(newSlot); | |
| 70 | + this.saveSchedule(companyId, schedule); | |
| 71 | + console.log(`[Schedule] 📅 ${companyId} 新增日程: ${newSlot.time} ${newSlot.agent} - ${newSlot.task}`); | |
| 72 | + return newSlot; | |
| 73 | + } | |
| 74 | + | |
| 75 | + /** | |
| 76 | + * 删除日程 | |
| 77 | + */ | |
| 78 | + removeSlot(companyId, slotId) { | |
| 79 | + const schedule = this.getSchedule(companyId); | |
| 80 | + schedule.slots = schedule.slots.filter(s => s.id !== slotId); | |
| 81 | + this.saveSchedule(companyId, schedule); | |
| 82 | + return true; | |
| 83 | + } | |
| 84 | + | |
| 85 | + /** | |
| 86 | + * 更新日程 | |
| 87 | + */ | |
| 88 | + updateSlot(companyId, slotId, updates) { | |
| 89 | + const schedule = this.getSchedule(companyId); | |
| 90 | + const slot = schedule.slots.find(s => s.id === slotId); | |
| 91 | + if (!slot) return null; | |
| 92 | + if (updates.time !== undefined) slot.time = updates.time; | |
| 93 | + if (updates.agent !== undefined) slot.agent = updates.agent; | |
| 94 | + if (updates.task !== undefined) slot.task = updates.task; | |
| 95 | + if (updates.days !== undefined) slot.days = updates.days; | |
| 96 | + if (updates.enabled !== undefined) slot.enabled = updates.enabled; | |
| 97 | + this.saveSchedule(companyId, schedule); | |
| 98 | + return slot; | |
| 99 | + } | |
| 100 | + | |
| 101 | + /** | |
| 102 | + * 获取当前时间应该触发的日程 | |
| 103 | + * | |
| 104 | + * @param {string} companyId | |
| 105 | + * @param {number} toleranceMinutes - 容差(分钟),默认 15 分钟 | |
| 106 | + * @returns {Array} 到期的 slots | |
| 107 | + */ | |
| 108 | + getDueSlots(companyId, toleranceMinutes = 15) { | |
| 109 | + const schedule = this.getSchedule(companyId); | |
| 110 | + if (!schedule.slots || schedule.slots.length === 0) return []; | |
| 111 | + | |
| 112 | + const now = new Date(); | |
| 113 | + // 转换到公司时区 | |
| 114 | + const tz = schedule.timezone || 'Asia/Shanghai'; | |
| 115 | + const localTime = new Date(now.toLocaleString('en-US', { timeZone: tz })); | |
| 116 | + const currentHour = localTime.getHours(); | |
| 117 | + const currentMinute = localTime.getMinutes(); | |
| 118 | + const dayNames = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']; | |
| 119 | + const currentDay = dayNames[localTime.getDay()]; | |
| 120 | + | |
| 121 | + // 记录已执行的日程(防重复触发) | |
| 122 | + const executedPath = path.join(DATA_DIR, companyId, '.schedule-executed.json'); | |
| 123 | + let executed = {}; | |
| 124 | + try { executed = JSON.parse(fs.readFileSync(executedPath, 'utf-8')); } catch {} | |
| 125 | + | |
| 126 | + const today = localTime.toISOString().slice(0, 10); | |
| 127 | + // 清理前一天的记录 | |
| 128 | + for (const key of Object.keys(executed)) { | |
| 129 | + if (!key.startsWith(today)) delete executed[key]; | |
| 130 | + } | |
| 131 | + | |
| 132 | + const dueSlots = []; | |
| 133 | + | |
| 134 | + for (const slot of schedule.slots) { | |
| 135 | + if (!slot.enabled) continue; | |
| 136 | + if (!slot.days.includes(currentDay)) continue; | |
| 137 | + | |
| 138 | + // 解析 slot 时间 | |
| 139 | + const [slotHour, slotMinute] = slot.time.split(':').map(Number); | |
| 140 | + const diffMinutes = (currentHour * 60 + currentMinute) - (slotHour * 60 + slotMinute); | |
| 141 | + | |
| 142 | + // 在容差范围内且未执行过 | |
| 143 | + if (diffMinutes >= 0 && diffMinutes < toleranceMinutes) { | |
| 144 | + const execKey = `${today}:${slot.id}`; | |
| 145 | + if (!executed[execKey]) { | |
| 146 | + dueSlots.push(slot); | |
| 147 | + executed[execKey] = new Date().toISOString(); | |
| 148 | + } | |
| 149 | + } | |
| 150 | + } | |
| 151 | + | |
| 152 | + // 保存执行记录 | |
| 153 | + if (dueSlots.length > 0) { | |
| 154 | + fs.writeFileSync(executedPath, JSON.stringify(executed, null, 2), 'utf-8'); | |
| 155 | + } | |
| 156 | + | |
| 157 | + return dueSlots; | |
| 158 | + } | |
| 159 | + | |
| 160 | + /** | |
| 161 | + * 生成人类可读的日程表文本 (用于注入 Agent prompt) | |
| 162 | + */ | |
| 163 | + formatScheduleForAgent(companyId, agentId) { | |
| 164 | + const schedule = this.getSchedule(companyId); | |
| 165 | + const mySlots = schedule.slots.filter(s => s.agent === agentId && s.enabled); | |
| 166 | + | |
| 167 | + if (mySlots.length === 0) return ''; | |
| 168 | + | |
| 169 | + const lines = ['## 📅 我的每日日程', '']; | |
| 170 | + const sorted = [...mySlots].sort((a, b) => a.time.localeCompare(b.time)); | |
| 171 | + | |
| 172 | + for (const slot of sorted) { | |
| 173 | + const daysStr = slot.days.length === 7 ? '每天' : slot.days.join('/'); | |
| 174 | + const typeEmoji = slot.type === 'meeting' ? '📋' : slot.type === 'review' ? '🔍' : '📌'; | |
| 175 | + lines.push(`- **${slot.time}** ${typeEmoji} ${slot.task} (${daysStr})`); | |
| 176 | + } | |
| 177 | + | |
| 178 | + return lines.join('\n'); | |
| 179 | + } | |
| 180 | +} | |
| 181 | + | |
| 182 | +module.exports = { ScheduleService }; | ... | ... |