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, ITaskRepository taskRepo, IActivityRepository activityRepo, ILogger logger) : ControllerBase { /// /// Gateway health + session_status + subagents count. /// Returns HTTP 200 even when gateway is down (gatewayOk: false). /// [HttpGet("status")] public async Task GetStatus() { try { return await gateway.GetStatusAsync(); } catch (Exception ex) { logger.LogWarning(ex, "Dashboard status check failed"); return new DashboardStatus(false, "Offline", 0, 0); } } /// /// Returns all agents with their current status. /// Combines sessions_list + sub_agents_list. /// [HttpGet("agents")] public async Task> GetAgents() { try { return await gateway.GetAgentsAsync(); } catch (Exception ex) { logger.LogWarning(ex, "Dashboard agents fetch failed"); return new List(); } } /// /// Returns the latest assistant messages (operations/feed) from the Iris session. /// Filtered to role == "assistant" — those are the work feed entries. /// [HttpGet("operations")] public async Task> GetOperations([FromQuery] int limit = 20) { try { var messages = await gateway.GetSessionHistoryAsync("iris", Math.Clamp(limit, 1, 100)); var feed = new List(); foreach (var msg in messages) { 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)); } return feed; } catch (Exception ex) { logger.LogWarning(ex, "Dashboard operations fetch failed"); return new List(); } } /// /// Send a chat message to the Iris session. /// [HttpPost("chat/send")] public async Task SendChat([FromBody] ChatRequest request) { if (string.IsNullOrWhiteSpace(request.Message)) return new ChatResponse(false, null, "Message is required"); try { var agentId = string.IsNullOrWhiteSpace(request.AgentId) ? "iris" : request.AgentId.Trim(); return await gateway.SendChatMessageAsync(agentId, request.Message.Trim()); } catch (Exception ex) { logger.LogWarning(ex, "Dashboard chat send failed"); return new ChatResponse(false, null, "Gateway nicht erreichbar"); } } /// /// Returns chat messages (user + assistant only, not tool messages). /// [HttpGet("chat/messages")] public async Task> GetMessages( [FromQuery] string? sessionKey, [FromQuery] int limit = 50, [FromQuery] int offset = 0) { try { var key = string.IsNullOrWhiteSpace(sessionKey) ? "agent:iris:main" : sessionKey.Trim(); var messages = await gateway.GetSessionHistoryAsync(key, Math.Clamp(limit, 1, 200), Math.Max(0, offset)); return messages .Where(m => string.Equals(m.Role, "user", StringComparison.OrdinalIgnoreCase) || string.Equals(m.Role, "assistant", StringComparison.OrdinalIgnoreCase)) .ToList(); } catch (Exception ex) { logger.LogWarning(ex, "Dashboard messages fetch failed"); return new List(); } } /// /// Returns the cron queue / pending tasks. /// [HttpGet("queue")] public async Task> GetQueue() { try { return await gateway.GetQueueAsync(); } catch (Exception ex) { logger.LogWarning(ex, "Dashboard queue fetch failed"); return new List(); } } /// /// Returns the current model and provider for a specific agent session. /// Calls session_status with the agent's session key. /// [HttpGet("agents/{id}/model")] public async Task> GetAgentModel(string id) { try { var info = await gateway.GetAgentModelAsync(id); if (info is null) return NotFound(new { error = $"Agent '{id}' not found or gateway unreachable" }); return Ok(info); } catch (Exception ex) { logger.LogWarning(ex, "GetAgentModel failed for {AgentId}", id); return StatusCode(500, new { error = "Internal error" }); } } /// /// Sets the model for a specific agent session. /// Calls session_status with model parameter. /// [HttpPut("agents/{id}/model")] public async Task SetAgentModel(string id, [FromBody] SetModelRequest request) { if (string.IsNullOrWhiteSpace(request.Model)) return BadRequest(new { error = "Model is required" }); try { var ok = await gateway.SetAgentModelAsync(id, request.Model); if (!ok) return StatusCode(502, new { error = "Gateway did not accept the change" }); return Ok(new { status = "ok", model = request.Model }); } catch (Exception ex) { logger.LogWarning(ex, "SetAgentModel failed for {AgentId}", id); return StatusCode(500, new { error = "Internal error" }); } } /// /// Returns the list of available models that can be assigned to agents. /// [HttpGet("models")] public ActionResult> GetAvailableModels() { var models = new List { new ModelOption("openai/gpt-5.4", "GPT-5.4", "openai"), new ModelOption("deepseek/deepseek-v4-flash", "DeepSeek V4 Flash", "deepseek"), new ModelOption("deepseek/deepseek-v4-pro", "DeepSeek V4 Pro", "deepseek") }; return Ok(models); } // ========== Task Endpoints ========== /// /// Returns all non-done tasks (status != 'Done'), ordered by creation date descending. /// [HttpGet("tasks")] public async Task> 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(); } } /// /// Creates a new task and logs an activity event. /// [HttpPost("tasks")] public async Task> 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)); } /// /// Updates an existing task (title, detail, source, priority, assignedTo). /// [HttpPut("tasks/{id:guid}")] public async Task> 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)); } /// /// Deletes a task (only if status is 'Done' or 'Backlog'). /// [HttpDelete("tasks/{id:guid}")] public async Task 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(); } /// /// Changes the status of a task. /// [HttpPatch("tasks/{id:guid}/status")] public async Task> 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 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 DateTimeOffset ParseTimestamp(string timestamp) { if (DateTimeOffset.TryParse(timestamp, null, System.Globalization.DateTimeStyles.None, out var dt)) return dt; return DateTimeOffset.UtcNow; } private static string FormatTimeAgo(DateTimeOffset ts) { var diff = DateTimeOffset.UtcNow - ts; if (diff.TotalMinutes < 1) return "just now"; if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}m ago"; if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}h ago"; if (diff.TotalDays < 7) return $"{(int)diff.TotalDays}d ago"; return ts.ToString("MMM dd"); } private static (string Agent, string Action) ExtractAgentAction(string content) { // Take first line or first ~80 chars as the action summary var firstLine = content.Split('\n', 2)[0].Trim(); var summary = firstLine.Length > 80 ? firstLine[..80] + "…" : firstLine; // Try to identify which agent this came from var agent = "Iris"; foreach (var marker in new[] { "**Agent:**", "**Agent:** ", "*Agent:* ", "Agent:" }) { var idx = content.IndexOf(marker, StringComparison.OrdinalIgnoreCase); if (idx >= 0) { var after = content[(idx + marker.Length)..].TrimStart(); var end = after.IndexOfAny(['\n', '\r', ',', '.']); var found = end > 0 ? after[..end].Trim() : after.Split('\n', 2)[0].Trim(); if (!string.IsNullOrWhiteSpace(found) && found.Length < 30) { agent = found; break; } } } // Try to find agent name at the start in brackets like [Agent: Iris] if (agent == "Iris") { var bracketMatch = System.Text.RegularExpressions.Regex.Match(content, @"\[Agent:\s*([^\]]+)\]"); if (bracketMatch.Success) agent = bracketMatch.Groups[1].Value.Trim(); } return (agent, summary); } }