From b771724510c1e00a762441ec1b91ff2437c60250 Mon Sep 17 00:00:00 2001 From: yibo Date: Mon, 30 Mar 2026 10:40:52 +0800 Subject: [PATCH] feat: Task Timeline dashboard with parent-child task tree --- bin/lemon.js | 9 ++++++--- data/system/agents/master/tools.md | 4 ++-- docs/task_timeline_design.md | 91 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ server/agent-server.js | 1 + server/index.js | 1 + server/routes/agent-chat.js | 43 +++++++++++++++++++++++++++++++++++++++---- server/routes/company.js | 12 +++++++++--- server/routes/tasks.js | 47 +++++++++++++++++++++++++++++++++++++++++++++++ server/services/task-service.js | 384 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ web/src/components/AppNav.vue | 4 ++++ web/src/router/index.js | 5 +++++ web/src/views/TaskTimeline.vue | 736 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 12 files changed, 1325 insertions(+), 12 deletions(-) create mode 100644 docs/task_timeline_design.md create mode 100644 server/routes/tasks.js create mode 100644 server/services/task-service.js create mode 100644 web/src/views/TaskTimeline.vue diff --git a/bin/lemon.js b/bin/lemon.js index 9e476a7..b37ce5a 100755 --- a/bin/lemon.js +++ b/bin/lemon.js @@ -84,9 +84,11 @@ async function run() { } else if (command === 'template train') { const { values } = parseArgs({ args: args.slice(2), options: { - id: { type: 'string' } + id: { type: 'string' }, instructions: { type: 'string' } }}); - const res = await request('POST', `/company/templates/${values.id}/train`); + const payload = {}; + if (values.instructions) payload.instructions = values.instructions; + const res = await request('POST', `/company/templates/${values.id}/train`, payload); console.log(JSON.stringify(res.data, null, 2)); } else if (command === 'company templates') { @@ -122,11 +124,12 @@ async function run() { else if (command === 'task dispatch') { const { values } = parseArgs({ args: args.slice(2), options: { from: { type: 'string' }, to: { type: 'string' }, task: { type: 'string' }, priority: { type: 'string', default: 'P1' }, - goal: { type: 'string' }, node: { type: 'string' }, auto: { type: 'boolean', default: true } + goal: { type: 'string' }, node: { type: 'string' }, auto: { type: 'boolean', default: true }, parent: { type: 'string' } }}); const payload = { from: values.from, to: values.to, task: values.task, priority: values.priority, autoExecute: values.auto }; if (values.goal) payload.goalId = values.goal; if (values.node) payload.nodeId = values.node; + if (values.parent) payload.parentTaskId = values.parent; const res = await request('POST', '/agent/task/dispatch', payload); console.log(JSON.stringify(res.data, null, 2)); diff --git a/data/system/agents/master/tools.md b/data/system/agents/master/tools.md index 52f9c6e..4f39e5d 100644 --- a/data/system/agents/master/tools.md +++ b/data/system/agents/master/tools.md @@ -36,9 +36,9 @@ node bin/lemon.js template create --id --name "<模板名称>" --d ### 3d. template train — 指派 Innovator 训练并优化模板 ```bash -node bin/lemon.js template train --id +node bin/lemon.js template train --id --instructions "<额外训练要求或参考材料位置>" ``` -当你建立完一个新模板,或者用户想加强某个模板时,调用此命令。这将唤醒 Innovator 对该模板执行多轮强化验证与能力补充。 +当你建立完一个新模板,或者用户想加强某个模板时,调用此命令。这将唤醒 Innovator 对该模板执行多轮强化验证与能力补充。如果用户指定了训练的参考资料(如特定的文档或代码),务必使用 `--instructions` 参数传递给 Innovator。 ### 3c. company fire — 辞退 Agent ```bash diff --git a/docs/task_timeline_design.md b/docs/task_timeline_design.md new file mode 100644 index 0000000..d4a4c4e --- /dev/null +++ b/docs/task_timeline_design.md @@ -0,0 +1,91 @@ +# Task Timeline View Design Document + +## 1. Overview +The **Task Timeline View (Task Trace UI)** is a new management page that provides a unified, chronological view of all messages, tool calls, and actions performed by multiple Agents collaborating on a single "Task" (identified by a `taskId`). +This page solves the "Black Box" problem by aggregating isolated agent conversation logs into a single, sortable, filterable timeline. + +## 2. Core Features +- **Left Panel (Task List):** Displays all recent tasks in the system. Cards show the `taskId`, task progress/status, and the agents involved. +- **Right Panel (Unified Timeline):** A chronological feed of all `history.json` items spanning across all agents involved in the selected task. +- **Filtering & Search:** + - **Filter by Agent:** A multi-select dropdown to only show messages from specific agents (e.g. Master, Innovator, Hunter). + - **Search Box:** Full-text search to filter messages by keyword. + - **Sort Toggle:** Switch between ascending (oldest first) and descending (newest first) chronological order. + +## 3. Backend Architecture Changes + +### Data Source Challenge +Tasks in the system often represent an orchestration spawned from Master, such as `jobId` or `conversationId` mappings. +To link conversations to a single logical "Task", we will scrape the meta tags from agent conversations. In recent builds, `meta.json` or directory names often reflect the global `jobId` or `conversationId` (e.g., `tpl-train-lemonv_drama-xxx` or `train-xxx`). + +### New API Endpoints (`server/routes/tasks.js`) + +**1. `GET /api/tasks`** +- **Action:** Scans the job store (`.data/jobs-train.json` or `.data/jobs.json`) and conversation metadata to return an array of top-level tasks. +- **Response Format:** + ```json + [ + { + "taskId": "train-xxx", + "type": "train-create", + "status": "completed", + "progress": "✅ 训练完成", + "createdAt": "2026-03-29T12:00:00Z", + "agentsInvolved": ["master", "innovator", "hunter", "digester", "evolver"] + } + ] + ``` + +**2. `GET /api/tasks/:taskId/timeline`** +- **Action:** Aggregates and flattens all conversation logs associated with a `taskId`. + - It searches all companies and system agents (`data/companies/*/agents/*` and `data/system/agents/*`). + - Finds all conversation folders where the folder name or `meta.json`'s `conversationId` / `jobId` matches or contains the target `taskId`. + - Reads `history.json` for all these matched conversations. + - Annotates each message with `agentId` and `companyId`. + - Sorts the final array globally by `timestamp`. +- **Response Format:** + ```json + [ + { + "timestamp": "2026-03-29T12:00:01Z", + "role": "assistant", + "content": "🔧 [TOOL_CALL] Glob...", + "agentId": "master", + "companyId": "system", + "conversationId": "conv-mnbq123" + }, + { + "timestamp": "2026-03-29T12:00:05Z", + "role": "assistant", + "content": "正在执行 hunted...", + "agentId": "hunter", + "companyId": "system", + "conversationId": "hunt-xxx" + } + ] + ``` + +## 4. Frontend Architecture Changes + +### Routing (`web/src/router/index.js`) +- Add a new route `/tasks` mapped to the `TaskTimelineView`. + +### Layout (`web/src/views/TaskTimelineView.vue`) +- **Structure:** Uses a `
` to split Left and Right. +- **Left Column:** Needs a highly scannable List View of Tasks. + - Each item should highlight its Status using distinct colors (Green for `completed`, Yellow for `running`, Red for `failed`). + - Highlights the list of involved agents using small tags (``). +- **Right Column:** + - **Header:** Sticky Header containing Search (``), Agent Select Dropdown (``), and Sort Button (``). + - **Timeline Feed:** A scrollable `div` containing blocks of agent messages. Reuses the markdown renderer (`vue-markdown`) and adds specialized syntax highlighting/styling for the `🔧 [TOOL_CALL]` blocks to make them easily readable. + - **Agent Identity Header:** Every message block starts with a distinct visual header `avatar/icon + Agent Name + precise time`. + +### Navigation (`web/src/components/Sidebar.vue`) +- Add a new navigation item "Task Logs" / "任务看板" right below "MasterBot" or "Companies". + +## 5. Implementation Steps +1. **Backend Base:** Create `server/services/task-service.js` containing the heavy logic for aggregating cross-agent logs. +2. **Backend Routes:** Create `server/routes/tasks.js` and mount it to `server/index.js` or `server/app.js`. +3. **Frontend API:** Expose API bindings for Tasks in the Vue frontend. +4. **Frontend View:** Implement `TaskTimelineView.vue` with dummy data to build the layout. +5. **Integration:** Connect the frontend view with the live `/api/tasks` backend and polish the UI interactions (filtering/sorting). diff --git a/server/agent-server.js b/server/agent-server.js index 0cc1f9f..7feb630 100644 --- a/server/agent-server.js +++ b/server/agent-server.js @@ -64,6 +64,7 @@ app.use('/api/agent', agentChatRoute); // Company API app.use('/api/company', require('./routes/company')); app.use('/api/goals', require('./routes/goals')); +app.use('/api/tasks', require('./routes/tasks')); // Health app.get('/api/health', (req, res) => res.json({ status: 'ok', service: 'agent-chat' })); diff --git a/server/index.js b/server/index.js index 873b23e..8e0d1be 100644 --- a/server/index.js +++ b/server/index.js @@ -92,6 +92,7 @@ app.use('/api/users', require('./routes/users')); app.use('/api/agent', require('./routes/agent-chat')); app.use('/api/goals', require('./routes/goals')); app.use('/api/company', require('./routes/company')); +app.use('/api/tasks', require('./routes/tasks')); // ─── 系统端点 ─── app.get('/api/health', (req, res) => res.json({ diff --git a/server/routes/agent-chat.js b/server/routes/agent-chat.js index b5ce237..a914155 100644 --- a/server/routes/agent-chat.js +++ b/server/routes/agent-chat.js @@ -1224,7 +1224,7 @@ function loadTaskMeta(taskId) { router.post('/task/dispatch', async (req, res) => { try { - const { from, to, task, priority = 'P1', planId, goalId, nodeId, autoExecute = true } = req.body; + const { from, to, task, priority = 'P1', planId, goalId, nodeId, autoExecute = true, parentTaskId: explicitParent } = req.body; if (!from || !to || !task) return res.status(400).json({ error: 'from, to, task are required' }); const targetDir = getAgentDir(to); @@ -1238,6 +1238,34 @@ router.post('/task/dispatch', async (req, res) => { // 净化多行文本和竖线,防止破坏 Markdown 表格格式 const safeTask = task.replace(/\n/g, '
').replace(/\|/g, '|'); + // ── 自动推断 parentTaskId ── + // 如果调用者没有显式传 parentTaskId,则尝试从 .tasks/ 目录下 + // 查找 from agent 正在执行的(status=dispatched 且 to=from)的 task + let parentTaskId = explicitParent || null; + let sourceConvId = null; + if (!parentTaskId) { + try { + const taskFiles = fs.readdirSync(TASK_META_DIR).filter(f => f.endsWith('.json')); + // 找到 "to === from" 且尚未完成的任务 → 这就是 from agent 正在执行的父任务 + let candidates = []; + for (const tf of taskFiles) { + try { + const tm = JSON.parse(fs.readFileSync(path.join(TASK_META_DIR, tf), 'utf-8')); + if (tm.to === from && tm.status === 'dispatched') { + candidates.push(tm); + } + } catch(e) {} + } + // 如果有多个,取最近创建的 + if (candidates.length > 0) { + candidates.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + parentTaskId = candidates[0].taskId; + sourceConvId = candidates[0].executionConvId || null; + console.log(`[TaskDispatch] 🔗 Auto-detected parentTaskId: ${parentTaskId} (${from} is executing it)`); + } + } catch(e) { /* ignore */ } + } + // 1. 写入目标 Agent 的 todo.md const todoPath = path.join(targetDir, 'todo.md'); let todoContent = fs.existsSync(todoPath) ? fs.readFileSync(todoPath, 'utf-8') : ''; @@ -1269,7 +1297,7 @@ router.post('/task/dispatch', async (req, res) => { } } - console.log(`[TaskDispatch] 📨 ${from} → ${to}: "${task}" (${taskId}, ${priority}${goalId ? `, goal: ${goalId}` : ''})`); + console.log(`[TaskDispatch] 📨 ${from} → ${to}: "${task}" (${taskId}, ${priority}${goalId ? `, goal: ${goalId}` : ''}${parentTaskId ? `, parent: ${parentTaskId}` : ''})`); // 4. 持久化 Task 元数据 (用于 task/report 回调) const executionConvId = autoExecute ? `task-${taskId}` : null; @@ -1281,6 +1309,8 @@ router.post('/task/dispatch', async (req, res) => { priority, goalId: goalId || null, nodeId: nodeId || null, + parentTaskId: parentTaskId || null, + sourceConvId: sourceConvId || null, executionConvId, createdAt: new Date().toISOString(), status: 'dispatched', @@ -1897,13 +1927,16 @@ router.post('/:agentId/chat', async (req, res) => { currentToolInput = ''; const toolName = event.params.name; const toolMsg = `\n🔧 [TOOL_CALL] ${toolName} (ID: ${event.params.tool_use_id || 'unknown'})\n`; + fullOutput += toolMsg; logMessageChunk(agentId, convId, toolMsg); sendSSE('tool_call', { tool: toolName, toolId: event.params.tool_use_id, message: `🔧 调用工具: ${toolName}` }); meta.stats.toolCallCount = (meta.stats.toolCallCount || 0) + 1; } else if (event.method === 'claude/stream/content_block_stop') { // 工具调用结束 — 输出积攒的输入参数 if (currentToolInput) { - logMessageChunk(agentId, convId, ` 输入: ${currentToolInput.slice(0, 500)}${currentToolInput.length > 500 ? '...' : ''}\n`); + const inputLog = ` 输入: ${currentToolInput.slice(0, 500)}${currentToolInput.length > 500 ? '...' : ''}\n`; + fullOutput += inputLog; + logMessageChunk(agentId, convId, inputLog); sendSSE('tool_input', { input: currentToolInput }); currentToolInput = ''; } @@ -1948,7 +1981,9 @@ router.post('/:agentId/chat', async (req, res) => { const result = innerEvent.content || innerEvent.content_block?.content || ''; const resultStr = typeof result === 'string' ? result : JSON.stringify(result); const truncated = resultStr.slice(0, 1000) + (resultStr.length > 1000 ? `...(${resultStr.length} chars)` : ''); - logMessageChunk(agentId, convId, ` 结果: ${truncated}\n`); + const resultLog = ` 结果: ${truncated}\n`; + fullOutput += `\n` + resultLog; + logMessageChunk(agentId, convId, resultLog); sendSSE('tool_result', { result: truncated }); } } diff --git a/server/routes/company.js b/server/routes/company.js index 68aa3e2..41a2cd4 100644 --- a/server/routes/company.js +++ b/server/routes/company.js @@ -131,6 +131,7 @@ router.post('/templates/:templateId/generate', async (req, res) => { router.post('/templates/:templateId/train', async (req, res) => { try { const { templateId } = req.params; + const { instructions } = req.body; const path = require('path'); const fs = require('fs'); const config = require('../config'); @@ -142,11 +143,16 @@ router.post('/templates/:templateId/train', async (req, res) => { } const PORT = config.port || 3000; - const prompt = `请你对模板 Agent "${templateId}" 进行评估和训练。 + let prompt = `请你对模板 Agent "${templateId}" 进行评估和训练。 模板位于 \`../../data/system/templates/${templateId}\` 目录。 请使用 FDE pipeline 对该模板的技能进行评测和进化训练,目标: Benchmark Score ≥ 95。 -训练过程包括 Hunt(验证数据), Digest(知识消化), Evolver(技能进化) 等。 -请立即开始评估和训练,训练完成后请汇报最终分数。`; +训练过程包括 Hunt(验证数据), Digest(知识消化), Evolver(技能进化) 等。`; + + if (instructions) { + prompt += `\n**【额外训练要求/指令】**\n${instructions}\n`; + } + + prompt += `\n请立即开始评估和训练,训练完成后请汇报最终分数。`; // 异步调用 innovator fetch(`http://127.0.0.1:${PORT}/api/agent/innovator/chat`, { diff --git a/server/routes/tasks.js b/server/routes/tasks.js new file mode 100644 index 0000000..6c7161d --- /dev/null +++ b/server/routes/tasks.js @@ -0,0 +1,47 @@ +const express = require('express'); +const router = express.Router(); +const { TaskService } = require('../services/task-service'); + +const taskService = new TaskService(); + +// GET /api/tasks — 扫描获取系统中聚合的 Task 列表(含父子关系) +router.get('/', (req, res) => { + try { + const tasks = taskService.getAggregatedTasks(); + + const taskList = tasks.map(t => ({ + taskId: t.taskId, + title: t.title, + createdAt: t.createdAt, + updatedAt: t.updatedAt, + status: t.status, + from: t.from, + to: t.to, + parentTaskId: t.parentTaskId, + children: t.children, + isRoot: t.isRoot, + agents: t.agents, + conversationCount: t.conversations.length, + })); + + res.json(taskList); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// GET /api/tasks/:taskId/timeline — 获取跨 Agent 的所有 Timeline Log(含子任务) +router.get('/:taskId/timeline', (req, res) => { + try { + const includeChildren = req.query.children !== 'false'; + const timeline = taskService.getTaskTimeline(req.params.taskId, includeChildren); + if (!timeline || timeline.length === 0) { + return res.status(404).json({ error: '找不到该任务相关的聊天记录。' }); + } + res.json(timeline); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +module.exports = router; diff --git a/server/services/task-service.js b/server/services/task-service.js new file mode 100644 index 0000000..ad76a12 --- /dev/null +++ b/server/services/task-service.js @@ -0,0 +1,384 @@ +const fs = require('fs'); +const path = require('path'); + +const SYSTEM_AGENTS_DIR = path.resolve(__dirname, '../../data/system/agents'); +const COMPANIES_DIR = path.resolve(__dirname, '../../data/companies'); +const TASK_META_DIR = path.resolve(__dirname, '../../data/system/agents/.tasks'); + +/** + * 递归扫描并读取系统中所有 Agent 的会话, + * 从 .tasks/ meta 和 conversation 模式中推断父子关系,构建任务树。 + */ +class TaskService { + + getAllConversations() { + const list = []; + + if (fs.existsSync(SYSTEM_AGENTS_DIR)) { + const agents = fs.readdirSync(SYSTEM_AGENTS_DIR).filter(d => !d.startsWith('.')); + for (const agentId of agents) { + const convDir = path.join(SYSTEM_AGENTS_DIR, agentId, 'conversations'); + this._scanConversationsDir(convDir, 'system', agentId, list); + } + } + + if (fs.existsSync(COMPANIES_DIR)) { + const dbNames = fs.readdirSync(COMPANIES_DIR).filter(d => !d.startsWith('.')); + for (const companyId of dbNames) { + if (companyId === '_archived_agents') continue; + const agentsDir = path.join(COMPANIES_DIR, companyId, 'agents'); + if (fs.existsSync(agentsDir)) { + const agents = fs.readdirSync(agentsDir).filter(d => !d.startsWith('.')); + for (const agentId of agents) { + const convDir = path.join(agentsDir, agentId, 'conversations'); + this._scanConversationsDir(convDir, companyId, agentId, list); + } + } + } + } + + return list; + } + + _loadTaskMetas() { + const metas = new Map(); + if (!fs.existsSync(TASK_META_DIR)) return metas; + const files = fs.readdirSync(TASK_META_DIR).filter(f => f.endsWith('.json')); + for (const f of files) { + try { + const tm = JSON.parse(fs.readFileSync(path.join(TASK_META_DIR, f), 'utf-8')); + if (tm.taskId) metas.set(tm.taskId, tm); + } catch(e) {} + } + return metas; + } + + /** + * 从 Conversation 聚合 Task 列表,并通过多种手段推断父子关系: + * 1) .tasks/ meta 中的显式 parentTaskId + * 2) conversationId 模式推断(task-T-xxx 从 tpl-train-* 派出,tpl-train 从 conv-* 派出) + * 3) title 中可解析的 task reference([TASK from xxx]) + */ + getAggregatedTasks() { + const convs = this.getAllConversations(); + const taskMetas = this._loadTaskMetas(); + const taskMap = new Map(); + + // 第一遍:建立所有 task group + for (const c of convs) { + let taskId = this._extractTaskId(c.conversationId); + + if (!taskMap.has(taskId)) { + const meta = taskMetas.get(taskId); + taskMap.set(taskId, { + taskId, + title: c.meta.title || taskId, + createdAt: c.meta.createdAt || new Date().toISOString(), + updatedAt: c.meta.updatedAt || c.meta.createdAt, + status: meta?.status || c.meta.status || 'unknown', + from: meta?.from || null, + to: meta?.to || null, + parentTaskId: meta?.parentTaskId || null, + agents: new Set(), + conversations: [], + children: [], + }); + } + + const t = taskMap.get(taskId); + t.agents.add(`${c.companyId}:${c.agentId}`); + t.conversations.push(c); + + if (c.meta.createdAt && new Date(c.meta.createdAt) < new Date(t.createdAt)) t.createdAt = c.meta.createdAt; + if (c.meta.updatedAt && new Date(c.meta.updatedAt) > new Date(t.updatedAt)) t.updatedAt = c.meta.updatedAt; + } + + // 第二遍:推断父子关系 + this._inferParentChild(taskMap, taskMetas); + + // 第三遍:构建 children 列表 + for (const t of taskMap.values()) { + if (t.parentTaskId && taskMap.has(t.parentTaskId)) { + const parent = taskMap.get(t.parentTaskId); + if (!parent.children.includes(t.taskId)) { + parent.children.push(t.taskId); + } + } + } + + const tasks = Array.from(taskMap.values()).map(t => ({ + ...t, + agents: Array.from(t.agents), + isRoot: !t.parentTaskId || !taskMap.has(t.parentTaskId), + conversationCount: t.conversations.length, + })); + + tasks.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + return tasks; + } + + /** + * 从 conversationId 提取逻辑 taskId + */ + _extractTaskId(convId) { + // "task-T-xxx" / "callback-T-xxx" → "T-xxx" + if (convId.includes('-T-')) { + const match = convId.match(/(T-[a-z0-9]+)/); + if (match) return match[1]; + } + // 其他 (conv-xxx, tpl-train-xxx) 保持原样 + return convId; + } + + /** + * 多策略推断父子关系: + * + * 策略1: .tasks/ meta 中的 parentTaskId(直接有的情况) + * 策略2: 通过 "from" agent 的 活跃会话 推断 + * - T-xxx 的 meta.from = "innovator" + * - innovator 同时有一个 tpl-train-* 会话(时间上包含 T-xxx) + * → tpl-train-* 就是 T-xxx 的父任务 + * 策略3: 通过 master conv 触发 tpl-train 推断 + * - conv-xxx (master) 的时间 < tpl-train-xxx 的时间 且 title 包含相同模板名 + * → conv-xxx 是 tpl-train-xxx 的父任务 + */ + _inferParentChild(taskMap, taskMetas) { + const allTasks = Array.from(taskMap.values()); + + // 策略1: 已有 parentTaskId (来自 .tasks/ meta),已在构建时设置 + + // 策略2: T-xxx 任务,推断父任务为 from agent 的活跃 tpl-train/task 会话 + for (const t of allTasks) { + if (t.parentTaskId) continue; + if (!t.taskId.startsWith('T-')) continue; + + const meta = taskMetas.get(t.taskId); + if (!meta) continue; + + const fromAgent = meta.from; + const taskCreatedAt = new Date(meta.createdAt); + + // 找 fromAgent 的其他会话,时间上在这个 task 之前开始且之后结束/仍活跃 + let bestParent = null; + let bestTimeDiff = Infinity; + + for (const candidate of allTasks) { + if (candidate.taskId === t.taskId) continue; + if (candidate.taskId.startsWith('T-') && !taskMetas.has(candidate.taskId)) continue; + + const hasAgent = candidate.conversations.some(c => c.agentId === fromAgent); + if (!hasAgent) continue; + + const candidateCreated = new Date(candidate.createdAt); + const candidateUpdated = new Date(candidate.updatedAt); + + if (candidateCreated < taskCreatedAt && candidateUpdated >= taskCreatedAt) { + const diff = taskCreatedAt - candidateCreated; + if (diff < bestTimeDiff) { + bestTimeDiff = diff; + bestParent = candidate; + } + } + } + + if (bestParent) { + t.parentTaskId = bestParent.taskId; + console.log(`[TaskService] 🔗 Inferred parent: ${t.taskId} → ${bestParent.taskId}`); + } + } + + // 策略4: T-xxx from master,但 master 的 conv-* 已经结束的情况 + // 这种情况发生在:master 原始对话结束后,通过 callback 链路继续派发新任务 + // 例如 conv-xxx → tpl-train → T-a (callback → master → 新 dispatch T-b) + // 此时 T-b 应该关联到 conv-xxx(同一个用户请求) + for (const t of allTasks) { + if (t.parentTaskId) continue; + if (!t.taskId.startsWith('T-')) continue; + + const meta = taskMetas.get(t.taskId); + if (!meta || meta.from !== 'master') continue; + + const taskCreatedAt = new Date(meta.createdAt); + + // 找最近的 master conv-* 会话,窗口放宽到 30 分钟 + let bestConv = null; + let bestDiff = 30 * 60 * 1000; + + for (const candidate of allTasks) { + if (!candidate.taskId.startsWith('conv-')) continue; + if (!candidate.conversations.some(c => c.agentId === 'master')) continue; + + const cCreated = new Date(candidate.createdAt); + const diff = taskCreatedAt - cCreated; + + if (diff > 0 && diff < bestDiff) { + bestDiff = diff; + bestConv = candidate; + } + } + + if (bestConv) { + t.parentTaskId = bestConv.taskId; + console.log(`[TaskService] 🔗 Inferred parent (master→T): ${t.taskId} → ${bestConv.taskId}`); + } + } + + // 策略3: tpl-train-* / task-* 与 conv-* (master) 的关联 + // master 的 conv-xxx 在时间上紧接着触发了 tpl-train-xxx + for (const t of allTasks) { + if (t.parentTaskId) continue; + // tpl-train-* 的 conversations 中有 innovator + if (!t.taskId.startsWith('tpl-train-') && !t.taskId.startsWith('train-')) continue; + + const tCreated = new Date(t.createdAt); + + // 找 master 的 conv-xxx,时间上在 tpl-train 之前且间隔小于 5 分钟 + let bestConv = null; + let bestDiff = 5 * 60 * 1000; // 5分钟窗口 + + for (const candidate of allTasks) { + if (!candidate.taskId.startsWith('conv-')) continue; + // 必须有 master 参与 + if (!candidate.conversations.some(c => c.agentId === 'master')) continue; + + const cCreated = new Date(candidate.createdAt); + const diff = tCreated - cCreated; + + if (diff > 0 && diff < bestDiff) { + // 额外检查:title 是否有模板名关联 + const templateName = t.taskId.replace(/^tpl-train-/, '').replace(/-[a-z0-9]+$/, ''); + if (candidate.title && candidate.title.toLowerCase().includes(templateName.replace(/_/g, ' ').split(' ')[0])) { + bestDiff = diff; + bestConv = candidate; + } else if (!bestConv) { + // 如果没有更好的匹配,用时间最近的 + bestDiff = diff; + bestConv = candidate; + } + } + } + + if (bestConv) { + t.parentTaskId = bestConv.taskId; + console.log(`[TaskService] 🔗 Inferred parent (conv→tpl): ${t.taskId} → ${bestConv.taskId}`); + } + } + } + + getTaskTimeline(filterTaskId, includeChildren = true) { + const tasks = this.getAggregatedTasks(); + const taskGroup = tasks.find(t => t.taskId === filterTaskId); + if (!taskGroup) return []; + + const taskIds = new Set([filterTaskId]); + if (includeChildren) { + this._collectDescendants(filterTaskId, tasks, taskIds); + } + + const relevantConvs = []; + for (const t of tasks) { + if (taskIds.has(t.taskId)) { + relevantConvs.push(...t.conversations); + } + } + + let timeline = []; + + for (const conv of relevantConvs) { + const logPath = path.join(path.dirname(conv.histPath), 'messages.log'); + + if (fs.existsSync(logPath)) { + try { + const logContent = fs.readFileSync(logPath, 'utf-8'); + const blocks = this._parseMessagesLog(logContent, conv); + timeline.push(...blocks); + } catch (e) { + console.warn('Error parsing messages.log', logPath, e.message); + } + } else if (fs.existsSync(conv.histPath)) { + try { + const history = JSON.parse(fs.readFileSync(conv.histPath, 'utf-8')); + history.forEach(msg => { + if (!msg.content && !msg.tool_calls) return; + timeline.push({ + key: `${conv.conversationId}-${msg.messageId || Math.random()}`, + companyId: conv.companyId, + agentId: conv.agentId, + conversationId: conv.conversationId, + role: msg.role, + content: msg.content, + timestamp: msg.timestamp || conv.meta.updatedAt, + }); + }); + } catch (e) { + console.warn('Error parsing history.json', conv.histPath); + } + } + } + + timeline.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()); + return timeline; + } + + _collectDescendants(taskId, allTasks, collected) { + const task = allTasks.find(t => t.taskId === taskId); + if (!task) return; + for (const childId of task.children) { + if (!collected.has(childId)) { + collected.add(childId); + this._collectDescendants(childId, allTasks, collected); + } + } + } + + _parseMessagesLog(logContent, conv) { + const blocks = []; + const blockRegex = /\[(\d{4}-\d{2}-\d{2}T[\d:.]+Z?)\]\s*\[(USER|AGENT|SYSTEM|TOOL)\]\n/g; + + const splits = []; + let match; + while ((match = blockRegex.exec(logContent)) !== null) { + splits.push({ + timestamp: match[1], + role: match[2].toLowerCase(), + startIndex: match.index + match[0].length, + }); + } + + for (let i = 0; i < splits.length; i++) { + const s = splits[i]; + const endIndex = (i + 1 < splits.length) ? splits[i + 1].startIndex - splits[i + 1].timestamp.length - 10 : logContent.length; + const content = logContent.substring(s.startIndex, endIndex).trim(); + if (!content) continue; + + blocks.push({ + key: `${conv.conversationId}-log-${i}`, + companyId: conv.companyId, + agentId: conv.agentId, + conversationId: conv.conversationId, + role: s.role === 'agent' ? 'assistant' : s.role, + content, + timestamp: s.timestamp.endsWith('Z') ? s.timestamp : s.timestamp + 'Z', + }); + } + return blocks; + } + + _scanConversationsDir(convDir, companyId, agentId, list) { + if (!fs.existsSync(convDir)) return; + const items = fs.readdirSync(convDir, { withFileTypes: true }); + for (const item of items) { + if (item.isDirectory()) { + const metaPath = path.join(convDir, item.name, 'meta.json'); + const histPath = path.join(convDir, item.name, 'history.json'); + if (fs.existsSync(metaPath)) { + let meta = {}; + try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8')); } catch(e){} + list.push({ companyId, agentId, conversationId: item.name, meta, histPath }); + } + } + } + } +} + +module.exports = { TaskService }; diff --git a/web/src/components/AppNav.vue b/web/src/components/AppNav.vue index b5ea5b5..adb7529 100644 --- a/web/src/components/AppNav.vue +++ b/web/src/components/AppNav.vue @@ -13,6 +13,10 @@ 📦 Templates + + 📜 Task Logs + + 📊 Quota diff --git a/web/src/router/index.js b/web/src/router/index.js index 72d9afb..535e6ea 100644 --- a/web/src/router/index.js +++ b/web/src/router/index.js @@ -55,6 +55,11 @@ const routes = [ component: () => import('@/views/JobsPanel.vue'), }, { + path: '/tasks/:taskId?', + name: 'TaskTimeline', + component: () => import('@/views/TaskTimeline.vue'), + }, + { path: '/pipelines', name: 'PipelineEditor', component: () => import('@/views/PipelineEditor.vue'), diff --git a/web/src/views/TaskTimeline.vue b/web/src/views/TaskTimeline.vue new file mode 100644 index 0000000..3c9cacc --- /dev/null +++ b/web/src/views/TaskTimeline.vue @@ -0,0 +1,736 @@ + + + + + -- libgit2 0.21.4