Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 068b0d31b8 | |||
| 97b8588dc3 | |||
| 6150ea96af | |||
| 81af81fb6f | |||
| 2877035c5c | |||
| 6a1366b472 | |||
| adecfea432 | |||
| b7b44494f0 | |||
| a538025049 | |||
| 6d7454a7c1 | |||
| 3c72e807da | |||
| 702692cf0c | |||
| 51d1917a7b | |||
| 85f3400076 | |||
| a5cbe98f25 | |||
| 5b0e3a19f6 | |||
| e1d6b1eeb3 |
@@ -91,6 +91,23 @@ public class AuthController(
|
||||
: Results.Ok(new UserInfo { Id = user.Id, Email = user.Email, DisplayName = user.DisplayName, Role = user.Role });
|
||||
}
|
||||
|
||||
[HttpPost("admin-reset-password")]
|
||||
[EnableRateLimiting("agents")]
|
||||
public async Task<IResult> AdminResetPassword([FromBody] AdminResetPasswordRequest request, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.NewPassword) || string.IsNullOrWhiteSpace(request.AdminToken))
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]> { ["request"] = ["Email, new password, and admin token are required."] });
|
||||
|
||||
if (request.NewPassword.Length < 10)
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]> { ["newPassword"] = ["New password must be at least 10 characters."] });
|
||||
|
||||
var success = await authService.AdminResetPasswordAsync(request.Email, request.NewPassword, request.AdminToken, ct);
|
||||
if (!success)
|
||||
return Results.Problem("Password reset failed. Check the admin token, email, and that the user exists.", statusCode: 400);
|
||||
|
||||
return Results.Ok(new { message = "Password reset successfully." });
|
||||
}
|
||||
|
||||
[HttpPost("change-password")]
|
||||
public async Task<IResult> ChangePassword([FromBody] ChangePasswordRequest request, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
@@ -95,11 +95,11 @@ public class DashboardController(OpenClawGatewayClient gateway, ILogger<Dashboar
|
||||
|
||||
try
|
||||
{
|
||||
var sessionKey = string.IsNullOrWhiteSpace(request.SessionKey)
|
||||
var agentId = string.IsNullOrWhiteSpace(request.AgentId)
|
||||
? "iris"
|
||||
: request.SessionKey.Trim();
|
||||
: request.AgentId.Trim();
|
||||
|
||||
return await gateway.SendChatMessageAsync(sessionKey, request.Message.Trim());
|
||||
return await gateway.SendChatMessageAsync(agentId, request.Message.Trim());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -119,10 +119,9 @@ public class DashboardController(OpenClawGatewayClient gateway, ILogger<Dashboar
|
||||
{
|
||||
try
|
||||
{
|
||||
var key = string.IsNullOrWhiteSpace(sessionKey) ? "iris" : sessionKey.Trim();
|
||||
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));
|
||||
|
||||
// Filter: only user and assistant messages (exclude tool/system)
|
||||
return messages
|
||||
.Where(m => string.Equals(m.Role, "user", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(m.Role, "assistant", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -152,57 +151,215 @@ public class DashboardController(OpenClawGatewayClient gateway, ILogger<Dashboar
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the current model and provider for a specific agent session.
|
||||
/// Calls session_status with the agent's session key.
|
||||
/// </summary>
|
||||
[HttpGet("agents/{id}/model")]
|
||||
public async Task<ActionResult<AgentModelInfo>> 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" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the model for a specific agent session.
|
||||
/// Calls session_status with model parameter.
|
||||
/// </summary>
|
||||
[HttpPut("agents/{id}/model")]
|
||||
public async Task<ActionResult> 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" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the list of available models that can be assigned to agents.
|
||||
/// </summary>
|
||||
[HttpGet("models")]
|
||||
public ActionResult<List<ModelOption>> GetAvailableModels()
|
||||
{
|
||||
var models = new List<ModelOption>
|
||||
{
|
||||
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 ==========
|
||||
|
||||
/// <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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Nexus.Api.DTOs;
|
||||
|
||||
public sealed record AdminResetPasswordRequest
|
||||
{
|
||||
/// <summary>The email of the user whose password should be reset.</summary>
|
||||
public required string Email { get; init; }
|
||||
|
||||
/// <summary>The new password to set.</summary>
|
||||
public required string NewPassword { get; init; }
|
||||
|
||||
/// <summary>Admin reset token from configuration (Admin:ResetToken).</summary>
|
||||
public required string AdminToken { get; init; }
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -6,7 +6,9 @@ public sealed record DashboardAgentInfo(
|
||||
string Role,
|
||||
string Model,
|
||||
bool IsActive,
|
||||
string? CurrentTask
|
||||
string? CurrentTask,
|
||||
string? Description,
|
||||
string[] Tags
|
||||
);
|
||||
|
||||
public sealed record MessageEntry(
|
||||
@@ -17,7 +19,7 @@ public sealed record MessageEntry(
|
||||
|
||||
public sealed record ChatRequest(
|
||||
string Message,
|
||||
string? SessionKey
|
||||
string? AgentId
|
||||
);
|
||||
|
||||
public sealed record ChatResponse(
|
||||
@@ -30,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(
|
||||
@@ -45,3 +49,52 @@ public sealed record QueueItem(
|
||||
string Name,
|
||||
string Status
|
||||
);
|
||||
|
||||
public sealed record AgentModelInfo(
|
||||
string Model,
|
||||
string Provider
|
||||
);
|
||||
|
||||
public sealed record SetModelRequest(
|
||||
string Model
|
||||
);
|
||||
|
||||
public sealed record ModelOption(
|
||||
string Id,
|
||||
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
|
||||
);
|
||||
|
||||
+3
-3
@@ -102,21 +102,21 @@ builder.Services.AddHttpClient<IAgentRuntime, OpenClawRuntime>(client =>
|
||||
{
|
||||
client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"]
|
||||
?? "http://127.0.0.1:18789");
|
||||
client.Timeout = TimeSpan.FromSeconds(5);
|
||||
client.Timeout = TimeSpan.FromSeconds(120);
|
||||
});
|
||||
|
||||
builder.Services.AddHttpClient("gateway", client =>
|
||||
{
|
||||
client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"]
|
||||
?? "http://127.0.0.1:18789");
|
||||
client.Timeout = TimeSpan.FromSeconds(5);
|
||||
client.Timeout = TimeSpan.FromSeconds(120);
|
||||
});
|
||||
|
||||
builder.Services.AddHttpClient<OpenClawGatewayClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"]
|
||||
?? "http://127.0.0.1:18789");
|
||||
client.Timeout = TimeSpan.FromSeconds(5);
|
||||
client.Timeout = TimeSpan.FromSeconds(120);
|
||||
});
|
||||
|
||||
// --- Application Services ---
|
||||
|
||||
@@ -17,6 +17,7 @@ public interface IAuthService
|
||||
Task<NexusUser?> GetUserAsync(Guid userId, CancellationToken ct = default);
|
||||
Task<NexusUser?> UpdateProfileAsync(Guid userId, UpdateProfileRequest request, CancellationToken ct = default);
|
||||
Task<bool> ChangePasswordAsync(Guid userId, ChangePasswordRequest request, CancellationToken ct = default);
|
||||
Task<bool> AdminResetPasswordAsync(string email, string newPassword, string adminToken, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public sealed record AuthSession(
|
||||
@@ -31,6 +32,8 @@ public sealed class AuthService : IAuthService
|
||||
private readonly IConfiguration _config;
|
||||
private readonly ILogger<AuthService> _logger;
|
||||
|
||||
private static string AdminResetToken => Environment.GetEnvironmentVariable("Admin__ResetToken") ?? string.Empty;
|
||||
|
||||
public AuthService(IUserRepository users, IConfiguration config, ILogger<AuthService> logger)
|
||||
{
|
||||
_users = users;
|
||||
@@ -128,6 +131,46 @@ public sealed class AuthService : IAuthService
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> AdminResetPasswordAsync(string email, string newPassword, string adminToken, CancellationToken ct = default)
|
||||
{
|
||||
// Validate admin token
|
||||
if (string.IsNullOrWhiteSpace(adminToken) || string.IsNullOrWhiteSpace(AdminResetToken))
|
||||
{
|
||||
_logger.LogWarning("Admin password reset attempted without admin token or token not configured");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(adminToken),
|
||||
Encoding.UTF8.GetBytes(AdminResetToken)))
|
||||
{
|
||||
_logger.LogWarning("Invalid admin reset token provided");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(newPassword))
|
||||
return false;
|
||||
|
||||
if (newPassword.Length < 10)
|
||||
return false;
|
||||
|
||||
var normalizedEmail = NormalizeEmail(email);
|
||||
var user = await _users.GetByEmailAsync(normalizedEmail, ct);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
_logger.LogWarning("Admin password reset: user {Email} not found", email);
|
||||
return false;
|
||||
}
|
||||
|
||||
user.PasswordHash = PasswordSecurity.Hash(newPassword);
|
||||
user.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _users.UpdateAsync(user, ct);
|
||||
|
||||
_logger.LogInformation("Admin password reset completed for {Email}", email);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<AuthSession?> CreateSessionAsync(
|
||||
NexusUser user,
|
||||
Guid familyId,
|
||||
|
||||
@@ -51,7 +51,6 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
||||
return null;
|
||||
|
||||
var node = JsonNode.Parse(json);
|
||||
// Unwrap the { ok: true, result: ... } envelope
|
||||
if (node?["ok"]?.GetValue<bool>() == true && node["result"] is not null)
|
||||
return node["result"];
|
||||
return node;
|
||||
@@ -63,134 +62,558 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts useful data from a tool response that may embed JSON in content[0].text.
|
||||
/// Returns the unwrapped "details" object if present, otherwise the raw result.
|
||||
/// 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 static JsonNode? ExtractToolData(JsonNode? result)
|
||||
private async Task<JsonNode?> TryGetAgentStatusAsync(string agentId)
|
||||
{
|
||||
if (result is null) return null;
|
||||
// Some tools return { details: {...}, content: [...] }
|
||||
if (result["details"] is JsonNode details && details is not JsonValue)
|
||||
return details;
|
||||
// Some tools wrap in content[0].text as JSON string
|
||||
if (result["content"] is JsonArray content && content.Count > 0)
|
||||
try
|
||||
{
|
||||
var text = content[0]?["text"]?.GetValue<string>();
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
try { return JsonNode.Parse(text); }
|
||||
catch { /* fall through */ }
|
||||
}
|
||||
return await InvokeToolAsync("session_status", new { sessionKey = "agent:" + agentId + ":main" });
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<List<DashboardAgentInfo>> GetAgentsAsync()
|
||||
{
|
||||
// Hardcoded agent list (known from OpenClaw config).
|
||||
// Tools/invoke visibility is restricted to agent:main scope,
|
||||
// so we can't enumerate Iris sessions programmatically.
|
||||
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 = "Iris", Model = "GPT-5.4" },
|
||||
new { Id = "programmer", Name = "Programmer", Model = "DeepSeek V4 Flash" },
|
||||
new { Id = "reviewer", Name = "Reviewer", Model = "DeepSeek V4 Pro" },
|
||||
new { Id = "architekt", Name = "DevOps", Model = "DeepSeek V4 Pro" },
|
||||
new { Id = "executor", Name = "Executor", Model = "DeepSeek V4 Flash" },
|
||||
new { Id = "researcher", Name = "Researcher", Model = "DeepSeek V4 Pro" },
|
||||
["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 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;
|
||||
string? currentTask = null;
|
||||
|
||||
// Check workspace activity via file timestamps
|
||||
var memDir = $"/mnt/workspace-{def.Id}/memory";
|
||||
try
|
||||
if (status is not null)
|
||||
{
|
||||
if (Directory.Exists(memDir))
|
||||
{
|
||||
var latestFile = Directory.GetFiles(memDir, "*", SearchOption.AllDirectories)
|
||||
.Select(f => new FileInfo(f))
|
||||
.OrderByDescending(f => f.LastWriteTimeUtc)
|
||||
.FirstOrDefault();
|
||||
// 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);
|
||||
|
||||
if (latestFile is not null)
|
||||
{
|
||||
var age = DateTime.UtcNow - latestFile.LastWriteTimeUtc;
|
||||
isActive = age.TotalMinutes < 15;
|
||||
if (isActive)
|
||||
currentTask = $"Modified: {latestFile.Name}";
|
||||
}
|
||||
}
|
||||
// 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 { /* workspace not mounted or inaccessible */ }
|
||||
|
||||
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
|
||||
CurrentTask: currentTask,
|
||||
Description: description,
|
||||
Tags: tags
|
||||
));
|
||||
}
|
||||
|
||||
return agents;
|
||||
}
|
||||
|
||||
public async Task<List<MessageEntry>> GetSessionHistoryAsync(string sessionKey, int limit = 50, int offset = 0)
|
||||
/// <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 result = await InvokeToolAsync("sessions_history", new { sessionKey, limit, offset });
|
||||
if (result is null) return new List<MessageEntry>();
|
||||
var configPath = configuration.GetValue<string>("AgentConfigPath")
|
||||
?? "/home/node/.openclaw/openclaw.json";
|
||||
|
||||
var messages = new List<MessageEntry>();
|
||||
var array = result as JsonArray ?? result.AsArray();
|
||||
if (array is null) return messages;
|
||||
if (!System.IO.File.Exists(configPath))
|
||||
return GetDefaultAgentIds();
|
||||
|
||||
foreach (var msg in array)
|
||||
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 (msg is null) continue;
|
||||
var role = msg["role"]?.GetValue<string>() ?? "";
|
||||
var content = msg["content"]?.GetValue<string>() ?? "";
|
||||
var timestamp = msg["timestamp"]?.GetValue<string>()
|
||||
?? msg["ts"]?.GetValue<string>()
|
||||
?? msg["createdAt"]?.GetValue<string>()
|
||||
?? DateTimeOffset.UtcNow.ToString("o");
|
||||
|
||||
messages.Add(new MessageEntry(role, content, timestamp));
|
||||
if (agentEl.TryGetProperty("id", out var idEl))
|
||||
{
|
||||
var id = idEl.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(id))
|
||||
ids.Add(id);
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
return ids.Count > 0 ? ids : GetDefaultAgentIds();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new List<MessageEntry>();
|
||||
return GetDefaultAgentIds();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ChatResponse> SendChatMessageAsync(string sessionKey, string message)
|
||||
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)
|
||||
{
|
||||
var result = new List<MessageEntry>();
|
||||
try
|
||||
{
|
||||
var toolResult = await InvokeToolAsync("sessions_history", new
|
||||
{
|
||||
sessionKey, limit, offset,
|
||||
includeTools = false
|
||||
});
|
||||
if (toolResult is null)
|
||||
return result;
|
||||
|
||||
var json = toolResult.ToJsonString(); result.Add(new MessageEntry("diag", "JSON[" + json.Substring(0, Math.Min(200, json.Length)) + "]", DateTimeOffset.UtcNow.ToString("o")));
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (!root.TryGetProperty("details", out var detailsEl))
|
||||
return result;
|
||||
if (!detailsEl.TryGetProperty("messages", out var messagesEl))
|
||||
return result;
|
||||
if (messagesEl.ValueKind != JsonValueKind.Array)
|
||||
return result;
|
||||
|
||||
foreach (var msg in messagesEl.EnumerateArray())
|
||||
{
|
||||
if (!msg.TryGetProperty("role", out var roleEl))
|
||||
continue;
|
||||
var role = roleEl.GetString() ?? "";
|
||||
if (role != "user" && role != "assistant")
|
||||
continue;
|
||||
|
||||
if (!msg.TryGetProperty("content", out var contentEl))
|
||||
continue;
|
||||
if (contentEl.ValueKind != JsonValueKind.Array)
|
||||
continue;
|
||||
|
||||
var texts = new List<string>();
|
||||
foreach (var block in contentEl.EnumerateArray())
|
||||
{
|
||||
if (!block.TryGetProperty("type", out var typeEl))
|
||||
continue;
|
||||
if (typeEl.GetString() != "text")
|
||||
continue;
|
||||
if (!block.TryGetProperty("text", out var textEl))
|
||||
continue;
|
||||
var text = textEl.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
texts.Add(text);
|
||||
}
|
||||
|
||||
if (texts.Count == 0)
|
||||
continue;
|
||||
|
||||
var content = string.Join(" ", texts).Trim();
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
continue;
|
||||
if (content == "REPLY_SKIP" || content == "ANNOUNCE_SKIP")
|
||||
continue;
|
||||
|
||||
var ts = DateTimeOffset.UtcNow.ToString("o");
|
||||
if (msg.TryGetProperty("timestamp", out var tsEl))
|
||||
ts = tsEl.GetString() ?? ts;
|
||||
|
||||
result.Add(new MessageEntry(role, content, ts));
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// return whatever we collected (may be empty)
|
||||
}
|
||||
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
|
||||
{
|
||||
var result = await InvokeToolAsync("sessions_send", new { sessionKey, message });
|
||||
if (result is null) return new ChatResponse(false, null, "Gateway nicht erreichbar");
|
||||
var result = await InvokeToolAsync("sessions_send", new { agentId, message });
|
||||
if (result is null)
|
||||
return new ChatResponse(false, null, "Gateway nicht erreichbar");
|
||||
|
||||
var ok = result["ok"]?.GetValue<bool>() ?? false;
|
||||
var reply = result["reply"]?.GetValue<string>()
|
||||
var details = result["details"];
|
||||
var ok = (details?["status"]?.GetValue<string>()
|
||||
?? result["status"]?.GetValue<string>()) == "ok";
|
||||
var reply = details?["reply"]?.GetValue<string>()
|
||||
?? result["reply"]?.GetValue<string>()
|
||||
?? result["response"]?.GetValue<string>()
|
||||
?? result["content"]?[0]?["text"]?.GetValue<string>();
|
||||
var error = result["error"]?.GetValue<string>();
|
||||
var error = details?["error"]?.GetValue<string>()
|
||||
?? result["error"]?.GetValue<string>();
|
||||
|
||||
return new ChatResponse(ok, reply, error);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ChatResponse(false, null, $"Fehler: {ex.Message}");
|
||||
return new ChatResponse(false, null, "Fehler: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,13 +622,15 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
||||
try
|
||||
{
|
||||
var result = await InvokeToolAsync("cron", new { action = "list" });
|
||||
var data = ExtractToolData(result);
|
||||
if (data is null) return new List<QueueItem>();
|
||||
if (result is null)
|
||||
return new List<QueueItem>();
|
||||
|
||||
var details = result["details"];
|
||||
var jobs = details?["jobs"] as JsonArray ?? result.AsArray();
|
||||
if (jobs is null)
|
||||
return new List<QueueItem>();
|
||||
|
||||
var items = new List<QueueItem>();
|
||||
var jobs = data["jobs"] as JsonArray ?? data.AsArray();
|
||||
if (jobs is null) return items;
|
||||
|
||||
foreach (var j in jobs)
|
||||
{
|
||||
if (j is null) continue;
|
||||
@@ -214,7 +639,6 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
||||
var status = j["state"]?["lastStatus"]?.GetValue<string>()
|
||||
?? j["status"]?.GetValue<string>()
|
||||
?? "unknown";
|
||||
|
||||
items.Add(new QueueItem(id, name, status));
|
||||
}
|
||||
return items;
|
||||
@@ -232,46 +656,37 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
||||
var activeAgents = 0;
|
||||
var pendingTasks = 0;
|
||||
|
||||
// Step 1: Health check (no auth needed)
|
||||
try
|
||||
{
|
||||
using var pingRequest = new HttpRequestMessage(HttpMethod.Get, "/health");
|
||||
using var pingResponse = await httpClient.SendAsync(pingRequest);
|
||||
gatewayOk = pingResponse.IsSuccessStatusCode;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// gatewayOk stays false
|
||||
}
|
||||
catch { }
|
||||
|
||||
if (gatewayOk)
|
||||
{
|
||||
// Step 2: Session status
|
||||
try
|
||||
{
|
||||
var sessionResult = await InvokeToolAsync("session_status");
|
||||
if (sessionResult is not null)
|
||||
{
|
||||
irisStatus = sessionResult["status"]?.GetValue<string>()
|
||||
?? sessionResult["sessionKey"]?.GetValue<string>()
|
||||
var r = await InvokeToolAsync("session_status");
|
||||
if (r is not null)
|
||||
irisStatus = r["status"]?.GetValue<string>()
|
||||
?? r["sessionKey"]?.GetValue<string>()
|
||||
?? "Active";
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
// Step 3: Active agents
|
||||
try
|
||||
{
|
||||
var agents = await GetAgentsAsync();
|
||||
activeAgents = agents.Count(a => a.IsActive);
|
||||
var a = await GetAgentsAsync();
|
||||
activeAgents = a.Count(x => x.IsActive);
|
||||
}
|
||||
catch { }
|
||||
|
||||
// Step 4: Queue items
|
||||
try
|
||||
{
|
||||
var queue = await GetQueueAsync();
|
||||
pendingTasks = queue.Count;
|
||||
var q = await GetQueueAsync();
|
||||
pendingTasks = q.Count;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
@@ -279,14 +694,56 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
||||
return new DashboardStatus(gatewayOk, irisStatus, activeAgents, pendingTasks);
|
||||
}
|
||||
|
||||
public async Task<AgentModelInfo?> GetAgentModelAsync(string agentId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await InvokeToolAsync("session_status", new
|
||||
{
|
||||
sessionKey = $"agent:{agentId}:main"
|
||||
});
|
||||
if (result is null)
|
||||
return null;
|
||||
|
||||
var model = result["model"]?.GetValue<string>();
|
||||
var provider = result["provider"]?.GetValue<string>();
|
||||
|
||||
if (model is null)
|
||||
return null;
|
||||
|
||||
return new AgentModelInfo(model, provider ?? "unknown");
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> SetAgentModelAsync(string agentId, string model)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await InvokeToolAsync("session_status", new
|
||||
{
|
||||
sessionKey = $"agent:{agentId}:main",
|
||||
model
|
||||
});
|
||||
return result is not null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string DeriveRole(string agentId) => agentId.ToLowerInvariant() switch
|
||||
{
|
||||
"iris" => "Orchestrator",
|
||||
"programmer" => "Developer",
|
||||
"reviewer" => "Reviewer",
|
||||
"architekt" => "Architect",
|
||||
"executor" => "Executor",
|
||||
"researcher" => "Researcher",
|
||||
"iris" => "Chief of Staff",
|
||||
"programmer" => "Full-Stack Developer",
|
||||
"reviewer" => "Code Quality Assurance",
|
||||
"architekt" => "Infrastructure Architect",
|
||||
"executor" => "Host Executor",
|
||||
"researcher" => "Research & Analysis",
|
||||
"main" => "Assistant",
|
||||
_ => "Custom"
|
||||
};
|
||||
|
||||
+6
-1
@@ -34,6 +34,7 @@ services:
|
||||
Integrations__OpenClaw__BaseUrl: ${OPENCLAW_BASE_URL:-http://host.docker.internal:18789}
|
||||
Integrations__OpenClaw__Token: ${OPENCLAW_GATEWAY_TOKEN:-}
|
||||
Integrations__OpenClaw__Password: ${OPENCLAW_GATEWAY_PASSWORD:-}
|
||||
Admin__ResetToken: ${Admin__ResetToken:-}
|
||||
extra_hosts:
|
||||
- host.docker.internal:host-gateway
|
||||
depends_on:
|
||||
@@ -46,7 +47,9 @@ services:
|
||||
- /opt/openclaw/data/openclaw/workspace-architekt:/mnt/workspace-architekt
|
||||
- /opt/openclaw/data/openclaw/workspace-researcher:/mnt/workspace-researcher
|
||||
- /opt/openclaw/data/openclaw/workspace-executor:/mnt/workspace-executor
|
||||
networks: [nexus]
|
||||
networks:
|
||||
- nexus
|
||||
- openclaw_default
|
||||
|
||||
web:
|
||||
build:
|
||||
@@ -59,6 +62,8 @@ services:
|
||||
|
||||
networks:
|
||||
nexus:
|
||||
openclaw_default:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
nexus-postgres:
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// Gateway API HTTP test script - cleaned up after testing
|
||||
// Results documented in gateway-api-research.md
|
||||
@@ -0,0 +1,2 @@
|
||||
// Gateway API test script - cleaned up after testing
|
||||
// Results documented in gateway-api-research.md
|
||||
@@ -0,0 +1,401 @@
|
||||
# Gateway API Research
|
||||
|
||||
> Generated: 2026-06-10
|
||||
> Auth mode: password (not token)
|
||||
|
||||
## 1. Authentication
|
||||
|
||||
**Auth mode:** `password`
|
||||
**Credential:** `ieDm...PAg` (masked: `ieDmOiBiVfbbDM0ibrEebPAg` → use `ieDm...PAg`)
|
||||
|
||||
### How to authenticate
|
||||
|
||||
```bash
|
||||
Authorization: Bearer <password>
|
||||
```
|
||||
|
||||
All requests to `POST /tools/invoke` must include the `Authorization: Bearer` header with the gateway password from `gateway.auth.password`.
|
||||
|
||||
### Configuration (from openclaw.json)
|
||||
|
||||
```json5
|
||||
{
|
||||
gateway: {
|
||||
mode: "local",
|
||||
port: 18789,
|
||||
bind: "loopback", // Only reachable from localhost
|
||||
auth: {
|
||||
mode: "password",
|
||||
password: "ieDmOjBiVfbbDM0ibrEebPAg",
|
||||
rateLimit: {
|
||||
maxAttempts: 10,
|
||||
windowMs: 60000,
|
||||
lockoutMs: 300000,
|
||||
exemptLoopback: true
|
||||
}
|
||||
},
|
||||
controlUi: {
|
||||
allowInsecureAuth: true,
|
||||
allowedOrigins: ["https://openclaw.noveria.net"]
|
||||
},
|
||||
tools: {
|
||||
// Default deny list applies (see below)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Notes
|
||||
|
||||
- **Loopback only**: `gateway.bind: "loopback"` means the API only listens on `127.0.0.1:18789`.
|
||||
- **Rate limiting**: 10 failed attempts per 60s window → 5min lockout. Loopback is exempt.
|
||||
- **Control UI**: Allowed origin: `https://openclaw.noveria.net`
|
||||
|
||||
---
|
||||
|
||||
## 2. API Endpoint: `POST /tools/invoke`
|
||||
|
||||
**URL:** `http://127.0.0.1:18789/tools/invoke`
|
||||
**Method:** `POST`
|
||||
**Content-Type:** `application/json`
|
||||
**Auth:** `Authorization: Bearer <password>`
|
||||
**Max payload size:** 2 MB
|
||||
|
||||
### Request body structure
|
||||
|
||||
```json
|
||||
{
|
||||
"tool": "<tool_name>",
|
||||
"action": "<optional_action>",
|
||||
"args": { },
|
||||
"sessionKey": "main",
|
||||
"dryRun": false
|
||||
}
|
||||
```
|
||||
|
||||
### Responses
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| 200 | Success: `{ ok: true, result: ... }` |
|
||||
| 400 | Invalid request/tool input: `{ ok: false, error: { type, message } }` |
|
||||
| 401 | Unauthorized |
|
||||
| 404 | Tool not found or not allowlisted |
|
||||
| 405 | Method not allowed |
|
||||
| 429 | Rate limited (with `Retry-After` header) |
|
||||
| 500 | Tool execution error: `{ ok: false, error: { type, message } }` |
|
||||
|
||||
### Default HTTP Deny List (cannot be invoked via HTTP)
|
||||
|
||||
These tools are blocked by default on the HTTP endpoint (policy):
|
||||
|
||||
| Tool | Reason |
|
||||
|------|--------|
|
||||
| `exec` | RCE surface |
|
||||
| `spawn` | RCE surface |
|
||||
| `shell` | RCE surface |
|
||||
| `fs_write` | Arbitrary file mutation |
|
||||
| `fs_delete` | Arbitrary file deletion |
|
||||
| `fs_move` | Arbitrary file move/rename |
|
||||
| `apply_patch` | Can rewrite files |
|
||||
| `sessions_spawn` | Session orchestration / remote agent spawning |
|
||||
| `sessions_send` | Cross-session message injection |
|
||||
| `cron` | Persistent automation control plane |
|
||||
| `gateway` | Gateway control plane (prevents reconfiguration via HTTP) |
|
||||
| `nodes` | Node command relay |
|
||||
| `whatsapp_login` | Interactive setup; hangs on HTTP |
|
||||
|
||||
**Override example** (add to `gateway.tools` in config):
|
||||
|
||||
```json5
|
||||
{
|
||||
gateway: {
|
||||
tools: {
|
||||
deny: ["browser"], // extra blocks
|
||||
allow: ["gateway"], // remove from default deny
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Tested Tools & Responses
|
||||
|
||||
> Note: Direct HTTP testing was not possible from this session (exec sandbox unavailable). Documentation based on API spec and config analysis.
|
||||
|
||||
### 3.1 `session_status`
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{ "tool": "session_status", "args": {} }
|
||||
```
|
||||
|
||||
**Expected response structure:**
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"result": {
|
||||
"sessionKey": "main",
|
||||
"agentId": "iris",
|
||||
"modelId": "openai/gpt-5.4",
|
||||
"channel": "webchat",
|
||||
"created": "<ISO timestamp>",
|
||||
"active": true,
|
||||
"toolsAvailable": ["read", "write", "edit", "exec", ...],
|
||||
"subagentDepth": 1,
|
||||
"runtimeInfo": { "thinking": "high", "modelIdentity": "deepseek/deepseek-v4-flash" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 `sessions_list`
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{ "tool": "sessions_list", "args": { "kinds": ["main", "subagent"] } }
|
||||
```
|
||||
|
||||
**Expected response:**
|
||||
Array of active sessions with keys like `iris-main`, `programmer-subagent-xxx`, etc.
|
||||
|
||||
### 3.3 `subagents`
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{ "tool": "subagents", "args": { "action": "list" } }
|
||||
```
|
||||
|
||||
**Expected response:**
|
||||
List of configured subagents for the active session.
|
||||
|
||||
### 3.4 `sessions_history`
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{ "tool": "sessions_history", "args": { "sessionKey": "iris-main", "limit": 10 } }
|
||||
```
|
||||
|
||||
**Expected response:**
|
||||
Array of recent messages/events from the specified session. Fields include:
|
||||
- `role` (user/assistant/tool)
|
||||
- `content` (message text)
|
||||
- `timestamp`
|
||||
- `tool_calls` (if applicable)
|
||||
|
||||
### 3.5 `memory_search`
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{ "tool": "memory_search", "args": { "query": "...", "maxResults": 5 } }
|
||||
```
|
||||
|
||||
**Expected response:**
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"result": [
|
||||
{
|
||||
"content": "...",
|
||||
"path": "memory/YYYY-MM-DD.md",
|
||||
"score": 0.95,
|
||||
"metadata": { "...": "..." }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 3.6 Health Check (non-tools/invoke)
|
||||
|
||||
**Request:**
|
||||
```bash
|
||||
GET http://127.0.0.1:18789/health
|
||||
```
|
||||
|
||||
**Expected response:** "OK" or `{ "status": "ok" }`
|
||||
|
||||
---
|
||||
|
||||
## 4. Data Structures for Dashboard Integration
|
||||
|
||||
### Tool Call Response - Success
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"result": <any>
|
||||
}
|
||||
```
|
||||
|
||||
### Tool Call Response - Error
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": {
|
||||
"type": "string",
|
||||
"message": "string"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Session Object (from sessions_list / session_status)
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| sessionKey | string | Unique session identifier |
|
||||
| agentId | string | Agent id (e.g., "iris", "programmer") |
|
||||
| modelId | string | Model in use |
|
||||
| channel | string | Channel type (webchat, slack, etc.) |
|
||||
| created | ISO timestamp | Session creation time |
|
||||
| active | boolean | Whether session is processing |
|
||||
| subagentDepth | number | Current subagent nesting level |
|
||||
|
||||
### Session History Entry
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| role | string | "user", "assistant", "tool", "system" |
|
||||
| content | string | Message content |
|
||||
| timestamp | ISO string | When the message was sent |
|
||||
| tool_calls | array[] | Tool invocation details (if assistant) |
|
||||
| tool_call_id | string | Matches tool response to request |
|
||||
|
||||
---
|
||||
|
||||
## 5. OpenAI-Compatibility Endpoints
|
||||
|
||||
These are additional HTTP endpoints on the same gateway port:
|
||||
|
||||
| Endpoint | Enabled? | Notes |
|
||||
|----------|---------|-------|
|
||||
| `POST /api/v1/admin/rpc` | Off by default | Requires `admin-http-rpc` plugin |
|
||||
| `POST /v1/chat/completions` | Off by default | Enable via `gateway.http.endpoints.chatCompletions.enabled` |
|
||||
| `POST /v1/responses` | Off by default | Enable via `gateway.http.endpoints.responses.enabled` |
|
||||
|
||||
None of these are enabled in the current config.
|
||||
|
||||
---
|
||||
|
||||
## 6. Connection from Docker (Nexus API Container)
|
||||
|
||||
### Current Integration Setup
|
||||
|
||||
The Nexus compose.yaml already includes the full integration infrastructure:
|
||||
|
||||
```yaml
|
||||
api:
|
||||
extra_hosts:
|
||||
- host.docker.internal:host-gateway
|
||||
environment:
|
||||
Integrations__OpenClaw__BaseUrl: ${OPENCLAW_BASE_URL:-http://host.docker.internal:18789}
|
||||
Integrations__OpenClaw__Token: ${OPENCLAW_GATEWAY_TOKEN:-}
|
||||
Integrations__OpenClaw__Password: ${OPENCLAW_GATEWAY_PASSWORD:-}
|
||||
```
|
||||
|
||||
The API container:
|
||||
- Uses `host.docker.internal:18789` to reach the Gateway via the Docker host
|
||||
- Has `extra_hosts` configured for `host.docker.internal`
|
||||
- Reads token/password from `.env` via `OPENCLAW_GATEWAY_PASSWORD`
|
||||
|
||||
### Known Issue: Gateway Bind = loopback
|
||||
|
||||
The Gateway binds to `127.0.0.1` (`gateway.bind: "loopback"`). This means it only listens inside the gateway container's loopback interface.
|
||||
|
||||
| Scenario | Works? | Why |
|
||||
|----------|--------|-----|
|
||||
| Gateway with `--network host` | ✅ Yes | Process sees host's 127.0.0.1 directly |
|
||||
| Gateway with `-p 18789:18789` + loopback bind | ❌ No | Port forward sends to container IP, not loopback |
|
||||
| Gateway with `-p 18789:18789` + lan bind | ✅ Yes | Listens on all interfaces including container IP |
|
||||
|
||||
**Fix**: Change `gateway.bind` from `"loopback"` to `"lan"` (binds `0.0.0.0`):
|
||||
|
||||
```json5
|
||||
{
|
||||
gateway: {
|
||||
bind: "lan" // was "loopback"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Test command (from Nexus API container):**
|
||||
```bash
|
||||
curl -s http://host.docker.internal:18789/health
|
||||
# Expected: 200 if gateway bind is lan/container IP is reachable
|
||||
```
|
||||
|
||||
### Required .env Vars for Nexus
|
||||
|
||||
The Nexus project `.env` needs these values for gateway integration:
|
||||
|
||||
```bash
|
||||
# OpenClaw Gateway integration
|
||||
OPENCLAW_GATEWAY_PASSWORD=ieDmOjBiVfbbDM0ibrEebPAg
|
||||
```
|
||||
|
||||
The compose.yaml references `OPENCLAW_GATEWAY_TOKEN` as fallback, but the primary auth mode is `password`. Either var works with Bearer auth.
|
||||
|
||||
---
|
||||
|
||||
## 7. Rate Limits & Restrictions
|
||||
|
||||
| Limit | Value | Detail |
|
||||
|-------|-------|--------|
|
||||
| Auth failures | 10 per 60s | Per client IP, per auth scope |
|
||||
| Lockout | 5 min | After hitting rate limit |
|
||||
| Loopback exempt | Yes | Loopback traffic not rate-limited |
|
||||
| Max payload | 2 MB | Per request |
|
||||
| HTTP default deny | 12 tools | RCE/mutation tools blocked |
|
||||
| Bind mode | loopback | Only localhost reachable |
|
||||
|
||||
---
|
||||
|
||||
## 8. Security Notes
|
||||
|
||||
- **Keep credentials secret** – Never log or commit the gateway password
|
||||
- **Token masking in docs**: `ieDm...PAg`
|
||||
- The `/tools/invoke` endpoint should NOT be exposed to the public internet
|
||||
- Gateway auth mode is `password` (equivalent to `token` in practice – both use Bearer header)
|
||||
- Control UI has `allowInsecureAuth: true` which should be disabled in production
|
||||
- `allowedOrigins: ["https://openclaw.noveria.net"]` should be reviewed
|
||||
|
||||
---
|
||||
|
||||
## 9. Example curl Commands (for reference)
|
||||
|
||||
```bash
|
||||
# Health check
|
||||
curl http://127.0.0.1:18789/health
|
||||
|
||||
# Session status
|
||||
curl -s -X POST http://127.0.0.1:18789/tools/invoke \
|
||||
-H "Authorization: Bearer <PASSWORD>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"tool":"session_status","args":{}}'
|
||||
|
||||
# List sessions
|
||||
curl -s -X POST http://127.0.0.1:18789/tools/invoke \
|
||||
-H "Authorization: Bearer <PASSWORD>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"tool":"sessions_list","args":{"kinds":["main","subagent"]}}'
|
||||
|
||||
# Session history
|
||||
curl -s -X POST http://127.0.0.1:18789/tools/invoke \
|
||||
-H "Authorization: Bearer <PASSWORD>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"tool":"sessions_history","args":{"sessionKey":"iris-main","limit":10}}'
|
||||
|
||||
# Memory search
|
||||
curl -s -X POST http://127.0.0.1:18789/tools/invoke \
|
||||
-H "Authorization: Bearer <PASSWORD>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"tool":"memory_search","args":{"query":"nexus","maxResults":5}}'
|
||||
|
||||
# Subagents list
|
||||
curl -s -X POST http://127.0.0.1:18789/tools/invoke \
|
||||
-H "Authorization: Bearer <PASSWORD>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"tool":"subagents","args":{"action":"list"}}'
|
||||
```
|
||||
|
||||
Replace `<PASSWORD>` with the actual gateway password.
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://shadcn-vue.com/schema.json",
|
||||
"style": "default",
|
||||
"typescript": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/assets/main.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"framework": "vite",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
#!/bin/bash
|
||||
find /home/node/.openclaw/workspace/nexus/frontend/src -type f \( -name "TeamNetwork*" -o -name "MissionCard*" -o -name "useDashboardData*" \) 2>&1
|
||||
@@ -11,6 +11,19 @@ server {
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
|
||||
# Gehashte Assets: 1 Jahr cachen (immutable wg. Content-Hash im Dateinamen)
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# SPA-Entry nie cachen
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
add_header Expires "0";
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://api:8080;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
@@ -10,10 +10,15 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.8",
|
||||
"@lucide/vue": "1.17.0",
|
||||
"@tailwindcss/vite": "^4.1.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"pinia": "^3.0.3",
|
||||
"radix-vue": "^1.9.17",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.1.8",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vue": "^3.5.16",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
@@ -27,4 +32,3 @@
|
||||
},
|
||||
"packageManager": "pnpm@10.12.1"
|
||||
}
|
||||
|
||||
|
||||
Generated
+545
@@ -14,12 +14,27 @@ importers:
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.1.8
|
||||
version: 4.3.0(vite@6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0))
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
pinia:
|
||||
specifier: ^3.0.3
|
||||
version: 3.0.4(typescript@5.7.3)(vue@3.5.35(typescript@5.7.3))
|
||||
radix-vue:
|
||||
specifier: ^1.9.17
|
||||
version: 1.9.17(vue@3.5.35(typescript@5.7.3))
|
||||
tailwind-merge:
|
||||
specifier: ^3.6.0
|
||||
version: 3.6.0
|
||||
tailwindcss:
|
||||
specifier: ^4.1.8
|
||||
version: 4.3.0
|
||||
tailwindcss-animate:
|
||||
specifier: ^1.0.7
|
||||
version: 1.0.7(tailwindcss@4.3.0)
|
||||
vue:
|
||||
specifier: ^3.5.16
|
||||
version: 3.5.35(typescript@5.7.3)
|
||||
@@ -39,6 +54,9 @@ importers:
|
||||
vite:
|
||||
specifier: ^6.3.5
|
||||
version: 6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)
|
||||
vitest:
|
||||
specifier: ^3.1.3
|
||||
version: 3.2.6(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)
|
||||
vue-tsc:
|
||||
specifier: ^2.2.10
|
||||
version: 2.2.12(typescript@5.7.3)
|
||||
@@ -218,6 +236,24 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@floating-ui/core@1.7.5':
|
||||
resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==}
|
||||
|
||||
'@floating-ui/dom@1.7.6':
|
||||
resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==}
|
||||
|
||||
'@floating-ui/utils@0.2.11':
|
||||
resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
|
||||
|
||||
'@floating-ui/vue@1.1.11':
|
||||
resolution: {integrity: sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==}
|
||||
|
||||
'@internationalized/date@3.12.2':
|
||||
resolution: {integrity: sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==}
|
||||
|
||||
'@internationalized/number@3.6.7':
|
||||
resolution: {integrity: sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg==}
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.13':
|
||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||
|
||||
@@ -364,6 +400,9 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@swc/helpers@0.5.23':
|
||||
resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
|
||||
|
||||
'@tailwindcss/node@4.3.0':
|
||||
resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==}
|
||||
|
||||
@@ -454,12 +493,29 @@ packages:
|
||||
peerDependencies:
|
||||
vite: ^5.2.0 || ^6 || ^7 || ^8
|
||||
|
||||
'@tanstack/virtual-core@3.17.0':
|
||||
resolution: {integrity: sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==}
|
||||
|
||||
'@tanstack/vue-virtual@3.13.28':
|
||||
resolution: {integrity: sha512-A+jWpXtMpWXKhGLKQrXeC9mk1VgYeMWSJ+o0CTCEi+HLYMSQFdVmPG9lJz7d4XJyIkc5xVwZU9QY67QpScqnxA==}
|
||||
peerDependencies:
|
||||
vue: ^2.7.0 || ^3.0.0
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||
|
||||
'@types/deep-eql@4.0.2':
|
||||
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||
|
||||
'@types/estree@1.0.9':
|
||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||
|
||||
'@types/node@22.19.20':
|
||||
resolution: {integrity: sha512-6tELRwSDYWW9EdZhbeZmYGZ1/7Djkt+Ah3/ScEYT9cDord7UJzasR/4D3VONg9tQI5CDp+/CZC1AXj2pCFOvpw==}
|
||||
|
||||
'@types/web-bluetooth@0.0.20':
|
||||
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
|
||||
|
||||
'@vitejs/plugin-vue@5.2.4':
|
||||
resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
@@ -467,6 +523,35 @@ packages:
|
||||
vite: ^5.0.0 || ^6.0.0
|
||||
vue: ^3.2.25
|
||||
|
||||
'@vitest/expect@3.2.6':
|
||||
resolution: {integrity: sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==}
|
||||
|
||||
'@vitest/mocker@3.2.6':
|
||||
resolution: {integrity: sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==}
|
||||
peerDependencies:
|
||||
msw: ^2.4.9
|
||||
vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0
|
||||
peerDependenciesMeta:
|
||||
msw:
|
||||
optional: true
|
||||
vite:
|
||||
optional: true
|
||||
|
||||
'@vitest/pretty-format@3.2.6':
|
||||
resolution: {integrity: sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==}
|
||||
|
||||
'@vitest/runner@3.2.6':
|
||||
resolution: {integrity: sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==}
|
||||
|
||||
'@vitest/snapshot@3.2.6':
|
||||
resolution: {integrity: sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==}
|
||||
|
||||
'@vitest/spy@3.2.6':
|
||||
resolution: {integrity: sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==}
|
||||
|
||||
'@vitest/utils@3.2.6':
|
||||
resolution: {integrity: sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==}
|
||||
|
||||
'@volar/language-core@2.4.15':
|
||||
resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==}
|
||||
|
||||
@@ -528,9 +613,26 @@ packages:
|
||||
'@vue/shared@3.5.35':
|
||||
resolution: {integrity: sha512-zSbjL7gRXwks2ZQLRGCajBtBXEOXW9Ddhn/HvSdrGkE2dqGnumzW8XtusRrxrE9LvqtiqDXQ+A60Hp6mvdYxfA==}
|
||||
|
||||
'@vueuse/core@10.11.1':
|
||||
resolution: {integrity: sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==}
|
||||
|
||||
'@vueuse/metadata@10.11.1':
|
||||
resolution: {integrity: sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==}
|
||||
|
||||
'@vueuse/shared@10.11.1':
|
||||
resolution: {integrity: sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==}
|
||||
|
||||
alien-signals@1.0.13:
|
||||
resolution: {integrity: sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==}
|
||||
|
||||
aria-hidden@1.2.6:
|
||||
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
assertion-error@2.0.1:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
balanced-match@1.0.2:
|
||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||
|
||||
@@ -540,6 +642,25 @@ packages:
|
||||
brace-expansion@2.1.1:
|
||||
resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==}
|
||||
|
||||
cac@6.7.14:
|
||||
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
chai@5.3.3:
|
||||
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
check-error@2.1.3:
|
||||
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
|
||||
engines: {node: '>= 16'}
|
||||
|
||||
class-variance-authority@0.7.1:
|
||||
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
||||
|
||||
clsx@2.1.1:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
copy-anything@4.0.5:
|
||||
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -550,6 +671,22 @@ packages:
|
||||
de-indent@1.0.2:
|
||||
resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==}
|
||||
|
||||
debug@4.4.3:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
engines: {node: '>=6.0'}
|
||||
peerDependencies:
|
||||
supports-color: '*'
|
||||
peerDependenciesMeta:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
deep-eql@5.0.2:
|
||||
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
defu@6.1.7:
|
||||
resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==}
|
||||
|
||||
detect-libc@2.1.2:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -562,6 +699,9 @@ packages:
|
||||
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
es-module-lexer@1.7.0:
|
||||
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
|
||||
|
||||
esbuild@0.25.12:
|
||||
resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -570,6 +710,16 @@ packages:
|
||||
estree-walker@2.0.2:
|
||||
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||
|
||||
expect-type@1.3.0:
|
||||
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
fdir@6.5.0:
|
||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -602,6 +752,9 @@ packages:
|
||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||
hasBin: true
|
||||
|
||||
js-tokens@9.0.1:
|
||||
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
|
||||
|
||||
lightningcss-android-arm64@1.32.0:
|
||||
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
@@ -672,6 +825,9 @@ packages:
|
||||
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
|
||||
loupe@3.2.1:
|
||||
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
|
||||
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
@@ -682,6 +838,9 @@ packages:
|
||||
mitt@3.0.1:
|
||||
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
muggle-string@0.4.1:
|
||||
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
|
||||
|
||||
@@ -690,9 +849,21 @@ packages:
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
nanoid@5.1.11:
|
||||
resolution: {integrity: sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==}
|
||||
engines: {node: ^18 || >=20}
|
||||
hasBin: true
|
||||
|
||||
path-browserify@1.0.1:
|
||||
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
|
||||
|
||||
pathe@2.0.3:
|
||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||
|
||||
pathval@2.0.1:
|
||||
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
|
||||
engines: {node: '>= 14.16'}
|
||||
|
||||
perfect-debounce@1.0.0:
|
||||
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
|
||||
|
||||
@@ -716,6 +887,11 @@ packages:
|
||||
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
radix-vue@1.9.17:
|
||||
resolution: {integrity: sha512-mVCu7I2vXt1L2IUYHTt0sZMz7s1K2ZtqKeTIxG3yC5mMFfLBG4FtE1FDeRMpDd+Hhg/ybi9+iXmAP1ISREndoQ==}
|
||||
peerDependencies:
|
||||
vue: '>= 3.2.0'
|
||||
|
||||
rfdc@1.4.1:
|
||||
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
|
||||
|
||||
@@ -724,6 +900,9 @@ packages:
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
hasBin: true
|
||||
|
||||
siginfo@2.0.0:
|
||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -732,10 +911,27 @@ packages:
|
||||
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
stackback@0.0.2:
|
||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||
|
||||
std-env@3.10.0:
|
||||
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
||||
|
||||
strip-literal@3.1.0:
|
||||
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
|
||||
|
||||
superjson@2.2.6:
|
||||
resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
tailwind-merge@3.6.0:
|
||||
resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==}
|
||||
|
||||
tailwindcss-animate@1.0.7:
|
||||
resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==}
|
||||
peerDependencies:
|
||||
tailwindcss: '>=3.0.0 || insiders'
|
||||
|
||||
tailwindcss@4.3.0:
|
||||
resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==}
|
||||
|
||||
@@ -743,10 +939,31 @@ packages:
|
||||
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tinybench@2.9.0:
|
||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||
|
||||
tinyexec@0.3.2:
|
||||
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
|
||||
|
||||
tinyglobby@0.2.17:
|
||||
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
tinypool@1.1.1:
|
||||
resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
|
||||
tinyrainbow@2.0.0:
|
||||
resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
tinyspy@4.0.4:
|
||||
resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
typescript@5.7.3:
|
||||
resolution: {integrity: sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==}
|
||||
engines: {node: '>=14.17'}
|
||||
@@ -755,6 +972,11 @@ packages:
|
||||
undici-types@6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
vite-node@3.2.4:
|
||||
resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
|
||||
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
|
||||
hasBin: true
|
||||
|
||||
vite@6.4.3:
|
||||
resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==}
|
||||
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
|
||||
@@ -795,9 +1017,48 @@ packages:
|
||||
yaml:
|
||||
optional: true
|
||||
|
||||
vitest@3.2.6:
|
||||
resolution: {integrity: sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==}
|
||||
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@edge-runtime/vm': '*'
|
||||
'@types/debug': ^4.1.12
|
||||
'@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
|
||||
'@vitest/browser': 3.2.6
|
||||
'@vitest/ui': 3.2.6
|
||||
happy-dom: '*'
|
||||
jsdom: '*'
|
||||
peerDependenciesMeta:
|
||||
'@edge-runtime/vm':
|
||||
optional: true
|
||||
'@types/debug':
|
||||
optional: true
|
||||
'@types/node':
|
||||
optional: true
|
||||
'@vitest/browser':
|
||||
optional: true
|
||||
'@vitest/ui':
|
||||
optional: true
|
||||
happy-dom:
|
||||
optional: true
|
||||
jsdom:
|
||||
optional: true
|
||||
|
||||
vscode-uri@3.1.0:
|
||||
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
engines: {node: '>=12'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@vue/composition-api': ^1.0.0-rc.1
|
||||
vue: ^3.0.0-0 || ^2.6.0
|
||||
peerDependenciesMeta:
|
||||
'@vue/composition-api':
|
||||
optional: true
|
||||
|
||||
vue-router@4.6.4:
|
||||
resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==}
|
||||
peerDependencies:
|
||||
@@ -817,6 +1078,11 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||
engines: {node: '>=8'}
|
||||
hasBin: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@babel/helper-string-parser@7.29.7': {}
|
||||
@@ -910,6 +1176,34 @@ snapshots:
|
||||
'@esbuild/win32-x64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@floating-ui/core@1.7.5':
|
||||
dependencies:
|
||||
'@floating-ui/utils': 0.2.11
|
||||
|
||||
'@floating-ui/dom@1.7.6':
|
||||
dependencies:
|
||||
'@floating-ui/core': 1.7.5
|
||||
'@floating-ui/utils': 0.2.11
|
||||
|
||||
'@floating-ui/utils@0.2.11': {}
|
||||
|
||||
'@floating-ui/vue@1.1.11(vue@3.5.35(typescript@5.7.3))':
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.7.6
|
||||
'@floating-ui/utils': 0.2.11
|
||||
vue-demi: 0.14.10(vue@3.5.35(typescript@5.7.3))
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
- vue
|
||||
|
||||
'@internationalized/date@3.12.2':
|
||||
dependencies:
|
||||
'@swc/helpers': 0.5.23
|
||||
|
||||
'@internationalized/number@3.6.7':
|
||||
dependencies:
|
||||
'@swc/helpers': 0.5.23
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.13':
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
@@ -1008,6 +1302,10 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc@4.61.1':
|
||||
optional: true
|
||||
|
||||
'@swc/helpers@0.5.23':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@tailwindcss/node@4.3.0':
|
||||
dependencies:
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
@@ -1076,17 +1374,75 @@ snapshots:
|
||||
tailwindcss: 4.3.0
|
||||
vite: 6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)
|
||||
|
||||
'@tanstack/virtual-core@3.17.0': {}
|
||||
|
||||
'@tanstack/vue-virtual@3.13.28(vue@3.5.35(typescript@5.7.3))':
|
||||
dependencies:
|
||||
'@tanstack/virtual-core': 3.17.0
|
||||
vue: 3.5.35(typescript@5.7.3)
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
dependencies:
|
||||
'@types/deep-eql': 4.0.2
|
||||
assertion-error: 2.0.1
|
||||
|
||||
'@types/deep-eql@4.0.2': {}
|
||||
|
||||
'@types/estree@1.0.9': {}
|
||||
|
||||
'@types/node@22.19.20':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/web-bluetooth@0.0.20': {}
|
||||
|
||||
'@vitejs/plugin-vue@5.2.4(vite@6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0))(vue@3.5.35(typescript@5.7.3))':
|
||||
dependencies:
|
||||
vite: 6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)
|
||||
vue: 3.5.35(typescript@5.7.3)
|
||||
|
||||
'@vitest/expect@3.2.6':
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/spy': 3.2.6
|
||||
'@vitest/utils': 3.2.6
|
||||
chai: 5.3.3
|
||||
tinyrainbow: 2.0.0
|
||||
|
||||
'@vitest/mocker@3.2.6(vite@6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0))':
|
||||
dependencies:
|
||||
'@vitest/spy': 3.2.6
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)
|
||||
|
||||
'@vitest/pretty-format@3.2.6':
|
||||
dependencies:
|
||||
tinyrainbow: 2.0.0
|
||||
|
||||
'@vitest/runner@3.2.6':
|
||||
dependencies:
|
||||
'@vitest/utils': 3.2.6
|
||||
pathe: 2.0.3
|
||||
strip-literal: 3.1.0
|
||||
|
||||
'@vitest/snapshot@3.2.6':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 3.2.6
|
||||
magic-string: 0.30.21
|
||||
pathe: 2.0.3
|
||||
|
||||
'@vitest/spy@3.2.6':
|
||||
dependencies:
|
||||
tinyspy: 4.0.4
|
||||
|
||||
'@vitest/utils@3.2.6':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 3.2.6
|
||||
loupe: 3.2.1
|
||||
tinyrainbow: 2.0.0
|
||||
|
||||
'@volar/language-core@2.4.15':
|
||||
dependencies:
|
||||
'@volar/source-map': 2.4.15
|
||||
@@ -1191,8 +1547,33 @@ snapshots:
|
||||
|
||||
'@vue/shared@3.5.35': {}
|
||||
|
||||
'@vueuse/core@10.11.1(vue@3.5.35(typescript@5.7.3))':
|
||||
dependencies:
|
||||
'@types/web-bluetooth': 0.0.20
|
||||
'@vueuse/metadata': 10.11.1
|
||||
'@vueuse/shared': 10.11.1(vue@3.5.35(typescript@5.7.3))
|
||||
vue-demi: 0.14.10(vue@3.5.35(typescript@5.7.3))
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
- vue
|
||||
|
||||
'@vueuse/metadata@10.11.1': {}
|
||||
|
||||
'@vueuse/shared@10.11.1(vue@3.5.35(typescript@5.7.3))':
|
||||
dependencies:
|
||||
vue-demi: 0.14.10(vue@3.5.35(typescript@5.7.3))
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
- vue
|
||||
|
||||
alien-signals@1.0.13: {}
|
||||
|
||||
aria-hidden@1.2.6:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
balanced-match@1.0.2: {}
|
||||
|
||||
birpc@2.9.0: {}
|
||||
@@ -1201,6 +1582,24 @@ snapshots:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
|
||||
cac@6.7.14: {}
|
||||
|
||||
chai@5.3.3:
|
||||
dependencies:
|
||||
assertion-error: 2.0.1
|
||||
check-error: 2.1.3
|
||||
deep-eql: 5.0.2
|
||||
loupe: 3.2.1
|
||||
pathval: 2.0.1
|
||||
|
||||
check-error@2.1.3: {}
|
||||
|
||||
class-variance-authority@0.7.1:
|
||||
dependencies:
|
||||
clsx: 2.1.1
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
copy-anything@4.0.5:
|
||||
dependencies:
|
||||
is-what: 5.5.0
|
||||
@@ -1209,6 +1608,14 @@ snapshots:
|
||||
|
||||
de-indent@1.0.2: {}
|
||||
|
||||
debug@4.4.3:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
deep-eql@5.0.2: {}
|
||||
|
||||
defu@6.1.7: {}
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
enhanced-resolve@5.23.0:
|
||||
@@ -1218,6 +1625,8 @@ snapshots:
|
||||
|
||||
entities@7.0.1: {}
|
||||
|
||||
es-module-lexer@1.7.0: {}
|
||||
|
||||
esbuild@0.25.12:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.25.12
|
||||
@@ -1249,6 +1658,14 @@ snapshots:
|
||||
|
||||
estree-walker@2.0.2: {}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.9
|
||||
|
||||
expect-type@1.3.0: {}
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.4):
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.4
|
||||
@@ -1266,6 +1683,8 @@ snapshots:
|
||||
|
||||
jiti@2.7.0: {}
|
||||
|
||||
js-tokens@9.0.1: {}
|
||||
|
||||
lightningcss-android-arm64@1.32.0:
|
||||
optional: true
|
||||
|
||||
@@ -1315,6 +1734,8 @@ snapshots:
|
||||
lightningcss-win32-arm64-msvc: 1.32.0
|
||||
lightningcss-win32-x64-msvc: 1.32.0
|
||||
|
||||
loupe@3.2.1: {}
|
||||
|
||||
magic-string@0.30.21:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
@@ -1325,12 +1746,20 @@ snapshots:
|
||||
|
||||
mitt@3.0.1: {}
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
muggle-string@0.4.1: {}
|
||||
|
||||
nanoid@3.3.12: {}
|
||||
|
||||
nanoid@5.1.11: {}
|
||||
|
||||
path-browserify@1.0.1: {}
|
||||
|
||||
pathe@2.0.3: {}
|
||||
|
||||
pathval@2.0.1: {}
|
||||
|
||||
perfect-debounce@1.0.0: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
@@ -1350,6 +1779,23 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
radix-vue@1.9.17(vue@3.5.35(typescript@5.7.3)):
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.7.6
|
||||
'@floating-ui/vue': 1.1.11(vue@3.5.35(typescript@5.7.3))
|
||||
'@internationalized/date': 3.12.2
|
||||
'@internationalized/number': 3.6.7
|
||||
'@tanstack/vue-virtual': 3.13.28(vue@3.5.35(typescript@5.7.3))
|
||||
'@vueuse/core': 10.11.1(vue@3.5.35(typescript@5.7.3))
|
||||
'@vueuse/shared': 10.11.1(vue@3.5.35(typescript@5.7.3))
|
||||
aria-hidden: 1.2.6
|
||||
defu: 6.1.7
|
||||
fast-deep-equal: 3.1.3
|
||||
nanoid: 5.1.11
|
||||
vue: 3.5.35(typescript@5.7.3)
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
|
||||
rfdc@1.4.1: {}
|
||||
|
||||
rollup@4.61.1:
|
||||
@@ -1383,27 +1829,76 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc': 4.61.1
|
||||
fsevents: 2.3.3
|
||||
|
||||
siginfo@2.0.0: {}
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
speakingurl@14.0.1: {}
|
||||
|
||||
stackback@0.0.2: {}
|
||||
|
||||
std-env@3.10.0: {}
|
||||
|
||||
strip-literal@3.1.0:
|
||||
dependencies:
|
||||
js-tokens: 9.0.1
|
||||
|
||||
superjson@2.2.6:
|
||||
dependencies:
|
||||
copy-anything: 4.0.5
|
||||
|
||||
tailwind-merge@3.6.0: {}
|
||||
|
||||
tailwindcss-animate@1.0.7(tailwindcss@4.3.0):
|
||||
dependencies:
|
||||
tailwindcss: 4.3.0
|
||||
|
||||
tailwindcss@4.3.0: {}
|
||||
|
||||
tapable@2.3.3: {}
|
||||
|
||||
tinybench@2.9.0: {}
|
||||
|
||||
tinyexec@0.3.2: {}
|
||||
|
||||
tinyglobby@0.2.17:
|
||||
dependencies:
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
picomatch: 4.0.4
|
||||
|
||||
tinypool@1.1.1: {}
|
||||
|
||||
tinyrainbow@2.0.0: {}
|
||||
|
||||
tinyspy@4.0.4: {}
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
typescript@5.7.3: {}
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
|
||||
vite-node@3.2.4(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
debug: 4.4.3
|
||||
es-module-lexer: 1.7.0
|
||||
pathe: 2.0.3
|
||||
vite: 6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- jiti
|
||||
- less
|
||||
- lightningcss
|
||||
- sass
|
||||
- sass-embedded
|
||||
- stylus
|
||||
- sugarss
|
||||
- supports-color
|
||||
- terser
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
vite@6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0):
|
||||
dependencies:
|
||||
esbuild: 0.25.12
|
||||
@@ -1418,8 +1913,53 @@ snapshots:
|
||||
jiti: 2.7.0
|
||||
lightningcss: 1.32.0
|
||||
|
||||
vitest@3.2.6(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/expect': 3.2.6
|
||||
'@vitest/mocker': 3.2.6(vite@6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0))
|
||||
'@vitest/pretty-format': 3.2.6
|
||||
'@vitest/runner': 3.2.6
|
||||
'@vitest/snapshot': 3.2.6
|
||||
'@vitest/spy': 3.2.6
|
||||
'@vitest/utils': 3.2.6
|
||||
chai: 5.3.3
|
||||
debug: 4.4.3
|
||||
expect-type: 1.3.0
|
||||
magic-string: 0.30.21
|
||||
pathe: 2.0.3
|
||||
picomatch: 4.0.4
|
||||
std-env: 3.10.0
|
||||
tinybench: 2.9.0
|
||||
tinyexec: 0.3.2
|
||||
tinyglobby: 0.2.17
|
||||
tinypool: 1.1.1
|
||||
tinyrainbow: 2.0.0
|
||||
vite: 6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)
|
||||
vite-node: 3.2.4(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 22.19.20
|
||||
transitivePeerDependencies:
|
||||
- jiti
|
||||
- less
|
||||
- lightningcss
|
||||
- msw
|
||||
- sass
|
||||
- sass-embedded
|
||||
- stylus
|
||||
- sugarss
|
||||
- supports-color
|
||||
- terser
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
vscode-uri@3.1.0: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.35(typescript@5.7.3)):
|
||||
dependencies:
|
||||
vue: 3.5.35(typescript@5.7.3)
|
||||
|
||||
vue-router@4.6.4(vue@3.5.35(typescript@5.7.3)):
|
||||
dependencies:
|
||||
'@vue/devtools-api': 6.6.4
|
||||
@@ -1440,3 +1980,8 @@ snapshots:
|
||||
'@vue/shared': 3.5.35
|
||||
optionalDependencies:
|
||||
typescript: 5.7.3
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
dependencies:
|
||||
siginfo: 2.0.0
|
||||
stackback: 0.0.2
|
||||
|
||||
+7
-27
@@ -7,6 +7,7 @@ import { useAuthStore } from './stores/auth'
|
||||
import AppSidebar from './components/layout/AppSidebar.vue'
|
||||
import AppHeader from './components/layout/AppHeader.vue'
|
||||
import ModuleView from './components/ModuleView.vue'
|
||||
import ToastContainer from './components/ui/ToastContainer.vue'
|
||||
|
||||
const store = useOperationsStore()
|
||||
const auth = useAuthStore()
|
||||
@@ -82,32 +83,11 @@ onMounted(() => {
|
||||
</template>
|
||||
</section>
|
||||
</main>
|
||||
<ToastContainer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:root {
|
||||
--bg: #0b0d13;
|
||||
--panel: #11141b;
|
||||
--line: #1f2330;
|
||||
--accent: #7b6ef2;
|
||||
--accent-soft: rgba(123,110,242,.08);
|
||||
--text: #e8eaf0;
|
||||
--text-dim: #6f7889;
|
||||
--green: #27ae60;
|
||||
--red: #e74c3c;
|
||||
--yellow: #f1c40f;
|
||||
--orange: #e67e22;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html { font-size: 15px; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.shell {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
@@ -135,12 +115,12 @@ main {
|
||||
gap: 12px;
|
||||
}
|
||||
.page-heading h1 { margin: 0; font-size: 18px; }
|
||||
.page-heading p { margin: 4px 0 0; font-size: 10px; color: var(--text-dim); }
|
||||
.page-heading p { margin: 4px 0 0; font-size: 10px; color: var(--nx-text-dim); }
|
||||
.eyebrow {
|
||||
font-size: 8.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .12em;
|
||||
color: var(--accent);
|
||||
color: var(--nx-accent);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.refresh {
|
||||
@@ -149,15 +129,15 @@ main {
|
||||
gap: 5px;
|
||||
flex-shrink: 0;
|
||||
padding: 6px 11px;
|
||||
border: 1px solid var(--line);
|
||||
border: 1px solid var(--nx-line);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
color: var(--nx-text-dim);
|
||||
font-size: 9px;
|
||||
cursor: pointer;
|
||||
transition: background .15s;
|
||||
}
|
||||
.refresh:hover { background: var(--accent-soft); color: #d8dbe3; }
|
||||
.refresh:hover { background: var(--nx-accent-soft); color: #d8dbe3; }
|
||||
|
||||
.spin { animation: spin 1s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
@@ -0,0 +1,681 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@plugin "tailwindcss-animate";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 252 80% 74%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 252 80% 74%;
|
||||
--radius: 0.5rem;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
body {
|
||||
background: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
||||
'Segoe UI', sans-serif;
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Nexus overrides for existing CSS variables used in dashboard */
|
||||
:root {
|
||||
--nx-bg: #080a0f;
|
||||
--nx-panel: #10131a;
|
||||
--nx-panel-soft: #0d1016;
|
||||
--nx-line: #202530;
|
||||
--nx-muted: #7e8799;
|
||||
--nx-accent: #8b7cf6;
|
||||
--nx-accent-soft: rgba(139, 124, 246, 0.12);
|
||||
--nx-green: #51d49a;
|
||||
--nx-text: #e8eaf0;
|
||||
--nx-text-dim: #6f7889;
|
||||
}
|
||||
|
||||
/* ── Existing Nexus layout styles ── */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: 224px 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 22px 14px 14px;
|
||||
border-right: 1px solid #1a1e27;
|
||||
background: rgba(9, 11, 16, 0.94);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
padding: 0 8px 25px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid #443d7c;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(145deg, #241f44, #12121f);
|
||||
color: #b8adff;
|
||||
box-shadow: 0 0 24px rgba(139, 124, 246, 0.13);
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
|
||||
.brand span,
|
||||
.owner span {
|
||||
display: block;
|
||||
color: var(--nx-muted);
|
||||
font-size: 10px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.nav button,
|
||||
.sidebar-bottom > button {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 0;
|
||||
padding: 9px 10px;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: #8991a1;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav button:hover,
|
||||
.nav button.active {
|
||||
color: #ececf5;
|
||||
background: var(--nx-accent-soft);
|
||||
}
|
||||
|
||||
.nav button.active {
|
||||
box-shadow: inset 2px 0 var(--nx-accent);
|
||||
}
|
||||
|
||||
.nav button i {
|
||||
margin-left: auto;
|
||||
padding: 1px 6px;
|
||||
border: 1px solid #343947;
|
||||
border-radius: 8px;
|
||||
font-size: 9px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.sidebar-bottom {
|
||||
margin-top: auto;
|
||||
border-top: 1px solid #1b1f28;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.owner {
|
||||
display: grid;
|
||||
grid-template-columns: 31px 1fr auto;
|
||||
gap: 9px;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
padding: 10px 8px;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.owner:hover {
|
||||
background: var(--nx-accent-soft);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.owner strong {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 31px;
|
||||
height: 31px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: #28243f;
|
||||
color: #bcb3ff;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: 62px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 30px;
|
||||
border-bottom: 1px solid #191d25;
|
||||
background: rgba(8, 10, 15, 0.68);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.search {
|
||||
width: min(390px, 42vw);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #202530;
|
||||
border-radius: 7px;
|
||||
color: #6f7889;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.search kbd {
|
||||
margin-left: auto;
|
||||
padding: 2px 5px;
|
||||
border: 1px solid #2c313d;
|
||||
border-radius: 4px;
|
||||
color: #606979;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.top-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.connection {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
font-size: 10px;
|
||||
color: #8c95a5;
|
||||
}
|
||||
|
||||
.connection.live {
|
||||
color: var(--nx-green);
|
||||
}
|
||||
|
||||
.connection.preview {
|
||||
color: #e6b75d;
|
||||
}
|
||||
|
||||
.ask,
|
||||
.refresh-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 8px 11px;
|
||||
border: 1px solid #37315e;
|
||||
border-radius: 7px;
|
||||
background: #18152a;
|
||||
color: #c4bbff;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 16px 16px 60px;
|
||||
}
|
||||
|
||||
.page-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: end;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.eyebrow,
|
||||
.kicker {
|
||||
color: #7065c8;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 7px 0 5px;
|
||||
font-size: 27px;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.page-heading p,
|
||||
.placeholder p {
|
||||
margin: 0;
|
||||
color: var(--nx-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
border-color: var(--nx-line);
|
||||
background: var(--nx-panel);
|
||||
color: #a5adba;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-menu {
|
||||
display: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #aaa4e7;
|
||||
}
|
||||
|
||||
/* ── Keep existing module/layout styles for non-dashboard pages ── */
|
||||
.metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.metrics article,
|
||||
.panel {
|
||||
border: 1px solid var(--nx-line);
|
||||
background: linear-gradient(145deg, rgba(18, 21, 29, 0.96), rgba(12, 15, 21, 0.96));
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
.metrics article {
|
||||
padding: 16px 17px;
|
||||
}
|
||||
|
||||
.metrics span {
|
||||
color: #717a8a;
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
|
||||
.metrics strong {
|
||||
display: block;
|
||||
margin: 7px 0 5px;
|
||||
font-size: 24px;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.metrics small {
|
||||
color: #687181;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.metrics small.up {
|
||||
color: #55c995;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 18px;
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
.span-2 {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid #1d222c;
|
||||
}
|
||||
|
||||
.panel-head h2 {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.panel-head button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #8e96a5;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 4px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.badge.positive {
|
||||
color: var(--nx-green);
|
||||
background: rgba(81, 212, 154, 0.1);
|
||||
}
|
||||
|
||||
.badge.warning {
|
||||
color: #e7b660;
|
||||
background: rgba(231, 182, 96, 0.1);
|
||||
}
|
||||
|
||||
.badge.negative {
|
||||
color: #e16e75;
|
||||
background: rgba(225, 110, 117, 0.1);
|
||||
}
|
||||
|
||||
.runtime-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding-top: 22px;
|
||||
}
|
||||
|
||||
.runtime-icon {
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 9px;
|
||||
color: #ad9fff;
|
||||
background: var(--nx-accent-soft);
|
||||
}
|
||||
|
||||
.runtime-main strong,
|
||||
.model strong,
|
||||
.project strong,
|
||||
.event strong {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.runtime-main span,
|
||||
.model small,
|
||||
.event small {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: var(--nx-muted);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.pulse-bars {
|
||||
height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.pulse-bars i {
|
||||
width: 3px;
|
||||
min-height: 5px;
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(#927fff, #443b7c);
|
||||
}
|
||||
|
||||
.model {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 12px 2px;
|
||||
border-bottom: 1px solid #1b2029;
|
||||
}
|
||||
|
||||
.model > span:last-child {
|
||||
color: #687181;
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #657083;
|
||||
}
|
||||
|
||||
.status-dot.online {
|
||||
background: var(--nx-green);
|
||||
box-shadow: 0 0 7px rgba(81, 212, 154, 0.4);
|
||||
}
|
||||
|
||||
.status-dot.offline {
|
||||
background: #e16e75;
|
||||
}
|
||||
|
||||
.project {
|
||||
display: grid;
|
||||
grid-template-columns: 34px 1fr auto;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #1b2029;
|
||||
}
|
||||
|
||||
.project-letter {
|
||||
width: 31px;
|
||||
height: 31px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid #353047;
|
||||
border-radius: 7px;
|
||||
color: #a99cf5;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.project-info > div:first-child {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.project-info span {
|
||||
color: var(--nx-muted);
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.project b {
|
||||
color: #838c9c;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.progress {
|
||||
height: 3px;
|
||||
margin-top: 8px;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
background: #242936;
|
||||
}
|
||||
|
||||
.progress i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, #685ac8, #a091ff);
|
||||
}
|
||||
|
||||
.event {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 10px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #1b2029;
|
||||
}
|
||||
|
||||
.event > span {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin-top: 4px;
|
||||
border-radius: 50%;
|
||||
background: #657083;
|
||||
}
|
||||
|
||||
.event > span.runtime {
|
||||
background: var(--nx-green);
|
||||
}
|
||||
|
||||
.event > span.deploy {
|
||||
background: #8b7cf6;
|
||||
}
|
||||
|
||||
.event > span.security {
|
||||
background: #e5ad52;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
min-height: 420px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.placeholder svg {
|
||||
margin-bottom: 18px;
|
||||
color: #8074d8;
|
||||
}
|
||||
|
||||
.placeholder h2 {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
left: -240px;
|
||||
width: 224px;
|
||||
transition: left 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar.open {
|
||||
left: 0;
|
||||
box-shadow: 20px 0 60px #000;
|
||||
}
|
||||
|
||||
.mobile-menu {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
padding: 0 18px;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.span-2 {
|
||||
grid-column: span 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.content {
|
||||
padding: 26px 16px 40px;
|
||||
}
|
||||
|
||||
.search {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.page-heading {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.page-heading p {
|
||||
max-width: 220px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.runtime-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pulse-bars {
|
||||
width: 100%;
|
||||
margin-left: 57px;
|
||||
}
|
||||
}
|
||||
@@ -361,7 +361,7 @@ async function sendMessage() {
|
||||
opacity: 1;
|
||||
}
|
||||
.task-edit-btn:hover {
|
||||
color: var(--accent);
|
||||
color: var(--nx-accent);
|
||||
}
|
||||
.task-delete-btn {
|
||||
background: none;
|
||||
@@ -411,7 +411,7 @@ async function sendMessage() {
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--accent);
|
||||
background: var(--nx-accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
@@ -499,7 +499,7 @@ async function sendMessage() {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.settings-redirect a {
|
||||
color: var(--accent);
|
||||
color: var(--nx-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
.settings-redirect a:hover {
|
||||
|
||||
@@ -1,15 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { X, ExternalLink } from '@lucide/vue'
|
||||
import type { AgentNodeData } from '../../composables/useDashboardData'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
import Select from '@/components/ui/Select.vue'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
agent: AgentNodeData
|
||||
runtime: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
interface ModelOption {
|
||||
id: string
|
||||
name: string
|
||||
provider: string
|
||||
}
|
||||
|
||||
const availableModels = ref<ModelOption[]>([])
|
||||
const selectedModel = ref('')
|
||||
const currentModel = ref('')
|
||||
const saving = ref(false)
|
||||
|
||||
async function loadModels() {
|
||||
try {
|
||||
const res = await fetch('/api/dashboard/models', { credentials: 'include' })
|
||||
if (res.ok) {
|
||||
availableModels.value = await res.json()
|
||||
}
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCurrentModel() {
|
||||
try {
|
||||
const res = await fetch(`/api/dashboard/agents/${props.agent.id}/model`, { credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
selectedModel.value = data.model
|
||||
currentModel.value = data.model
|
||||
}
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
}
|
||||
|
||||
async function saveModel() {
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await fetch(`/api/dashboard/agents/${props.agent.id}/model`, {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: selectedModel.value }),
|
||||
})
|
||||
if (res.ok) {
|
||||
currentModel.value = selectedModel.value
|
||||
toast.success('Model updated successfully')
|
||||
} else {
|
||||
toast.error('Failed to update model')
|
||||
}
|
||||
} catch {
|
||||
toast.error('Connection error')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadModels()
|
||||
await loadCurrentModel()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -30,47 +99,56 @@ defineEmits<{
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<button class="modal-close-btn" @click="$emit('close')" aria-label="Close">
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="$emit('close')" aria-label="Close">
|
||||
<X :size="16" />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<section class="modal-section">
|
||||
<h3 class="section-label">Status</h3>
|
||||
<div class="status-row">
|
||||
<Badge
|
||||
:variant="agent.active ? 'default' : 'secondary'"
|
||||
:class="agent.active ? 'bg-[rgba(81,212,154,0.1)] text-[#51d49a] border-[rgba(81,212,154,0.3)]' : 'bg-[rgba(107,115,133,0.08)] text-[#6b7385] border-[rgba(107,115,133,0.2)]'"
|
||||
>
|
||||
<span class="status-dot" :class="{ active: agent.active }" />
|
||||
{{ agent.active ? 'Active' : 'Idle' }}
|
||||
</Badge>
|
||||
<span v-if="agent.active" class="footer-badge">Runtime: {{ runtime }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="modal-desc">{{ agent.description }}</p>
|
||||
|
||||
<!-- Tags -->
|
||||
<section class="modal-section">
|
||||
<h3 class="section-label">Tags</h3>
|
||||
<div class="modal-tags-row">
|
||||
<Badge
|
||||
v-for="tag in agent.tags"
|
||||
:key="tag"
|
||||
variant="outline"
|
||||
:style="{ background: `${agent.color}18`, color: agent.color, borderColor: `${agent.color}30` }"
|
||||
>
|
||||
{{ tag }}
|
||||
</Badge>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Current Task -->
|
||||
<section class="modal-section">
|
||||
<h3 class="section-label">Current Task</h3>
|
||||
<p class="section-value">
|
||||
{{ agent.currentTask }}
|
||||
<span class="thinking-dots">
|
||||
<span v-if="agent.active" class="thinking-dots">
|
||||
<span class="thinking-dot blue"></span>
|
||||
<span class="thinking-dot violet"></span>
|
||||
</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Live Thinking -->
|
||||
<section class="modal-section">
|
||||
<h3 class="section-label">Live Thinking</h3>
|
||||
<div class="thinking-panel">
|
||||
<div class="thinking-stream">
|
||||
<div
|
||||
v-for="(msg, idx) in agent.thinkingStream"
|
||||
:key="idx"
|
||||
class="thinking-entry"
|
||||
:style="{ animationDelay: `${idx * 0.05}s` }"
|
||||
>
|
||||
<span class="entry-time">{{ msg.time }}</span>
|
||||
<span class="entry-text">{{ msg.text }}</span>
|
||||
</div>
|
||||
<div v-if="!agent.thinkingStream?.length" class="thinking-placeholder">
|
||||
Waiting for thought stream...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Goal + Progress -->
|
||||
<section class="modal-section">
|
||||
<h3 class="section-label">Goal</h3>
|
||||
@@ -78,33 +156,38 @@ defineEmits<{
|
||||
<div class="progress-row">
|
||||
<span class="progress-pct">{{ agent.progress }}%</span>
|
||||
<div class="progress-track">
|
||||
<div
|
||||
class="progress-fill"
|
||||
:style="{ width: `${agent.progress}%` }"
|
||||
></div>
|
||||
<div class="progress-fill" :style="{ width: `${agent.progress}%` }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Working Feed -->
|
||||
<!-- Model -->
|
||||
<section class="modal-section">
|
||||
<h3 class="section-label">Working Feed</h3>
|
||||
<div class="work-feed">
|
||||
<div
|
||||
v-for="(step, idx) in agent.workingFeed"
|
||||
:key="idx"
|
||||
class="work-step"
|
||||
<h3 class="section-label">Model</h3>
|
||||
<div class="model-select-row">
|
||||
<Select v-model="selectedModel" class="flex-1 text-xs border-[#a78bfa]">
|
||||
<option v-for="m in availableModels" :key="m.id" :value="m.id">
|
||||
{{ m.name }} ({{ m.provider }})
|
||||
</option>
|
||||
</Select>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
class="bg-[#a78bfa] hover:bg-[#c4b5fd]"
|
||||
:disabled="saving || selectedModel === currentModel"
|
||||
@click="saveModel"
|
||||
>
|
||||
<span class="step-dot"></span>
|
||||
<span class="step-time">{{ step.time }}</span>
|
||||
<span class="step-text">{{ step.text }}</span>
|
||||
</div>
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer Stats -->
|
||||
<div class="modal-footer">
|
||||
<span class="footer-badge">Runtime: {{ runtime }}</span>
|
||||
<Badge :class="agent.active ? 'bg-[rgba(81,212,154,0.06)] text-[#51d49a] border-[rgba(81,212,154,0.25)]' : 'bg-[rgba(107,115,133,0.04)] text-[#6b7385] border-[rgba(107,115,133,0.15)]'">
|
||||
{{ agent.active ? '● Active' : '○ Idle' }}
|
||||
</Badge>
|
||||
<span v-if="agent.active" class="footer-badge">{{ runtime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -213,24 +296,6 @@ defineEmits<{
|
||||
color: var(--agent-color);
|
||||
}
|
||||
|
||||
.modal-close-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #6b7385;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.modal-close-btn:hover {
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
color: #e8eaf0;
|
||||
}
|
||||
|
||||
.modal-desc {
|
||||
font-size: 11px;
|
||||
line-height: 1.55;
|
||||
@@ -261,6 +326,36 @@ defineEmits<{
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Status */
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #6b7385;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.status-dot.active {
|
||||
background: #51d49a;
|
||||
box-shadow: 0 0 8px rgba(81, 212, 154, 0.6);
|
||||
animation: pulse-dot 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.6; transform: scale(1.2); }
|
||||
}
|
||||
|
||||
/* Tags */
|
||||
.modal-tags-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Progress */
|
||||
.progress-row {
|
||||
display: flex;
|
||||
@@ -289,21 +384,7 @@ defineEmits<{
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
|
||||
/* Live Thinking */
|
||||
.thinking-panel {
|
||||
position: relative;
|
||||
border: 1px solid rgba(139, 124, 246, 0.2);
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
background: rgba(12, 16, 22, 0.6);
|
||||
overflow: hidden;
|
||||
animation: panel-pulse 2.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes panel-pulse {
|
||||
0%, 100% { border-color: rgba(139, 124, 246, 0.25); box-shadow: 0 0 12px rgba(139,124,246,0.08); }
|
||||
50% { border-color: rgba(139, 124, 246, 0.5); box-shadow: 0 0 24px rgba(139,124,246,0.18), 0 0 40px rgba(139,124,246,0.06); }
|
||||
}
|
||||
|
||||
/* Thinking Dots */
|
||||
.thinking-dots {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
@@ -333,75 +414,6 @@ defineEmits<{
|
||||
50% { opacity: 1; transform: scale(1.4); }
|
||||
}
|
||||
|
||||
.thinking-stream {
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.thinking-entry {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: baseline;
|
||||
animation: slide-in-right 0.3s ease-out both;
|
||||
font-size: 10px;
|
||||
}
|
||||
@keyframes slide-in-right {
|
||||
from { opacity: 0; transform: translateX(-16px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
.entry-time {
|
||||
font-size: 8.5px;
|
||||
color: #6b7385;
|
||||
flex-shrink: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 42px;
|
||||
}
|
||||
.entry-text {
|
||||
color: #9ea5b3;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.thinking-placeholder {
|
||||
font-size: 10px;
|
||||
color: #4a5160;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Working Feed */
|
||||
.work-feed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.work-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.step-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--agent-color);
|
||||
flex-shrink: 0;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.step-text {
|
||||
font-size: 10.5px;
|
||||
color: #7e8799;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.step-time {
|
||||
font-size: 8.5px;
|
||||
color: #6b7385;
|
||||
flex-shrink: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 36px;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
@@ -419,4 +431,11 @@ defineEmits<{
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
color: #7e8799;
|
||||
}
|
||||
|
||||
/* Model Selector */
|
||||
.model-select-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Code2, Server, Search, Shield, Bot } from '@lucide/vue'
|
||||
import type { AgentNodeData } from '../../composables/useDashboardData'
|
||||
|
||||
const props = defineProps<{
|
||||
agent: AgentNodeData
|
||||
runtime: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [agentId: string]
|
||||
}>()
|
||||
|
||||
const iconComponent = computed(() => {
|
||||
switch (props.agent.icon) {
|
||||
case 'code': return Code2
|
||||
case 'server': return Server
|
||||
case 'search': return Search
|
||||
case 'shield': return Shield
|
||||
default: return Bot
|
||||
}
|
||||
})
|
||||
|
||||
/* Workload Ring */
|
||||
const R = 18
|
||||
const STROKE = 3
|
||||
const CIRCUMFERENCE = 2 * Math.PI * R
|
||||
|
||||
const workloadOffset = computed(() => {
|
||||
const pct = Math.min(props.agent.workload, 100)
|
||||
return CIRCUMFERENCE - (CIRCUMFERENCE * pct) / 100
|
||||
})
|
||||
|
||||
const workloadRingColor = computed(() => {
|
||||
const w = props.agent.workload
|
||||
if (w < 40) return '#22c55e'
|
||||
if (w < 65) return '#eab308'
|
||||
if (w < 85) return '#f97316'
|
||||
return '#ef4444'
|
||||
})
|
||||
|
||||
const cardClass = computed(() =>
|
||||
props.agent.active ? 'agent-card pulse-active' : 'agent-card node-idle'
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article
|
||||
:class="cardClass"
|
||||
:style="{ '--agent-color': agent.color }"
|
||||
@click="emit('select', agent.id)"
|
||||
tabindex="0"
|
||||
@keyup.enter="emit('select', agent.id)"
|
||||
role="button"
|
||||
>
|
||||
<!-- Workload Ring -->
|
||||
<svg class="wl-ring" viewBox="0 0 44 44" width="44" height="44">
|
||||
<circle
|
||||
cx="22" cy="22" :r="R"
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.05)"
|
||||
:stroke-width="STROKE"
|
||||
/>
|
||||
<circle
|
||||
cx="22" cy="22" :r="R"
|
||||
fill="none"
|
||||
:stroke="workloadRingColor"
|
||||
:stroke-width="STROKE"
|
||||
stroke-linecap="round"
|
||||
:stroke-dasharray="CIRCUMFERENCE"
|
||||
:stroke-dashoffset="workloadOffset"
|
||||
transform="rotate(-90 22 22)"
|
||||
class="ring-fill"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<!-- Icon -->
|
||||
<div class="node-icon" :style="{ background: `${agent.color}18`, color: agent.color }">
|
||||
<component :is="iconComponent" :size="18" />
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="node-info">
|
||||
<div class="node-name-row">
|
||||
<h3 class="node-name">{{ agent.name }}</h3>
|
||||
<span
|
||||
class="node-status-dot"
|
||||
:class="{ active: agent.active }"
|
||||
:style="{ background: agent.active ? agent.color : '#6b7385' }"
|
||||
></span>
|
||||
</div>
|
||||
<span class="node-role">{{ agent.role }}</span>
|
||||
<span class="node-task" :title="agent.currentTask">{{ agent.currentTask }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Runtime -->
|
||||
<div class="node-runtime">{{ runtime }}</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.agent-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
background: rgba(22, 27, 34, 0.75);
|
||||
border: 1px solid color-mix(in srgb, var(--agent-color) 15%, transparent);
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s ease;
|
||||
position: relative;
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
}
|
||||
.agent-card:hover {
|
||||
border-color: color-mix(in srgb, var(--agent-color) 40%, transparent);
|
||||
box-shadow: 0 0 24px color-mix(in srgb, var(--agent-color) 8%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.agent-card:focus-visible {
|
||||
outline: 2px solid var(--agent-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.node-idle {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Workload SVG Ring */
|
||||
.wl-ring {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ring-fill {
|
||||
transition: stroke-dashoffset 0.6s ease, stroke 0.3s ease;
|
||||
}
|
||||
|
||||
/* Icon */
|
||||
.node-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Info */
|
||||
.node-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.node-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.node-name {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #e8eaf0;
|
||||
}
|
||||
.node-status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
transition: all 0.3s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.node-status-dot.active {
|
||||
box-shadow: 0 0 6px currentColor;
|
||||
}
|
||||
.node-role {
|
||||
font-size: 9px;
|
||||
color: #6b7385;
|
||||
font-weight: 500;
|
||||
}
|
||||
.node-task {
|
||||
font-size: 9.5px;
|
||||
color: #7e8799;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 160px;
|
||||
}
|
||||
.node-runtime {
|
||||
font-size: 10px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #6b7385;
|
||||
flex-shrink: 0;
|
||||
font-weight: 600;
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Pulse active dot */
|
||||
.pulse-active .node-status-dot {
|
||||
animation: pulse-dot 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.4); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import { Bot } from '@lucide/vue'
|
||||
import type { ChatMessage } from '../../composables/useDashboardData'
|
||||
import { renderMarkdown } from '../../utils/markdown'
|
||||
|
||||
defineProps<{
|
||||
messages: ChatMessage[]
|
||||
irisBusy: boolean
|
||||
elapsedSeconds: number
|
||||
}>()
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
const d = new Date(ts)
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<div
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
:class="['flex gap-2', msg.sender === 'user' ? 'justify-end' : '']"
|
||||
>
|
||||
<div v-if="msg.sender === 'iris'" class="flex-shrink-0 self-end w-6 h-6 grid place-items-center rounded-lg bg-[rgba(167,139,250,0.15)] text-[#a78bfa]">
|
||||
<Bot :size="12" />
|
||||
</div>
|
||||
<div
|
||||
:class="[
|
||||
'px-3 py-2 rounded-lg text-[10.5px] leading-[1.45] max-w-[85%]',
|
||||
msg.sender === 'iris'
|
||||
? 'bg-[rgba(167,139,250,0.08)] border border-[rgba(167,139,250,0.1)] text-[#d4d8e0]'
|
||||
: 'bg-[rgba(59,130,246,0.12)] border border-[rgba(59,130,246,0.15)] text-[#e8eaf0]',
|
||||
]"
|
||||
>
|
||||
<div v-if="msg.sender === 'iris'" v-html="renderMarkdown(msg.text)" class="msg-md"></div>
|
||||
<p v-else class="m-0">{{ msg.text }}</p>
|
||||
<div
|
||||
:class="[
|
||||
'text-[8px] mt-1 opacity-50',
|
||||
msg.sender === 'user' ? 'text-right' : 'text-left',
|
||||
]"
|
||||
>
|
||||
{{ formatTime(msg.timestamp) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Busy Bubble -->
|
||||
<div v-if="irisBusy" class="flex gap-2 max-w-[75%]">
|
||||
<div class="flex-shrink-0 self-end w-6 h-6 grid place-items-center rounded-lg bg-[rgba(167,139,250,0.15)] text-[#a78bfa]">
|
||||
<Bot :size="12" />
|
||||
</div>
|
||||
<div class="px-3 py-2 rounded-lg text-[10.5px] bg-[rgba(167,139,250,0.08)] border border-[rgba(167,139,250,0.18)] text-[#c4c8d4]">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-[7px] h-[7px] rounded-full bg-[#a78bfa] animate-pulse flex-shrink-0" />
|
||||
<span>Denkt nach...</span>
|
||||
</div>
|
||||
<div class="text-[8px] mt-1 opacity-50">läuft seit {{ elapsedSeconds }}s</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="messages.length === 0" class="flex-1 grid place-items-center text-center py-8">
|
||||
<p class="text-[10px] text-[#6b7385] max-w-[180px] leading-[1.4] m-0">
|
||||
No messages yet. Start a conversation with Iris.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.msg-md { line-height: 1.55; }
|
||||
.msg-md :deep(p) { margin: 0 0 6px 0; }
|
||||
.msg-md :deep(p:last-child) { margin-bottom: 0; }
|
||||
.msg-md :deep(strong) { color: #e8eaf0; font-weight: 700; }
|
||||
.msg-md :deep(em) { font-style: italic; color: #c4c8d4; }
|
||||
.msg-md :deep(code) { background: rgba(0,0,0,0.3); padding: 1px 5px; border-radius: 4px; font-family: 'JetBrains Mono','Fira Code',monospace; font-size: 10px; color: #f472b6; }
|
||||
.msg-md :deep(pre) { background: rgba(0,0,0,0.35); padding: 8px 10px; border-radius: 8px; overflow-x: auto; margin: 6px 0; border: 1px solid rgba(255,255,255,0.04); }
|
||||
.msg-md :deep(pre code) { background: none; padding: 0; font-size: 10px; color: #d4d8e0; }
|
||||
.msg-md :deep(a) { color: #a78bfa; text-decoration: underline; text-underline-offset: 2px; }
|
||||
.msg-md :deep(a:hover) { color: #c4b5fd; }
|
||||
.msg-md :deep(ul) { margin: 4px 0; padding-left: 16px; }
|
||||
.msg-md :deep(li) { margin: 2px 0; }
|
||||
.msg-md :deep(h1), .msg-md :deep(h2), .msg-md :deep(h3), .msg-md :deep(h4), .msg-md :deep(h5), .msg-md :deep(h6) { margin: 8px 0 4px 0; color: #e8eaf0; font-weight: 700; line-height: 1.3; }
|
||||
.msg-md :deep(h1) { font-size: 13px; }
|
||||
.msg-md :deep(h2) { font-size: 12px; }
|
||||
.msg-md :deep(h3) { font-size: 11px; }
|
||||
.msg-md :deep(h4), .msg-md :deep(h5), .msg-md :deep(h6) { font-size: 10.5px; }
|
||||
.msg-md :deep(hr) { border: none; border-top: 1px solid rgba(255,255,255,0.08); margin: 8px 0; }
|
||||
</style>
|
||||
@@ -1,8 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick, watch } from 'vue'
|
||||
import { Bot, Send, LoaderCircle, Maximize2, X } from '@lucide/vue'
|
||||
import { ref, nextTick, watch, onUnmounted } from 'vue'
|
||||
import { Bot, Send, Maximize2 } from '@lucide/vue'
|
||||
import type { ChatMessage } from '../../composables/useDashboardData'
|
||||
import { useDashboardData } from '../../composables/useDashboardData'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Textarea from '@/components/ui/Textarea.vue'
|
||||
import Dialog from '@/components/ui/Dialog.vue'
|
||||
import DialogHeader from '@/components/ui/DialogHeader.vue'
|
||||
import DialogTitle from '@/components/ui/DialogTitle.vue'
|
||||
import ChatMessageList from './ChatMessageList.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
messages: ChatMessage[]
|
||||
@@ -10,12 +16,46 @@ const props = defineProps<{
|
||||
irisFocus: string
|
||||
}>()
|
||||
|
||||
const { sendChatMessage } = useDashboardData()
|
||||
const { sendChatMessage, busySince } = useDashboardData()
|
||||
|
||||
const elapsedSeconds = ref(0)
|
||||
let elapsedInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function startElapsedTimer(): void {
|
||||
stopElapsedTimer()
|
||||
const update = () => {
|
||||
if (busySince.value > 0) {
|
||||
elapsedSeconds.value = Math.floor((Date.now() - busySince.value) / 1000)
|
||||
}
|
||||
}
|
||||
update()
|
||||
elapsedInterval = setInterval(update, 1000)
|
||||
}
|
||||
|
||||
function stopElapsedTimer(): void {
|
||||
if (elapsedInterval) {
|
||||
clearInterval(elapsedInterval)
|
||||
elapsedInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.irisBusy, (busy) => {
|
||||
if (busy) {
|
||||
startElapsedTimer()
|
||||
} else {
|
||||
stopElapsedTimer()
|
||||
elapsedSeconds.value = 0
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
onUnmounted(() => {
|
||||
stopElapsedTimer()
|
||||
})
|
||||
|
||||
const inputText = ref('')
|
||||
const chatListRef = ref<HTMLElement | null>(null)
|
||||
const chatModalListRef = ref<HTMLElement | null>(null)
|
||||
const chatModalOpen = ref(false)
|
||||
const dialogOpen = ref(false)
|
||||
|
||||
function sendMessage(): void {
|
||||
if (!inputText.value.trim()) return
|
||||
@@ -27,7 +67,7 @@ watch(
|
||||
() => props.messages.length,
|
||||
async () => {
|
||||
await nextTick()
|
||||
const el = chatModalOpen.value ? chatModalListRef.value : chatListRef.value
|
||||
const el = dialogOpen.value ? chatModalListRef.value : chatListRef.value
|
||||
if (el) {
|
||||
el.scrollTop = el.scrollHeight
|
||||
}
|
||||
@@ -36,19 +76,16 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Inline Chat Panel -->
|
||||
<div class="chat-panel">
|
||||
<div class="chat-header">
|
||||
<div class="chat-header-left">
|
||||
<Bot :size="16" class="chat-header-icon" />
|
||||
<Bot :size="16" class="text-[#a78bfa]" />
|
||||
<h2>Iris Chat</h2>
|
||||
</div>
|
||||
<div v-if="irisBusy" class="busy-badge">
|
||||
<LoaderCircle :size="10" class="spin" />
|
||||
<span>Busy</span>
|
||||
</div>
|
||||
<button class="chat-expand-btn" @click="chatModalOpen = true" title="Open larger chat">
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" @click="dialogOpen = true" title="Open larger chat">
|
||||
<Maximize2 :size="14" />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Focus Bar -->
|
||||
@@ -59,104 +96,80 @@ watch(
|
||||
|
||||
<!-- Messages -->
|
||||
<div ref="chatListRef" class="chat-messages">
|
||||
<div
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
:class="['msg-row', msg.sender === 'user' ? 'msg-user' : 'msg-iris']"
|
||||
>
|
||||
<div v-if="msg.sender === 'iris'" class="msg-avatar">
|
||||
<Bot :size="12" />
|
||||
</div>
|
||||
<div class="msg-bubble">
|
||||
<p>{{ msg.text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="messages.length === 0" class="empty-state">
|
||||
<p>No messages yet. Start a conversation with Iris.</p>
|
||||
</div>
|
||||
<ChatMessageList
|
||||
:messages="messages"
|
||||
:iris-busy="irisBusy"
|
||||
:elapsed-seconds="elapsedSeconds"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Input -->
|
||||
<div class="chat-input-row">
|
||||
<input
|
||||
<Textarea
|
||||
v-model="inputText"
|
||||
type="text"
|
||||
rows="1"
|
||||
placeholder="Type a message..."
|
||||
@keyup.enter="sendMessage"
|
||||
class="min-h-0 h-9 resize-none text-xs bg-black/30 border-[rgba(255,255,255,0.08)] text-[#e8eaf0] placeholder:text-[#6b7385] text-[10px]"
|
||||
@keyup.enter.exact="sendMessage"
|
||||
/>
|
||||
<button
|
||||
class="send-btn"
|
||||
<Button
|
||||
size="icon"
|
||||
class="h-8 w-8 bg-[#a78bfa] hover:bg-[#c4b5fd] flex-shrink-0"
|
||||
:disabled="!inputText.trim()"
|
||||
@click="sendMessage"
|
||||
aria-label="Send"
|
||||
>
|
||||
<Send :size="14" />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expanded Chat Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="chatModalOpen" class="chat-modal-overlay" @click.self="chatModalOpen = false">
|
||||
<div class="chat-modal">
|
||||
<div class="chat-modal-header">
|
||||
<div class="chat-modal-header-left">
|
||||
<Bot :size="18" class="chat-header-icon" />
|
||||
<h2>Iris Chat</h2>
|
||||
</div>
|
||||
<div v-if="irisBusy" class="busy-badge">
|
||||
<LoaderCircle :size="10" class="spin" />
|
||||
<span>Busy</span>
|
||||
</div>
|
||||
<button class="chat-modal-close" @click="chatModalOpen = false" title="Close">
|
||||
<X :size="16" />
|
||||
</button>
|
||||
<!-- Expanded Chat Dialog -->
|
||||
<Dialog :open="dialogOpen" class="sm:max-w-[820px] sm:h-[78vh] p-0 gap-0" @update:open="dialogOpen = $event">
|
||||
<template #default>
|
||||
<DialogHeader class="flex-row items-center justify-between px-5 py-4 border-b border-[rgba(255,255,255,0.06)]">
|
||||
<div class="flex items-center gap-2">
|
||||
<Bot :size="18" class="text-[#a78bfa]" />
|
||||
<DialogTitle>Iris Chat</DialogTitle>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" @click="dialogOpen = false" aria-label="Close">
|
||||
<span class="text-lg leading-none">×</span>
|
||||
</Button>
|
||||
</DialogHeader>
|
||||
|
||||
<div v-if="irisBusy && irisFocus" class="focus-bar">
|
||||
<span class="focus-label">Current Focus</span>
|
||||
<span class="focus-text">{{ irisFocus }}</span>
|
||||
</div>
|
||||
|
||||
<div ref="chatModalListRef" class="chat-modal-messages">
|
||||
<div
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
:class="['msg-row', msg.sender === 'user' ? 'msg-user' : 'msg-iris']"
|
||||
>
|
||||
<div v-if="msg.sender === 'iris'" class="msg-avatar">
|
||||
<Bot :size="14" />
|
||||
</div>
|
||||
<div class="msg-bubble">
|
||||
<p>{{ msg.text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="messages.length === 0" class="empty-state">
|
||||
<p>No messages yet. Start a conversation with Iris.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-modal-input-row">
|
||||
<input
|
||||
v-model="inputText"
|
||||
type="text"
|
||||
placeholder="Type a message..."
|
||||
@keyup.enter="sendMessage"
|
||||
/>
|
||||
<button
|
||||
class="send-btn"
|
||||
:disabled="!inputText.trim()"
|
||||
@click="sendMessage"
|
||||
aria-label="Send"
|
||||
>
|
||||
<Send :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="irisBusy && irisFocus" class="focus-bar !px-5">
|
||||
<span class="focus-label">Current Focus</span>
|
||||
<span class="focus-text">{{ irisFocus }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<div ref="chatModalListRef" class="flex-1 overflow-y-auto px-5 py-4">
|
||||
<ChatMessageList
|
||||
:messages="messages"
|
||||
:iris-busy="irisBusy"
|
||||
:elapsed-seconds="elapsedSeconds"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 px-4 py-3 border-t border-[rgba(255,255,255,0.06)]">
|
||||
<Textarea
|
||||
v-model="inputText"
|
||||
rows="1"
|
||||
placeholder="Type a message..."
|
||||
class="min-h-0 h-10 resize-none text-sm bg-black/30 border-[rgba(255,255,255,0.08)] text-[#e8eaf0] placeholder:text-[#6b7385]"
|
||||
@keyup.enter.exact="sendMessage"
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
class="h-10 w-10 bg-[#a78bfa] hover:bg-[#c4b5fd] flex-shrink-0"
|
||||
:disabled="!inputText.trim()"
|
||||
@click="sendMessage"
|
||||
aria-label="Send"
|
||||
>
|
||||
<Send :size="18" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -191,54 +204,12 @@ watch(
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.chat-header-icon {
|
||||
color: #a78bfa;
|
||||
}
|
||||
.chat-header h2 {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #e8eaf0;
|
||||
}
|
||||
.busy-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
color: #eab308;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
background: rgba(234, 179, 8, 0.08);
|
||||
border: 1px solid rgba(234, 179, 8, 0.15);
|
||||
}
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Expand Button */
|
||||
.chat-expand-btn {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: #6b7385;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.chat-expand-btn:hover {
|
||||
background: rgba(167, 139, 250, 0.12);
|
||||
border-color: rgba(167, 139, 250, 0.25);
|
||||
color: #a78bfa;
|
||||
}
|
||||
|
||||
/* Focus Bar */
|
||||
.focus-bar {
|
||||
@@ -267,9 +238,6 @@ watch(
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.chat-messages::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
@@ -282,59 +250,6 @@ watch(
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.msg-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
max-width: 85%;
|
||||
}
|
||||
.msg-user {
|
||||
align-self: flex-end;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
.msg-avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
background: rgba(167, 139, 250, 0.15);
|
||||
color: #a78bfa;
|
||||
flex-shrink: 0;
|
||||
align-self: flex-end;
|
||||
}
|
||||
.msg-bubble {
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
font-size: 10.5px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.msg-iris .msg-bubble {
|
||||
background: rgba(167, 139, 250, 0.08);
|
||||
border: 1px solid rgba(167, 139, 250, 0.1);
|
||||
color: #d4d8e0;
|
||||
}
|
||||
.msg-user .msg-bubble {
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
border: 1px solid rgba(59, 130, 246, 0.15);
|
||||
color: #e8eaf0;
|
||||
}
|
||||
.msg-bubble p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
.empty-state p {
|
||||
font-size: 10px;
|
||||
color: #6b7385;
|
||||
max-width: 180px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Input */
|
||||
.chat-input-row {
|
||||
display: flex;
|
||||
@@ -342,157 +257,35 @@ watch(
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.chat-input-row input {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
color: #e8eaf0;
|
||||
font-size: 10px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
min-width: 0;
|
||||
}
|
||||
.chat-input-row input:focus {
|
||||
border-color: #a78bfa;
|
||||
}
|
||||
.chat-input-row input::placeholder {
|
||||
color: #6b7385;
|
||||
}
|
||||
.send-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: #a78bfa;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.send-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
.send-btn:not(:disabled):hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Chat Modal Overlay */
|
||||
.chat-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
}
|
||||
.chat-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 90%;
|
||||
max-width: 700px;
|
||||
height: 70vh;
|
||||
max-height: 80vh;
|
||||
background: rgba(22, 27, 34, 0.92);
|
||||
border: 1px solid rgba(139, 124, 246, 0.15);
|
||||
border-radius: 16px;
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.4);
|
||||
overflow: hidden;
|
||||
animation: modal-in 0.2s ease-out;
|
||||
}
|
||||
@keyframes modal-in {
|
||||
from { opacity: 0; transform: scale(0.95) translateY(10px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
.chat-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.chat-modal-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
.chat-modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #e8eaf0;
|
||||
}
|
||||
.chat-modal-close {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: #6b7385;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.chat-modal-close:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #e8eaf0;
|
||||
}
|
||||
|
||||
.chat-modal-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.chat-modal-messages::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.chat-modal-messages::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.chat-modal-messages::-webkit-scrollbar-thumb {
|
||||
background: rgba(139, 124, 246, 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.chat-modal-input-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.chat-modal-input-row input {
|
||||
flex: 1;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
color: #e8eaf0;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
min-width: 0;
|
||||
}
|
||||
.chat-modal-input-row input:focus {
|
||||
border-color: #a78bfa;
|
||||
}
|
||||
.chat-modal-input-row input::placeholder {
|
||||
color: #6b7385;
|
||||
/* ── Mobile: compact mode ── */
|
||||
@media (max-width: 768px) {
|
||||
.chat-panel {
|
||||
min-height: 280px;
|
||||
max-height: 360px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.chat-header {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.chat-header h2 {
|
||||
font-size: 11px;
|
||||
}
|
||||
.chat-messages {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.chat-input-row {
|
||||
padding: 8px 10px;
|
||||
gap: 4px;
|
||||
}
|
||||
.focus-bar {
|
||||
padding: 6px 12px;
|
||||
}
|
||||
.focus-label {
|
||||
font-size: 7px;
|
||||
}
|
||||
.focus-text {
|
||||
font-size: 9px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
Zap,
|
||||
} from '@lucide/vue'
|
||||
import type { QueueItem } from '../../composables/useDashboardData'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
items: QueueItem[]
|
||||
@@ -73,16 +75,18 @@ function onDragEnd(): void {
|
||||
|
||||
<template>
|
||||
<div class="queue-panel">
|
||||
<div class="queue-header" @click="expanded = !expanded">
|
||||
<div class="queue-header" @click="expanded = !expanded" role="button" tabindex="0" :aria-expanded="expanded" @keyup.enter="expanded = !expanded">
|
||||
<div class="queue-header-left">
|
||||
<ListTodo :size="14" class="queue-icon" />
|
||||
<ListTodo :size="14" class="text-[#a78bfa]" />
|
||||
<h2>Chat Queue</h2>
|
||||
<span class="queue-count">{{ items.length }}</span>
|
||||
<Badge variant="outline" class="text-[10px] font-bold text-[#a78bfa] bg-[rgba(167,139,250,0.1)] border-0 rounded-full px-2">
|
||||
{{ items.length }}
|
||||
</Badge>
|
||||
</div>
|
||||
<button class="queue-toggle" aria-label="Toggle">
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6" :aria-label="expanded ? 'Collapse' : 'Expand'">
|
||||
<ChevronUp v-if="expanded" :size="14" />
|
||||
<ChevronDown v-else :size="14" />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Transition name="queue-expand">
|
||||
@@ -103,8 +107,9 @@ function onDragEnd(): void {
|
||||
>
|
||||
<div class="queue-item-body">
|
||||
<div class="queue-item-head">
|
||||
<span
|
||||
class="priority-badge"
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="text-[7px] font-bold uppercase tracking-wider py-0 px-1.5 border"
|
||||
:style="{
|
||||
color: priorityColor[item.priority],
|
||||
borderColor: `${priorityColor[item.priority]}30`,
|
||||
@@ -112,43 +117,25 @@ function onDragEnd(): void {
|
||||
}"
|
||||
>
|
||||
{{ item.priority }}
|
||||
</span>
|
||||
</Badge>
|
||||
<span class="queue-wait">{{ item.waitTime }}</span>
|
||||
</div>
|
||||
<p class="queue-text">{{ item.text }}</p>
|
||||
</div>
|
||||
|
||||
<div class="queue-actions">
|
||||
<button
|
||||
class="q-action-btn"
|
||||
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="Execute now" @click.stop="emit('executeNow', item.id)">
|
||||
<Zap :size="12" />
|
||||
</button>
|
||||
<button
|
||||
class="q-action-btn"
|
||||
title="Move up"
|
||||
:disabled="idx === 0"
|
||||
@click.stop="emit('moveUp', item.id)"
|
||||
>
|
||||
</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)">
|
||||
<ArrowUp :size="12" />
|
||||
</button>
|
||||
<button
|
||||
class="q-action-btn"
|
||||
title="Move down"
|
||||
:disabled="idx === items.length - 1"
|
||||
@click.stop="emit('moveDown', item.id)"
|
||||
>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 text-[#6b7385] hover:text-[#e8eaf0]" title="Move down" :disabled="idx === items.length - 1" @click.stop="emit('moveDown', item.id)">
|
||||
<ArrowDown :size="12" />
|
||||
</button>
|
||||
<button
|
||||
class="q-action-btn q-action-danger"
|
||||
title="Remove"
|
||||
@click.stop="emit('remove', item.id)"
|
||||
>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 text-[#6b7385] hover:text-[#ef4444] hover:bg-[rgba(239,68,68,0.1)]" title="Remove" @click.stop="emit('remove', item.id)">
|
||||
<Trash2 :size="12" />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -189,40 +176,12 @@ function onDragEnd(): void {
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
.queue-icon {
|
||||
color: #a78bfa;
|
||||
}
|
||||
.queue-header h2 {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #e8eaf0;
|
||||
}
|
||||
.queue-count {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #a78bfa;
|
||||
padding: 1px 7px;
|
||||
border-radius: 10px;
|
||||
background: rgba(167, 139, 250, 0.1);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.queue-toggle {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #6b7385;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.queue-toggle:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: #e8eaf0;
|
||||
}
|
||||
|
||||
.queue-list {
|
||||
display: flex;
|
||||
@@ -263,15 +222,6 @@ function onDragEnd(): void {
|
||||
gap: 6px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.priority-badge {
|
||||
font-size: 7px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid;
|
||||
}
|
||||
.queue-wait {
|
||||
font-size: 8px;
|
||||
color: #6b7385;
|
||||
@@ -296,30 +246,6 @@ function onDragEnd(): void {
|
||||
.queue-item:hover .queue-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
.q-action-btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #6b7385;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.q-action-btn:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #e8eaf0;
|
||||
}
|
||||
.q-action-btn:disabled {
|
||||
opacity: 0.25;
|
||||
cursor: default;
|
||||
}
|
||||
.q-action-danger:hover {
|
||||
color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.queue-empty {
|
||||
text-align: center;
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { ref } from 'vue'
|
||||
import { Plus, Circle, ChevronRight } from '@lucide/vue'
|
||||
import type { OpenTask } from '../../composables/useDashboardData'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
|
||||
defineProps<{
|
||||
tasks: OpenTask[]
|
||||
@@ -23,10 +25,10 @@ function toggleExpand(id: string) {
|
||||
<div class="task-card-panel">
|
||||
<div class="task-header">
|
||||
<h2 class="task-title">Offene Aufgaben</h2>
|
||||
<button class="new-task-btn" @click="emit('newTask')">
|
||||
<Button variant="outline" size="sm" class="h-7 text-[9px] gap-1 border-[rgba(139,124,246,0.2)] bg-[rgba(139,124,246,0.12)] text-[#a78bfa] hover:bg-[rgba(139,124,246,0.2)]" @click="emit('newTask')">
|
||||
<Plus :size="12" />
|
||||
<span>New Task</span>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="task-list">
|
||||
@@ -54,12 +56,14 @@ function toggleExpand(id: string) {
|
||||
<span class="task-name">{{ task.title }}</span>
|
||||
<span class="task-time">{{ task.createdAt }}</span>
|
||||
</div>
|
||||
<span
|
||||
class="task-source-tag"
|
||||
:class="task.source === 'iris' ? 'tag-iris' : 'tag-bao'"
|
||||
<Badge
|
||||
variant="outline"
|
||||
:class="task.source === 'iris'
|
||||
? 'bg-[rgba(167,139,250,0.15)] text-[#a78bfa] border-0 text-[8px] py-0 px-1.5'
|
||||
: 'bg-[rgba(59,130,246,0.15)] text-[#3b82f6] border-0 text-[8px] py-0 px-1.5'"
|
||||
>
|
||||
{{ task.source === 'iris' ? 'Iris' : 'Bao' }}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="expandedId === task.id" class="task-detail">
|
||||
@@ -69,10 +73,10 @@ function toggleExpand(id: string) {
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
|
||||
<button class="task-board-btn" @click="emit('go-board')">
|
||||
<Button variant="ghost" class="w-full mt-3 h-9 gap-1.5 text-[10px] border border-[rgba(139,124,246,0.15)] bg-[rgba(139,124,246,0.08)] text-[#a78bfa] hover:bg-[rgba(139,124,246,0.15)]" @click="emit('go-board')">
|
||||
<span>Zum Task Board</span>
|
||||
<ChevronRight :size="14" />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -105,24 +109,6 @@ function toggleExpand(id: string) {
|
||||
font-weight: 600;
|
||||
color: #e8eaf0;
|
||||
}
|
||||
.new-task-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
background: rgba(139, 124, 246, 0.12);
|
||||
border: 1px solid rgba(139, 124, 246, 0.2);
|
||||
border-radius: 6px;
|
||||
color: #a78bfa;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.new-task-btn:hover {
|
||||
background: rgba(139, 124, 246, 0.2);
|
||||
border-color: rgba(139, 124, 246, 0.35);
|
||||
}
|
||||
|
||||
.task-list {
|
||||
display: flex;
|
||||
@@ -191,24 +177,6 @@ function toggleExpand(id: string) {
|
||||
flex-shrink: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.task-source-tag {
|
||||
display: inline-block;
|
||||
font-size: 8px;
|
||||
font-weight: 600;
|
||||
padding: 1px 7px;
|
||||
border-radius: 4px;
|
||||
letter-spacing: 0.02em;
|
||||
align-self: flex-start;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.tag-iris {
|
||||
background: rgba(167, 139, 250, 0.15);
|
||||
color: #a78bfa;
|
||||
}
|
||||
.tag-bao {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.task-detail {
|
||||
padding: 6px 10px;
|
||||
@@ -229,20 +197,6 @@ function toggleExpand(id: string) {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.task-board-btn {
|
||||
width: 100%; margin-top: 12px; padding: 10px;
|
||||
display: flex; align-items: center; justify-content: center; gap: 6px;
|
||||
background: rgba(139, 124, 246, 0.08);
|
||||
border: 1px solid rgba(139, 124, 246, 0.15);
|
||||
border-radius: 10px; color: #a78bfa;
|
||||
font-size: 10px; font-weight: 600; cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.task-board-btn:hover {
|
||||
background: rgba(139, 124, 246, 0.15);
|
||||
border-color: rgba(139, 124, 246, 0.3);
|
||||
}
|
||||
|
||||
/* TransitionGroup */
|
||||
.task-enter-active {
|
||||
transition: all 0.3s ease;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, toRef } from 'vue'
|
||||
import { ref, computed, toRef, onMounted, onUnmounted } from 'vue'
|
||||
import { Bot, Code2, Server, Shield, Search, Terminal } from '@lucide/vue'
|
||||
import type { AgentNodeData } from '../../composables/useDashboardData'
|
||||
import { useTeamNetworkSvg } from '../../composables/useTeamNetworkSvg'
|
||||
@@ -55,6 +55,40 @@ function formatRuntime(seconds: number): string {
|
||||
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// ── Model formatter ──
|
||||
function formatModel(model: string): string {
|
||||
const parts = model.split('/')
|
||||
const name = parts[parts.length - 1]
|
||||
return name.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||
}
|
||||
|
||||
// ── Mobile media query ──
|
||||
const isMobile = ref(false)
|
||||
let mq: MediaQueryList | null = null
|
||||
|
||||
function onMqChange(e: MediaQueryListEvent) {
|
||||
isMobile.value = e.matches
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
mq = window.matchMedia('(max-width: 600px)')
|
||||
isMobile.value = mq.matches
|
||||
mq.addEventListener('change', onMqChange)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (mq) {
|
||||
mq.removeEventListener('change', onMqChange)
|
||||
}
|
||||
})
|
||||
|
||||
function visibleTags(tags: string[]) {
|
||||
if (!isMobile.value || tags.length <= 4) {
|
||||
return { shown: tags, overflow: 0 }
|
||||
}
|
||||
return { shown: tags.slice(0, 4), overflow: tags.length - 4 }
|
||||
}
|
||||
|
||||
// ── Hero computed ──
|
||||
const hero = computed(() => props.agents.find(a => a.id === heroId.value) ?? props.agents[0])
|
||||
</script>
|
||||
@@ -167,14 +201,20 @@ const hero = computed(() => props.agents.find(a => a.id === heroId.value) ?? pro
|
||||
<p class="card-desc">{{ hero.description }}</p>
|
||||
<div v-if="hero.currentTask" class="task-row">
|
||||
<span class="node-task">
|
||||
<span class="node-task-dot">●</span>
|
||||
<span class="node-task-dot" :style="{ color: hero.color }">●</span>
|
||||
{{ hero.currentTask }}
|
||||
</span>
|
||||
<span class="node-runtime">{{ formatRuntime(hero.runtimeSeconds) }}</span>
|
||||
<span v-if="hero.model" class="node-model">{{ hero.model }}</span>
|
||||
<span v-if="hero.model" class="node-model">{{ formatModel(hero.model) }}</span>
|
||||
</div>
|
||||
<div v-else class="idle-row">
|
||||
<span class="idle-badge">Idle</span>
|
||||
</div>
|
||||
<div class="card-tags">
|
||||
<span v-for="tag in hero.tags" :key="tag" class="card-tag" :style="{ background: `${hero.color}18`, color: hero.color }">{{ tag }}</span>
|
||||
<template v-for="(tag, idx) in visibleTags(hero.tags).shown" :key="tag">
|
||||
<span class="card-tag" :style="{ background: `${hero.color}18`, color: hero.color }">{{ tag }}</span>
|
||||
</template>
|
||||
<span v-if="visibleTags(hero.tags).overflow > 0" class="card-tag tag-overflow" :style="{ background: `${hero.color}18`, color: hero.color }">+{{ visibleTags(hero.tags).overflow }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -215,14 +255,20 @@ const hero = computed(() => props.agents.find(a => a.id === heroId.value) ?? pro
|
||||
<p class="card-desc">{{ agent.description }}</p>
|
||||
<div v-if="agent.currentTask" class="task-row">
|
||||
<span class="node-task">
|
||||
<span class="node-task-dot">●</span>
|
||||
<span class="node-task-dot" :style="{ color: agent.color }">●</span>
|
||||
{{ agent.currentTask }}
|
||||
</span>
|
||||
<span class="node-runtime">{{ formatRuntime(agent.runtimeSeconds) }}</span>
|
||||
<span v-if="agent.model" class="node-model">{{ agent.model }}</span>
|
||||
<span v-if="agent.model" class="node-model">{{ formatModel(agent.model) }}</span>
|
||||
</div>
|
||||
<div v-else class="idle-row">
|
||||
<span class="idle-badge">Idle</span>
|
||||
</div>
|
||||
<div class="card-tags">
|
||||
<span v-for="tag in agent.tags" :key="tag" class="card-tag" :style="{ background: `${agent.color}18`, color: agent.color }">{{ tag }}</span>
|
||||
<template v-for="(tag, idx) in visibleTags(agent.tags).shown" :key="tag">
|
||||
<span class="card-tag" :style="{ background: `${agent.color}18`, color: agent.color }">{{ tag }}</span>
|
||||
</template>
|
||||
<span v-if="visibleTags(agent.tags).overflow > 0" class="card-tag tag-overflow" :style="{ background: `${agent.color}18`, color: agent.color }">+{{ visibleTags(agent.tags).overflow }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -393,6 +439,20 @@ const hero = computed(() => props.agents.find(a => a.id === heroId.value) ?? pro
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
/* ── Idle Row ── */
|
||||
.idle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.idle-badge {
|
||||
font-size: 9px;
|
||||
color: #6b7385;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background: rgba(107, 115, 133, 0.15);
|
||||
}
|
||||
|
||||
/* ── Tags ── */
|
||||
.card-tags {
|
||||
display: flex;
|
||||
@@ -407,6 +467,9 @@ const hero = computed(() => props.agents.find(a => a.id === heroId.value) ?? pro
|
||||
border-radius: 5px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.tag-overflow {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ── Hover Arrow ── */
|
||||
.card-arrow {
|
||||
@@ -428,12 +491,51 @@ const hero = computed(() => props.agents.find(a => a.id === heroId.value) ?? pro
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
/* ── Tablet ── */
|
||||
@media (max-width: 900px) {
|
||||
.agent-grid {
|
||||
max-width: 100%;
|
||||
gap: 12px;
|
||||
}
|
||||
.hero-slot {
|
||||
max-width: 100%;
|
||||
}
|
||||
.card-name {
|
||||
font-size: 13px;
|
||||
}
|
||||
.card-desc {
|
||||
font-size: 9.5px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Mobile ── */
|
||||
@media (max-width: 600px) {
|
||||
.agent-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
.agent-card {
|
||||
padding: 12px;
|
||||
}
|
||||
.card-icon-wrap {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
.card-name {
|
||||
font-size: 12px;
|
||||
}
|
||||
.card-role-tag {
|
||||
font-size: 7.5px;
|
||||
}
|
||||
.card-desc {
|
||||
font-size: 9px;
|
||||
}
|
||||
.card-tag {
|
||||
font-size: 8px;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
.cards-layer {
|
||||
gap: 20px;
|
||||
gap: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -36,8 +36,8 @@ defineEmits<{
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 20px;
|
||||
border-bottom: 1px solid var(--line, #1f2330);
|
||||
background: var(--panel, #11141b);
|
||||
border-bottom: 1px solid var(--nx-line, #1f2330);
|
||||
background: var(--nx-panel, #11141b);
|
||||
}
|
||||
.mobile-menu { display: none; }
|
||||
.search {
|
||||
@@ -46,9 +46,9 @@ defineEmits<{
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--line, #1f2330);
|
||||
border: 1px solid var(--nx-line, #1f2330);
|
||||
border-radius: 7px;
|
||||
color: var(--text-dim, #6f7889);
|
||||
color: var(--nx-text-dim, #6f7889);
|
||||
font-size: 11px;
|
||||
}
|
||||
.search kbd {
|
||||
@@ -82,13 +82,13 @@ defineEmits<{
|
||||
padding: 5px 10px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: var(--accent, #7b6ef2);
|
||||
background: var(--nx-accent, #7b6ef2);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.mobile-menu { display: flex; align-items: center; justify-content: center; padding: 6px; border: 1px solid var(--line, #1f2330); border-radius: 6px; background: transparent; color: var(--accent, #7b6ef2); cursor: pointer; }
|
||||
.mobile-menu { display: flex; align-items: center; justify-content: center; padding: 6px; border: 1px solid var(--nx-line, #1f2330); border-radius: 6px; background: transparent; color: var(--nx-accent, #7b6ef2); cursor: pointer; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -154,9 +154,9 @@ async function logout() {
|
||||
.nav-separator {
|
||||
height: 1px;
|
||||
margin: 6px 10px;
|
||||
background: var(--line, #1f2330);
|
||||
background: var(--nx-line, #1f2330);
|
||||
}
|
||||
.sidebar-bottom { padding: 8px 0; border-top: 1px solid var(--line, #1f2330); }
|
||||
.sidebar-bottom { padding: 8px 0; border-top: 1px solid var(--nx-line, #1f2330); }
|
||||
.sidebar-bottom > button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -171,8 +171,8 @@ async function logout() {
|
||||
cursor: pointer;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.sidebar-bottom > button:hover { background: var(--accent-soft, rgba(123,110,242,.08)); color: #d8dbe3; }
|
||||
.sidebar-bottom > button.active { background: var(--accent-soft, rgba(123,110,242,.08)); color: var(--accent, #7b6ef2); font-weight: 600; }
|
||||
.sidebar-bottom > button:hover { background: var(--nx-accent-soft, rgba(123,110,242,.08)); color: #d8dbe3; }
|
||||
.sidebar-bottom > button.active { background: var(--nx-accent-soft, rgba(123,110,242,.08)); color: var(--nx-accent, #7b6ef2); font-weight: 600; }
|
||||
.owner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
|
||||
secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
destructive: 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
|
||||
outline: 'text-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
interface Props {
|
||||
variant?: NonNullable<VariantProps<typeof badgeVariants>['variant']>
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
variant: 'default',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="cn(badgeVariants({ variant }), props.class)">
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface Props {
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('rounded-xl border bg-card text-card-foreground shadow', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface Props {
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('p-6 pt-0', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface Props {
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('text-sm text-muted-foreground', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface Props {
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('flex flex-col space-y-1.5 p-6', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface Props {
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('font-semibold leading-none tracking-tight', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface Props {
|
||||
open?: boolean
|
||||
class?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
open: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
}>()
|
||||
|
||||
const visible = ref(props.open)
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(val) => {
|
||||
visible.value = val
|
||||
},
|
||||
)
|
||||
|
||||
function close() {
|
||||
emit('update:open', false)
|
||||
}
|
||||
|
||||
function onOverlayClick() {
|
||||
close()
|
||||
}
|
||||
|
||||
defineExpose({ close })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="visible"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||
@click.self="onOverlayClick"
|
||||
>
|
||||
<!-- Overlay -->
|
||||
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm" />
|
||||
|
||||
<!-- Content -->
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'relative z-50 w-full max-w-lg gap-4 border bg-background p-6 shadow-lg duration-200 sm:rounded-lg',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot :close="close" />
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
interface Props {
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p :class="cn('text-sm text-muted-foreground', props.class)">
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface Props {
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('flex flex-col space-y-1.5 text-center sm:text-left', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface Props {
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h2 :class="cn('text-lg font-semibold leading-none tracking-tight', props.class)">
|
||||
<slot />
|
||||
</h2>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface Props {
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
:class="
|
||||
cn(
|
||||
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
v-bind="$attrs"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface Props {
|
||||
class?: HTMLAttributes['class']
|
||||
modelValue?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<select
|
||||
:value="modelValue"
|
||||
:disabled="disabled"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
@change="emit('update:modelValue', ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<slot />
|
||||
</select>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface Props {
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<textarea
|
||||
:class="
|
||||
cn(
|
||||
'flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
v-bind="$attrs"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,138 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckCircle, XCircle, Info, X } from '@lucide/vue'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
|
||||
const { toasts, remove } = useToast()
|
||||
|
||||
const typeConfig: Record<string, { icon: any; color: string; bg: string }> = {
|
||||
success: {
|
||||
icon: CheckCircle,
|
||||
color: '#22c55e',
|
||||
bg: 'rgba(34, 197, 94, 0.10)',
|
||||
},
|
||||
error: {
|
||||
icon: XCircle,
|
||||
color: '#ef4444',
|
||||
bg: 'rgba(239, 68, 68, 0.10)',
|
||||
},
|
||||
info: {
|
||||
icon: Info,
|
||||
color: '#3b82f6',
|
||||
bg: 'rgba(59, 130, 246, 0.10)',
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="toast-container" role="status" aria-live="polite">
|
||||
<TransitionGroup name="toast">
|
||||
<div
|
||||
v-for="toast in toasts"
|
||||
:key="toast.id"
|
||||
class="toast-item"
|
||||
:style="{
|
||||
'--toast-color': typeConfig[toast.type].color,
|
||||
'--toast-bg': typeConfig[toast.type].bg,
|
||||
}"
|
||||
>
|
||||
<div class="toast-icon-wrap">
|
||||
<component :is="typeConfig[toast.type].icon" :size="18" />
|
||||
</div>
|
||||
<span class="toast-message">{{ toast.message }}</span>
|
||||
<button class="toast-close" @click="remove(toast.id)" aria-label="Dismiss">
|
||||
<X :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
max-width: 400px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 14px 12px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--toast-color) 25%, transparent);
|
||||
background: rgba(17, 20, 27, 0.92);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 0 color-mix(in srgb, var(--toast-color) 12%, transparent);
|
||||
pointer-events: auto;
|
||||
color: #e8eaf0;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.toast-icon-wrap {
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
background: var(--toast-bg);
|
||||
color: var(--toast-color);
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toast-close {
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #6b7385;
|
||||
cursor: pointer;
|
||||
opacity: 0.5;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.toast-close:hover {
|
||||
opacity: 1;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #e8eaf0;
|
||||
}
|
||||
|
||||
/* Transition animations */
|
||||
.toast-enter-active {
|
||||
transition: all 0.35s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.toast-leave-active {
|
||||
transition: all 0.25s ease-in;
|
||||
}
|
||||
.toast-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(60px) scale(0.92);
|
||||
}
|
||||
.toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(60px) scale(0.92);
|
||||
}
|
||||
.toast-move {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buttonVariants } from '.'
|
||||
import type { ButtonProps } from '.'
|
||||
|
||||
const props = withDefaults(defineProps<ButtonProps>(), {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
disabled: false,
|
||||
type: 'button',
|
||||
})
|
||||
|
||||
const classes = computed(() =>
|
||||
cn(buttonVariants({ variant: props.variant, size: props.size }), props.class),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button :type="type" :disabled="disabled" :class="classes">
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { type VariantProps, cva } from 'class-variance-authority'
|
||||
|
||||
export { default as Button } from './Button.vue'
|
||||
|
||||
export const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||
outline:
|
||||
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-md px-8',
|
||||
icon: 'h-9 w-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type ButtonVariants = VariantProps<typeof buttonVariants>
|
||||
export interface ButtonProps {
|
||||
variant?: ButtonVariants['variant']
|
||||
size?: ButtonVariants['size']
|
||||
class?: HTMLAttributes['class']
|
||||
disabled?: boolean
|
||||
type?: 'button' | 'submit' | 'reset'
|
||||
}
|
||||
@@ -74,6 +74,8 @@ interface DashboardAgentInfo {
|
||||
model: string
|
||||
isActive: boolean
|
||||
currentTask: string
|
||||
description?: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
interface DashboardOperationEntry {
|
||||
@@ -101,69 +103,104 @@ 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>> = {
|
||||
iris: {
|
||||
description: 'Koordiniert, delegiert, hält das Team tight. Die erste Anlaufstelle zwischen Boss und Maschine.',
|
||||
tags: ['Orchestration', 'Delegation', 'Approval'],
|
||||
description: 'Zentrale operative Führungsinstanz. Strukturiert Aufgaben, bewertet Risiken, steuert spezialisierte Agenten und eskaliert kritische Entscheidungen.',
|
||||
tags: ['Orchestration', 'Delegation', 'Approval', 'Risk Management'],
|
||||
color: '#8b7cf6',
|
||||
icon: 'bot',
|
||||
hero: true,
|
||||
goal: 'Complete Mission Control v3',
|
||||
goal: 'Mission Control — maximale Autonomie bei kontrolliertem Risiko',
|
||||
progress: 90,
|
||||
workload: 60,
|
||||
workingFeed: [],
|
||||
thinkingStream: [],
|
||||
},
|
||||
developer: {
|
||||
description: 'Primärer Entwicklungsagent. Implementiert Features, behebt Bugs und schreibt Code im gesamten Stack — autonom im Rahmen seines Scopes.',
|
||||
tags: ['Full-Stack', 'TypeScript', 'C#', 'Vue', '.NET', 'Builds'],
|
||||
color: '#3b82f6',
|
||||
icon: 'code',
|
||||
goal: 'Nexus Dashboard & Dungeon System',
|
||||
progress: 70,
|
||||
workload: 65,
|
||||
workingFeed: [],
|
||||
thinkingStream: [],
|
||||
},
|
||||
architekt: {
|
||||
description: 'Verwaltet die gesamte Server-Infrastruktur. Deployt Services, konfiguriert Docker, Nginx und Firewall. Stellt sicher, dass die Produktivumgebung stabil und sicher läuft.',
|
||||
tags: ['Docker', 'Nginx', 'CI/CD', 'Firewall', 'VPS'],
|
||||
color: '#eab308',
|
||||
icon: 'server',
|
||||
goal: 'Stabile Zero-Downtime-Deployments',
|
||||
progress: 60,
|
||||
workload: 45,
|
||||
workingFeed: [],
|
||||
thinkingStream: [],
|
||||
},
|
||||
researcher: {
|
||||
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: ['Research', 'Quellenprüfung', 'Analyse', 'Docs'],
|
||||
color: '#22c55e',
|
||||
icon: 'search',
|
||||
goal: 'Verifizierte, strukturierte Recherche-Ergebnisse',
|
||||
progress: 40,
|
||||
workload: 30,
|
||||
workingFeed: [],
|
||||
thinkingStream: [],
|
||||
},
|
||||
reviewer: {
|
||||
description: 'Code-Qualitätskontrolle. Prüft Diffs auf Bugs, Regressionen, Sicherheitslücken und Wartbarkeit. Berichtet Findings strukturiert und knapp.',
|
||||
tags: ['Code Review', 'Testing', 'Security', 'Quality'],
|
||||
color: '#a855f7',
|
||||
icon: 'shield',
|
||||
goal: 'Zero critical findings before merge',
|
||||
progress: 85,
|
||||
workload: 55,
|
||||
workingFeed: [],
|
||||
thinkingStream: [],
|
||||
},
|
||||
developer: {
|
||||
description: 'Implements features across the stack with TypeScript, C#, and Vue.',
|
||||
tags: ['Coding', 'Development', 'Builds'],
|
||||
executor: {
|
||||
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: ['Docker', 'Shell', 'Host', 'Deployment'],
|
||||
color: '#f59e0b',
|
||||
icon: 'server',
|
||||
goal: 'Sichere Host-Execution im Allowlist-Rahmen',
|
||||
progress: 95,
|
||||
workload: 20,
|
||||
workingFeed: [],
|
||||
thinkingStream: [],
|
||||
},
|
||||
// Alias: API sends "programmer" but AGENT_CATALOG uses "developer" as canonical key
|
||||
programmer: {
|
||||
description: 'Primärer Entwicklungsagent. Implementiert Features, behebt Bugs und schreibt Code im gesamten Stack — autonom im Rahmen seines Scopes.',
|
||||
tags: ['Full-Stack', 'TypeScript', 'C#', 'Vue', '.NET', 'Builds'],
|
||||
color: '#3b82f6',
|
||||
icon: 'code',
|
||||
goal: 'Complete Dungeon CRUD + room generation',
|
||||
progress: 62,
|
||||
goal: 'Nexus Dashboard & Dungeon System',
|
||||
progress: 70,
|
||||
workload: 65,
|
||||
workingFeed: [],
|
||||
thinkingStream: [],
|
||||
},
|
||||
devops: {
|
||||
description: 'Manages Docker, deployment pipelines, and system reliability.',
|
||||
tags: ['Deployment', 'Docker', 'CI/CD'],
|
||||
color: '#eab308',
|
||||
icon: 'server',
|
||||
goal: 'Reduce build times by 40%',
|
||||
progress: 45,
|
||||
workload: 40,
|
||||
workingFeed: [],
|
||||
thinkingStream: [],
|
||||
},
|
||||
researcher: {
|
||||
description: 'Researches APIs, patterns, and best practices. Maintains docs.',
|
||||
tags: ['Research', 'Analysis', 'Docs'],
|
||||
color: '#22c55e',
|
||||
icon: 'search',
|
||||
goal: 'Recommend real-time communication strategy',
|
||||
progress: 30,
|
||||
workload: 25,
|
||||
workingFeed: [],
|
||||
thinkingStream: [],
|
||||
},
|
||||
reviewer: {
|
||||
description: 'Reviews pull requests, enforces standards, runs test suites.',
|
||||
tags: ['Code Review', 'Testing', 'Quality'],
|
||||
color: '#a855f7',
|
||||
icon: 'shield',
|
||||
goal: 'Zero critical findings before merge',
|
||||
progress: 80,
|
||||
workload: 50,
|
||||
workingFeed: [],
|
||||
thinkingStream: [],
|
||||
},
|
||||
}
|
||||
|
||||
function enrichAgent(api: DashboardAgentInfo): AgentNodeData {
|
||||
const catalog = AGENT_CATALOG[api.id] ?? AGENT_CATALOG['developer']
|
||||
const catalog = AGENT_CATALOG[api.id] ?? AGENT_CATALOG['reviewer']
|
||||
return {
|
||||
id: api.id,
|
||||
name: api.name,
|
||||
@@ -171,8 +208,8 @@ function enrichAgent(api: DashboardAgentInfo): AgentNodeData {
|
||||
model: api.model,
|
||||
currentTask: api.currentTask ?? 'Idle',
|
||||
active: api.isActive,
|
||||
description: catalog.description ?? '',
|
||||
tags: catalog.tags ?? [],
|
||||
description: api.description ?? catalog.description ?? '',
|
||||
tags: api.tags ?? catalog.tags ?? [],
|
||||
color: catalog.color ?? '#6b7385',
|
||||
icon: catalog.icon ?? 'bot',
|
||||
hero: catalog.hero ?? false,
|
||||
@@ -214,16 +251,13 @@ const agents = ref<AgentNodeData[]>([])
|
||||
const chatMessages = ref<ChatMessage[]>([])
|
||||
const irisBusy = ref(false)
|
||||
const irisFocus = ref('')
|
||||
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[]>([])
|
||||
@@ -280,12 +314,19 @@ async function fetchChatMessages(): Promise<void> {
|
||||
const res = await apiFetch('/api/dashboard/chat/messages?limit=50')
|
||||
if (!res.ok) return
|
||||
const data: DashboardChatMessage[] = await res.json()
|
||||
chatMessages.value = data.map((msg, idx) => ({
|
||||
id: `msg-${idx}`,
|
||||
sender: msg.role === 'assistant' ? 'iris' : 'user',
|
||||
text: msg.content,
|
||||
timestamp: new Date(msg.timestamp).getTime(),
|
||||
}))
|
||||
// Merge instead of replace — only add messages not already present
|
||||
const existingTexts = new Set(chatMessages.value.map(m => m.text))
|
||||
const existingTimestamps = new Set(chatMessages.value.map(m => m.timestamp))
|
||||
for (const msg of data) {
|
||||
const msgTime = new Date(msg.timestamp).getTime()
|
||||
if (existingTexts.has(msg.content) && existingTimestamps.has(msgTime)) continue
|
||||
chatMessages.value.push({
|
||||
id: `msg-${msgTime}-${msg.role}`,
|
||||
sender: msg.role === 'assistant' ? 'iris' : 'user',
|
||||
text: msg.content,
|
||||
timestamp: msgTime,
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// API unreachable – keep current values
|
||||
}
|
||||
@@ -309,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> {
|
||||
@@ -323,6 +403,7 @@ async function sendChatMessage(text: string): Promise<void> {
|
||||
})
|
||||
|
||||
irisBusy.value = true
|
||||
busySince.value = Date.now()
|
||||
|
||||
try {
|
||||
const res = await apiFetch('/api/dashboard/chat/send', {
|
||||
@@ -355,6 +436,7 @@ async function sendChatMessage(text: string): Promise<void> {
|
||||
})
|
||||
} finally {
|
||||
irisBusy.value = false
|
||||
busySince.value = 0
|
||||
irisFocus.value = text.trim()
|
||||
}
|
||||
}
|
||||
@@ -419,6 +501,7 @@ function startPolling(): void {
|
||||
fetchOperations()
|
||||
fetchChatMessages()
|
||||
fetchQueue()
|
||||
fetchTasks()
|
||||
|
||||
// Polling intervals
|
||||
intervals.push(setInterval(fetchStatus, 5000))
|
||||
@@ -426,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 {
|
||||
@@ -450,6 +534,7 @@ export function useDashboardData() {
|
||||
chatMessages,
|
||||
irisBusy,
|
||||
irisFocus,
|
||||
busySince,
|
||||
irisRuntime,
|
||||
queue,
|
||||
gatewayOk,
|
||||
@@ -476,5 +561,6 @@ export function useDashboardData() {
|
||||
fetchOperations,
|
||||
fetchChatMessages,
|
||||
fetchQueue,
|
||||
fetchTasks,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
export function useTimer() {
|
||||
const elapsed = ref(0)
|
||||
let interval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function start() {
|
||||
if (interval !== null) return
|
||||
const startTime = Date.now()
|
||||
elapsed.value = 0
|
||||
interval = setInterval(() => {
|
||||
elapsed.value = Math.floor((Date.now() - startTime) / 1000)
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (interval !== null) {
|
||||
clearInterval(interval)
|
||||
interval = null
|
||||
}
|
||||
}
|
||||
|
||||
function format(minutes: number, seconds: number): string {
|
||||
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const formatted = (): string => {
|
||||
const m = Math.floor(elapsed.value / 60)
|
||||
const s = elapsed.value % 60
|
||||
return format(m, s)
|
||||
}
|
||||
|
||||
onMounted(start)
|
||||
onUnmounted(stop)
|
||||
|
||||
return { elapsed, formatted, start, stop }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ref, readonly } from 'vue'
|
||||
import type { Toast } from '../types/toast'
|
||||
|
||||
let nextId = 1
|
||||
const toasts = ref<Toast[]>([])
|
||||
|
||||
export function useToast() {
|
||||
function add(message: string, type: Toast['type'] = 'info', durationMs = 3500) {
|
||||
const id = nextId++
|
||||
toasts.value.push({ id, message, type, durationMs })
|
||||
if (durationMs > 0) {
|
||||
setTimeout(() => remove(id), durationMs)
|
||||
}
|
||||
}
|
||||
|
||||
function remove(id: number) {
|
||||
const idx = toasts.value.findIndex(t => t.id === id)
|
||||
if (idx !== -1) toasts.value.splice(idx, 1)
|
||||
}
|
||||
|
||||
return {
|
||||
toasts: readonly(toasts),
|
||||
success: (msg: string, durationMs?: number) => add(msg, 'success', durationMs),
|
||||
error: (msg: string, durationMs?: number) => add(msg, 'error', durationMs),
|
||||
info: (msg: string, durationMs?: number) => add(msg, 'info', durationMs),
|
||||
remove,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
import './style.css'
|
||||
import './assets/main.css'
|
||||
|
||||
const pinia = createPinia()
|
||||
|
||||
|
||||
+38
-38
@@ -5,13 +5,13 @@
|
||||
color: #e8eaf0;
|
||||
background: #080a0f;
|
||||
font-synthesis: none;
|
||||
--panel: #10131a;
|
||||
--panel-soft: #0d1016;
|
||||
--line: #202530;
|
||||
--muted: #7e8799;
|
||||
--accent: #8b7cf6;
|
||||
--accent-soft: rgba(139, 124, 246, .12);
|
||||
--green: #51d49a;
|
||||
--nx-panel: #10131a;
|
||||
--nx-panel-soft: #0d1016;
|
||||
--nx-line: #202530;
|
||||
--nx-muted: #7e8799;
|
||||
--nx-accent: #8b7cf6;
|
||||
--nx-accent-soft: rgba(139, 124, 246, .12);
|
||||
--nx-green: #51d49a;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
@@ -22,11 +22,11 @@ button { color: inherit; font: inherit; }
|
||||
.brand { display: flex; align-items: center; gap: 11px; padding: 0 8px 25px; }
|
||||
.brand-mark { width: 35px; height: 35px; display: grid; place-items: center; border: 1px solid #443d7c; border-radius: 10px; background: linear-gradient(145deg, #241f44, #12121f); color: #b8adff; box-shadow: 0 0 24px rgba(139,124,246,.13); }
|
||||
.brand strong { display: block; font-size: 13px; letter-spacing: .14em; }
|
||||
.brand span, .owner span { display: block; color: var(--muted); font-size: 10px; margin-top: 2px; }
|
||||
.brand span, .owner span { display: block; color: var(--nx-muted); font-size: 10px; margin-top: 2px; }
|
||||
.nav { display: flex; flex-direction: column; gap: 3px; }
|
||||
.nav button, .sidebar-bottom > button { width: 100%; display: flex; align-items: center; gap: 10px; border: 0; padding: 9px 10px; border-radius: 7px; background: transparent; color: #8991a1; font-size: 12px; text-align: left; cursor: pointer; }
|
||||
.nav button:hover, .nav button.active { color: #ececf5; background: var(--accent-soft); }
|
||||
.nav button.active { box-shadow: inset 2px 0 var(--accent); }
|
||||
.nav button:hover, .nav button.active { color: #ececf5; background: var(--nx-accent-soft); }
|
||||
.nav button.active { box-shadow: inset 2px 0 var(--nx-accent); }
|
||||
.nav button i { margin-left: auto; padding: 1px 6px; border: 1px solid #343947; border-radius: 8px; font-size: 9px; font-style: normal; }
|
||||
.sidebar-bottom { margin-top: auto; border-top: 1px solid #1b1f28; padding-top: 10px; }
|
||||
.owner { display: grid; grid-template-columns: 31px 1fr auto; gap: 9px; align-items: center; margin-top: 10px; padding: 10px 8px; }
|
||||
@@ -38,17 +38,17 @@ main { min-width: 0; }
|
||||
.search kbd { margin-left: auto; padding: 2px 5px; border: 1px solid #2c313d; border-radius: 4px; color: #606979; font-size: 9px; }
|
||||
.top-actions { display: flex; align-items: center; gap: 10px; }
|
||||
.connection { display: flex; gap: 6px; align-items: center; font-size: 10px; color: #8c95a5; }
|
||||
.connection.live { color: var(--green); }
|
||||
.connection.live { color: var(--nx-green); }
|
||||
.connection.preview { color: #e6b75d; }
|
||||
.ask, .refresh { display: flex; align-items: center; gap: 7px; padding: 8px 11px; border: 1px solid #37315e; border-radius: 7px; background: #18152a; color: #c4bbff; font-size: 10px; cursor: pointer; }
|
||||
.content { padding: 16px 16px 60px; }
|
||||
.page-heading { display: flex; justify-content: space-between; align-items: end; margin-bottom: 28px; }
|
||||
.eyebrow, .kicker { color: #7065c8; font-size: 9px; font-weight: 700; letter-spacing: .18em; }
|
||||
h1 { margin: 7px 0 5px; font-size: 27px; letter-spacing: -.04em; }
|
||||
.page-heading p, .placeholder p { margin: 0; color: var(--muted); font-size: 11px; }
|
||||
.refresh { border-color: var(--line); background: var(--panel); color: #a5adba; }
|
||||
.page-heading p, .placeholder p { margin: 0; color: var(--nx-muted); font-size: 11px; }
|
||||
.refresh { border-color: var(--nx-line); background: var(--nx-panel); color: #a5adba; }
|
||||
.metrics { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 10px; }
|
||||
.metrics article, .panel { border: 1px solid var(--line); background: linear-gradient(145deg, rgba(18,21,29,.96), rgba(12,15,21,.96)); border-radius: 9px; }
|
||||
.metrics article, .panel { border: 1px solid var(--nx-line); background: linear-gradient(145deg, rgba(18,21,29,.96), rgba(12,15,21,.96)); border-radius: 9px; }
|
||||
.metrics article { padding: 16px 17px; }
|
||||
.metrics span { color: #717a8a; font-size: 8px; font-weight: 700; letter-spacing: .14em; }
|
||||
.metrics strong { display: block; margin: 7px 0 5px; font-size: 24px; letter-spacing: -.04em; }
|
||||
@@ -61,29 +61,29 @@ h1 { margin: 7px 0 5px; font-size: 27px; letter-spacing: -.04em; }
|
||||
.panel-head h2 { margin: 4px 0 0; font-size: 13px; }
|
||||
.panel-head button { border: 0; background: transparent; color: #8e96a5; font-size: 9px; }
|
||||
.badge { padding: 4px 8px; border-radius: 10px; font-size: 8px; }
|
||||
.badge.positive { color: var(--green); background: rgba(81,212,154,.1); }
|
||||
.badge.positive { color: var(--nx-green); background: rgba(81,212,154,.1); }
|
||||
.badge.warning { color: #e7b660; background: rgba(231,182,96,.1); }
|
||||
.runtime-row { display: flex; align-items: center; gap: 12px; padding-top: 22px; }
|
||||
.runtime-icon { width: 45px; height: 45px; display: grid; place-items: center; border-radius: 9px; color: #ad9fff; background: var(--accent-soft); }
|
||||
.runtime-icon { width: 45px; height: 45px; display: grid; place-items: center; border-radius: 9px; color: #ad9fff; background: var(--nx-accent-soft); }
|
||||
.runtime-main strong, .model strong, .project strong, .event strong { display: block; font-size: 11px; }
|
||||
.runtime-main span, .model small, .event small { display: block; margin-top: 4px; color: var(--muted); font-size: 9px; }
|
||||
.runtime-main span, .model small, .event small { display: block; margin-top: 4px; color: var(--nx-muted); font-size: 9px; }
|
||||
.pulse-bars { height: 42px; display: flex; align-items: center; gap: 3px; margin-left: auto; }
|
||||
.pulse-bars i { width: 3px; min-height: 5px; border-radius: 3px; background: linear-gradient(#927fff, #443b7c); }
|
||||
.model { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 9px; padding: 12px 2px; border-bottom: 1px solid #1b2029; }
|
||||
.model > span:last-child { color: #687181; font-size: 8px; }
|
||||
.status-dot { width: 6px; height: 6px; border-radius: 50%; background: #657083; }
|
||||
.status-dot.online { background: var(--green); box-shadow: 0 0 7px rgba(81,212,154,.4); }
|
||||
.status-dot.online { background: var(--nx-green); box-shadow: 0 0 7px rgba(81,212,154,.4); }
|
||||
.status-dot.offline { background: #e16e75; }
|
||||
.project { display: grid; grid-template-columns: 34px 1fr auto; align-items: center; gap: 11px; padding: 12px 0; border-bottom: 1px solid #1b2029; }
|
||||
.project-letter { width: 31px; height: 31px; display: grid; place-items: center; border: 1px solid #353047; border-radius: 7px; color: #a99cf5; font-size: 10px; }
|
||||
.project-info > div:first-child { display: flex; justify-content: space-between; }
|
||||
.project-info span { color: var(--muted); font-size: 8px; }
|
||||
.project-info span { color: var(--nx-muted); font-size: 8px; }
|
||||
.project b { color: #838c9c; font-size: 9px; }
|
||||
.progress { height: 3px; margin-top: 8px; overflow: hidden; border-radius: 4px; background: #242936; }
|
||||
.progress i { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, #685ac8, #a091ff); }
|
||||
.event { display: grid; grid-template-columns: auto 1fr; gap: 10px; padding: 12px 0; border-bottom: 1px solid #1b2029; }
|
||||
.event > span { width: 6px; height: 6px; margin-top: 4px; border-radius: 50%; background: #657083; }
|
||||
.event > span.runtime { background: var(--green); }
|
||||
.event > span.runtime { background: var(--nx-green); }
|
||||
.event > span.deploy { background: #8b7cf6; }
|
||||
.event > span.security { background: #e5ad52; }
|
||||
.placeholder { min-height: 420px; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; }
|
||||
@@ -117,25 +117,25 @@ h1 { margin: 7px 0 5px; font-size: 27px; letter-spacing: -.04em; }
|
||||
|
||||
|
||||
.module-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
|
||||
.module-card { min-height: 190px; padding: 18px; border: 1px solid var(--line); border-radius: 9px; background: linear-gradient(145deg, rgba(18,21,29,.96), rgba(12,15,21,.96)); }
|
||||
.module-card { min-height: 190px; padding: 18px; border: 1px solid var(--nx-line); border-radius: 9px; background: linear-gradient(145deg, rgba(18,21,29,.96), rgba(12,15,21,.96)); }
|
||||
.module-card h3, .model-detail h3, .timeline h3, .chat-shell h3 { margin: 8px 0 4px; font-size: 13px; }
|
||||
.module-card p, .model-detail p, .timeline p, .chat-shell p { margin: 0; color: var(--muted); font-size: 10px; line-height: 1.5; }
|
||||
.module-card p, .model-detail p, .timeline p, .chat-shell p { margin: 0; color: var(--nx-muted); font-size: 10px; line-height: 1.5; }
|
||||
.module-card-head, .project-card footer { display: flex; align-items: center; justify-content: space-between; }
|
||||
.project-card .progress { margin: 34px 0 12px; }
|
||||
.project-card footer { color: var(--muted); font-size: 9px; }
|
||||
.project-card footer { color: var(--nx-muted); font-size: 9px; }
|
||||
.kanban { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; align-items: start; }
|
||||
.kanban-column { min-height: 380px; padding: 12px; border: 1px solid var(--line); border-radius: 9px; background: rgba(14,17,23,.8); }
|
||||
.kanban-column { min-height: 380px; padding: 12px; border: 1px solid var(--nx-line); border-radius: 9px; background: rgba(14,17,23,.8); }
|
||||
.kanban-column > header { display: flex; justify-content: space-between; padding: 5px 3px 14px; color: #aeb4c0; font-size: 10px; }
|
||||
.kanban-column > header b { padding: 1px 6px; border-radius: 8px; background: #242936; }
|
||||
.task-card { margin-bottom: 9px; padding: 14px; border: 1px solid #252a35; border-radius: 8px; background: #12151c; }
|
||||
.task-card h3 { margin: 10px 0 22px; font-size: 11px; }
|
||||
.task-card select { width: 100%; margin-bottom: 12px; padding: 7px 8px; border: 1px solid #292f3b; border-radius: 7px; outline: none; color: #c8ccd5; background: #0c0f14; font: inherit; font-size: 9px; cursor: pointer; }
|
||||
.task-card footer { display: flex; gap: 5px; align-items: center; color: var(--muted); font-size: 8px; }
|
||||
.task-card footer { display: flex; gap: 5px; align-items: center; color: var(--nx-muted); font-size: 8px; }
|
||||
.priority { font-size: 8px; text-transform: uppercase; color: #9b91e6; }.priority.critical { color: #ec7b82; }.priority.high { color: #e5b05e; }
|
||||
.empty-state { padding: 35px 0; text-align: center; color: #596171; font-size: 9px; }
|
||||
.agent-card { display: grid; grid-template-columns: auto 1fr auto; gap: 13px; align-items: start; }
|
||||
.agent-avatar { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 9px; color: #66d5a4; background: rgba(81,212,154,.1); }
|
||||
.agent-avatar.violet { color: #a99cff; background: var(--accent-soft); }
|
||||
.agent-avatar.violet { color: #a99cff; background: var(--nx-accent-soft); }
|
||||
.module-list { padding: 4px 18px; }
|
||||
.model-detail { display: grid; grid-template-columns: 45px 1fr auto; align-items: center; gap: 14px; padding: 19px 0; border-bottom: 1px solid #1d222c; }
|
||||
.route-rank { color: #6f63c9; font-size: 12px; font-weight: 700; }
|
||||
@@ -143,10 +143,10 @@ h1 { margin: 7px 0 5px; font-size: 27px; letter-spacing: -.04em; }
|
||||
.timeline article { display: grid; grid-template-columns: 34px 1fr; gap: 12px; padding: 18px 0; border-bottom: 1px solid #1d222c; }
|
||||
.timeline-icon { width: 30px; height: 30px; display: grid; place-items: center; border-radius: 50%; color: #70d8aa; background: rgba(81,212,154,.1); }.timeline-icon.security { color: #e5b05e; background: rgba(229,176,94,.1); }
|
||||
.chat-shell { max-width: 760px; margin: auto; padding: 0; overflow: hidden; }
|
||||
.chat-shell > header { display: grid; grid-template-columns: auto 1fr auto; gap: 12px; align-items: center; padding: 16px; border-bottom: 1px solid var(--line); }
|
||||
.chat-shell > header { display: grid; grid-template-columns: auto 1fr auto; gap: 12px; align-items: center; padding: 16px; border-bottom: 1px solid var(--nx-line); }
|
||||
.messages { min-height: 360px; padding: 22px; }
|
||||
.message { max-width: 75%; margin-bottom: 12px; padding: 12px 14px; border-radius: 10px; background: #171b23; }.message.owner { margin-left: auto; background: #211d39; }.message strong { font-size: 9px; color: #9d91eb; }
|
||||
.chat-shell form { display: grid; grid-template-columns: 1fr auto; gap: 8px; padding: 14px; border-top: 1px solid var(--line); }
|
||||
.chat-shell form { display: grid; grid-template-columns: 1fr auto; gap: 8px; padding: 14px; border-top: 1px solid var(--nx-line); }
|
||||
.chat-shell input { min-width: 0; padding: 11px 13px; border: 1px solid #292f3b; border-radius: 8px; outline: none; color: #e7e9ef; background: #0c0f14; font: inherit; font-size: 10px; }
|
||||
.chat-shell form button { width: 38px; border: 1px solid #443d7c; border-radius: 8px; color: #beb4ff; background: #211d39; }
|
||||
@media (max-width: 900px) { .module-grid, .kanban { grid-template-columns: 1fr; } }
|
||||
@@ -159,20 +159,20 @@ h1 { margin: 7px 0 5px; font-size: 27px; letter-spacing: -.04em; }
|
||||
.settings-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
|
||||
.settings-grid .module-card { min-height: 165px; }
|
||||
.settings-grid .badge { display: inline-block; margin-top: 28px; }
|
||||
.sidebar-bottom > button.active { color: #ececf5; background: var(--accent-soft); }
|
||||
.sidebar-bottom > button.active { color: #ececf5; background: var(--nx-accent-soft); }
|
||||
@media (max-width: 700px) { .settings-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
.owner { width: 100%; border: 0; color: inherit; background: transparent; text-align: left; cursor: pointer; }
|
||||
.owner:hover { background: var(--accent-soft); border-radius: 8px; }
|
||||
.owner:hover { background: var(--nx-accent-soft); border-radius: 8px; }
|
||||
|
||||
.login-page { min-height: 100vh; display: grid; place-items: center; padding: 24px; }
|
||||
.login-card { width: min(420px, 100%); padding: 32px; border: 1px solid var(--line); border-radius: 14px; background: linear-gradient(145deg, rgba(18,21,29,.98), rgba(10,12,18,.98)); box-shadow: 0 28px 90px rgba(0,0,0,.4); }
|
||||
.login-card { width: min(420px, 100%); padding: 32px; border: 1px solid var(--nx-line); border-radius: 14px; background: linear-gradient(145deg, rgba(18,21,29,.98), rgba(10,12,18,.98)); box-shadow: 0 28px 90px rgba(0,0,0,.4); }
|
||||
.login-brand { display: flex; align-items: center; gap: 12px; padding-bottom: 28px; border-bottom: 1px solid #1d222c; }
|
||||
.login-brand strong { display: block; font-size: 13px; letter-spacing: .14em; }
|
||||
.login-brand span { display: block; margin-top: 3px; color: var(--muted); font-size: 10px; }
|
||||
.login-brand span { display: block; margin-top: 3px; color: var(--nx-muted); font-size: 10px; }
|
||||
.login-heading { padding: 28px 0 20px; }
|
||||
.login-heading h1 { margin-top: 9px; font-size: 25px; }
|
||||
.login-heading p { margin: 0; color: var(--muted); font-size: 11px; line-height: 1.6; }
|
||||
.login-heading p { margin: 0; color: var(--nx-muted); font-size: 11px; line-height: 1.6; }
|
||||
.login-card form { display: grid; gap: 14px; }
|
||||
.login-card label span { display: block; margin-bottom: 7px; color: #aab1bf; font-size: 10px; }
|
||||
.login-card input { width: 100%; padding: 12px 13px; border: 1px solid #2a303b; border-radius: 8px; outline: none; color: #eef0f5; background: #0a0d12; font: inherit; font-size: 12px; }
|
||||
@@ -184,10 +184,10 @@ h1 { margin: 7px 0 5px; font-size: 27px; letter-spacing: -.04em; }
|
||||
|
||||
/* Project health row */
|
||||
.project-health-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 10px; }
|
||||
.health-card { padding: 12px 16px; border: 1px solid var(--line); border-radius: 9px; background: linear-gradient(145deg, rgba(18,21,29,.96), rgba(12,15,21,.96)); text-align: center; }
|
||||
.health-card { padding: 12px 16px; border: 1px solid var(--nx-line); border-radius: 9px; background: linear-gradient(145deg, rgba(18,21,29,.96), rgba(12,15,21,.96)); text-align: center; }
|
||||
.health-label { display: block; color: #717a8a; font-size: 8px; font-weight: 700; letter-spacing: .14em; text-transform: uppercase; }
|
||||
.health-card strong { display: block; margin-top: 6px; font-size: 22px; letter-spacing: -.04em; }
|
||||
.health-online { color: var(--green); }
|
||||
.health-online { color: var(--nx-green); }
|
||||
.health-degraded { color: #e7b660; }
|
||||
.health-offline { color: #e16e75; }
|
||||
.health-unknown { color: #7e8799; }
|
||||
@@ -195,9 +195,9 @@ h1 { margin: 7px 0 5px; font-size: 27px; letter-spacing: -.04em; }
|
||||
/* Runtime health indicators */
|
||||
.runtime-health-row { display: flex; align-items: center; gap: 6px; margin-top: 8px; }
|
||||
.runtime-health-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
|
||||
.runtime-health-dot.healthy { background: var(--green); box-shadow: 0 0 7px rgba(81,212,154,.4); }
|
||||
.runtime-health-dot.healthy { background: var(--nx-green); box-shadow: 0 0 7px rgba(81,212,154,.4); }
|
||||
.runtime-health-dot.unhealthy { background: #e7b660; box-shadow: 0 0 7px rgba(231,182,96,.3); }
|
||||
.runtime-health-text { font-size: 9px; color: var(--muted); }
|
||||
.runtime-health-text { font-size: 9px; color: var(--nx-muted); }
|
||||
.runtime-incident { margin-top: 6px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.incident-badge { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border: 1px solid rgba(229,176,94,.3); border-radius: 6px; background: rgba(229,176,94,.08); color: #e5b05e; font-size: 9px; }
|
||||
|
||||
@@ -209,7 +209,7 @@ h1 { margin: 7px 0 5px; font-size: 27px; letter-spacing: -.04em; }
|
||||
.snapshot-agent-item .name { font-size: 10px; color: #e8eaf0; }
|
||||
.snapshot-agent-item .role-tag { margin-left: auto; font-size: 8px; padding: 1px 6px; border: 1px solid #343947; border-radius: 6px; color: #8991a1; }
|
||||
.status-dot--sm { width: 5px; height: 5px; border-radius: 50%; flex-shrink: 0; }
|
||||
.status-dot--sm.online { background: var(--green); }
|
||||
.status-dot--sm.online { background: var(--nx-green); }
|
||||
.status-dot--sm.degraded { background: #e7b660; }
|
||||
.status-dot--sm.offline { background: #e16e75; }
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface Toast {
|
||||
id: number
|
||||
message: string
|
||||
type: 'success' | 'error' | 'info'
|
||||
durationMs: number
|
||||
}
|
||||
@@ -101,7 +101,9 @@ function onQueueExecuteNow(id: string): void {
|
||||
|
||||
<style scoped>
|
||||
.dashboard {
|
||||
display: grid; grid-template-columns: 280px 1fr 320px; gap: 14px;
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr 320px;
|
||||
gap: 14px;
|
||||
height: 100%; min-height: 0;
|
||||
animation: fade-in 0.35s ease-out;
|
||||
}
|
||||
@@ -118,7 +120,7 @@ function onQueueExecuteNow(id: string): void {
|
||||
|
||||
/* Quote Pill */
|
||||
.quote-pill {
|
||||
background: var(--panel);
|
||||
background: var(--nx-panel);
|
||||
border: 1px solid rgba(139, 124, 246, 0.25);
|
||||
border-radius: 14px;
|
||||
padding: 14px 22px;
|
||||
@@ -163,8 +165,8 @@ function onQueueExecuteNow(id: string): void {
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
padding: 12px 20px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--nx-panel);
|
||||
border: 1px solid var(--nx-line);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.legend-item {
|
||||
@@ -200,8 +202,42 @@ function onQueueExecuteNow(id: string): void {
|
||||
.col-right { display: flex; flex-direction: column; gap: 12px; overflow-y: auto; padding-left: 4px; }
|
||||
.missions-section { display: flex; flex-direction: column; gap: 8px; }
|
||||
.column-title { margin: 0; font-size: 13px; font-weight: 600; color: #e8eaf0; letter-spacing: 0.01em; }
|
||||
/* Tablet: 2 columns — left+center together, right column alongside */
|
||||
@media (max-width: 1100px) {
|
||||
.dashboard { grid-template-columns: 1fr; }
|
||||
.col-left, .col-center, .col-right { overflow: visible; padding: 0; }
|
||||
.dashboard {
|
||||
grid-template-columns: 1fr 320px;
|
||||
grid-template-rows: auto auto;
|
||||
}
|
||||
.col-left {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
}
|
||||
.col-center {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
}
|
||||
.col-right {
|
||||
grid-column: 2;
|
||||
grid-row: 1 / 3;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile: 1 column, everything stacked */
|
||||
@media (max-width: 768px) {
|
||||
.dashboard {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.col-left, .col-center, .col-right {
|
||||
grid-column: 1;
|
||||
grid-row: auto;
|
||||
overflow: visible;
|
||||
padding: 0;
|
||||
}
|
||||
.quote-pill { padding: 10px 14px; }
|
||||
.quote-text { font-size: 10px; }
|
||||
.team-title { font-size: 20px; }
|
||||
.legend-row { gap: 12px; padding: 8px 12px; flex-wrap: wrap; }
|
||||
.legend-item { font-size: 8px; gap: 4px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -439,7 +439,7 @@ onMounted(loadDocs)
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: #0d1016;
|
||||
border: 1px solid var(--line);
|
||||
border: 1px solid var(--nx-line);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.memory-rendered :deep(pre code) {
|
||||
@@ -461,7 +461,7 @@ onMounted(loadDocs)
|
||||
}
|
||||
.memory-rendered :deep(hr) {
|
||||
border: none;
|
||||
border-top: 1px solid var(--line);
|
||||
border-top: 1px solid var(--nx-line);
|
||||
margin: 1.2em 0;
|
||||
}
|
||||
.memory-rendered :deep(strong) {
|
||||
|
||||
@@ -410,7 +410,7 @@ onMounted(loadIncidents)
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: #0d1016;
|
||||
border: 1px solid var(--line);
|
||||
border: 1px solid var(--nx-line);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.incident-rendered :deep(pre code) {
|
||||
@@ -432,7 +432,7 @@ onMounted(loadIncidents)
|
||||
}
|
||||
.incident-rendered :deep(hr) {
|
||||
border: none;
|
||||
border-top: 1px solid var(--line);
|
||||
border-top: 1px solid var(--nx-line);
|
||||
margin: 1.2em 0;
|
||||
}
|
||||
.incident-rendered :deep(strong) {
|
||||
|
||||
@@ -417,7 +417,7 @@ onMounted(loadMemories)
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: #0d1016;
|
||||
border: 1px solid var(--line);
|
||||
border: 1px solid var(--nx-line);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.memory-rendered :deep(pre code) {
|
||||
@@ -439,7 +439,7 @@ onMounted(loadMemories)
|
||||
}
|
||||
.memory-rendered :deep(hr) {
|
||||
border: none;
|
||||
border-top: 1px solid var(--line);
|
||||
border-top: 1px solid var(--nx-line);
|
||||
margin: 1.2em 0;
|
||||
}
|
||||
.memory-rendered :deep(strong) {
|
||||
|
||||
@@ -282,8 +282,8 @@ onMounted(loadProject)
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.btn-icon:hover {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
background: var(--nx-accent-soft);
|
||||
color: var(--nx-accent);
|
||||
}
|
||||
.btn-icon.btn-danger:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
@@ -321,7 +321,7 @@ onMounted(loadProject)
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.4rem 0.8rem;
|
||||
background: var(--accent);
|
||||
background: var(--nx-accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
@@ -361,7 +361,7 @@ onMounted(loadProject)
|
||||
.progress-bar i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--accent), var(--accent-secondary));
|
||||
background: linear-gradient(90deg, var(--nx-accent), var(--accent-secondary));
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
@@ -403,7 +403,7 @@ onMounted(loadProject)
|
||||
.task-icon.done { color: rgb(34, 197, 94); }
|
||||
.task-icon.blocked { color: rgb(239, 68, 68); }
|
||||
.task-icon.backlog { color: var(--text-muted); }
|
||||
.task-icon.in-progress { color: var(--accent); }
|
||||
.task-icon.in-progress { color: var(--nx-accent); }
|
||||
.task-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -231,7 +231,7 @@ async function changePassword() {
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.55rem 1rem;
|
||||
background: var(--accent);
|
||||
background: var(--nx-accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
@@ -267,8 +267,8 @@ async function changePassword() {
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.5rem;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
background: var(--nx-accent-soft);
|
||||
color: var(--nx-accent);
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
|
||||
@@ -11,7 +11,11 @@
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"]
|
||||
"types": ["vite/client"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
|
||||
@@ -2,12 +2,18 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
},
|
||||
plugins: [vue(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8080',
|
||||
|
||||
Reference in New Issue
Block a user