feat(dashboard): multi-agent operations feed aggregating all agent sessions
CI - Build & Test / Backend (.NET) (push) Successful in 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 3s

This commit is contained in:
2026-06-11 15:54:32 +02:00
parent 6150ea96af
commit 97b8588dc3
3 changed files with 150 additions and 72 deletions
+15 -71
View File
@@ -52,36 +52,30 @@ public class DashboardController(
}
/// <summary>
/// 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.
/// </summary>
[HttpGet("operations")]
public async Task<List<FeedEntry>> GetOperations([FromQuery] int limit = 20)
public async Task<List<FeedEntry>> 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<FeedEntry>();
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);
}
}
+3 -1
View File
@@ -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(
+132
View File
@@ -459,6 +459,138 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
return result;
}
/// <summary>
/// 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.
/// </summary>
public async Task<List<FeedEntry>> GetAllAgentOperationsAsync(int limit = 30)
{
var allEntries = new List<FeedEntry>();
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");
}
/// <summary>
/// Determines a FeedEntry event type from message content heuristics.
/// </summary>
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";
}
/// <summary>
/// Extracts a human-readable agent name and action summary from message content.
/// </summary>
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<ChatResponse> SendChatMessageAsync(string agentId, string message)
{
try