From 97b8588dc3accfcc83ee77052246f2b9ca30fdca Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 11 Jun 2026 15:54:32 +0200 Subject: [PATCH] feat(dashboard): multi-agent operations feed aggregating all agent sessions --- backend/Controllers/DashboardController.cs | 86 +++----------- backend/Models/Dashboard.cs | 4 +- backend/Services/OpenClawGatewayClient.cs | 132 +++++++++++++++++++++ 3 files changed, 150 insertions(+), 72 deletions(-) diff --git a/backend/Controllers/DashboardController.cs b/backend/Controllers/DashboardController.cs index 14b7eb1..376daf4 100644 --- a/backend/Controllers/DashboardController.cs +++ b/backend/Controllers/DashboardController.cs @@ -52,36 +52,30 @@ public class DashboardController( } /// - /// Returns the latest assistant messages (operations/feed) from the Iris session. - /// Filtered to role == "assistant" — those are the work feed entries. + /// Returns the latest assistant messages aggregated from ALL agent sessions. + /// Events are sorted by timestamp descending (newest first). + /// Supports optional agent filter via ?agent= query parameter. + /// Falls back to Iris-only feed if multi-agent feed fails. /// [HttpGet("operations")] - public async Task> GetOperations([FromQuery] int limit = 20) + public async Task> GetOperations( + [FromQuery] int limit = 20, + [FromQuery] string? agent = null) { try { - var messages = await gateway.GetSessionHistoryAsync("iris", Math.Clamp(limit, 1, 100)); - var feed = new List(); + var entries = await gateway.GetAllAgentOperationsAsync(Math.Clamp(limit, 1, 100)); - foreach (var msg in messages) + // Optional agent filter + if (!string.IsNullOrWhiteSpace(agent)) { - if (!string.Equals(msg.Role, "assistant", StringComparison.OrdinalIgnoreCase)) - continue; - - if (string.IsNullOrWhiteSpace(msg.Content)) - continue; - - // Parse timestamp for display-friendly "time ago" - var ts = ParseTimestamp(msg.Timestamp); - var timeAgo = FormatTimeAgo(ts); - - // Extract a short agent indicator and action from content - var (agent, action) = ExtractAgentAction(msg.Content); - - feed.Add(new FeedEntry(agent, action, msg.Timestamp, timeAgo)); + entries = entries + .Where(e => string.Equals(e.AgentId, agent, StringComparison.OrdinalIgnoreCase) + || string.Equals(e.Agent, agent, StringComparison.OrdinalIgnoreCase)) + .ToList(); } - return feed; + return entries; } catch (Exception ex) { @@ -367,55 +361,5 @@ public class DashboardController( t.UpdatedAt ); - private static DateTimeOffset ParseTimestamp(string timestamp) - { - if (DateTimeOffset.TryParse(timestamp, null, System.Globalization.DateTimeStyles.None, out var dt)) - return dt; - return DateTimeOffset.UtcNow; - } - private static string FormatTimeAgo(DateTimeOffset ts) - { - var diff = DateTimeOffset.UtcNow - ts; - if (diff.TotalMinutes < 1) return "just now"; - if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}m ago"; - if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}h ago"; - if (diff.TotalDays < 7) return $"{(int)diff.TotalDays}d ago"; - return ts.ToString("MMM dd"); - } - - private static (string Agent, string Action) ExtractAgentAction(string content) - { - // Take first line or first ~80 chars as the action summary - var firstLine = content.Split('\n', 2)[0].Trim(); - var summary = firstLine.Length > 80 ? firstLine[..80] + "…" : firstLine; - - // Try to identify which agent this came from - var agent = "Iris"; - foreach (var marker in new[] { "**Agent:**", "**Agent:** ", "*Agent:* ", "Agent:" }) - { - var idx = content.IndexOf(marker, StringComparison.OrdinalIgnoreCase); - if (idx >= 0) - { - var after = content[(idx + marker.Length)..].TrimStart(); - var end = after.IndexOfAny(['\n', '\r', ',', '.']); - var found = end > 0 ? after[..end].Trim() : after.Split('\n', 2)[0].Trim(); - if (!string.IsNullOrWhiteSpace(found) && found.Length < 30) - { - agent = found; - break; - } - } - } - - // Try to find agent name at the start in brackets like [Agent: Iris] - if (agent == "Iris") - { - var bracketMatch = System.Text.RegularExpressions.Regex.Match(content, @"\[Agent:\s*([^\]]+)\]"); - if (bracketMatch.Success) - agent = bracketMatch.Groups[1].Value.Trim(); - } - - return (agent, summary); - } } diff --git a/backend/Models/Dashboard.cs b/backend/Models/Dashboard.cs index 2b16484..6663e06 100644 --- a/backend/Models/Dashboard.cs +++ b/backend/Models/Dashboard.cs @@ -32,7 +32,9 @@ public sealed record FeedEntry( string Agent, string Action, string Timestamp, - string Time + string Time, + string? AgentId = null, + string? Type = null ); public sealed record DashboardStatus( diff --git a/backend/Services/OpenClawGatewayClient.cs b/backend/Services/OpenClawGatewayClient.cs index 453dd35..98c432c 100644 --- a/backend/Services/OpenClawGatewayClient.cs +++ b/backend/Services/OpenClawGatewayClient.cs @@ -459,6 +459,138 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration return result; } + /// + /// Collects assistant messages from ALL agent sessions (multi-agent operations feed). + /// Merges, sorts by timestamp descending, and limits the result. + /// Falls back to an empty list if any agent session is unreachable. + /// + public async Task> GetAllAgentOperationsAsync(int limit = 30) + { + var allEntries = new List(); + var agentIds = LoadAgentIdsFromConfig(); + + foreach (var agentId in agentIds) + { + try + { + var sessionKey = $"agent:{agentId}:main"; + var messages = await GetSessionHistoryAsync(sessionKey, Math.Min(limit * 2, 50)); + foreach (var msg in messages) + { + if (!string.Equals(msg.Role, "assistant", StringComparison.OrdinalIgnoreCase)) + continue; + + if (string.IsNullOrWhiteSpace(msg.Content)) + continue; + + // Parse timestamp + var ts = ParseTimestamp(msg.Timestamp); + var timeAgo = FormatTimeAgo(ts); + + // Extract a short agent indicator and action from content + var (agent, action) = ExtractAgentAction(msg.Content); + + // Determine event type based on content heuristics + var eventType = DetectEventType(msg.Content); + + allEntries.Add(new FeedEntry( + agent, + action, + msg.Timestamp, + timeAgo, + AgentId: agentId, + Type: eventType + )); + } + } + catch + { + // Agent session unreachable — skip; we still have data from other agents + } + } + + // Sort descending by timestamp, then limit + return allEntries + .OrderByDescending(e => ParseTimestamp(e.Timestamp)) + .Take(Math.Clamp(limit, 1, 100)) + .ToList(); + } + + private static DateTimeOffset ParseTimestamp(string timestamp) + { + if (DateTimeOffset.TryParse(timestamp, null, System.Globalization.DateTimeStyles.None, out var dt)) + return dt; + return DateTimeOffset.UtcNow; + } + + private static string FormatTimeAgo(DateTimeOffset ts) + { + var diff = DateTimeOffset.UtcNow - ts; + if (diff.TotalMinutes < 1) return "just now"; + if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}m ago"; + if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}h ago"; + if (diff.TotalDays < 7) return $"{(int)diff.TotalDays}d ago"; + return ts.ToString("MMM dd"); + } + + /// + /// Determines a FeedEntry event type from message content heuristics. + /// + private static string DetectEventType(string content) + { + if (content.Contains("Subagent Task") || content.Contains("subagent")) + { + if (content.Contains("complete") || content.Contains("done") || content.Contains("finished")) + return "task_complete"; + return "task_start"; + } + if (content.Contains("Deploy") || content.Contains("deploy") || content.Contains("publish")) + return "deploy"; + if (content.Contains("System") || content.Contains("system") || content.Contains("health")) + return "system"; + if (content.Contains("Gestartet") || content.Contains("started") || content.Contains("Session")) + return "session_start"; + return "chat"; + } + + /// + /// Extracts a human-readable agent name and action summary from message content. + /// + private static (string Agent, string Action) ExtractAgentAction(string content) + { + // Take first line or first ~80 chars as the action summary + var firstLine = content.Split('\n', 2)[0].Trim(); + var summary = firstLine.Length > 80 ? firstLine[..80] + "\u2026" : firstLine; + + // Try to identify which agent this came from + var agent = "Iris"; + foreach (var marker in new[] { "**Agent:**", "**Agent:** ", "*Agent:* ", "Agent:" }) + { + var idx = content.IndexOf(marker, StringComparison.OrdinalIgnoreCase); + if (idx >= 0) + { + var after = content[(idx + marker.Length)..].TrimStart(); + var end = after.IndexOfAny(['\n', '\r', ',', '.']); + var found = end > 0 ? after[..end].Trim() : after.Split('\n', 2)[0].Trim(); + if (!string.IsNullOrWhiteSpace(found) && found.Length < 30) + { + agent = found; + break; + } + } + } + + // Try to find agent name at the start in brackets like [Agent: Iris] + if (agent == "Iris") + { + var bracketMatch = System.Text.RegularExpressions.Regex.Match(content, @"\[Agent:\s*([^\]]+)\]"); + if (bracketMatch.Success) + agent = bracketMatch.Groups[1].Value.Trim(); + } + + return (agent, summary); + } + public async Task SendChatMessageAsync(string agentId, string message) { try