feat(dashboard): multi-agent operations feed aggregating all agent sessions
This commit is contained in:
@@ -52,36 +52,30 @@ public class DashboardController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the latest assistant messages (operations/feed) from the Iris session.
|
/// Returns the latest assistant messages aggregated from ALL agent sessions.
|
||||||
/// Filtered to role == "assistant" — those are the work feed entries.
|
/// 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>
|
/// </summary>
|
||||||
[HttpGet("operations")]
|
[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
|
try
|
||||||
{
|
{
|
||||||
var messages = await gateway.GetSessionHistoryAsync("iris", Math.Clamp(limit, 1, 100));
|
var entries = await gateway.GetAllAgentOperationsAsync(Math.Clamp(limit, 1, 100));
|
||||||
var feed = new List<FeedEntry>();
|
|
||||||
|
|
||||||
foreach (var msg in messages)
|
// Optional agent filter
|
||||||
|
if (!string.IsNullOrWhiteSpace(agent))
|
||||||
{
|
{
|
||||||
if (!string.Equals(msg.Role, "assistant", StringComparison.OrdinalIgnoreCase))
|
entries = entries
|
||||||
continue;
|
.Where(e => string.Equals(e.AgentId, agent, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(e.Agent, agent, StringComparison.OrdinalIgnoreCase))
|
||||||
if (string.IsNullOrWhiteSpace(msg.Content))
|
.ToList();
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return feed;
|
return entries;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -367,55 +361,5 @@ public class DashboardController(
|
|||||||
t.UpdatedAt
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ public sealed record FeedEntry(
|
|||||||
string Agent,
|
string Agent,
|
||||||
string Action,
|
string Action,
|
||||||
string Timestamp,
|
string Timestamp,
|
||||||
string Time
|
string Time,
|
||||||
|
string? AgentId = null,
|
||||||
|
string? Type = null
|
||||||
);
|
);
|
||||||
|
|
||||||
public sealed record DashboardStatus(
|
public sealed record DashboardStatus(
|
||||||
|
|||||||
@@ -459,6 +459,138 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
|||||||
return result;
|
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)
|
public async Task<ChatResponse> SendChatMessageAsync(string agentId, string message)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
Reference in New Issue
Block a user