Compare commits

...

6 Commits

Author SHA1 Message Date
devops 068b0d31b8 chore: bump version to v0.2.46 [skip ci] 2026-06-11 13:55:20 +00:00
developer 97b8588dc3 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
2026-06-11 15:54:32 +02:00
devops 6150ea96af chore: bump version to v0.2.45 [skip ci] 2026-06-11 13:52:39 +00:00
developer 81af81fb6f feat(dashboard): task system with DB persistence, CRUD endpoints, frontend API integration
CI - Build & Test / Backend (.NET) (push) Successful in 27s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-11 15:51:48 +02:00
devops 2877035c5c chore: bump version to v0.2.44 [skip ci] 2026-06-11 13:49:27 +00:00
developer 6a1366b472 feat(dashboard): dynamic agent data from gateway sessions instead of hardcoded list
CI - Build & Test / Backend (.NET) (push) Successful in 27s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 14s
CI - Build & Test / Security Check (push) Successful in 2s
2026-06-11 15:48:33 +02:00
10 changed files with 1052 additions and 138 deletions
+1 -1
View File
@@ -1 +1 @@
0.2.43
0.2.46
+169 -71
View File
@@ -1,12 +1,18 @@
using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Data;
using Nexus.Api.Models;
using Nexus.Api.Repositories;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
[ApiController]
[Route("api/dashboard")]
public class DashboardController(OpenClawGatewayClient gateway, ILogger<DashboardController> logger)
public class DashboardController(
OpenClawGatewayClient gateway,
ITaskRepository taskRepo,
IActivityRepository activityRepo,
ILogger<DashboardController> logger)
: ControllerBase
{
/// <summary>
@@ -46,36 +52,30 @@ public class DashboardController(OpenClawGatewayClient gateway, ILogger<Dashboar
}
/// <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)
{
@@ -211,57 +211,155 @@ public class DashboardController(OpenClawGatewayClient gateway, ILogger<Dashboar
return Ok(models);
}
// ========== Task Endpoints ==========
/// <summary>
/// Returns all non-done tasks (status != 'Done'), ordered by creation date descending.
/// </summary>
[HttpGet("tasks")]
public async Task<List<DashboardTaskDto>> GetTasks(CancellationToken ct)
{
try
{
var tasks = await taskRepo.GetAllAsync(ct);
return tasks
.Where(t => !string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase))
.OrderByDescending(t => t.CreatedAt)
.Select(MapToDto)
.ToList();
}
catch (Exception ex)
{
logger.LogWarning(ex, "Dashboard tasks fetch failed");
return new List<DashboardTaskDto>();
}
}
/// <summary>
/// Creates a new task and logs an activity event.
/// </summary>
[HttpPost("tasks")]
public async Task<ActionResult<DashboardTaskDto>> CreateTask(
[FromBody] CreateDashboardTaskRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Title))
return BadRequest(new { error = "Title is required." });
var task = new WorkTask
{
Title = request.Title.Trim(),
Detail = request.Detail?.Trim(),
Source = string.IsNullOrWhiteSpace(request.Source) ? "bao" : request.Source.Trim(),
Priority = string.IsNullOrWhiteSpace(request.Priority) ? "Normal" : request.Priority.Trim(),
AssignedTo = request.AssignedTo?.Trim(),
};
await taskRepo.AddAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent
{
Type = "task",
Message = $"Task \"{task.Title}\" created ({task.Source})"
}, ct);
return Created($"/api/dashboard/tasks/{task.Id}", MapToDto(task));
}
/// <summary>
/// Updates an existing task (title, detail, source, priority, assignedTo).
/// </summary>
[HttpPut("tasks/{id:guid}")]
public async Task<ActionResult<DashboardTaskDto>> UpdateTask(
Guid id, [FromBody] UpdateDashboardTaskRequest request, CancellationToken ct)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null)
return NotFound(new { error = "Task not found." });
if (!string.IsNullOrWhiteSpace(request.Title))
task.Title = request.Title.Trim();
if (request.Detail is not null)
task.Detail = string.IsNullOrWhiteSpace(request.Detail) ? null : request.Detail.Trim();
if (!string.IsNullOrWhiteSpace(request.Source))
task.Source = request.Source.Trim();
if (!string.IsNullOrWhiteSpace(request.Priority))
task.Priority = request.Priority.Trim();
if (request.AssignedTo is not null)
task.AssignedTo = string.IsNullOrWhiteSpace(request.AssignedTo) ? null : request.AssignedTo.Trim();
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent
{
Type = "task",
Message = $"Task \"{task.Title}\" updated"
}, ct);
return Ok(MapToDto(task));
}
/// <summary>
/// Deletes a task (only if status is 'Done' or 'Backlog').
/// </summary>
[HttpDelete("tasks/{id:guid}")]
public async Task<ActionResult> DeleteTask(Guid id, CancellationToken ct)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null)
return NotFound(new { error = "Task not found." });
if (!TaskStateHelper.IsDoneOrBacklog(task.State))
return StatusCode(403, new { error = "Only tasks in 'Done' or 'Backlog' state can be deleted." });
await activityRepo.AddAsync(new ActivityEvent
{
Type = "task",
Message = $"Task \"{task.Title}\" deleted"
}, ct);
await taskRepo.DeleteAsync(task, ct);
return NoContent();
}
/// <summary>
/// Changes the status of a task.
/// </summary>
[HttpPatch("tasks/{id:guid}/status")]
public async Task<ActionResult<DashboardTaskDto>> UpdateTaskStatus(
Guid id, [FromBody] UpdateDashboardTaskStatusRequest request, CancellationToken ct)
{
if (!TaskStateHelper.IsValidState(request.Status))
return BadRequest(new { error = $"Unsupported status: '{request.Status}'. Valid: {string.Join(", ", TaskStateHelper.AllStates)}" });
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null)
return NotFound(new { error = "Task not found." });
var canonicalState = TaskStateHelper.AllStates.First(s =>
s.Equals(request.Status, StringComparison.OrdinalIgnoreCase));
task.State = canonicalState;
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent
{
Type = "task",
Message = $"Task \"{task.Title}\" → {canonicalState}"
}, ct);
return Ok(MapToDto(task));
}
// ========== Helpers ==========
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 DashboardTaskDto MapToDto(WorkTask t) => new(
t.Id,
t.Title,
t.Detail,
t.Source,
t.State,
t.Priority,
t.AssignedTo,
t.CreatedAt,
t.UpdatedAt
);
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);
}
}
+4
View File
@@ -77,9 +77,13 @@ public sealed class WorkTask
{
public Guid Id { get; init; } = Guid.NewGuid();
public required string Title { get; set; }
public string? Detail { get; set; }
public string State { get; set; } = "Backlog";
public string Priority { get; set; } = "Normal";
public string Source { get; set; } = "bao";
public string? AssignedTo { get; set; }
public Guid? ProjectId { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}
@@ -0,0 +1,240 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Nexus.Api.Data;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Nexus.Api.Migrations
{
[DbContext(typeof(NexusDbContext))]
[Migration("20260611154800_AddTaskDetailFields")]
partial class AddTaskDetailFields
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Nexus.Api.Data.ActivityEvent", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Activity");
});
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<DateTimeOffset?>("LastLoginAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("Nexus.Api.Data.Project", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<int>("Progress")
.HasColumnType("integer");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Projects");
});
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("FamilyId")
.HasColumnType("uuid");
b.Property<string>("ReplacedByTokenHash")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TokenHash")
.IsUnique();
b.HasIndex("UserId", "FamilyId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AssignedTo")
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Detail")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<string>("Priority")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<string>("State")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("AssignedTo");
b.HasIndex("Source");
b.ToTable("Tasks");
});
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
{
b.HasOne("Nexus.Api.Data.NexusUser", "User")
.WithMany("RefreshTokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
{
b.Navigation("RefreshTokens");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,81 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nexus.Api.Migrations
{
/// <inheritdoc />
public partial class AddTaskDetailFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "AssignedTo",
table: "Tasks",
type: "character varying(60)",
maxLength: 60,
nullable: true);
migrationBuilder.AddColumn<DateTimeOffset>(
name: "CreatedAt",
table: "Tasks",
type: "timestamp with time zone",
nullable: false,
defaultValueSql: "NOW()");
migrationBuilder.AddColumn<string>(
name: "Detail",
table: "Tasks",
type: "character varying(2000)",
maxLength: 2000,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Source",
table: "Tasks",
type: "character varying(60)",
maxLength: 60,
nullable: false,
defaultValue: "bao");
migrationBuilder.CreateIndex(
name: "IX_Tasks_AssignedTo",
table: "Tasks",
column: "AssignedTo");
migrationBuilder.CreateIndex(
name: "IX_Tasks_Source",
table: "Tasks",
column: "Source");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Tasks_AssignedTo",
table: "Tasks");
migrationBuilder.DropIndex(
name: "IX_Tasks_Source",
table: "Tasks");
migrationBuilder.DropColumn(
name: "AssignedTo",
table: "Tasks");
migrationBuilder.DropColumn(
name: "CreatedAt",
table: "Tasks");
migrationBuilder.DropColumn(
name: "Detail",
table: "Tasks");
migrationBuilder.DropColumn(
name: "Source",
table: "Tasks");
}
}
}
@@ -172,6 +172,17 @@ namespace Nexus.Api.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AssignedTo")
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Detail")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<string>("Priority")
.IsRequired()
.HasColumnType("text");
@@ -179,6 +190,11 @@ namespace Nexus.Api.Migrations
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<string>("State")
.IsRequired()
.HasColumnType("text");
@@ -193,6 +209,10 @@ namespace Nexus.Api.Migrations
b.HasKey("Id");
b.HasIndex("AssignedTo");
b.HasIndex("Source");
b.ToTable("Tasks");
});
+9 -1
View File
@@ -13,7 +13,15 @@ public sealed class NexusDbContext(DbContextOptions<NexusDbContext> options) : D
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Project>().Property(x => x.Name).HasMaxLength(160);
modelBuilder.Entity<WorkTask>().Property(x => x.Title).HasMaxLength(240);
modelBuilder.Entity<WorkTask>(entity =>
{
entity.Property(x => x.Title).HasMaxLength(240);
entity.Property(x => x.Detail).HasMaxLength(2000);
entity.Property(x => x.Source).HasMaxLength(60);
entity.Property(x => x.AssignedTo).HasMaxLength(60);
entity.HasIndex(x => x.Source);
entity.HasIndex(x => x.AssignedTo);
});
modelBuilder.Entity<ActivityEvent>().Property(x => x.Message).HasMaxLength(1000);
modelBuilder.Entity<NexusUser>().HasIndex(u => u.NormalizedEmail).IsUnique();
modelBuilder.Entity<RefreshToken>().HasIndex(r => r.TokenHash).IsUnique();
+37 -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(
@@ -62,3 +64,37 @@ public sealed record ModelOption(
string Name,
string Provider
);
// ── Dashboard Task DTOs ──
public sealed record DashboardTaskDto(
Guid Id,
string Title,
string? Detail,
string Source,
string State,
string Priority,
string? AssignedTo,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt
);
public sealed record CreateDashboardTaskRequest(
string Title,
string? Detail,
string? Source,
string? Priority,
string? AssignedTo
);
public sealed record UpdateDashboardTaskRequest(
string? Title,
string? Detail,
string? Source,
string? Priority,
string? AssignedTo
);
public sealed record UpdateDashboardTaskStatusRequest(
string Status
);
+435 -58
View File
@@ -61,84 +61,329 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
}
}
public async Task<List<DashboardAgentInfo>> GetAgentsAsync()
{
var agentDefs = new[]
{
new { Id = "iris", Name = "Chief of Staff", Model = "deepseek/deepseek-v4-flash",
Description = "Zentrale operative Führungsinstanz. Strukturiert Aufgaben, bewertet Risiken, steuert spezialisierte Agenten und eskaliert kritische Entscheidungen.",
Tags = new[] { "Orchestration", "Delegation", "Approval", "Risk Management" } },
new { Id = "programmer", Name = "Full-Stack Developer", Model = "deepseek/deepseek-v4-flash",
Description = "Primärer Entwicklungsagent. Implementiert Features, behebt Bugs und schreibt Code im gesamten Stack — autonom im Rahmen seines Scopes.",
Tags = new[] { "Full-Stack", "TypeScript", "C#", "Vue", ".NET", "Builds" } },
new { Id = "reviewer", Name = "Code Quality Assurance", Model = "deepseek/deepseek-v4-pro",
Description = "Code-Qualitätskontrolle. Prüft Diffs auf Bugs, Regressionen, Sicherheitslücken und Wartbarkeit. Berichtet Findings strukturiert und knapp.",
Tags = new[] { "Code Review", "Testing", "Security", "Quality" } },
new { Id = "architekt", Name = "Infrastructure Architect", Model = "deepseek/deepseek-v4-pro",
Description = "Verwaltet die gesamte Server-Infrastruktur. Deployt Services, konfiguriert Docker, Nginx und Firewall. Stellt sicher, dass die Produktivumgebung stabil und sicher läuft.",
Tags = new[] { "Docker", "Nginx", "CI/CD", "Firewall", "VPS" } },
new { Id = "executor", Name = "Host Executor", Model = "deepseek/deepseek-v4-flash",
Description = "Einziger Agent mit Host-Exec-Rechten. Führt Docker- und Shell-Befehle auf dem VPS aus — ausschließlich im Auftrag von Iris. Handelt niemals eigeninitiativ.",
Tags = new[] { "Docker", "Shell", "Host", "Deployment" } },
new { Id = "researcher", Name = "Research & Analysis", Model = "deepseek/deepseek-v4-pro",
Description = "Spezialisierter Recherche-Agent. Sucht online, prüft Quellen, analysiert Inhalte (inkl. YouTube-Videos) und übergibt strukturierte Erkenntnisse. Ausschließlich Lese- und Analyse-Rechte.",
Tags = new[] { "Research", "Quellenprüfung", "Analyse", "Docs" } },
};
var agents = new List<DashboardAgentInfo>();
foreach (var def in agentDefs)
{
var isActive = false;
string? currentTask = null;
try
{
var memDir = "/mnt/workspace-" + def.Id + "/memory";
if (Directory.Exists(memDir))
{
var latestFile = Directory.GetFiles(memDir, "*", SearchOption.AllDirectories)
.Select(f => new FileInfo(f))
.OrderByDescending(f => f.LastWriteTimeUtc)
.FirstOrDefault();
if (latestFile is not null)
{
var age = DateTime.UtcNow - latestFile.LastWriteTimeUtc;
isActive = age.TotalMinutes < 15;
if (isActive)
/// <summary>
/// Reads the agent session status from the Gateway via session_status tool.
/// Returns fields like model, provider, status, lastActivity, isActive, currentTask.
/// Returns null if the session is unreachable.
/// </summary>
private async Task<JsonNode?> TryGetAgentStatusAsync(string agentId)
{
try
{
var firstLine = File.ReadLines(latestFile.FullName).FirstOrDefault()?.Trim();
if (!string.IsNullOrWhiteSpace(firstLine) && firstLine.Length > 60)
currentTask = firstLine[..60];
else if (!string.IsNullOrWhiteSpace(firstLine))
currentTask = firstLine;
else
currentTask = "Working...";
return await InvokeToolAsync("session_status", new { sessionKey = "agent:" + agentId + ":main" });
}
catch
{
currentTask = "Working...";
return null;
}
}
public async Task<List<DashboardAgentInfo>> GetAgentsAsync()
{
// Fallback hardcoded descriptions and tags known for each agent
var knownInfo = new Dictionary<string, (string Name, string Description, string[] Tags)>
{
["iris"] = (
"Iris",
"Zentrale operative Führungsinstanz. Strukturiert Aufgaben, bewertet Risiken, steuert spezialisierte Agenten und eskaliert kritische Entscheidungen.",
new[] { "Orchestration", "Delegation", "Approval", "Risk Management" }
),
["programmer"] = (
"Full-Stack Developer",
"Primärer Entwicklungsagent. Implementiert Features, behebt Bugs und schreibt Code im gesamten Stack — autonom im Rahmen seines Scopes.",
new[] { "Full-Stack", "TypeScript", "C#", "Vue", ".NET", "Builds" }
),
["reviewer"] = (
"Code Quality Assurance",
"Code-Qualitätskontrolle. Prüft Diffs auf Bugs, Regressionen, Sicherheitslücken und Wartbarkeit. Berichtet Findings strukturiert und knapp.",
new[] { "Code Review", "Testing", "Security", "Quality" }
),
["architekt"] = (
"Infrastructure Architect",
"Verwaltet die gesamte Server-Infrastruktur. Deployt Services, konfiguriert Docker, Nginx und Firewall. Stellt sicher, dass die Produktivumgebung stabil und sicher läuft.",
new[] { "Docker", "Nginx", "CI/CD", "Firewall", "VPS" }
),
["executor"] = (
"Host Executor",
"Einziger Agent mit Host-Exec-Rechten. Führt Docker- und Shell-Befehle auf dem VPS aus — ausschließlich im Auftrag von Iris. Handelt niemals eigeninitiativ.",
new[] { "Docker", "Shell", "Host", "Deployment" }
),
["researcher"] = (
"Research & Analysis",
"Spezialisierter Recherche-Agent. Sucht online, prüft Quellen, analysiert Inhalte (inkl. YouTube-Videos) und übergibt strukturierte Erkenntnisse. Ausschließlich Lese- und Analyse-Rechte.",
new[] { "Research", "Quellenprüfung", "Analyse", "Docs" }
)
};
// Load agent IDs from openclaw.json config
var agentIds = LoadAgentIdsFromConfig();
var agents = new List<DashboardAgentInfo>();
foreach (var id in agentIds)
{
// Skip the "main" agent (it's the default assistant, not a sub-agent)
if (string.Equals(id, "main", StringComparison.OrdinalIgnoreCase))
continue;
// 1. Try to get dynamic session status from Gateway
var status = await TryGetAgentStatusAsync(id);
// 2. Extract model from session_status (dynamic)
var model = status?["model"]?.GetValue<string>();
// 3. Extract activity from session_status
var isActive = false;
string? currentTask = null;
if (status is not null)
{
// Check explicit isActive field
var activeVal = status["isActive"];
if (activeVal is not null && activeVal.GetValueKind() == JsonValueKind.True)
isActive = true;
else if (activeVal is not null && activeVal.GetValueKind() == JsonValueKind.String)
isActive = string.Equals(activeVal.GetValue<string>(), "true", StringComparison.OrdinalIgnoreCase);
// Fall back to status text
var statusText = status["status"]?.GetValue<string>();
if (!isActive && statusText is not null)
isActive = string.Equals(statusText, "active", StringComparison.OrdinalIgnoreCase)
|| string.Equals(statusText, "running", StringComparison.OrdinalIgnoreCase);
currentTask = status["currentTask"]?.GetValue<string>()
?? status["task"]?.GetValue<string>()
?? (isActive ? "Working..." : null);
}
// 4. Try to read workspace metadata for richer info
var (name, description, tags) = await ReadAgentMetadataAsync(id);
// 5. Fallback to known info if workspace metadata not available
if (string.IsNullOrWhiteSpace(name) && knownInfo.TryGetValue(id, out var kn))
{
name = kn.Name;
if (string.IsNullOrWhiteSpace(description))
description = kn.Description;
if (tags.Length == 0)
tags = kn.Tags;
}
if (string.IsNullOrWhiteSpace(model))
{
model = id.ToLowerInvariant() switch
{
"iris" or "programmer" or "executor" => "deepseek/deepseek-v4-flash",
"reviewer" or "architekt" or "researcher" => "deepseek/deepseek-v4-pro",
_ => "deepseek/deepseek-v4-flash"
};
}
catch { }
agents.Add(new DashboardAgentInfo(
Id: def.Id,
Name: def.Name,
Role: DeriveRole(def.Id),
Model: def.Model,
Id: id,
Name: string.IsNullOrWhiteSpace(name) ? DeriveRole(id) : name,
Role: DeriveRole(id),
Model: model,
IsActive: isActive,
CurrentTask: currentTask,
Description: def.Description,
Tags: def.Tags
Description: description,
Tags: tags
));
}
return agents;
}
/// <summary>
/// Loads agent IDs from the OpenClaw config file (openclaw.json).
/// Falls back to the known list if the config file is unavailable.
/// </summary>
private List<string> LoadAgentIdsFromConfig()
{
try
{
var configPath = configuration.GetValue<string>("AgentConfigPath")
?? "/home/node/.openclaw/openclaw.json";
if (!System.IO.File.Exists(configPath))
return GetDefaultAgentIds();
var json = System.IO.File.ReadAllText(configPath);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (!root.TryGetProperty("agents", out var agentsEl))
return GetDefaultAgentIds();
if (!agentsEl.TryGetProperty("list", out var listEl))
return GetDefaultAgentIds();
var ids = new List<string>();
foreach (var agentEl in listEl.EnumerateArray())
{
if (agentEl.TryGetProperty("id", out var idEl))
{
var id = idEl.GetString();
if (!string.IsNullOrWhiteSpace(id))
ids.Add(id);
}
}
return ids.Count > 0 ? ids : GetDefaultAgentIds();
}
catch
{
return GetDefaultAgentIds();
}
}
private static List<string> GetDefaultAgentIds()
=> new() { "iris", "programmer", "reviewer", "architekt", "executor", "researcher" };
/// <summary>
/// Reads agent metadata from workspace files (IDENTITY.md, SOUL.md).
/// Returns (Name, Description, Tags) — empty strings/arrays if unavailable.
/// Tags are not read from files (kept as empty for dynamic agents).
/// </summary>
private async Task<(string Name, string Description, string[] Tags)> ReadAgentMetadataAsync(string agentId)
{
var name = string.Empty;
var description = string.Empty;
var tags = Array.Empty<string>();
try
{
// Try the host-mounted workspace path (used by the API container)
var workspacePath = "/mnt/workspace-" + agentId;
var identityPath = Path.Combine(workspacePath, "IDENTITY.md");
if (System.IO.File.Exists(identityPath))
{
var content = await System.IO.File.ReadAllTextAsync(identityPath);
ParseIdentityContent(content, out name, out description);
}
else
{
// Fallback: try the OpenClaw workspace path inside the gateway container
var altPath = "/home/node/.openclaw/workspace-" + agentId + "/IDENTITY.md";
if (System.IO.File.Exists(altPath))
{
var content = await System.IO.File.ReadAllTextAsync(altPath);
ParseIdentityContent(content, out name, out description);
}
}
// If description is still empty, try SOUL.md
if (string.IsNullOrWhiteSpace(description))
{
var soulPath = Path.Combine(workspacePath, "SOUL.md");
string? soulContent = null;
if (System.IO.File.Exists(soulPath))
{
soulContent = await System.IO.File.ReadAllTextAsync(soulPath);
}
else
{
var altSoulPath = "/home/node/.openclaw/workspace-" + agentId + "/SOUL.md";
if (System.IO.File.Exists(altSoulPath))
{
soulContent = await System.IO.File.ReadAllTextAsync(altSoulPath);
}
}
if (soulContent is not null)
{
description = ExtractDescriptionFromSoul(soulContent);
}
}
}
catch
{
// Fallback to hardcoded values will handle this
}
return (name, description, tags);
}
/// <summary>
/// Parses an IDENTITY.md file to extract Name and a short description (role/creature).
/// Looks for markdown list items like "- **Name:** ...", "- **Rolle:** ...", etc.
/// </summary>
private static void ParseIdentityContent(string content, out string name, out string description)
{
name = string.Empty;
description = string.Empty;
using var reader = new StringReader(content);
string? line;
while ((line = reader.ReadLine()) is not null)
{
// Extract name from "- **Name:** ..."
if (string.IsNullOrWhiteSpace(name))
{
var nameMarker = "- **Name:**";
var idx = line.IndexOf(nameMarker, StringComparison.OrdinalIgnoreCase);
if (idx >= 0)
{
name = line[(idx + nameMarker.Length)..].Trim();
continue;
}
}
// Extract role/theme from "- **Rolle:** ..." or "- **Role:** ..." or "- **Creature:** ..."
if (string.IsNullOrWhiteSpace(description))
{
var descMarkers = new[] { "- **Rolle:**", "- **Role:**", "- **Creature:**" };
foreach (var marker in descMarkers)
{
var idx = line.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
if (idx >= 0)
{
description = line[(idx + marker.Length)..].Trim();
break;
}
}
}
}
}
/// <summary>
/// Extracts a short description from SOUL.md (first heading or first paragraph after the "Rolle" section).
/// Returns the first ~200 characters of meaningful content.
/// </summary>
private static string ExtractDescriptionFromSoul(string content)
{
// Look for "## Rolle" section and take the first paragraph after it
using var reader = new StringReader(content);
string? line;
var inRoleSection = false;
var paragraphs = new List<string>();
while ((line = reader.ReadLine()) is not null)
{
var trimmed = line.Trim();
if (trimmed.StartsWith("## ", StringComparison.OrdinalIgnoreCase))
{
if (inRoleSection)
break; // We've moved past the "Rolle" section
if (trimmed.IndexOf("Rolle", StringComparison.OrdinalIgnoreCase) >= 0
|| trimmed.IndexOf("Role", StringComparison.OrdinalIgnoreCase) >= 0)
{
inRoleSection = true;
}
continue;
}
if (inRoleSection && !string.IsNullOrWhiteSpace(trimmed))
{
paragraphs.Add(trimmed);
if (paragraphs.Count >= 3)
break;
}
}
var summary = string.Join(" ", paragraphs);
return summary.Length > 200 ? summary[..200] + "…" : summary;
}
public async Task<List<MessageEntry>> GetSessionHistoryAsync(
string sessionKey, int limit = 50, int offset = 0)
{
@@ -214,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
+56 -6
View File
@@ -103,6 +103,18 @@ interface DashboardQueueItem {
status: string
}
interface DashboardTaskResponse {
id: string
title: string
detail: string | null
source: string
state: string
priority: string
assignedTo: string | null
createdAt: string
updatedAt: string
}
// ── Agent Catalog (static enrichment) ──
const AGENT_CATALOG: Record<string, Partial<AgentNodeData>> = {
@@ -244,12 +256,8 @@ const busySince = ref(0)
// Operations Feed
const feedEntries = ref<FeedEntry[]>([])
// Open Tasks (mock only no API endpoint)
const openTasks = ref<OpenTask[]>([
{ id: 't1', title: 'Agent Thinking Panel visualisieren', detail: 'Live-Animation der Denkprozesse im AgentModal', source: 'iris', createdAt: '22:30' },
{ id: 't2', title: 'CI/CD Pipeline Monitoring Dashboard', detail: 'Echtzeit-Status der Gitea Actions im Dashboard', source: 'iris', createdAt: '21:15' },
{ id: 't3', title: 'Dungeon System Dokumentation', detail: 'API-Doku für Room-Generation-Endpunkte schreiben', source: 'bao', createdAt: '20:00' },
])
// Open Tasks (fetched from API)
const openTasks = ref<OpenTask[]>([])
// Queue
const queue = ref<QueueItem[]>([])
@@ -342,6 +350,45 @@ async function fetchQueue(): Promise<void> {
}
}
async function fetchTasks(): Promise<void> {
try {
const res = await apiFetch('/api/dashboard/tasks')
if (!res.ok) return
const data: DashboardTaskResponse[] = await res.json()
openTasks.value = data.map(mapTaskResponse)
} catch {
// API unreachable keep current values
}
}
function mapTaskResponse(t: DashboardTaskResponse): OpenTask {
const source: OpenTask['source'] = t.source === 'iris' ? 'iris' : 'bao'
// Format createdAt as relative time string (like "22:30")
const created = new Date(t.createdAt)
const now = new Date()
const diffMs = now.getTime() - created.getTime()
const diffMins = Math.floor(diffMs / 60000)
let createdAt: string
if (diffMins < 1) {
createdAt = 'just now'
} else if (diffMins < 60) {
createdAt = `${diffMins}m`
} else if (diffMins < 1440) {
createdAt = `${Math.floor(diffMins / 60)}h`
} else {
createdAt = created.toLocaleDateString('de-DE', { month: 'short', day: 'numeric' })
}
return {
id: t.id,
title: t.title,
detail: t.detail ?? '',
source,
createdAt,
}
}
// ── Chat Send ──
async function sendChatMessage(text: string): Promise<void> {
@@ -454,6 +501,7 @@ function startPolling(): void {
fetchOperations()
fetchChatMessages()
fetchQueue()
fetchTasks()
// Polling intervals
intervals.push(setInterval(fetchStatus, 5000))
@@ -461,6 +509,7 @@ function startPolling(): void {
intervals.push(setInterval(fetchOperations, 10000))
intervals.push(setInterval(fetchChatMessages, 3000))
intervals.push(setInterval(fetchQueue, 10000))
intervals.push(setInterval(fetchTasks, 15000))
}
function stopPolling(): void {
@@ -512,5 +561,6 @@ export function useDashboardData() {
fetchOperations,
fetchChatMessages,
fetchQueue,
fetchTasks,
}
}