Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 45c6b24928 | |||
| 5fb62bef8a | |||
| 068b0d31b8 | |||
| 97b8588dc3 | |||
| 6150ea96af | |||
| 81af81fb6f | |||
| 2877035c5c | |||
| 6a1366b472 |
@@ -1,12 +1,18 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Nexus.Api.Data;
|
||||||
using Nexus.Api.Models;
|
using Nexus.Api.Models;
|
||||||
|
using Nexus.Api.Repositories;
|
||||||
using Nexus.Api.Services;
|
using Nexus.Api.Services;
|
||||||
|
|
||||||
namespace Nexus.Api.Controllers;
|
namespace Nexus.Api.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/dashboard")]
|
[Route("api/dashboard")]
|
||||||
public class DashboardController(OpenClawGatewayClient gateway, ILogger<DashboardController> logger)
|
public class DashboardController(
|
||||||
|
OpenClawGatewayClient gateway,
|
||||||
|
ITaskRepository taskRepo,
|
||||||
|
IActivityRepository activityRepo,
|
||||||
|
ILogger<DashboardController> logger)
|
||||||
: ControllerBase
|
: ControllerBase
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -46,36 +52,30 @@ public class DashboardController(OpenClawGatewayClient gateway, ILogger<Dashboar
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <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)
|
||||||
{
|
{
|
||||||
@@ -135,14 +135,52 @@ public class DashboardController(OpenClawGatewayClient gateway, ILogger<Dashboar
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the cron queue / pending tasks.
|
/// Returns aggregated queue: cron jobs + open tasks (merged, sorted by priority).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet("queue")]
|
[HttpGet("queue")]
|
||||||
public async Task<List<QueueItem>> GetQueue()
|
public async Task<List<QueueItem>> GetQueue(CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return await gateway.GetQueueAsync();
|
// Fetch cron jobs and open tasks concurrently
|
||||||
|
var cronTask = gateway.GetQueueAsync();
|
||||||
|
var tasksTask = taskRepo.GetAllAsync(ct);
|
||||||
|
|
||||||
|
await Task.WhenAll(cronTask, tasksTask);
|
||||||
|
|
||||||
|
var cronJobs = cronTask.Result;
|
||||||
|
var openTasks = tasksTask.Result
|
||||||
|
.Where(t => !string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var merged = new List<QueueItem>();
|
||||||
|
|
||||||
|
// Map cron jobs (already in QueueItem format from gateway)
|
||||||
|
merged.AddRange(cronJobs);
|
||||||
|
|
||||||
|
// Map open tasks to QueueItems
|
||||||
|
foreach (var t in openTasks)
|
||||||
|
{
|
||||||
|
var priority = NormalizePriority(t.Priority);
|
||||||
|
merged.Add(new QueueItem(
|
||||||
|
"task-" + t.Id.ToString(),
|
||||||
|
t.Title,
|
||||||
|
t.State,
|
||||||
|
priority,
|
||||||
|
"task",
|
||||||
|
"--"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort: high priority first, then medium, then low
|
||||||
|
var priorityOrder = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["high"] = 0,
|
||||||
|
["medium"] = 1,
|
||||||
|
["low"] = 2
|
||||||
|
};
|
||||||
|
|
||||||
|
return merged.OrderBy(q => priorityOrder.GetValueOrDefault(q.Priority, 99)).ToList();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -151,6 +189,115 @@ public class DashboardController(OpenClawGatewayClient gateway, ILogger<Dashboar
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string NormalizePriority(string priority)
|
||||||
|
{
|
||||||
|
return priority.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"high" or "critical" or "urgent" => "high",
|
||||||
|
"low" or "minor" => "low",
|
||||||
|
_ => "medium"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes a queue item: cron jobs are deleted via gateway, tasks are set to Done.
|
||||||
|
/// </summary>
|
||||||
|
[HttpDelete("queue/{id}")]
|
||||||
|
public async Task<ActionResult> DeleteQueueItem(string id, [FromQuery] string? source, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (string.Equals(source, "cron", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var ok = await gateway.DeleteCronJobAsync(id);
|
||||||
|
if (!ok)
|
||||||
|
return StatusCode(502, new { error = "Gateway could not delete cron job" });
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
else if (string.Equals(source, "task", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
// Extract the actual GUID from the prefixed id ("task-{guid}")
|
||||||
|
if (!id.StartsWith("task-"))
|
||||||
|
return BadRequest(new { error = "Invalid task id format" });
|
||||||
|
|
||||||
|
var guidStr = id["task-".Length..];
|
||||||
|
if (!Guid.TryParse(guidStr, out var guid))
|
||||||
|
return BadRequest(new { error = "Invalid task id" });
|
||||||
|
|
||||||
|
var task = await taskRepo.GetByIdAsync(guid, ct);
|
||||||
|
if (task is null)
|
||||||
|
return NotFound(new { error = "Task not found" });
|
||||||
|
|
||||||
|
// Set task status to Done instead of deleting
|
||||||
|
task.State = "Done";
|
||||||
|
await taskRepo.UpdateAsync(task, ct);
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent
|
||||||
|
{
|
||||||
|
Type = "task",
|
||||||
|
Message = $"Task \"{task.Title}\" completed via queue"
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: try cron
|
||||||
|
var deleted = await gateway.DeleteCronJobAsync(id);
|
||||||
|
if (!deleted)
|
||||||
|
return NotFound(new { error = "Queue item not found" });
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Delete queue item failed for {Id}", id);
|
||||||
|
return StatusCode(500, new { error = "Internal error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Changes the priority of a queue item (only for tasks; cron jobs are ignored).
|
||||||
|
/// Cycles: high → medium → low → high.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPut("queue/{id}/priority")]
|
||||||
|
public async Task<ActionResult> ChangeQueuePriority(string id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!id.StartsWith("task-"))
|
||||||
|
return Ok(new { status = "ignored", reason = "Cron job priorities are managed by the gateway" });
|
||||||
|
|
||||||
|
var guidStr = id["task-".Length..];
|
||||||
|
if (!Guid.TryParse(guidStr, out var guid))
|
||||||
|
return BadRequest(new { error = "Invalid task id" });
|
||||||
|
|
||||||
|
var task = await taskRepo.GetByIdAsync(guid, ct);
|
||||||
|
if (task is null)
|
||||||
|
return NotFound(new { error = "Task not found" });
|
||||||
|
|
||||||
|
// Cycle priority: high → medium → low → high
|
||||||
|
task.Priority = task.Priority.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"high" => "Medium",
|
||||||
|
"medium" => "Low",
|
||||||
|
"low" => "High",
|
||||||
|
_ => "Medium"
|
||||||
|
};
|
||||||
|
|
||||||
|
await taskRepo.UpdateAsync(task, ct);
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent
|
||||||
|
{
|
||||||
|
Type = "task",
|
||||||
|
Message = $"Task \"{task.Title}\" priority → {task.Priority}"
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
return Ok(new { status = "ok", priority = task.Priority });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Change queue priority failed for {Id}", id);
|
||||||
|
return StatusCode(500, new { error = "Internal error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the current model and provider for a specific agent session.
|
/// Returns the current model and provider for a specific agent session.
|
||||||
/// Calls session_status with the agent's session key.
|
/// Calls session_status with the agent's session key.
|
||||||
@@ -211,57 +358,155 @@ public class DashboardController(OpenClawGatewayClient gateway, ILogger<Dashboar
|
|||||||
return Ok(models);
|
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 ==========
|
// ========== Helpers ==========
|
||||||
|
|
||||||
private static DateTimeOffset ParseTimestamp(string timestamp)
|
private static DashboardTaskDto MapToDto(WorkTask t) => new(
|
||||||
{
|
t.Id,
|
||||||
if (DateTimeOffset.TryParse(timestamp, null, System.Globalization.DateTimeStyles.None, out var dt))
|
t.Title,
|
||||||
return dt;
|
t.Detail,
|
||||||
return DateTimeOffset.UtcNow;
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,9 +77,13 @@ public sealed class WorkTask
|
|||||||
{
|
{
|
||||||
public Guid Id { get; init; } = Guid.NewGuid();
|
public Guid Id { get; init; } = Guid.NewGuid();
|
||||||
public required string Title { get; set; }
|
public required string Title { get; set; }
|
||||||
|
public string? Detail { get; set; }
|
||||||
public string State { get; set; } = "Backlog";
|
public string State { get; set; } = "Backlog";
|
||||||
public string Priority { get; set; } = "Normal";
|
public string Priority { get; set; } = "Normal";
|
||||||
|
public string Source { get; set; } = "bao";
|
||||||
|
public string? AssignedTo { get; set; }
|
||||||
public Guid? ProjectId { get; set; }
|
public Guid? ProjectId { get; set; }
|
||||||
|
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
public DateTimeOffset UpdatedAt { 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()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("uuid");
|
.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")
|
b.Property<string>("Priority")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
@@ -179,6 +190,11 @@ namespace Nexus.Api.Migrations
|
|||||||
b.Property<Guid?>("ProjectId")
|
b.Property<Guid?>("ProjectId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Source")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
b.Property<string>("State")
|
b.Property<string>("State")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
@@ -193,6 +209,10 @@ namespace Nexus.Api.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("AssignedTo");
|
||||||
|
|
||||||
|
b.HasIndex("Source");
|
||||||
|
|
||||||
b.ToTable("Tasks");
|
b.ToTable("Tasks");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,15 @@ public sealed class NexusDbContext(DbContextOptions<NexusDbContext> options) : D
|
|||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
modelBuilder.Entity<Project>().Property(x => x.Name).HasMaxLength(160);
|
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<ActivityEvent>().Property(x => x.Message).HasMaxLength(1000);
|
||||||
modelBuilder.Entity<NexusUser>().HasIndex(u => u.NormalizedEmail).IsUnique();
|
modelBuilder.Entity<NexusUser>().HasIndex(u => u.NormalizedEmail).IsUnique();
|
||||||
modelBuilder.Entity<RefreshToken>().HasIndex(r => r.TokenHash).IsUnique();
|
modelBuilder.Entity<RefreshToken>().HasIndex(r => r.TokenHash).IsUnique();
|
||||||
|
|||||||
@@ -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(
|
||||||
@@ -45,7 +47,10 @@ public sealed record DashboardStatus(
|
|||||||
public sealed record QueueItem(
|
public sealed record QueueItem(
|
||||||
string Id,
|
string Id,
|
||||||
string Name,
|
string Name,
|
||||||
string Status
|
string Status,
|
||||||
|
string Priority,
|
||||||
|
string Source,
|
||||||
|
string WaitTime
|
||||||
);
|
);
|
||||||
|
|
||||||
public sealed record AgentModelInfo(
|
public sealed record AgentModelInfo(
|
||||||
@@ -62,3 +67,37 @@ public sealed record ModelOption(
|
|||||||
string Name,
|
string Name,
|
||||||
string Provider
|
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
|
||||||
|
);
|
||||||
|
|||||||
@@ -61,84 +61,329 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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
|
||||||
|
{
|
||||||
|
return await InvokeToolAsync("session_status", new { sessionKey = "agent:" + agentId + ":main" });
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<List<DashboardAgentInfo>> GetAgentsAsync()
|
public async Task<List<DashboardAgentInfo>> GetAgentsAsync()
|
||||||
{
|
{
|
||||||
var agentDefs = new[]
|
// Fallback hardcoded descriptions and tags known for each agent
|
||||||
|
var knownInfo = new Dictionary<string, (string Name, string Description, string[] Tags)>
|
||||||
{
|
{
|
||||||
new { Id = "iris", Name = "Chief of Staff", Model = "deepseek/deepseek-v4-flash",
|
["iris"] = (
|
||||||
Description = "Zentrale operative Führungsinstanz. Strukturiert Aufgaben, bewertet Risiken, steuert spezialisierte Agenten und eskaliert kritische Entscheidungen.",
|
"Iris",
|
||||||
Tags = new[] { "Orchestration", "Delegation", "Approval", "Risk Management" } },
|
"Zentrale operative Führungsinstanz. Strukturiert Aufgaben, bewertet Risiken, steuert spezialisierte Agenten und eskaliert kritische Entscheidungen.",
|
||||||
new { Id = "programmer", Name = "Full-Stack Developer", Model = "deepseek/deepseek-v4-flash",
|
new[] { "Orchestration", "Delegation", "Approval", "Risk Management" }
|
||||||
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" } },
|
["programmer"] = (
|
||||||
new { Id = "reviewer", Name = "Code Quality Assurance", Model = "deepseek/deepseek-v4-pro",
|
"Full-Stack Developer",
|
||||||
Description = "Code-Qualitätskontrolle. Prüft Diffs auf Bugs, Regressionen, Sicherheitslücken und Wartbarkeit. Berichtet Findings strukturiert und knapp.",
|
"Primärer Entwicklungsagent. Implementiert Features, behebt Bugs und schreibt Code im gesamten Stack — autonom im Rahmen seines Scopes.",
|
||||||
Tags = new[] { "Code Review", "Testing", "Security", "Quality" } },
|
new[] { "Full-Stack", "TypeScript", "C#", "Vue", ".NET", "Builds" }
|
||||||
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.",
|
["reviewer"] = (
|
||||||
Tags = new[] { "Docker", "Nginx", "CI/CD", "Firewall", "VPS" } },
|
"Code Quality Assurance",
|
||||||
new { Id = "executor", Name = "Host Executor", Model = "deepseek/deepseek-v4-flash",
|
"Code-Qualitätskontrolle. Prüft Diffs auf Bugs, Regressionen, Sicherheitslücken und Wartbarkeit. Berichtet Findings strukturiert und knapp.",
|
||||||
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.",
|
new[] { "Code Review", "Testing", "Security", "Quality" }
|
||||||
Tags = new[] { "Docker", "Shell", "Host", "Deployment" } },
|
),
|
||||||
new { Id = "researcher", Name = "Research & Analysis", Model = "deepseek/deepseek-v4-pro",
|
["architekt"] = (
|
||||||
Description = "Spezialisierter Recherche-Agent. Sucht online, prüft Quellen, analysiert Inhalte (inkl. YouTube-Videos) und übergibt strukturierte Erkenntnisse. Ausschließlich Lese- und Analyse-Rechte.",
|
"Infrastructure Architect",
|
||||||
Tags = new[] { "Research", "Quellenprüfung", "Analyse", "Docs" } },
|
"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>();
|
var agents = new List<DashboardAgentInfo>();
|
||||||
foreach (var def in agentDefs)
|
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;
|
var isActive = false;
|
||||||
string? currentTask = null;
|
string? currentTask = null;
|
||||||
try
|
if (status is not null)
|
||||||
{
|
{
|
||||||
var memDir = "/mnt/workspace-" + def.Id + "/memory";
|
// Check explicit isActive field
|
||||||
if (Directory.Exists(memDir))
|
var activeVal = status["isActive"];
|
||||||
{
|
if (activeVal is not null && activeVal.GetValueKind() == JsonValueKind.True)
|
||||||
var latestFile = Directory.GetFiles(memDir, "*", SearchOption.AllDirectories)
|
isActive = true;
|
||||||
.Select(f => new FileInfo(f))
|
else if (activeVal is not null && activeVal.GetValueKind() == JsonValueKind.String)
|
||||||
.OrderByDescending(f => f.LastWriteTimeUtc)
|
isActive = string.Equals(activeVal.GetValue<string>(), "true", StringComparison.OrdinalIgnoreCase);
|
||||||
.FirstOrDefault();
|
|
||||||
if (latestFile is not null)
|
// Fall back to status text
|
||||||
{
|
var statusText = status["status"]?.GetValue<string>();
|
||||||
var age = DateTime.UtcNow - latestFile.LastWriteTimeUtc;
|
if (!isActive && statusText is not null)
|
||||||
isActive = age.TotalMinutes < 15;
|
isActive = string.Equals(statusText, "active", StringComparison.OrdinalIgnoreCase)
|
||||||
if (isActive)
|
|| string.Equals(statusText, "running", StringComparison.OrdinalIgnoreCase);
|
||||||
{
|
|
||||||
try
|
currentTask = status["currentTask"]?.GetValue<string>()
|
||||||
{
|
?? status["task"]?.GetValue<string>()
|
||||||
var firstLine = File.ReadLines(latestFile.FullName).FirstOrDefault()?.Trim();
|
?? (isActive ? "Working..." : null);
|
||||||
if (!string.IsNullOrWhiteSpace(firstLine) && firstLine.Length > 60)
|
}
|
||||||
currentTask = firstLine[..60];
|
|
||||||
else if (!string.IsNullOrWhiteSpace(firstLine))
|
// 4. Try to read workspace metadata for richer info
|
||||||
currentTask = firstLine;
|
var (name, description, tags) = await ReadAgentMetadataAsync(id);
|
||||||
else
|
|
||||||
currentTask = "Working...";
|
// 5. Fallback to known info if workspace metadata not available
|
||||||
}
|
if (string.IsNullOrWhiteSpace(name) && knownInfo.TryGetValue(id, out var kn))
|
||||||
catch
|
{
|
||||||
{
|
name = kn.Name;
|
||||||
currentTask = "Working...";
|
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(
|
agents.Add(new DashboardAgentInfo(
|
||||||
Id: def.Id,
|
Id: id,
|
||||||
Name: def.Name,
|
Name: string.IsNullOrWhiteSpace(name) ? DeriveRole(id) : name,
|
||||||
Role: DeriveRole(def.Id),
|
Role: DeriveRole(id),
|
||||||
Model: def.Model,
|
Model: model,
|
||||||
IsActive: isActive,
|
IsActive: isActive,
|
||||||
CurrentTask: currentTask,
|
CurrentTask: currentTask,
|
||||||
Description: def.Description,
|
Description: description,
|
||||||
Tags: def.Tags
|
Tags: tags
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
return agents;
|
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(
|
public async Task<List<MessageEntry>> GetSessionHistoryAsync(
|
||||||
string sessionKey, int limit = 50, int offset = 0)
|
string sessionKey, int limit = 50, int offset = 0)
|
||||||
{
|
{
|
||||||
@@ -214,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
|
||||||
@@ -262,7 +639,28 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
|||||||
var status = j["state"]?["lastStatus"]?.GetValue<string>()
|
var status = j["state"]?["lastStatus"]?.GetValue<string>()
|
||||||
?? j["status"]?.GetValue<string>()
|
?? j["status"]?.GetValue<string>()
|
||||||
?? "unknown";
|
?? "unknown";
|
||||||
items.Add(new QueueItem(id, name, status));
|
|
||||||
|
// Calculate waitTime from nextRun if available
|
||||||
|
var waitTime = "--";
|
||||||
|
var nextRunStr = j["nextRun"]?.GetValue<string>()
|
||||||
|
?? j["next_run"]?.GetValue<string>()
|
||||||
|
?? j["scheduledAt"]?.GetValue<string>();
|
||||||
|
if (nextRunStr is not null && DateTimeOffset.TryParse(nextRunStr, out var nextRun))
|
||||||
|
{
|
||||||
|
var diff = nextRun - DateTimeOffset.UtcNow;
|
||||||
|
if (diff.TotalMinutes < 0)
|
||||||
|
waitTime = "now";
|
||||||
|
else if (diff.TotalMinutes < 1)
|
||||||
|
waitTime = "<1m";
|
||||||
|
else if (diff.TotalMinutes < 60)
|
||||||
|
waitTime = $"{(int)diff.TotalMinutes}m";
|
||||||
|
else if (diff.TotalHours < 24)
|
||||||
|
waitTime = $"{(int)diff.TotalHours}h";
|
||||||
|
else
|
||||||
|
waitTime = $"{(int)diff.TotalDays}d";
|
||||||
|
}
|
||||||
|
|
||||||
|
items.Add(new QueueItem(id, name, status, "medium", "cron", waitTime));
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
@@ -272,6 +670,19 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<bool> DeleteCronJobAsync(string id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await InvokeToolAsync("cron", new { action = "delete", id });
|
||||||
|
return result is not null;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<DashboardStatus> GetStatusAsync()
|
public async Task<DashboardStatus> GetStatusAsync()
|
||||||
{
|
{
|
||||||
var gatewayOk = false;
|
var gatewayOk = false;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
ArrowDown,
|
ArrowDown,
|
||||||
Trash2,
|
Trash2,
|
||||||
Zap,
|
Zap,
|
||||||
|
RefreshCw,
|
||||||
} from '@lucide/vue'
|
} from '@lucide/vue'
|
||||||
import type { QueueItem } from '../../composables/useDashboardData'
|
import type { QueueItem } from '../../composables/useDashboardData'
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
@@ -107,6 +108,7 @@ function onDragEnd(): void {
|
|||||||
>
|
>
|
||||||
<div class="queue-item-body">
|
<div class="queue-item-body">
|
||||||
<div class="queue-item-head">
|
<div class="queue-item-head">
|
||||||
|
<span class="queue-source-badge" :class="item.source">{{ item.source }}</span>
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="text-[7px] font-bold uppercase tracking-wider py-0 px-1.5 border"
|
class="text-[7px] font-bold uppercase tracking-wider py-0 px-1.5 border"
|
||||||
@@ -124,8 +126,8 @@ function onDragEnd(): void {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="queue-actions">
|
<div class="queue-actions">
|
||||||
<Button variant="ghost" size="icon" class="h-5 w-5 text-[#6b7385] hover:text-[#e8eaf0]" title="Execute now" @click.stop="emit('executeNow', item.id)">
|
<Button variant="ghost" size="icon" class="h-5 w-5 text-[#6b7385] hover:text-[#e8eaf0]" title="Cycle priority" @click.stop="emit('changePriority', item.id, item.priority === 'high' ? 'medium' : item.priority === 'medium' ? 'low' : 'high')">
|
||||||
<Zap :size="12" />
|
<RefreshCw :size="12" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" class="h-5 w-5 text-[#6b7385] hover:text-[#e8eaf0]" title="Move up" :disabled="idx === 0" @click.stop="emit('moveUp', item.id)">
|
<Button variant="ghost" size="icon" class="h-5 w-5 text-[#6b7385] hover:text-[#e8eaf0]" title="Move up" :disabled="idx === 0" @click.stop="emit('moveUp', item.id)">
|
||||||
<ArrowUp :size="12" />
|
<ArrowUp :size="12" />
|
||||||
@@ -257,6 +259,27 @@ function onDragEnd(): void {
|
|||||||
color: #6b7385;
|
color: #6b7385;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Source badge */
|
||||||
|
.queue-source-badge {
|
||||||
|
font-size: 7px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
line-height: 14px;
|
||||||
|
}
|
||||||
|
.queue-source-badge.cron {
|
||||||
|
color: #22c55e;
|
||||||
|
background: rgba(34, 197, 94, 0.1);
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.2);
|
||||||
|
}
|
||||||
|
.queue-source-badge.task {
|
||||||
|
color: #a78bfa;
|
||||||
|
background: rgba(167, 139, 250, 0.1);
|
||||||
|
border: 1px solid rgba(167, 139, 250, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
/* Transition */
|
/* Transition */
|
||||||
.queue-expand-enter-active,
|
.queue-expand-enter-active,
|
||||||
.queue-expand-leave-active {
|
.queue-expand-leave-active {
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export interface QueueItem {
|
|||||||
text: string
|
text: string
|
||||||
priority: 'high' | 'medium' | 'low'
|
priority: 'high' | 'medium' | 'low'
|
||||||
waitTime: string
|
waitTime: string
|
||||||
|
source: 'cron' | 'task'
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── API Response Interfaces ──
|
// ── API Response Interfaces ──
|
||||||
@@ -101,6 +102,21 @@ interface DashboardQueueItem {
|
|||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
status: string
|
status: string
|
||||||
|
priority: string
|
||||||
|
source: string
|
||||||
|
waitTime: 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) ──
|
// ── Agent Catalog (static enrichment) ──
|
||||||
@@ -244,12 +260,8 @@ const busySince = ref(0)
|
|||||||
// Operations Feed
|
// Operations Feed
|
||||||
const feedEntries = ref<FeedEntry[]>([])
|
const feedEntries = ref<FeedEntry[]>([])
|
||||||
|
|
||||||
// Open Tasks (mock only – no API endpoint)
|
// Open Tasks (fetched from API)
|
||||||
const openTasks = ref<OpenTask[]>([
|
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' },
|
|
||||||
])
|
|
||||||
|
|
||||||
// Queue
|
// Queue
|
||||||
const queue = ref<QueueItem[]>([])
|
const queue = ref<QueueItem[]>([])
|
||||||
@@ -332,16 +344,56 @@ async function fetchQueue(): Promise<void> {
|
|||||||
queue.value = data.map((item) => ({
|
queue.value = data.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
text: item.name,
|
text: item.name,
|
||||||
priority: (item.status === 'high' || item.status === 'medium' || item.status === 'low')
|
priority: (item.priority === 'high' || item.priority === 'medium' || item.priority === 'low')
|
||||||
? item.status as 'high' | 'medium' | 'low'
|
? item.priority as 'high' | 'medium' | 'low'
|
||||||
: 'medium',
|
: 'medium',
|
||||||
waitTime: '--',
|
waitTime: item.waitTime || '--',
|
||||||
|
source: (item.source === 'cron' || item.source === 'task') ? item.source as 'cron' | 'task' : 'cron',
|
||||||
}))
|
}))
|
||||||
} catch {
|
} catch {
|
||||||
// API unreachable – keep current values
|
// API unreachable – keep current values
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 ──
|
// ── Chat Send ──
|
||||||
|
|
||||||
async function sendChatMessage(text: string): Promise<void> {
|
async function sendChatMessage(text: string): Promise<void> {
|
||||||
@@ -396,9 +448,24 @@ async function sendChatMessage(text: string): Promise<void> {
|
|||||||
|
|
||||||
// ── Queue Operations ──
|
// ── Queue Operations ──
|
||||||
|
|
||||||
function removeQueueItem(id: string): void {
|
async function removeQueueItem(id: string): Promise<void> {
|
||||||
const idx = queue.value.findIndex(q => q.id === id)
|
const item = queue.value.find(q => q.id === id)
|
||||||
if (idx !== -1) queue.value.splice(idx, 1)
|
if (!item) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await apiFetch(`/api/dashboard/queue/${encodeURIComponent(id)}?source=${item.source}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
console.warn('[Dashboard] Failed to remove queue item', id, res.status)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Remove from local state on success
|
||||||
|
const idx = queue.value.findIndex(q => q.id === id)
|
||||||
|
if (idx !== -1) queue.value.splice(idx, 1)
|
||||||
|
} catch {
|
||||||
|
console.warn('[Dashboard] Error removing queue item', id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveQueueItem(fromIdx: number, toIdx: number): void {
|
function moveQueueItem(fromIdx: number, toIdx: number): void {
|
||||||
@@ -407,9 +474,40 @@ function moveQueueItem(fromIdx: number, toIdx: number): void {
|
|||||||
queue.value.splice(toIdx, 0, item)
|
queue.value.splice(toIdx, 0, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
function changeQueuePriority(id: string, priority: QueueItem['priority']): void {
|
async function changeQueuePriority(id: string, priority: QueueItem['priority']): Promise<void> {
|
||||||
const item = queue.value.find(q => q.id === id)
|
const item = queue.value.find(q => q.id === id)
|
||||||
if (item) item.priority = priority
|
if (!item) return
|
||||||
|
|
||||||
|
// For cron jobs, just update locally (gateway manages its own priorities)
|
||||||
|
if (item.source === 'cron') {
|
||||||
|
item.priority = priority
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await apiFetch(`/api/dashboard/queue/${encodeURIComponent(id)}/priority`, {
|
||||||
|
method: 'PUT',
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
console.warn('[Dashboard] Failed to change priority', id, res.status)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Update priority from API response
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.priority) {
|
||||||
|
const normalized = data.priority.toLowerCase()
|
||||||
|
if (normalized === 'high' || normalized === 'medium' || normalized === 'low') {
|
||||||
|
item.priority = normalized as 'high' | 'medium' | 'low'
|
||||||
|
} else {
|
||||||
|
// Fallback: cycle locally
|
||||||
|
item.priority = priority
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
item.priority = priority
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.warn('[Dashboard] Error changing priority', id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Runtime ──
|
// ── Runtime ──
|
||||||
@@ -454,6 +552,7 @@ function startPolling(): void {
|
|||||||
fetchOperations()
|
fetchOperations()
|
||||||
fetchChatMessages()
|
fetchChatMessages()
|
||||||
fetchQueue()
|
fetchQueue()
|
||||||
|
fetchTasks()
|
||||||
|
|
||||||
// Polling intervals
|
// Polling intervals
|
||||||
intervals.push(setInterval(fetchStatus, 5000))
|
intervals.push(setInterval(fetchStatus, 5000))
|
||||||
@@ -461,6 +560,7 @@ function startPolling(): void {
|
|||||||
intervals.push(setInterval(fetchOperations, 10000))
|
intervals.push(setInterval(fetchOperations, 10000))
|
||||||
intervals.push(setInterval(fetchChatMessages, 3000))
|
intervals.push(setInterval(fetchChatMessages, 3000))
|
||||||
intervals.push(setInterval(fetchQueue, 10000))
|
intervals.push(setInterval(fetchQueue, 10000))
|
||||||
|
intervals.push(setInterval(fetchTasks, 15000))
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopPolling(): void {
|
function stopPolling(): void {
|
||||||
@@ -512,5 +612,6 @@ export function useDashboardData() {
|
|||||||
fetchOperations,
|
fetchOperations,
|
||||||
fetchChatMessages,
|
fetchChatMessages,
|
||||||
fetchQueue,
|
fetchQueue,
|
||||||
|
fetchTasks,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user