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 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. /// [HttpGet("operations")] public async Task> GetOperations( [FromQuery] int limit = 20, [FromQuery] string? agent = null) { try { var entries = await gateway.GetAllAgentOperationsAsync(Math.Clamp(limit, 1, 100)); // Optional agent filter if (!string.IsNullOrWhiteSpace(agent)) { entries = entries .Where(e => string.Equals(e.AgentId, agent, StringComparison.OrdinalIgnoreCase) || string.Equals(e.Agent, agent, StringComparison.OrdinalIgnoreCase)) .ToList(); } return entries; } 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 ); }