Commit b771724510c1e00a762441ec1b91ff2437c60250
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
Showing
12 changed files
with
1325 additions
and
12 deletions
bin/lemon.js
| ... | ... | @@ -84,9 +84,11 @@ async function run() { |
| 84 | 84 | } |
| 85 | 85 | else if (command === 'template train') { |
| 86 | 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 | 92 | console.log(JSON.stringify(res.data, null, 2)); |
| 91 | 93 | } |
| 92 | 94 | else if (command === 'company templates') { |
| ... | ... | @@ -122,11 +124,12 @@ async function run() { |
| 122 | 124 | else if (command === 'task dispatch') { |
| 123 | 125 | const { values } = parseArgs({ args: args.slice(2), options: { |
| 124 | 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 | 129 | const payload = { from: values.from, to: values.to, task: values.task, priority: values.priority, autoExecute: values.auto }; |
| 128 | 130 | if (values.goal) payload.goalId = values.goal; |
| 129 | 131 | if (values.node) payload.nodeId = values.node; |
| 132 | + if (values.parent) payload.parentTaskId = values.parent; | |
| 130 | 133 | |
| 131 | 134 | const res = await request('POST', '/agent/task/dispatch', payload); |
| 132 | 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 | 36 | |
| 37 | 37 | ### 3d. template train — 指派 Innovator 训练并优化模板 |
| 38 | 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 | 43 | ### 3c. company fire — 辞退 Agent |
| 44 | 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('/api/agent', agentChatRoute); |
| 64 | 64 | // Company API |
| 65 | 65 | app.use('/api/company', require('./routes/company')); |
| 66 | 66 | app.use('/api/goals', require('./routes/goals')); |
| 67 | +app.use('/api/tasks', require('./routes/tasks')); | |
| 67 | 68 | |
| 68 | 69 | // Health |
| 69 | 70 | app.get('/api/health', (req, res) => res.json({ status: 'ok', service: 'agent-chat' })); | ... | ... |
server/index.js
| ... | ... | @@ -92,6 +92,7 @@ app.use('/api/users', require('./routes/users')); |
| 92 | 92 | app.use('/api/agent', require('./routes/agent-chat')); |
| 93 | 93 | app.use('/api/goals', require('./routes/goals')); |
| 94 | 94 | app.use('/api/company', require('./routes/company')); |
| 95 | +app.use('/api/tasks', require('./routes/tasks')); | |
| 95 | 96 | |
| 96 | 97 | // ─── 系统端点 ─── |
| 97 | 98 | app.get('/api/health', (req, res) => res.json({ | ... | ... |
server/routes/agent-chat.js
| ... | ... | @@ -1224,7 +1224,7 @@ function loadTaskMeta(taskId) { |
| 1224 | 1224 | |
| 1225 | 1225 | router.post('/task/dispatch', async (req, res) => { |
| 1226 | 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 | 1228 | if (!from || !to || !task) return res.status(400).json({ error: 'from, to, task are required' }); |
| 1229 | 1229 | |
| 1230 | 1230 | const targetDir = getAgentDir(to); |
| ... | ... | @@ -1238,6 +1238,34 @@ router.post('/task/dispatch', async (req, res) => { |
| 1238 | 1238 | // 净化多行文本和竖线,防止破坏 Markdown 表格格式 |
| 1239 | 1239 | const safeTask = task.replace(/\n/g, '<br>').replace(/\|/g, '|'); |
| 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 | 1269 | // 1. 写入目标 Agent 的 todo.md |
| 1242 | 1270 | const todoPath = path.join(targetDir, 'todo.md'); |
| 1243 | 1271 | let todoContent = fs.existsSync(todoPath) ? fs.readFileSync(todoPath, 'utf-8') : ''; |
| ... | ... | @@ -1269,7 +1297,7 @@ router.post('/task/dispatch', async (req, res) => { |
| 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 | 1302 | // 4. 持久化 Task 元数据 (用于 task/report 回调) |
| 1275 | 1303 | const executionConvId = autoExecute ? `task-${taskId}` : null; |
| ... | ... | @@ -1281,6 +1309,8 @@ router.post('/task/dispatch', async (req, res) => { |
| 1281 | 1309 | priority, |
| 1282 | 1310 | goalId: goalId || null, |
| 1283 | 1311 | nodeId: nodeId || null, |
| 1312 | + parentTaskId: parentTaskId || null, | |
| 1313 | + sourceConvId: sourceConvId || null, | |
| 1284 | 1314 | executionConvId, |
| 1285 | 1315 | createdAt: new Date().toISOString(), |
| 1286 | 1316 | status: 'dispatched', |
| ... | ... | @@ -1897,13 +1927,16 @@ router.post('/:agentId/chat', async (req, res) => { |
| 1897 | 1927 | currentToolInput = ''; |
| 1898 | 1928 | const toolName = event.params.name; |
| 1899 | 1929 | const toolMsg = `\n🔧 [TOOL_CALL] ${toolName} (ID: ${event.params.tool_use_id || 'unknown'})\n`; |
| 1930 | + fullOutput += toolMsg; | |
| 1900 | 1931 | logMessageChunk(agentId, convId, toolMsg); |
| 1901 | 1932 | sendSSE('tool_call', { tool: toolName, toolId: event.params.tool_use_id, message: `🔧 调用工具: ${toolName}` }); |
| 1902 | 1933 | meta.stats.toolCallCount = (meta.stats.toolCallCount || 0) + 1; |
| 1903 | 1934 | } else if (event.method === 'claude/stream/content_block_stop') { |
| 1904 | 1935 | // 工具调用结束 — 输出积攒的输入参数 |
| 1905 | 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 | 1940 | sendSSE('tool_input', { input: currentToolInput }); |
| 1908 | 1941 | currentToolInput = ''; |
| 1909 | 1942 | } |
| ... | ... | @@ -1948,7 +1981,9 @@ router.post('/:agentId/chat', async (req, res) => { |
| 1948 | 1981 | const result = innerEvent.content || innerEvent.content_block?.content || ''; |
| 1949 | 1982 | const resultStr = typeof result === 'string' ? result : JSON.stringify(result); |
| 1950 | 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 | 1987 | sendSSE('tool_result', { result: truncated }); |
| 1953 | 1988 | } |
| 1954 | 1989 | } | ... | ... |
server/routes/company.js
| ... | ... | @@ -131,6 +131,7 @@ router.post('/templates/:templateId/generate', async (req, res) => { |
| 131 | 131 | router.post('/templates/:templateId/train', async (req, res) => { |
| 132 | 132 | try { |
| 133 | 133 | const { templateId } = req.params; |
| 134 | + const { instructions } = req.body; | |
| 134 | 135 | const path = require('path'); |
| 135 | 136 | const fs = require('fs'); |
| 136 | 137 | const config = require('../config'); |
| ... | ... | @@ -142,11 +143,16 @@ router.post('/templates/:templateId/train', async (req, res) => { |
| 142 | 143 | } |
| 143 | 144 | |
| 144 | 145 | const PORT = config.port || 3000; |
| 145 | - const prompt = `请你对模板 Agent "${templateId}" 进行评估和训练。 | |
| 146 | + let prompt = `请你对模板 Agent "${templateId}" 进行评估和训练。 | |
| 146 | 147 | 模板位于 \`../../data/system/templates/${templateId}\` 目录。 |
| 147 | 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 | 157 | // 异步调用 innovator |
| 152 | 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 | 13 | <span>📦</span> Templates |
| 14 | 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 | 20 | <router-link to="/quota" class="nav-link" :class="{ active: $route.path === '/quota' }"> |
| 17 | 21 | <span>📊</span> Quota |
| 18 | 22 | </router-link> | ... | ... |
web/src/router/index.js
| ... | ... | @@ -55,6 +55,11 @@ const routes = [ |
| 55 | 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 | 63 | path: '/pipelines', |
| 59 | 64 | name: 'PipelineEditor', |
| 60 | 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> | ... | ... |