Commit b771724510c1e00a762441ec1b91ff2437c60250

Authored by yibo
1 parent 4611a4b7

feat: Task Timeline dashboard with parent-child task tree

- Add TaskTimeline.vue: two-pane dashboard with task list + message timeline
- Add task-service.js: aggregate cross-agent conversations into task tree
  - 4-strategy parent-child inference (explicit meta, time-overlap, conv→tpl, master→T)
  - Read messages.log for full tool call details (not just history.json)
- Add tasks.js API routes: GET /api/tasks, GET /api/tasks/:taskId/timeline
- Mount /api/tasks in agent-server.js
- Add 'Task Logs' nav entry in AppNav.vue after Templates
- Support taskId in URL (/tasks/:taskId?) for sharing/bookmarking
- Add --parent flag to CLI task dispatch for explicit parentTaskId
- Auto-detect parentTaskId in task/dispatch from sender's active task
- Add --instructions flag to template train CLI command
- Update master tools.md with new parameters
bin/lemon.js
@@ -84,9 +84,11 @@ async function run() { @@ -84,9 +84,11 @@ async function run() {
84 } 84 }
85 else if (command === 'template train') { 85 else if (command === 'template train') {
86 const { values } = parseArgs({ args: args.slice(2), options: { 86 const { values } = parseArgs({ args: args.slice(2), options: {
87 - id: { type: 'string' } 87 + id: { type: 'string' }, instructions: { type: 'string' }
88 }}); 88 }});
89 - const res = await request('POST', `/company/templates/${values.id}/train`); 89 + const payload = {};
  90 + if (values.instructions) payload.instructions = values.instructions;
  91 + const res = await request('POST', `/company/templates/${values.id}/train`, payload);
90 console.log(JSON.stringify(res.data, null, 2)); 92 console.log(JSON.stringify(res.data, null, 2));
91 } 93 }
92 else if (command === 'company templates') { 94 else if (command === 'company templates') {
@@ -122,11 +124,12 @@ async function run() { @@ -122,11 +124,12 @@ async function run() {
122 else if (command === 'task dispatch') { 124 else if (command === 'task dispatch') {
123 const { values } = parseArgs({ args: args.slice(2), options: { 125 const { values } = parseArgs({ args: args.slice(2), options: {
124 from: { type: 'string' }, to: { type: 'string' }, task: { type: 'string' }, priority: { type: 'string', default: 'P1' }, 126 from: { type: 'string' }, to: { type: 'string' }, task: { type: 'string' }, priority: { type: 'string', default: 'P1' },
125 - goal: { type: 'string' }, node: { type: 'string' }, auto: { type: 'boolean', default: true } 127 + goal: { type: 'string' }, node: { type: 'string' }, auto: { type: 'boolean', default: true }, parent: { type: 'string' }
126 }}); 128 }});
127 const payload = { from: values.from, to: values.to, task: values.task, priority: values.priority, autoExecute: values.auto }; 129 const payload = { from: values.from, to: values.to, task: values.task, priority: values.priority, autoExecute: values.auto };
128 if (values.goal) payload.goalId = values.goal; 130 if (values.goal) payload.goalId = values.goal;
129 if (values.node) payload.nodeId = values.node; 131 if (values.node) payload.nodeId = values.node;
  132 + if (values.parent) payload.parentTaskId = values.parent;
130 133
131 const res = await request('POST', '/agent/task/dispatch', payload); 134 const res = await request('POST', '/agent/task/dispatch', payload);
132 console.log(JSON.stringify(res.data, null, 2)); 135 console.log(JSON.stringify(res.data, null, 2));
data/system/agents/master/tools.md
@@ -36,9 +36,9 @@ node bin/lemon.js template create --id <template_id> --name "<模板名称>" --d @@ -36,9 +36,9 @@ node bin/lemon.js template create --id <template_id> --name "<模板名称>" --d
36 36
37 ### 3d. template train — 指派 Innovator 训练并优化模板 37 ### 3d. template train — 指派 Innovator 训练并优化模板
38 ```bash 38 ```bash
39 -node bin/lemon.js template train --id <template_id> 39 +node bin/lemon.js template train --id <template_id> --instructions "<额外训练要求或参考材料位置>"
40 ``` 40 ```
41 -当你建立完一个新模板,或者用户想加强某个模板时,调用此命令。这将唤醒 Innovator 对该模板执行多轮强化验证与能力补充。 41 +当你建立完一个新模板,或者用户想加强某个模板时,调用此命令。这将唤醒 Innovator 对该模板执行多轮强化验证与能力补充。如果用户指定了训练的参考资料(如特定的文档或代码),务必使用 `--instructions` 参数传递给 Innovator。
42 42
43 ### 3c. company fire — 辞退 Agent 43 ### 3c. company fire — 辞退 Agent
44 ```bash 44 ```bash
docs/task_timeline_design.md 0 → 100644
  1 +# Task Timeline View Design Document
  2 +
  3 +## 1. Overview
  4 +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`).
  5 +This page solves the "Black Box" problem by aggregating isolated agent conversation logs into a single, sortable, filterable timeline.
  6 +
  7 +## 2. Core Features
  8 +- **Left Panel (Task List):** Displays all recent tasks in the system. Cards show the `taskId`, task progress/status, and the agents involved.
  9 +- **Right Panel (Unified Timeline):** A chronological feed of all `history.json` items spanning across all agents involved in the selected task.
  10 +- **Filtering & Search:**
  11 + - **Filter by Agent:** A multi-select dropdown to only show messages from specific agents (e.g. Master, Innovator, Hunter).
  12 + - **Search Box:** Full-text search to filter messages by keyword.
  13 + - **Sort Toggle:** Switch between ascending (oldest first) and descending (newest first) chronological order.
  14 +
  15 +## 3. Backend Architecture Changes
  16 +
  17 +### Data Source Challenge
  18 +Tasks in the system often represent an orchestration spawned from Master, such as `jobId` or `conversationId` mappings.
  19 +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`).
  20 +
  21 +### New API Endpoints (`server/routes/tasks.js`)
  22 +
  23 +**1. `GET /api/tasks`**
  24 +- **Action:** Scans the job store (`.data/jobs-train.json` or `.data/jobs.json`) and conversation metadata to return an array of top-level tasks.
  25 +- **Response Format:**
  26 + ```json
  27 + [
  28 + {
  29 + "taskId": "train-xxx",
  30 + "type": "train-create",
  31 + "status": "completed",
  32 + "progress": "✅ 训练完成",
  33 + "createdAt": "2026-03-29T12:00:00Z",
  34 + "agentsInvolved": ["master", "innovator", "hunter", "digester", "evolver"]
  35 + }
  36 + ]
  37 + ```
  38 +
  39 +**2. `GET /api/tasks/:taskId/timeline`**
  40 +- **Action:** Aggregates and flattens all conversation logs associated with a `taskId`.
  41 + - It searches all companies and system agents (`data/companies/*/agents/*` and `data/system/agents/*`).
  42 + - Finds all conversation folders where the folder name or `meta.json`'s `conversationId` / `jobId` matches or contains the target `taskId`.
  43 + - Reads `history.json` for all these matched conversations.
  44 + - Annotates each message with `agentId` and `companyId`.
  45 + - Sorts the final array globally by `timestamp`.
  46 +- **Response Format:**
  47 + ```json
  48 + [
  49 + {
  50 + "timestamp": "2026-03-29T12:00:01Z",
  51 + "role": "assistant",
  52 + "content": "🔧 [TOOL_CALL] Glob...",
  53 + "agentId": "master",
  54 + "companyId": "system",
  55 + "conversationId": "conv-mnbq123"
  56 + },
  57 + {
  58 + "timestamp": "2026-03-29T12:00:05Z",
  59 + "role": "assistant",
  60 + "content": "正在执行 hunted...",
  61 + "agentId": "hunter",
  62 + "companyId": "system",
  63 + "conversationId": "hunt-xxx"
  64 + }
  65 + ]
  66 + ```
  67 +
  68 +## 4. Frontend Architecture Changes
  69 +
  70 +### Routing (`web/src/router/index.js`)
  71 +- Add a new route `/tasks` mapped to the `TaskTimelineView`.
  72 +
  73 +### Layout (`web/src/views/TaskTimelineView.vue`)
  74 +- **Structure:** Uses a `<div class="flex h-screen">` to split Left and Right.
  75 +- **Left Column:** Needs a highly scannable List View of Tasks.
  76 + - Each item should highlight its Status using distinct colors (Green for `completed`, Yellow for `running`, Red for `failed`).
  77 + - Highlights the list of involved agents using small tags (`<el-tag size="small">`).
  78 +- **Right Column:**
  79 + - **Header:** Sticky Header containing Search (`<el-input>`), Agent Select Dropdown (`<el-select multiple>`), and Sort Button (`<el-button>`).
  80 + - **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.
  81 + - **Agent Identity Header:** Every message block starts with a distinct visual header `avatar/icon + Agent Name + precise time`.
  82 +
  83 +### Navigation (`web/src/components/Sidebar.vue`)
  84 +- Add a new navigation item "Task Logs" / "任务看板" right below "MasterBot" or "Companies".
  85 +
  86 +## 5. Implementation Steps
  87 +1. **Backend Base:** Create `server/services/task-service.js` containing the heavy logic for aggregating cross-agent logs.
  88 +2. **Backend Routes:** Create `server/routes/tasks.js` and mount it to `server/index.js` or `server/app.js`.
  89 +3. **Frontend API:** Expose API bindings for Tasks in the Vue frontend.
  90 +4. **Frontend View:** Implement `TaskTimelineView.vue` with dummy data to build the layout.
  91 +5. **Integration:** Connect the frontend view with the live `/api/tasks` backend and polish the UI interactions (filtering/sorting).
server/agent-server.js
@@ -64,6 +64,7 @@ app.use(&#39;/api/agent&#39;, agentChatRoute); @@ -64,6 +64,7 @@ app.use(&#39;/api/agent&#39;, agentChatRoute);
64 // Company API 64 // Company API
65 app.use('/api/company', require('./routes/company')); 65 app.use('/api/company', require('./routes/company'));
66 app.use('/api/goals', require('./routes/goals')); 66 app.use('/api/goals', require('./routes/goals'));
  67 +app.use('/api/tasks', require('./routes/tasks'));
67 68
68 // Health 69 // Health
69 app.get('/api/health', (req, res) => res.json({ status: 'ok', service: 'agent-chat' })); 70 app.get('/api/health', (req, res) => res.json({ status: 'ok', service: 'agent-chat' }));
server/index.js
@@ -92,6 +92,7 @@ app.use(&#39;/api/users&#39;, require(&#39;./routes/users&#39;)); @@ -92,6 +92,7 @@ app.use(&#39;/api/users&#39;, require(&#39;./routes/users&#39;));
92 app.use('/api/agent', require('./routes/agent-chat')); 92 app.use('/api/agent', require('./routes/agent-chat'));
93 app.use('/api/goals', require('./routes/goals')); 93 app.use('/api/goals', require('./routes/goals'));
94 app.use('/api/company', require('./routes/company')); 94 app.use('/api/company', require('./routes/company'));
  95 +app.use('/api/tasks', require('./routes/tasks'));
95 96
96 // ─── 系统端点 ─── 97 // ─── 系统端点 ───
97 app.get('/api/health', (req, res) => res.json({ 98 app.get('/api/health', (req, res) => res.json({
server/routes/agent-chat.js
@@ -1224,7 +1224,7 @@ function loadTaskMeta(taskId) { @@ -1224,7 +1224,7 @@ function loadTaskMeta(taskId) {
1224 1224
1225 router.post('/task/dispatch', async (req, res) => { 1225 router.post('/task/dispatch', async (req, res) => {
1226 try { 1226 try {
1227 - const { from, to, task, priority = 'P1', planId, goalId, nodeId, autoExecute = true } = req.body; 1227 + const { from, to, task, priority = 'P1', planId, goalId, nodeId, autoExecute = true, parentTaskId: explicitParent } = req.body;
1228 if (!from || !to || !task) return res.status(400).json({ error: 'from, to, task are required' }); 1228 if (!from || !to || !task) return res.status(400).json({ error: 'from, to, task are required' });
1229 1229
1230 const targetDir = getAgentDir(to); 1230 const targetDir = getAgentDir(to);
@@ -1238,6 +1238,34 @@ router.post(&#39;/task/dispatch&#39;, async (req, res) =&gt; { @@ -1238,6 +1238,34 @@ router.post(&#39;/task/dispatch&#39;, async (req, res) =&gt; {
1238 // 净化多行文本和竖线,防止破坏 Markdown 表格格式 1238 // 净化多行文本和竖线,防止破坏 Markdown 表格格式
1239 const safeTask = task.replace(/\n/g, '<br>').replace(/\|/g, '&#124;'); 1239 const safeTask = task.replace(/\n/g, '<br>').replace(/\|/g, '&#124;');
1240 1240
  1241 + // ── 自动推断 parentTaskId ──
  1242 + // 如果调用者没有显式传 parentTaskId,则尝试从 .tasks/ 目录下
  1243 + // 查找 from agent 正在执行的(status=dispatched 且 to=from)的 task
  1244 + let parentTaskId = explicitParent || null;
  1245 + let sourceConvId = null;
  1246 + if (!parentTaskId) {
  1247 + try {
  1248 + const taskFiles = fs.readdirSync(TASK_META_DIR).filter(f => f.endsWith('.json'));
  1249 + // 找到 "to === from" 且尚未完成的任务 → 这就是 from agent 正在执行的父任务
  1250 + let candidates = [];
  1251 + for (const tf of taskFiles) {
  1252 + try {
  1253 + const tm = JSON.parse(fs.readFileSync(path.join(TASK_META_DIR, tf), 'utf-8'));
  1254 + if (tm.to === from && tm.status === 'dispatched') {
  1255 + candidates.push(tm);
  1256 + }
  1257 + } catch(e) {}
  1258 + }
  1259 + // 如果有多个,取最近创建的
  1260 + if (candidates.length > 0) {
  1261 + candidates.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
  1262 + parentTaskId = candidates[0].taskId;
  1263 + sourceConvId = candidates[0].executionConvId || null;
  1264 + console.log(`[TaskDispatch] 🔗 Auto-detected parentTaskId: ${parentTaskId} (${from} is executing it)`);
  1265 + }
  1266 + } catch(e) { /* ignore */ }
  1267 + }
  1268 +
1241 // 1. 写入目标 Agent 的 todo.md 1269 // 1. 写入目标 Agent 的 todo.md
1242 const todoPath = path.join(targetDir, 'todo.md'); 1270 const todoPath = path.join(targetDir, 'todo.md');
1243 let todoContent = fs.existsSync(todoPath) ? fs.readFileSync(todoPath, 'utf-8') : ''; 1271 let todoContent = fs.existsSync(todoPath) ? fs.readFileSync(todoPath, 'utf-8') : '';
@@ -1269,7 +1297,7 @@ router.post(&#39;/task/dispatch&#39;, async (req, res) =&gt; { @@ -1269,7 +1297,7 @@ router.post(&#39;/task/dispatch&#39;, async (req, res) =&gt; {
1269 } 1297 }
1270 } 1298 }
1271 1299
1272 - console.log(`[TaskDispatch] 📨 ${from} → ${to}: "${task}" (${taskId}, ${priority}${goalId ? `, goal: ${goalId}` : ''})`); 1300 + console.log(`[TaskDispatch] 📨 ${from} → ${to}: "${task}" (${taskId}, ${priority}${goalId ? `, goal: ${goalId}` : ''}${parentTaskId ? `, parent: ${parentTaskId}` : ''})`);
1273 1301
1274 // 4. 持久化 Task 元数据 (用于 task/report 回调) 1302 // 4. 持久化 Task 元数据 (用于 task/report 回调)
1275 const executionConvId = autoExecute ? `task-${taskId}` : null; 1303 const executionConvId = autoExecute ? `task-${taskId}` : null;
@@ -1281,6 +1309,8 @@ router.post(&#39;/task/dispatch&#39;, async (req, res) =&gt; { @@ -1281,6 +1309,8 @@ router.post(&#39;/task/dispatch&#39;, async (req, res) =&gt; {
1281 priority, 1309 priority,
1282 goalId: goalId || null, 1310 goalId: goalId || null,
1283 nodeId: nodeId || null, 1311 nodeId: nodeId || null,
  1312 + parentTaskId: parentTaskId || null,
  1313 + sourceConvId: sourceConvId || null,
1284 executionConvId, 1314 executionConvId,
1285 createdAt: new Date().toISOString(), 1315 createdAt: new Date().toISOString(),
1286 status: 'dispatched', 1316 status: 'dispatched',
@@ -1897,13 +1927,16 @@ router.post(&#39;/:agentId/chat&#39;, async (req, res) =&gt; { @@ -1897,13 +1927,16 @@ router.post(&#39;/:agentId/chat&#39;, async (req, res) =&gt; {
1897 currentToolInput = ''; 1927 currentToolInput = '';
1898 const toolName = event.params.name; 1928 const toolName = event.params.name;
1899 const toolMsg = `\n🔧 [TOOL_CALL] ${toolName} (ID: ${event.params.tool_use_id || 'unknown'})\n`; 1929 const toolMsg = `\n🔧 [TOOL_CALL] ${toolName} (ID: ${event.params.tool_use_id || 'unknown'})\n`;
  1930 + fullOutput += toolMsg;
1900 logMessageChunk(agentId, convId, toolMsg); 1931 logMessageChunk(agentId, convId, toolMsg);
1901 sendSSE('tool_call', { tool: toolName, toolId: event.params.tool_use_id, message: `🔧 调用工具: ${toolName}` }); 1932 sendSSE('tool_call', { tool: toolName, toolId: event.params.tool_use_id, message: `🔧 调用工具: ${toolName}` });
1902 meta.stats.toolCallCount = (meta.stats.toolCallCount || 0) + 1; 1933 meta.stats.toolCallCount = (meta.stats.toolCallCount || 0) + 1;
1903 } else if (event.method === 'claude/stream/content_block_stop') { 1934 } else if (event.method === 'claude/stream/content_block_stop') {
1904 // 工具调用结束 — 输出积攒的输入参数 1935 // 工具调用结束 — 输出积攒的输入参数
1905 if (currentToolInput) { 1936 if (currentToolInput) {
1906 - logMessageChunk(agentId, convId, ` 输入: ${currentToolInput.slice(0, 500)}${currentToolInput.length > 500 ? '...' : ''}\n`); 1937 + const inputLog = ` 输入: ${currentToolInput.slice(0, 500)}${currentToolInput.length > 500 ? '...' : ''}\n`;
  1938 + fullOutput += inputLog;
  1939 + logMessageChunk(agentId, convId, inputLog);
1907 sendSSE('tool_input', { input: currentToolInput }); 1940 sendSSE('tool_input', { input: currentToolInput });
1908 currentToolInput = ''; 1941 currentToolInput = '';
1909 } 1942 }
@@ -1948,7 +1981,9 @@ router.post(&#39;/:agentId/chat&#39;, async (req, res) =&gt; { @@ -1948,7 +1981,9 @@ router.post(&#39;/:agentId/chat&#39;, async (req, res) =&gt; {
1948 const result = innerEvent.content || innerEvent.content_block?.content || ''; 1981 const result = innerEvent.content || innerEvent.content_block?.content || '';
1949 const resultStr = typeof result === 'string' ? result : JSON.stringify(result); 1982 const resultStr = typeof result === 'string' ? result : JSON.stringify(result);
1950 const truncated = resultStr.slice(0, 1000) + (resultStr.length > 1000 ? `...(${resultStr.length} chars)` : ''); 1983 const truncated = resultStr.slice(0, 1000) + (resultStr.length > 1000 ? `...(${resultStr.length} chars)` : '');
1951 - logMessageChunk(agentId, convId, ` 结果: ${truncated}\n`); 1984 + const resultLog = ` 结果: ${truncated}\n`;
  1985 + fullOutput += `\n` + resultLog;
  1986 + logMessageChunk(agentId, convId, resultLog);
1952 sendSSE('tool_result', { result: truncated }); 1987 sendSSE('tool_result', { result: truncated });
1953 } 1988 }
1954 } 1989 }
server/routes/company.js
@@ -131,6 +131,7 @@ router.post(&#39;/templates/:templateId/generate&#39;, async (req, res) =&gt; { @@ -131,6 +131,7 @@ router.post(&#39;/templates/:templateId/generate&#39;, async (req, res) =&gt; {
131 router.post('/templates/:templateId/train', async (req, res) => { 131 router.post('/templates/:templateId/train', async (req, res) => {
132 try { 132 try {
133 const { templateId } = req.params; 133 const { templateId } = req.params;
  134 + const { instructions } = req.body;
134 const path = require('path'); 135 const path = require('path');
135 const fs = require('fs'); 136 const fs = require('fs');
136 const config = require('../config'); 137 const config = require('../config');
@@ -142,11 +143,16 @@ router.post(&#39;/templates/:templateId/train&#39;, async (req, res) =&gt; { @@ -142,11 +143,16 @@ router.post(&#39;/templates/:templateId/train&#39;, async (req, res) =&gt; {
142 } 143 }
143 144
144 const PORT = config.port || 3000; 145 const PORT = config.port || 3000;
145 - const prompt = `请你对模板 Agent "${templateId}" 进行评估和训练。 146 + let prompt = `请你对模板 Agent "${templateId}" 进行评估和训练。
146 模板位于 \`../../data/system/templates/${templateId}\` 目录。 147 模板位于 \`../../data/system/templates/${templateId}\` 目录。
147 请使用 FDE pipeline 对该模板的技能进行评测和进化训练,目标: Benchmark Score ≥ 95。 148 请使用 FDE pipeline 对该模板的技能进行评测和进化训练,目标: Benchmark Score ≥ 95。
148 -训练过程包括 Hunt(验证数据), Digest(知识消化), Evolver(技能进化) 等。  
149 -请立即开始评估和训练,训练完成后请汇报最终分数。`; 149 +训练过程包括 Hunt(验证数据), Digest(知识消化), Evolver(技能进化) 等。`;
  150 +
  151 + if (instructions) {
  152 + prompt += `\n**【额外训练要求/指令】**\n${instructions}\n`;
  153 + }
  154 +
  155 + prompt += `\n请立即开始评估和训练,训练完成后请汇报最终分数。`;
150 156
151 // 异步调用 innovator 157 // 异步调用 innovator
152 fetch(`http://127.0.0.1:${PORT}/api/agent/innovator/chat`, { 158 fetch(`http://127.0.0.1:${PORT}/api/agent/innovator/chat`, {
server/routes/tasks.js 0 → 100644
  1 +const express = require('express');
  2 +const router = express.Router();
  3 +const { TaskService } = require('../services/task-service');
  4 +
  5 +const taskService = new TaskService();
  6 +
  7 +// GET /api/tasks — 扫描获取系统中聚合的 Task 列表(含父子关系)
  8 +router.get('/', (req, res) => {
  9 + try {
  10 + const tasks = taskService.getAggregatedTasks();
  11 +
  12 + const taskList = tasks.map(t => ({
  13 + taskId: t.taskId,
  14 + title: t.title,
  15 + createdAt: t.createdAt,
  16 + updatedAt: t.updatedAt,
  17 + status: t.status,
  18 + from: t.from,
  19 + to: t.to,
  20 + parentTaskId: t.parentTaskId,
  21 + children: t.children,
  22 + isRoot: t.isRoot,
  23 + agents: t.agents,
  24 + conversationCount: t.conversations.length,
  25 + }));
  26 +
  27 + res.json(taskList);
  28 + } catch (err) {
  29 + res.status(500).json({ error: err.message });
  30 + }
  31 +});
  32 +
  33 +// GET /api/tasks/:taskId/timeline — 获取跨 Agent 的所有 Timeline Log(含子任务)
  34 +router.get('/:taskId/timeline', (req, res) => {
  35 + try {
  36 + const includeChildren = req.query.children !== 'false';
  37 + const timeline = taskService.getTaskTimeline(req.params.taskId, includeChildren);
  38 + if (!timeline || timeline.length === 0) {
  39 + return res.status(404).json({ error: '找不到该任务相关的聊天记录。' });
  40 + }
  41 + res.json(timeline);
  42 + } catch (err) {
  43 + res.status(500).json({ error: err.message });
  44 + }
  45 +});
  46 +
  47 +module.exports = router;
server/services/task-service.js 0 → 100644
  1 +const fs = require('fs');
  2 +const path = require('path');
  3 +
  4 +const SYSTEM_AGENTS_DIR = path.resolve(__dirname, '../../data/system/agents');
  5 +const COMPANIES_DIR = path.resolve(__dirname, '../../data/companies');
  6 +const TASK_META_DIR = path.resolve(__dirname, '../../data/system/agents/.tasks');
  7 +
  8 +/**
  9 + * 递归扫描并读取系统中所有 Agent 的会话,
  10 + * 从 .tasks/ meta 和 conversation 模式中推断父子关系,构建任务树。
  11 + */
  12 +class TaskService {
  13 +
  14 + getAllConversations() {
  15 + const list = [];
  16 +
  17 + if (fs.existsSync(SYSTEM_AGENTS_DIR)) {
  18 + const agents = fs.readdirSync(SYSTEM_AGENTS_DIR).filter(d => !d.startsWith('.'));
  19 + for (const agentId of agents) {
  20 + const convDir = path.join(SYSTEM_AGENTS_DIR, agentId, 'conversations');
  21 + this._scanConversationsDir(convDir, 'system', agentId, list);
  22 + }
  23 + }
  24 +
  25 + if (fs.existsSync(COMPANIES_DIR)) {
  26 + const dbNames = fs.readdirSync(COMPANIES_DIR).filter(d => !d.startsWith('.'));
  27 + for (const companyId of dbNames) {
  28 + if (companyId === '_archived_agents') continue;
  29 + const agentsDir = path.join(COMPANIES_DIR, companyId, 'agents');
  30 + if (fs.existsSync(agentsDir)) {
  31 + const agents = fs.readdirSync(agentsDir).filter(d => !d.startsWith('.'));
  32 + for (const agentId of agents) {
  33 + const convDir = path.join(agentsDir, agentId, 'conversations');
  34 + this._scanConversationsDir(convDir, companyId, agentId, list);
  35 + }
  36 + }
  37 + }
  38 + }
  39 +
  40 + return list;
  41 + }
  42 +
  43 + _loadTaskMetas() {
  44 + const metas = new Map();
  45 + if (!fs.existsSync(TASK_META_DIR)) return metas;
  46 + const files = fs.readdirSync(TASK_META_DIR).filter(f => f.endsWith('.json'));
  47 + for (const f of files) {
  48 + try {
  49 + const tm = JSON.parse(fs.readFileSync(path.join(TASK_META_DIR, f), 'utf-8'));
  50 + if (tm.taskId) metas.set(tm.taskId, tm);
  51 + } catch(e) {}
  52 + }
  53 + return metas;
  54 + }
  55 +
  56 + /**
  57 + * 从 Conversation 聚合 Task 列表,并通过多种手段推断父子关系:
  58 + * 1) .tasks/ meta 中的显式 parentTaskId
  59 + * 2) conversationId 模式推断(task-T-xxx 从 tpl-train-* 派出,tpl-train 从 conv-* 派出)
  60 + * 3) title 中可解析的 task reference([TASK from xxx])
  61 + */
  62 + getAggregatedTasks() {
  63 + const convs = this.getAllConversations();
  64 + const taskMetas = this._loadTaskMetas();
  65 + const taskMap = new Map();
  66 +
  67 + // 第一遍:建立所有 task group
  68 + for (const c of convs) {
  69 + let taskId = this._extractTaskId(c.conversationId);
  70 +
  71 + if (!taskMap.has(taskId)) {
  72 + const meta = taskMetas.get(taskId);
  73 + taskMap.set(taskId, {
  74 + taskId,
  75 + title: c.meta.title || taskId,
  76 + createdAt: c.meta.createdAt || new Date().toISOString(),
  77 + updatedAt: c.meta.updatedAt || c.meta.createdAt,
  78 + status: meta?.status || c.meta.status || 'unknown',
  79 + from: meta?.from || null,
  80 + to: meta?.to || null,
  81 + parentTaskId: meta?.parentTaskId || null,
  82 + agents: new Set(),
  83 + conversations: [],
  84 + children: [],
  85 + });
  86 + }
  87 +
  88 + const t = taskMap.get(taskId);
  89 + t.agents.add(`${c.companyId}:${c.agentId}`);
  90 + t.conversations.push(c);
  91 +
  92 + if (c.meta.createdAt && new Date(c.meta.createdAt) < new Date(t.createdAt)) t.createdAt = c.meta.createdAt;
  93 + if (c.meta.updatedAt && new Date(c.meta.updatedAt) > new Date(t.updatedAt)) t.updatedAt = c.meta.updatedAt;
  94 + }
  95 +
  96 + // 第二遍:推断父子关系
  97 + this._inferParentChild(taskMap, taskMetas);
  98 +
  99 + // 第三遍:构建 children 列表
  100 + for (const t of taskMap.values()) {
  101 + if (t.parentTaskId && taskMap.has(t.parentTaskId)) {
  102 + const parent = taskMap.get(t.parentTaskId);
  103 + if (!parent.children.includes(t.taskId)) {
  104 + parent.children.push(t.taskId);
  105 + }
  106 + }
  107 + }
  108 +
  109 + const tasks = Array.from(taskMap.values()).map(t => ({
  110 + ...t,
  111 + agents: Array.from(t.agents),
  112 + isRoot: !t.parentTaskId || !taskMap.has(t.parentTaskId),
  113 + conversationCount: t.conversations.length,
  114 + }));
  115 +
  116 + tasks.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
  117 + return tasks;
  118 + }
  119 +
  120 + /**
  121 + * 从 conversationId 提取逻辑 taskId
  122 + */
  123 + _extractTaskId(convId) {
  124 + // "task-T-xxx" / "callback-T-xxx" → "T-xxx"
  125 + if (convId.includes('-T-')) {
  126 + const match = convId.match(/(T-[a-z0-9]+)/);
  127 + if (match) return match[1];
  128 + }
  129 + // 其他 (conv-xxx, tpl-train-xxx) 保持原样
  130 + return convId;
  131 + }
  132 +
  133 + /**
  134 + * 多策略推断父子关系:
  135 + *
  136 + * 策略1: .tasks/ meta 中的 parentTaskId(直接有的情况)
  137 + * 策略2: 通过 "from" agent 的 活跃会话 推断
  138 + * - T-xxx 的 meta.from = "innovator"
  139 + * - innovator 同时有一个 tpl-train-* 会话(时间上包含 T-xxx)
  140 + * → tpl-train-* 就是 T-xxx 的父任务
  141 + * 策略3: 通过 master conv 触发 tpl-train 推断
  142 + * - conv-xxx (master) 的时间 < tpl-train-xxx 的时间 且 title 包含相同模板名
  143 + * → conv-xxx 是 tpl-train-xxx 的父任务
  144 + */
  145 + _inferParentChild(taskMap, taskMetas) {
  146 + const allTasks = Array.from(taskMap.values());
  147 +
  148 + // 策略1: 已有 parentTaskId (来自 .tasks/ meta),已在构建时设置
  149 +
  150 + // 策略2: T-xxx 任务,推断父任务为 from agent 的活跃 tpl-train/task 会话
  151 + for (const t of allTasks) {
  152 + if (t.parentTaskId) continue;
  153 + if (!t.taskId.startsWith('T-')) continue;
  154 +
  155 + const meta = taskMetas.get(t.taskId);
  156 + if (!meta) continue;
  157 +
  158 + const fromAgent = meta.from;
  159 + const taskCreatedAt = new Date(meta.createdAt);
  160 +
  161 + // 找 fromAgent 的其他会话,时间上在这个 task 之前开始且之后结束/仍活跃
  162 + let bestParent = null;
  163 + let bestTimeDiff = Infinity;
  164 +
  165 + for (const candidate of allTasks) {
  166 + if (candidate.taskId === t.taskId) continue;
  167 + if (candidate.taskId.startsWith('T-') && !taskMetas.has(candidate.taskId)) continue;
  168 +
  169 + const hasAgent = candidate.conversations.some(c => c.agentId === fromAgent);
  170 + if (!hasAgent) continue;
  171 +
  172 + const candidateCreated = new Date(candidate.createdAt);
  173 + const candidateUpdated = new Date(candidate.updatedAt);
  174 +
  175 + if (candidateCreated < taskCreatedAt && candidateUpdated >= taskCreatedAt) {
  176 + const diff = taskCreatedAt - candidateCreated;
  177 + if (diff < bestTimeDiff) {
  178 + bestTimeDiff = diff;
  179 + bestParent = candidate;
  180 + }
  181 + }
  182 + }
  183 +
  184 + if (bestParent) {
  185 + t.parentTaskId = bestParent.taskId;
  186 + console.log(`[TaskService] 🔗 Inferred parent: ${t.taskId} → ${bestParent.taskId}`);
  187 + }
  188 + }
  189 +
  190 + // 策略4: T-xxx from master,但 master 的 conv-* 已经结束的情况
  191 + // 这种情况发生在:master 原始对话结束后,通过 callback 链路继续派发新任务
  192 + // 例如 conv-xxx → tpl-train → T-a (callback → master → 新 dispatch T-b)
  193 + // 此时 T-b 应该关联到 conv-xxx(同一个用户请求)
  194 + for (const t of allTasks) {
  195 + if (t.parentTaskId) continue;
  196 + if (!t.taskId.startsWith('T-')) continue;
  197 +
  198 + const meta = taskMetas.get(t.taskId);
  199 + if (!meta || meta.from !== 'master') continue;
  200 +
  201 + const taskCreatedAt = new Date(meta.createdAt);
  202 +
  203 + // 找最近的 master conv-* 会话,窗口放宽到 30 分钟
  204 + let bestConv = null;
  205 + let bestDiff = 30 * 60 * 1000;
  206 +
  207 + for (const candidate of allTasks) {
  208 + if (!candidate.taskId.startsWith('conv-')) continue;
  209 + if (!candidate.conversations.some(c => c.agentId === 'master')) continue;
  210 +
  211 + const cCreated = new Date(candidate.createdAt);
  212 + const diff = taskCreatedAt - cCreated;
  213 +
  214 + if (diff > 0 && diff < bestDiff) {
  215 + bestDiff = diff;
  216 + bestConv = candidate;
  217 + }
  218 + }
  219 +
  220 + if (bestConv) {
  221 + t.parentTaskId = bestConv.taskId;
  222 + console.log(`[TaskService] 🔗 Inferred parent (master→T): ${t.taskId} → ${bestConv.taskId}`);
  223 + }
  224 + }
  225 +
  226 + // 策略3: tpl-train-* / task-* 与 conv-* (master) 的关联
  227 + // master 的 conv-xxx 在时间上紧接着触发了 tpl-train-xxx
  228 + for (const t of allTasks) {
  229 + if (t.parentTaskId) continue;
  230 + // tpl-train-* 的 conversations 中有 innovator
  231 + if (!t.taskId.startsWith('tpl-train-') && !t.taskId.startsWith('train-')) continue;
  232 +
  233 + const tCreated = new Date(t.createdAt);
  234 +
  235 + // 找 master 的 conv-xxx,时间上在 tpl-train 之前且间隔小于 5 分钟
  236 + let bestConv = null;
  237 + let bestDiff = 5 * 60 * 1000; // 5分钟窗口
  238 +
  239 + for (const candidate of allTasks) {
  240 + if (!candidate.taskId.startsWith('conv-')) continue;
  241 + // 必须有 master 参与
  242 + if (!candidate.conversations.some(c => c.agentId === 'master')) continue;
  243 +
  244 + const cCreated = new Date(candidate.createdAt);
  245 + const diff = tCreated - cCreated;
  246 +
  247 + if (diff > 0 && diff < bestDiff) {
  248 + // 额外检查:title 是否有模板名关联
  249 + const templateName = t.taskId.replace(/^tpl-train-/, '').replace(/-[a-z0-9]+$/, '');
  250 + if (candidate.title && candidate.title.toLowerCase().includes(templateName.replace(/_/g, ' ').split(' ')[0])) {
  251 + bestDiff = diff;
  252 + bestConv = candidate;
  253 + } else if (!bestConv) {
  254 + // 如果没有更好的匹配,用时间最近的
  255 + bestDiff = diff;
  256 + bestConv = candidate;
  257 + }
  258 + }
  259 + }
  260 +
  261 + if (bestConv) {
  262 + t.parentTaskId = bestConv.taskId;
  263 + console.log(`[TaskService] 🔗 Inferred parent (conv→tpl): ${t.taskId} → ${bestConv.taskId}`);
  264 + }
  265 + }
  266 + }
  267 +
  268 + getTaskTimeline(filterTaskId, includeChildren = true) {
  269 + const tasks = this.getAggregatedTasks();
  270 + const taskGroup = tasks.find(t => t.taskId === filterTaskId);
  271 + if (!taskGroup) return [];
  272 +
  273 + const taskIds = new Set([filterTaskId]);
  274 + if (includeChildren) {
  275 + this._collectDescendants(filterTaskId, tasks, taskIds);
  276 + }
  277 +
  278 + const relevantConvs = [];
  279 + for (const t of tasks) {
  280 + if (taskIds.has(t.taskId)) {
  281 + relevantConvs.push(...t.conversations);
  282 + }
  283 + }
  284 +
  285 + let timeline = [];
  286 +
  287 + for (const conv of relevantConvs) {
  288 + const logPath = path.join(path.dirname(conv.histPath), 'messages.log');
  289 +
  290 + if (fs.existsSync(logPath)) {
  291 + try {
  292 + const logContent = fs.readFileSync(logPath, 'utf-8');
  293 + const blocks = this._parseMessagesLog(logContent, conv);
  294 + timeline.push(...blocks);
  295 + } catch (e) {
  296 + console.warn('Error parsing messages.log', logPath, e.message);
  297 + }
  298 + } else if (fs.existsSync(conv.histPath)) {
  299 + try {
  300 + const history = JSON.parse(fs.readFileSync(conv.histPath, 'utf-8'));
  301 + history.forEach(msg => {
  302 + if (!msg.content && !msg.tool_calls) return;
  303 + timeline.push({
  304 + key: `${conv.conversationId}-${msg.messageId || Math.random()}`,
  305 + companyId: conv.companyId,
  306 + agentId: conv.agentId,
  307 + conversationId: conv.conversationId,
  308 + role: msg.role,
  309 + content: msg.content,
  310 + timestamp: msg.timestamp || conv.meta.updatedAt,
  311 + });
  312 + });
  313 + } catch (e) {
  314 + console.warn('Error parsing history.json', conv.histPath);
  315 + }
  316 + }
  317 + }
  318 +
  319 + timeline.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
  320 + return timeline;
  321 + }
  322 +
  323 + _collectDescendants(taskId, allTasks, collected) {
  324 + const task = allTasks.find(t => t.taskId === taskId);
  325 + if (!task) return;
  326 + for (const childId of task.children) {
  327 + if (!collected.has(childId)) {
  328 + collected.add(childId);
  329 + this._collectDescendants(childId, allTasks, collected);
  330 + }
  331 + }
  332 + }
  333 +
  334 + _parseMessagesLog(logContent, conv) {
  335 + const blocks = [];
  336 + const blockRegex = /\[(\d{4}-\d{2}-\d{2}T[\d:.]+Z?)\]\s*\[(USER|AGENT|SYSTEM|TOOL)\]\n/g;
  337 +
  338 + const splits = [];
  339 + let match;
  340 + while ((match = blockRegex.exec(logContent)) !== null) {
  341 + splits.push({
  342 + timestamp: match[1],
  343 + role: match[2].toLowerCase(),
  344 + startIndex: match.index + match[0].length,
  345 + });
  346 + }
  347 +
  348 + for (let i = 0; i < splits.length; i++) {
  349 + const s = splits[i];
  350 + const endIndex = (i + 1 < splits.length) ? splits[i + 1].startIndex - splits[i + 1].timestamp.length - 10 : logContent.length;
  351 + const content = logContent.substring(s.startIndex, endIndex).trim();
  352 + if (!content) continue;
  353 +
  354 + blocks.push({
  355 + key: `${conv.conversationId}-log-${i}`,
  356 + companyId: conv.companyId,
  357 + agentId: conv.agentId,
  358 + conversationId: conv.conversationId,
  359 + role: s.role === 'agent' ? 'assistant' : s.role,
  360 + content,
  361 + timestamp: s.timestamp.endsWith('Z') ? s.timestamp : s.timestamp + 'Z',
  362 + });
  363 + }
  364 + return blocks;
  365 + }
  366 +
  367 + _scanConversationsDir(convDir, companyId, agentId, list) {
  368 + if (!fs.existsSync(convDir)) return;
  369 + const items = fs.readdirSync(convDir, { withFileTypes: true });
  370 + for (const item of items) {
  371 + if (item.isDirectory()) {
  372 + const metaPath = path.join(convDir, item.name, 'meta.json');
  373 + const histPath = path.join(convDir, item.name, 'history.json');
  374 + if (fs.existsSync(metaPath)) {
  375 + let meta = {};
  376 + try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8')); } catch(e){}
  377 + list.push({ companyId, agentId, conversationId: item.name, meta, histPath });
  378 + }
  379 + }
  380 + }
  381 + }
  382 +}
  383 +
  384 +module.exports = { TaskService };
web/src/components/AppNav.vue
@@ -13,6 +13,10 @@ @@ -13,6 +13,10 @@
13 <span>📦</span> Templates 13 <span>📦</span> Templates
14 </router-link> 14 </router-link>
15 15
  16 + <router-link to="/tasks" class="nav-link" :class="{ active: $route.path.startsWith('/tasks') }">
  17 + <span>📜</span> Task Logs
  18 + </router-link>
  19 +
16 <router-link to="/quota" class="nav-link" :class="{ active: $route.path === '/quota' }"> 20 <router-link to="/quota" class="nav-link" :class="{ active: $route.path === '/quota' }">
17 <span>📊</span> Quota 21 <span>📊</span> Quota
18 </router-link> 22 </router-link>
web/src/router/index.js
@@ -55,6 +55,11 @@ const routes = [ @@ -55,6 +55,11 @@ const routes = [
55 component: () => import('@/views/JobsPanel.vue'), 55 component: () => import('@/views/JobsPanel.vue'),
56 }, 56 },
57 { 57 {
  58 + path: '/tasks/:taskId?',
  59 + name: 'TaskTimeline',
  60 + component: () => import('@/views/TaskTimeline.vue'),
  61 + },
  62 + {
58 path: '/pipelines', 63 path: '/pipelines',
59 name: 'PipelineEditor', 64 name: 'PipelineEditor',
60 component: () => import('@/views/PipelineEditor.vue'), 65 component: () => import('@/views/PipelineEditor.vue'),
web/src/views/TaskTimeline.vue 0 → 100644
  1 +<template>
  2 + <div class="task-timeline-page">
  3 + <AppNav />
  4 + <div class="tl-container">
  5 + <!-- Left Panel: Task List -->
  6 + <div class="tl-left">
  7 + <div class="tl-left-header">
  8 + <h2>📜 All Tasks</h2>
  9 + <button class="btn-refresh" @click="fetchTasks">🔄</button>
  10 + </div>
  11 +
  12 + <div class="tl-search-bar">
  13 + <input
  14 + v-model="taskSearch"
  15 + placeholder="Search tasks..."
  16 + class="tl-input"
  17 + />
  18 + </div>
  19 +
  20 + <div class="tl-task-list">
  21 + <div v-if="loadingTasks" class="tl-empty">Loading tasks...</div>
  22 + <div v-else-if="filteredTasks.length === 0" class="tl-empty">No tasks found</div>
  23 +
  24 + <template v-for="task in filteredTasks" :key="task.taskId">
  25 + <!-- Root task card -->
  26 + <div
  27 + @click="selectTask(task)"
  28 + class="tl-task-card"
  29 + :class="{ active: selectedTask?.taskId === task.taskId }"
  30 + >
  31 + <div class="task-card-title" :title="task.title">{{ task.title }}</div>
  32 + <div class="task-card-id">
  33 + <span v-if="task.from && task.to" class="task-flow">{{ task.from }} → {{ task.to }}</span>
  34 + {{ task.taskId }}
  35 + <span v-if="task.status" class="task-status-dot" :class="'status-' + task.status"></span>
  36 + </div>
  37 +
  38 + <div class="task-card-agents">
  39 + <span
  40 + v-for="agent in task.agents.slice(0, 4)"
  41 + :key="agent"
  42 + class="agent-tag"
  43 + >
  44 + {{ agent.split(':')[1] || agent }}
  45 + </span>
  46 + <span v-if="task.agents.length > 4" class="agent-tag more">
  47 + +{{ task.agents.length - 4 }}
  48 + </span>
  49 + </div>
  50 +
  51 + <div class="task-card-meta">
  52 + Convs: {{ task.conversationCount }}
  53 + <template v-if="task.children && task.children.length"> · 🔗 {{ task.children.length }} subtask(s)</template>
  54 + · {{ formatTime(task.updatedAt) }}
  55 + </div>
  56 + </div>
  57 +
  58 + <!-- Child task cards (indented) -->
  59 + <div
  60 + v-for="childId in task.children || []"
  61 + :key="childId"
  62 + class="tl-task-card tl-child-card"
  63 + :class="{ active: selectedTask?.taskId === childId }"
  64 + @click="selectTaskById(childId)"
  65 + >
  66 + <div class="child-connector">└─</div>
  67 + <div class="child-content">
  68 + <div class="task-card-title" :title="getTaskTitle(childId)">{{ getTaskTitle(childId) }}</div>
  69 + <div class="task-card-id">
  70 + <span class="task-flow">{{ getTaskFlow(childId) }}</span>
  71 + {{ childId }}
  72 + </div>
  73 + <!-- Grandchildren indicator -->
  74 + <div v-if="getTaskChildren(childId).length" class="task-card-meta">
  75 + 🔗 {{ getTaskChildren(childId).length }} subtask(s)
  76 + </div>
  77 + </div>
  78 + </div>
  79 + </template>
  80 + </div>
  81 + </div>
  82 +
  83 + <!-- Right Panel: Unified Timeline -->
  84 + <div class="tl-right">
  85 + <div v-if="!selectedTask" class="tl-placeholder">
  86 + <div class="placeholder-icon">📜</div>
  87 + <h3>Select a task from the left to view timeline</h3>
  88 + </div>
  89 +
  90 + <template v-else>
  91 + <!-- Header Controls -->
  92 + <div class="tl-right-header">
  93 + <div class="tl-right-title">
  94 + <h2>Timeline: {{ selectedTask.taskId }}</h2>
  95 + <div class="tl-right-subtitle">{{ selectedTask.title }}</div>
  96 + </div>
  97 +
  98 + <div class="tl-filters">
  99 + <input
  100 + v-model="timelineSearch"
  101 + placeholder="Filter messages..."
  102 + class="tl-input filter-input"
  103 + />
  104 +
  105 + <select v-model="filterAgent" class="tl-select">
  106 + <option value="">All Agents</option>
  107 + <option v-for="a in uniqueAgents" :key="a" :value="a">{{ a }}</option>
  108 + </select>
  109 +
  110 + <select v-model="filterCompany" class="tl-select">
  111 + <option value="">All Companies</option>
  112 + <option v-for="c in uniqueCompanies" :key="c" :value="c">{{ c }}</option>
  113 + </select>
  114 +
  115 + <select v-model="filterConversation" class="tl-select filter-conv">
  116 + <option value="">All Conversations</option>
  117 + <option v-for="cv in uniqueConversations" :key="cv" :value="cv">{{ cv }}</option>
  118 + </select>
  119 +
  120 + <button @click="sortDesc = !sortDesc" class="btn-sort">
  121 + {{ sortDesc ? '↑ Newest' : '↓ Oldest' }}
  122 + </button>
  123 + </div>
  124 + </div>
  125 +
  126 + <!-- Timeline Log List -->
  127 + <div class="tl-timeline-scroll" ref="timelineScrollRef">
  128 + <div v-if="loadingTimeline" class="tl-empty">Loading timeline data...</div>
  129 + <div v-else-if="filteredTimeline.length === 0" class="tl-empty">No matching events found.</div>
  130 +
  131 + <div class="tl-timeline-list" v-else>
  132 + <div
  133 + v-for="(item, index) in filteredTimeline"
  134 + :key="item.key || index"
  135 + class="tl-msg-card"
  136 + >
  137 + <!-- Agent Header Bar -->
  138 + <div class="tl-msg-header" :class="'agent-' + item.agentId.toLowerCase()">
  139 + <div class="tl-msg-header-left">
  140 + <span class="agent-dot"></span>
  141 + <strong class="agent-name">{{ item.agentId }}</strong>
  142 + <span class="company-badge">{{ item.companyId }}</span>
  143 + <span class="conv-id">{{ item.conversationId }}</span>
  144 + </div>
  145 + <div class="tl-msg-time">{{ formatTimeDetail(item.timestamp) }}</div>
  146 + </div>
  147 +
  148 + <!-- Message Content -->
  149 + <div class="tl-msg-body">
  150 + <div v-if="isToolCall(item.content)" class="tool-call-block">
  151 + {{ item.content }}
  152 + </div>
  153 + <div v-else class="msg-text">{{ item.content }}</div>
  154 + </div>
  155 + </div>
  156 + </div>
  157 + </div>
  158 + </template>
  159 + </div>
  160 + </div>
  161 + </div>
  162 +</template>
  163 +
  164 +<script setup>
  165 +import { ref, computed, onMounted, watch } from 'vue'
  166 +import { useRoute, useRouter } from 'vue-router'
  167 +import AppNav from '@/components/AppNav.vue'
  168 +
  169 +const route = useRoute()
  170 +const router = useRouter()
  171 +
  172 +const tasks = ref([])
  173 +const loadingTasks = ref(false)
  174 +const taskSearch = ref('')
  175 +
  176 +const selectedTask = ref(null)
  177 +const timeline = ref([])
  178 +const loadingTimeline = ref(false)
  179 +
  180 +const timelineSearch = ref('')
  181 +const filterAgent = ref('')
  182 +const filterCompany = ref('')
  183 +const filterConversation = ref('')
  184 +const sortDesc = ref(false)
  185 +const timelineScrollRef = ref(null)
  186 +
  187 +const filteredTasks = computed(() => {
  188 + let list = tasks.value.filter(t => t.isRoot) // Only show root-level tasks
  189 + if (taskSearch.value) {
  190 + const term = taskSearch.value.toLowerCase()
  191 + list = list.filter(t =>
  192 + t.taskId.toLowerCase().includes(term) ||
  193 + t.title.toLowerCase().includes(term) ||
  194 + t.agents.some(a => a.toLowerCase().includes(term)) ||
  195 + // Also match if any child matches
  196 + (t.children || []).some(cid => cid.toLowerCase().includes(term))
  197 + )
  198 + }
  199 + return list
  200 +})
  201 +
  202 +// Tree helper functions
  203 +function getTaskTitle(taskId) {
  204 + const t = tasks.value.find(x => x.taskId === taskId)
  205 + return t ? t.title : taskId
  206 +}
  207 +function getTaskFlow(taskId) {
  208 + const t = tasks.value.find(x => x.taskId === taskId)
  209 + return t && t.from && t.to ? `${t.from} → ${t.to}` : ''
  210 +}
  211 +function getTaskChildren(taskId) {
  212 + const t = tasks.value.find(x => x.taskId === taskId)
  213 + return t ? (t.children || []) : []
  214 +}
  215 +function selectTaskById(taskId) {
  216 + const t = tasks.value.find(x => x.taskId === taskId)
  217 + if (t) selectTask(t)
  218 +}
  219 +
  220 +const uniqueAgents = computed(() => [...new Set(timeline.value.map(i => i.agentId))].sort())
  221 +const uniqueCompanies = computed(() => [...new Set(timeline.value.map(i => i.companyId))].sort())
  222 +const uniqueConversations = computed(() => [...new Set(timeline.value.map(i => i.conversationId))].sort())
  223 +
  224 +const filteredTimeline = computed(() => {
  225 + let stream = [...timeline.value]
  226 + if (filterAgent.value) stream = stream.filter(t => t.agentId === filterAgent.value)
  227 + if (filterCompany.value) stream = stream.filter(t => t.companyId === filterCompany.value)
  228 + if (filterConversation.value) stream = stream.filter(t => t.conversationId === filterConversation.value)
  229 + if (timelineSearch.value) {
  230 + const kw = timelineSearch.value.toLowerCase()
  231 + stream = stream.filter(t => t.content && t.content.toLowerCase().includes(kw))
  232 + }
  233 + stream.sort((a, b) => {
  234 + const ta = new Date(a.timestamp).getTime()
  235 + const tb = new Date(b.timestamp).getTime()
  236 + return sortDesc.value ? tb - ta : ta - tb
  237 + })
  238 + return stream
  239 +})
  240 +
  241 +async function fetchTasks() {
  242 + loadingTasks.value = true
  243 + try {
  244 + const res = await fetch('/api/tasks')
  245 + if (res.ok) tasks.value = await res.json()
  246 + } catch (err) {
  247 + console.error('Failed to fetch tasks', err)
  248 + } finally {
  249 + loadingTasks.value = false
  250 + }
  251 +}
  252 +
  253 +async function selectTask(task) {
  254 + selectedTask.value = task
  255 + timelineSearch.value = ''
  256 + filterAgent.value = ''
  257 + filterCompany.value = ''
  258 + filterConversation.value = ''
  259 + sortDesc.value = false
  260 +
  261 + // Update URL
  262 + router.replace({ path: `/tasks/${encodeURIComponent(task.taskId)}` })
  263 +
  264 + await loadTimeline(task.taskId)
  265 +}
  266 +
  267 +async function loadTimeline(taskId) {
  268 + loadingTimeline.value = true
  269 + try {
  270 + const res = await fetch(`/api/tasks/${encodeURIComponent(taskId)}/timeline`)
  271 + if (res.ok) {
  272 + timeline.value = await res.json()
  273 + setTimeout(() => {
  274 + if (timelineScrollRef.value) timelineScrollRef.value.scrollTop = 0
  275 + }, 50)
  276 + }
  277 + } catch (err) {
  278 + console.error('Failed to fetch timeline', err)
  279 + } finally {
  280 + loadingTimeline.value = false
  281 + }
  282 +}
  283 +
  284 +function isToolCall(text) {
  285 + if (!text) return false
  286 + return text.trim().startsWith('🔧') || text.includes('[TOOL_CALL]')
  287 +}
  288 +
  289 +function formatTime(ts) {
  290 + if (!ts) return '-'
  291 + return new Date(ts).toLocaleString('zh-CN')
  292 +}
  293 +
  294 +function formatTimeDetail(ts) {
  295 + if (!ts) return '-'
  296 + return new Date(ts).toLocaleTimeString('zh-CN', { hour12: false })
  297 +}
  298 +
  299 +onMounted(async () => {
  300 + await fetchTasks()
  301 +
  302 + // Restore from URL param
  303 + const taskIdFromUrl = route.params.taskId
  304 + if (taskIdFromUrl && tasks.value.length > 0) {
  305 + const found = tasks.value.find(t => t.taskId === taskIdFromUrl)
  306 + if (found) {
  307 + selectedTask.value = found
  308 + await loadTimeline(found.taskId)
  309 + }
  310 + }
  311 +})
  312 +</script>
  313 +
  314 +<style scoped>
  315 +.task-timeline-page {
  316 + min-height: 100vh;
  317 + background: var(--bg-primary);
  318 + color: var(--text-primary);
  319 +}
  320 +
  321 +.tl-container {
  322 + display: flex;
  323 + height: calc(100vh - 44px);
  324 + overflow: hidden;
  325 +}
  326 +
  327 +/* ═══ Left Panel ═══ */
  328 +.tl-left {
  329 + width: 340px;
  330 + min-width: 280px;
  331 + border-right: 1px solid var(--border-primary);
  332 + display: flex;
  333 + flex-direction: column;
  334 + background: var(--bg-secondary);
  335 +}
  336 +
  337 +.tl-left-header {
  338 + padding: 16px 20px;
  339 + border-bottom: 1px solid var(--border-primary);
  340 + display: flex;
  341 + justify-content: space-between;
  342 + align-items: center;
  343 +}
  344 +
  345 +.tl-left-header h2 {
  346 + font-size: 18px;
  347 + font-weight: 700;
  348 +}
  349 +
  350 +.btn-refresh {
  351 + padding: 6px 10px;
  352 + border-radius: 6px;
  353 + border: 1px solid var(--border-primary);
  354 + background: var(--bg-surface);
  355 + color: var(--text-secondary);
  356 + cursor: pointer;
  357 + font-size: 14px;
  358 + transition: all 0.2s;
  359 +}
  360 +.btn-refresh:hover {
  361 + background: var(--bg-surface-hover);
  362 + border-color: var(--border-active);
  363 +}
  364 +
  365 +.tl-search-bar {
  366 + padding: 10px 16px;
  367 + border-bottom: 1px solid var(--border-primary);
  368 +}
  369 +
  370 +.tl-input {
  371 + width: 100%;
  372 + padding: 8px 12px;
  373 + border-radius: 6px;
  374 + border: 1px solid var(--border-primary);
  375 + background: var(--bg-input);
  376 + color: var(--text-primary);
  377 + font-size: 13px;
  378 + outline: none;
  379 + transition: all 0.2s;
  380 +}
  381 +.tl-input:focus {
  382 + background: var(--bg-input-focus);
  383 + border-color: var(--border-active);
  384 +}
  385 +
  386 +.tl-task-list {
  387 + flex: 1;
  388 + overflow-y: auto;
  389 +}
  390 +
  391 +.tl-empty {
  392 + text-align: center;
  393 + padding: 40px 16px;
  394 + color: var(--text-muted);
  395 + font-size: 14px;
  396 +}
  397 +
  398 +.tl-task-card {
  399 + padding: 14px 20px;
  400 + border-bottom: 1px solid var(--border-primary);
  401 + cursor: pointer;
  402 + transition: all 0.15s;
  403 +}
  404 +.tl-task-card:hover {
  405 + background: var(--bg-surface-hover);
  406 +}
  407 +.tl-task-card.active {
  408 + background: var(--bg-surface-active);
  409 + border-left: 3px solid var(--accent);
  410 +}
  411 +
  412 +.task-card-title {
  413 + font-size: 14px;
  414 + font-weight: 700;
  415 + color: var(--text-primary);
  416 + white-space: nowrap;
  417 + overflow: hidden;
  418 + text-overflow: ellipsis;
  419 + margin-bottom: 4px;
  420 +}
  421 +
  422 +.task-card-id {
  423 + font-size: 11px;
  424 + color: var(--text-muted);
  425 + font-family: monospace;
  426 + margin-bottom: 8px;
  427 +}
  428 +
  429 +.task-card-agents {
  430 + display: flex;
  431 + flex-wrap: wrap;
  432 + gap: 4px;
  433 + margin-bottom: 6px;
  434 +}
  435 +
  436 +.agent-tag {
  437 + padding: 2px 8px;
  438 + border-radius: 4px;
  439 + font-size: 10px;
  440 + font-weight: 700;
  441 + text-transform: uppercase;
  442 + background: var(--accent-bg);
  443 + color: var(--accent-light);
  444 + letter-spacing: 0.3px;
  445 +}
  446 +.agent-tag.more {
  447 + background: var(--bg-surface);
  448 + color: var(--text-muted);
  449 +}
  450 +
  451 +.task-card-meta {
  452 + font-size: 11px;
  453 + color: var(--text-tertiary);
  454 +}
  455 +
  456 +/* Task flow badge (from → to) */
  457 +.task-flow {
  458 + display: inline-block;
  459 + font-size: 10px;
  460 + padding: 1px 5px;
  461 + border-radius: 3px;
  462 + background: var(--purple-bg);
  463 + color: var(--purple-text);
  464 + margin-right: 4px;
  465 + font-weight: 600;
  466 +}
  467 +
  468 +/* Status dot */
  469 +.task-status-dot {
  470 + display: inline-block;
  471 + width: 7px;
  472 + height: 7px;
  473 + border-radius: 50%;
  474 + margin-left: 6px;
  475 + vertical-align: middle;
  476 +}
  477 +.status-done, .status-completed { background: var(--green-text); }
  478 +.status-dispatched, .status-running { background: var(--blue-text); animation: pulse-dot 1.5s infinite; }
  479 +.status-failed { background: #f87171; }
  480 +.status-unknown { background: var(--text-muted); }
  481 +
  482 +@keyframes pulse-dot {
  483 + 0%, 100% { opacity: 1; }
  484 + 50% { opacity: 0.3; }
  485 +}
  486 +
  487 +/* ── Child task cards ── */
  488 +.tl-child-card {
  489 + display: flex;
  490 + padding: 10px 12px 10px 16px;
  491 + background: var(--bg-surface);
  492 + border-left: 2px solid var(--border-active);
  493 + margin-left: 16px;
  494 +}
  495 +.tl-child-card .task-card-title {
  496 + font-size: 13px;
  497 + font-weight: 600;
  498 +}
  499 +.tl-child-card .task-card-id {
  500 + font-size: 10px;
  501 +}
  502 +
  503 +.child-connector {
  504 + color: var(--text-muted);
  505 + font-family: monospace;
  506 + font-size: 13px;
  507 + margin-right: 8px;
  508 + flex-shrink: 0;
  509 + padding-top: 2px;
  510 +}
  511 +
  512 +.child-content {
  513 + flex: 1;
  514 + min-width: 0;
  515 +}
  516 +
  517 +/* ═══ Right Panel ═══ */
  518 +.tl-right {
  519 + flex: 1;
  520 + display: flex;
  521 + flex-direction: column;
  522 + overflow: hidden;
  523 + background: var(--bg-primary);
  524 +}
  525 +
  526 +.tl-placeholder {
  527 + flex: 1;
  528 + display: flex;
  529 + flex-direction: column;
  530 + align-items: center;
  531 + justify-content: center;
  532 + color: var(--text-muted);
  533 +}
  534 +.placeholder-icon {
  535 + font-size: 56px;
  536 + margin-bottom: 16px;
  537 +}
  538 +.tl-placeholder h3 {
  539 + font-weight: 500;
  540 + font-size: 16px;
  541 +}
  542 +
  543 +.tl-right-header {
  544 + padding: 16px 24px;
  545 + border-bottom: 1px solid var(--border-primary);
  546 + background: var(--bg-secondary);
  547 + flex-shrink: 0;
  548 +}
  549 +
  550 +.tl-right-title h2 {
  551 + font-size: 16px;
  552 + font-weight: 700;
  553 +}
  554 +.tl-right-subtitle {
  555 + font-size: 13px;
  556 + color: var(--text-tertiary);
  557 + margin-top: 4px;
  558 + white-space: nowrap;
  559 + overflow: hidden;
  560 + text-overflow: ellipsis;
  561 + max-width: 600px;
  562 +}
  563 +
  564 +.tl-filters {
  565 + display: flex;
  566 + gap: 10px;
  567 + margin-top: 12px;
  568 + flex-wrap: wrap;
  569 + align-items: center;
  570 +}
  571 +
  572 +.filter-input {
  573 + width: 180px;
  574 +}
  575 +
  576 +.tl-select {
  577 + padding: 6px 10px;
  578 + border-radius: 6px;
  579 + border: 1px solid var(--border-primary);
  580 + background: var(--bg-input);
  581 + color: var(--text-primary);
  582 + font-size: 13px;
  583 + outline: none;
  584 + cursor: pointer;
  585 + transition: all 0.2s;
  586 +}
  587 +.tl-select:focus {
  588 + border-color: var(--border-active);
  589 + background: var(--bg-input-focus);
  590 +}
  591 +.filter-conv {
  592 + max-width: 200px;
  593 +}
  594 +
  595 +.btn-sort {
  596 + padding: 6px 12px;
  597 + border-radius: 6px;
  598 + border: 1px solid var(--border-primary);
  599 + background: var(--bg-surface);
  600 + color: var(--text-secondary);
  601 + cursor: pointer;
  602 + font-size: 13px;
  603 + font-weight: 600;
  604 + transition: all 0.2s;
  605 +}
  606 +.btn-sort:hover {
  607 + background: var(--bg-surface-hover);
  608 + border-color: var(--border-active);
  609 +}
  610 +
  611 +/* ═══ Timeline List ═══ */
  612 +.tl-timeline-scroll {
  613 + flex: 1;
  614 + overflow-y: auto;
  615 + padding: 20px 24px;
  616 +}
  617 +
  618 +.tl-timeline-list {
  619 + max-width: 900px;
  620 + margin: 0 auto;
  621 + display: flex;
  622 + flex-direction: column;
  623 + gap: 16px;
  624 +}
  625 +
  626 +.tl-msg-card {
  627 + background: var(--bg-secondary);
  628 + border: 1px solid var(--border-primary);
  629 + border-radius: 10px;
  630 + overflow: hidden;
  631 + transition: box-shadow 0.2s;
  632 +}
  633 +.tl-msg-card:hover {
  634 + box-shadow: 0 4px 20px rgba(0,0,0,0.15);
  635 +}
  636 +
  637 +/* Agent Header */
  638 +.tl-msg-header {
  639 + padding: 8px 16px;
  640 + display: flex;
  641 + align-items: center;
  642 + justify-content: space-between;
  643 + border-bottom: 1px solid var(--border-primary);
  644 + font-size: 13px;
  645 +}
  646 +
  647 +.tl-msg-header-left {
  648 + display: flex;
  649 + align-items: center;
  650 + gap: 8px;
  651 +}
  652 +
  653 +.agent-dot {
  654 + width: 8px;
  655 + height: 8px;
  656 + border-radius: 50%;
  657 + background: var(--accent);
  658 +}
  659 +
  660 +.agent-name {
  661 + text-transform: uppercase;
  662 + font-size: 12px;
  663 + letter-spacing: 0.5px;
  664 +}
  665 +
  666 +.company-badge {
  667 + font-size: 11px;
  668 + padding: 1px 6px;
  669 + border-radius: 4px;
  670 + background: var(--bg-surface);
  671 + color: var(--text-tertiary);
  672 +}
  673 +
  674 +.conv-id {
  675 + font-size: 10px;
  676 + color: var(--text-muted);
  677 + font-family: monospace;
  678 +}
  679 +
  680 +.tl-msg-time {
  681 + font-size: 12px;
  682 + color: var(--text-tertiary);
  683 + font-family: monospace;
  684 +}
  685 +
  686 +/* Agent-specific colors */
  687 +.agent-master { background: rgba(139,92,246,0.08); border-left: 3px solid #8b5cf6; }
  688 +.agent-master .agent-dot { background: #8b5cf6; }
  689 +.agent-master .agent-name { color: var(--purple-text); }
  690 +
  691 +.agent-innovator { background: rgba(59,130,246,0.08); border-left: 3px solid #3b82f6; }
  692 +.agent-innovator .agent-dot { background: #3b82f6; }
  693 +.agent-innovator .agent-name { color: var(--blue-text); }
  694 +
  695 +.agent-hunter { background: rgba(16,185,129,0.08); border-left: 3px solid #10b981; }
  696 +.agent-hunter .agent-dot { background: #10b981; }
  697 +.agent-hunter .agent-name { color: var(--green-text); }
  698 +
  699 +.agent-digester { background: rgba(251,191,36,0.08); border-left: 3px solid #fbbf24; }
  700 +.agent-digester .agent-dot { background: #fbbf24; }
  701 +.agent-digester .agent-name { color: var(--yellow-text); }
  702 +
  703 +.agent-evolver { background: rgba(239,68,68,0.08); border-left: 3px solid #ef4444; }
  704 +.agent-evolver .agent-dot { background: #ef4444; }
  705 +.agent-evolver .agent-name { color: #f87171; }
  706 +
  707 +.agent-agent-creator { background: rgba(236,72,153,0.08); border-left: 3px solid #ec4899; }
  708 +.agent-agent-creator .agent-dot { background: #ec4899; }
  709 +.agent-agent-creator .agent-name { color: #f472b6; }
  710 +
  711 +/* Message Body */
  712 +.tl-msg-body {
  713 + padding: 14px 16px;
  714 + font-size: 14px;
  715 + line-height: 1.7;
  716 +}
  717 +
  718 +.msg-text {
  719 + white-space: pre-wrap;
  720 + word-break: break-word;
  721 + color: var(--text-secondary);
  722 +}
  723 +
  724 +.tool-call-block {
  725 + background: var(--bg-code);
  726 + padding: 10px 14px;
  727 + border-radius: 6px;
  728 + font-family: 'SF Mono', 'Fira Code', monospace;
  729 + font-size: 12px;
  730 + color: var(--cyan-text);
  731 + white-space: pre-wrap;
  732 + word-break: break-all;
  733 + border: 1px solid var(--border-primary);
  734 + overflow-x: auto;
  735 +}
  736 +</style>