Compare commits

...

21 Commits

Author SHA1 Message Date
devops ac4e1cd3cf chore: bump version to v0.2.51 [skip ci] 2026-06-13 19:04:36 +00:00
reviewer 01c9bda339 fix(ops): change api healthcheck from curl to wget (curl not in base dotnet-aspnet image) [skip ci] 2026-06-13 21:04:29 +02:00
devops 1b11793dad chore: bump version to v0.2.50 [skip ci] 2026-06-13 19:03:48 +00:00
reviewer 98f98b55d5 fix(ops): change web healthcheck from wget to curl (wget IPv6 causes false unhealthy on nginx-alpine) [skip ci] 2026-06-13 21:03:41 +02:00
devops f28c398d16 chore: bump version to v0.2.49 [skip ci] 2026-06-13 19:00:26 +00:00
reviewer 358ec3e65d feat(frontend): Dashboard V2 — FlowCanvas, AgentModal, IrisChat, Stores, PlaceholderViews, Icon-Library
CI - Build & Test / Backend (.NET) (push) Failing after 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-13 20:58:53 +02:00
devops 5f3d04f44c chore: bump version to v0.2.48 [skip ci] 2026-06-13 18:04:57 +00:00
reviewer d169cbe9d5 feat(ops): production resilience — healthchecks, restart_policy, log-rotation, --wait deploy [skip ci] 2026-06-13 20:04:42 +02:00
reviewer 6cedd8410f refactor(frontend): deduplicate CSS keyframes, unify types, extract format utils, add UI states, trim mock data
CI - Build & Test / Backend (.NET) (push) Failing after 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
- Remove duplicate @keyframes pulse-* from 3 component files (already in nexus-tokens.css)
- Rename AgentDetail → AgentDetailData in dashboard types to avoid collision with types/agent.ts
- Extract shared formatNumber/initials/formatTime to utils/format.ts
- Simplify FlowBoard: use agentStore modal/selection getters instead of duplicating local state
- Add error banner + empty state to IrisChat; add loading skeleton + error/empty states to TaskStrip
- Remove 105-line unused mockAgents array from useFlowLayout
- Reduce operations store fallbacks from hardcoded preview data to minimal safe defaults
- Update operations store tests to match lean fallback structure
- Net: -73 lines, cleaner imports, fewer magic strings
2026-06-12 17:02:50 +02:00
developer 9033ff2973 feat(v2): live sidebar counts, /dashboard = V2 default route, remove V1 dead code
CI - Build & Test / Backend (.NET) (push) Failing after 21s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 14s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-12 01:01:50 +02:00
developer 676dbd7589 feat(v2): Pinia stores (agents/tasks/chat) + live backend integration, remove mock data
CI - Build & Test / Backend (.NET) (push) Failing after 22s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-12 00:57:28 +02:00
developer 9330de7af0 feat(v2): AgentDetailModal — metrics grid, thinking feed, model dropdown, keyboard nav
CI - Build & Test / Backend (.NET) (push) Failing after 21s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-12 00:52:59 +02:00
developer 6023b5ea24 fix(v2): reviewer bugfixes — scroll, block-status, NaN guard, dead code cleanup
CI - Build & Test / Backend (.NET) (push) Failing after 22s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-12 00:51:42 +02:00
developer 166c9f9051 feat(v2): IrisChat + TaskStrip components, mock data integration
CI - Build & Test / Backend (.NET) (push) Failing after 20s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-12 00:48:13 +02:00
developer 2d6e3537e8 feat(v2): FlowCanvas, AgentNode, AlertBar, useFlowLayout composable
CI - Build & Test / Backend (.NET) (push) Failing after 21s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 2s
2026-06-12 00:24:56 +02:00
developer 3672e56994 feat(v2): NexusLayout, Sidebar, NavGroup, NavItem, Topbar, FlowBoard placeholder
CI - Build & Test / Backend (.NET) (push) Failing after 22s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 14s
CI - Build & Test / Security Check (push) Successful in 2s
2026-06-12 00:20:27 +02:00
developer f378d7aed4 feat(v2): design tokens, fonts (Manrope/Space Grotesk/JetBrains Mono), galaxy background
CI - Build & Test / Backend (.NET) (push) Failing after 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-12 00:16:01 +02:00
developer 1a7bf8ca11 revert: remove Claude models (API not working)
CI - Build & Test / Backend (.NET) (push) Failing after 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-11 18:11:02 +02:00
developer 3907548a1d feat(models): add Claude Sonnet 4.6 + Opus 4.8, mount openclaw.json in API container
CI - Build & Test / Backend (.NET) (push) Failing after 22s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-11 17:11:59 +02:00
developer b1888bd8ef feat(dashboard): AgentModal live working feed & thinking stream
CI - Build & Test / Backend (.NET) (push) Failing after 24s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 2s
2026-06-11 16:13:28 +02:00
developer c29740a466 feat(dashboard): dynamic agent metrics + model list from config, no more hardcoded data
CI - Build & Test / Backend (.NET) (push) Failing after 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-11 16:01:33 +02:00
56 changed files with 5132 additions and 4357 deletions
+8
View File
@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(npx tsc *)",
"Bash(npx vite *)"
]
}
}
+2 -2
View File
@@ -152,11 +152,11 @@ jobs:
if [ -n '${{ inputs.service }}' ]; then
echo '🚀 Deploying service: ${{ inputs.service }}'
docker compose build ${BUILD_ARGS} ${{ inputs.service }}
docker compose up -d --force-recreate ${{ inputs.service }}
docker compose up -d --wait --force-recreate ${{ inputs.service }}
else
echo '🚀 Deploying all services'
docker compose build ${BUILD_ARGS}
docker compose up -d --force-recreate
docker compose up -d --wait --force-recreate
fi
"
+1 -1
View File
@@ -1 +1 @@
0.2.47
0.2.51
+19 -6
View File
@@ -343,18 +343,31 @@ public class DashboardController(
}
}
/// <summary>
/// Returns the most recent activity entries (assistant messages) for a specific agent.
/// </summary>
[HttpGet("agents/{id}/activity")]
public async Task<List<AgentActivityEntry>> GetAgentActivity(string id, [FromQuery] int limit = 5)
{
try
{
return await gateway.GetAgentActivityAsync(id, Math.Clamp(limit, 1, 20));
}
catch (Exception ex)
{
logger.LogWarning(ex, "GetAgentActivity failed for {AgentId}", id);
return new List<AgentActivityEntry>();
}
}
/// <summary>
/// Returns the list of available models that can be assigned to agents.
/// Reads from OpenClaw config dynamically, falls back to hardcoded list.
/// </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")
};
var models = gateway.GetAvailableModels();
return Ok(models);
}
+1
View File
@@ -8,6 +8,7 @@ RUN dotnet publish -c Release -o /app/publish
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine
WORKDIR /app
COPY --from=build /app/publish .
RUN apk add --no-cache curl
USER $APP_UID
EXPOSE 8080
ENTRYPOINT ["dotnet", "Nexus.Api.dll"]
+9 -1
View File
@@ -8,7 +8,10 @@ public sealed record DashboardAgentInfo(
bool IsActive,
string? CurrentTask,
string? Description,
string[] Tags
string[] Tags,
int Progress = 0,
int Workload = 0,
string? Goal = null
);
public sealed record MessageEntry(
@@ -101,3 +104,8 @@ public sealed record UpdateDashboardTaskRequest(
public sealed record UpdateDashboardTaskStatusRequest(
string Status
);
public sealed record AgentActivityEntry(
string Time,
string Text
);
+291 -1
View File
@@ -119,6 +119,11 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
var agentIds = LoadAgentIdsFromConfig();
var agents = new List<DashboardAgentInfo>();
// Read queue once for workload calculation
var queueItems = new List<QueueItem>();
try { queueItems = await GetQueueAsync(); } catch { }
foreach (var id in agentIds)
{
// Skip the "main" agent (it's the default assistant, not a sub-agent)
@@ -177,6 +182,15 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
};
}
// 6. Read goal from workspace files
var goal = await ReadAgentGoalAsync(id);
// 7. Calculate progress dynamically
var progress = CalculateAgentProgress(id, isActive, status);
// 8. Calculate workload from queue items
var workload = CalculateAgentWorkload(id, queueItems);
agents.Add(new DashboardAgentInfo(
Id: id,
Name: string.IsNullOrWhiteSpace(name) ? DeriveRole(id) : name,
@@ -185,7 +199,10 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
IsActive: isActive,
CurrentTask: currentTask,
Description: description,
Tags: tags
Tags: tags,
Progress: progress,
Workload: workload,
Goal: goal
));
}
return agents;
@@ -770,6 +787,279 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
}
}
/// <summary>
/// Reads the agent's goal from workspace files (goals.md preferred, then AGENTS.md, SOUL.md).
/// Returns the first meaningful line or null.
/// </summary>
private async Task<string?> ReadAgentGoalAsync(string agentId)
{
try
{
var workspacePath = "/home/node/.openclaw/workspace-" + agentId;
// 1. Try goals.md first
var goalsPath = Path.Combine(workspacePath, "goals.md");
if (System.IO.File.Exists(goalsPath))
{
var content = await System.IO.File.ReadAllTextAsync(goalsPath);
foreach (var line in content.Split('\n'))
{
var trimmed = line.Trim();
if (!string.IsNullOrWhiteSpace(trimmed) && !trimmed.StartsWith('#') && !trimmed.StartsWith('-'))
return trimmed.Length > 120 ? trimmed[..117] + "..." : trimmed;
}
}
// 2. Try SOUL.md "## Rolle" section
var soulPath = Path.Combine(workspacePath, "SOUL.md");
if (System.IO.File.Exists(soulPath))
{
var soul = await System.IO.File.ReadAllTextAsync(soulPath);
using var reader = new StringReader(soul);
string? line;
var inRoleSection = false;
while ((line = reader.ReadLine()) is not null)
{
var trimmed = line.Trim();
if (trimmed.StartsWith("## ", StringComparison.OrdinalIgnoreCase))
{
if (inRoleSection) break;
if (trimmed.IndexOf("Rolle", StringComparison.OrdinalIgnoreCase) >= 0
|| trimmed.IndexOf("Role", StringComparison.OrdinalIgnoreCase) >= 0
|| trimmed.IndexOf("Oberstes", StringComparison.OrdinalIgnoreCase) >= 0)
{
inRoleSection = true;
}
continue;
}
if (inRoleSection && !string.IsNullOrWhiteSpace(trimmed) && !trimmed.StartsWith('-'))
{
return trimmed.Length > 120 ? trimmed[..117] + "..." : trimmed;
}
}
// 3. Look for "## Oberstes Prinzip" as second choice
inRoleSection = false;
reader = new StringReader(soul);
while ((line = reader.ReadLine()) is not null)
{
var trimmed = line.Trim();
if (trimmed.StartsWith("## ") && trimmed.IndexOf("Prinzip", StringComparison.OrdinalIgnoreCase) >= 0)
{
inRoleSection = true;
continue;
}
if (inRoleSection && !string.IsNullOrWhiteSpace(trimmed) && !trimmed.StartsWith('-') && !trimmed.StartsWith('#'))
{
return trimmed.Length > 120 ? trimmed[..117] + "..." : trimmed;
}
}
}
// 4. Try AGENTS.md first heading
var agentsPath = Path.Combine(workspacePath, "AGENTS.md");
if (System.IO.File.Exists(agentsPath))
{
var agentsContent = await System.IO.File.ReadAllTextAsync(agentsPath);
foreach (var line in agentsContent.Split('\n'))
{
var trimmed = line.Trim();
if (trimmed.StartsWith("# "))
return trimmed[2..].Trim();
}
}
}
catch
{
// Fallback to hardcoded
}
// Fallback: known goals
return agentId.ToLowerInvariant() switch
{
"iris" => "Mission Control — maximale Autonomie bei kontrolliertem Risiko",
"programmer" => "Nexus Dashboard & Dungeon System",
"reviewer" => "Zero critical findings before merge",
"architekt" => "Stabile Zero-Downtime-Deployments",
"executor" => "Sichere Host-Execution im Allowlist-Rahmen",
"researcher" => "Verifizierte, strukturierte Recherche-Ergebnisse",
_ => null
};
}
/// <summary>
/// Calculates agent progress (0-100) based on session activity.
/// Active agents with recent activity get higher scores.
/// Idle agents or inactive sessions get lower scores.
/// </summary>
private static int CalculateAgentProgress(string agentId, bool isActive, JsonNode? status)
{
if (!isActive)
return 0;
// Base progress from active status
var progress = 50;
// Boost for agents that have a current task
var hasTask = status?["currentTask"]?.GetValue<string>() is not null
|| status?["task"]?.GetValue<string>() is not null;
if (hasTask)
progress += 20;
// Check for last activity timestamp — more recent = higher progress
var lastActivity = status?["lastActivity"]?.GetValue<string>()
?? status?["lastMessage"]?.GetValue<string>();
if (lastActivity is not null && DateTimeOffset.TryParse(lastActivity, out var lastTs))
{
var minutesSinceActivity = (DateTimeOffset.UtcNow - lastTs).TotalMinutes;
if (minutesSinceActivity < 1)
progress += 25; // actively working
else if (minutesSinceActivity < 5)
progress += 15;
else if (minutesSinceActivity < 15)
progress += 10;
else
progress -= 10; // stale
}
else if (hasTask)
{
// Has task but no timestamp — assume actively working
progress += 15;
}
return Math.Clamp(progress, 0, 100);
}
/// <summary>
/// Calculates agent workload (0-100) based on queue items and active status.
/// More queued tasks or active sessions = higher workload.
/// </summary>
private static int CalculateAgentWorkload(string agentId, List<QueueItem> queueItems)
{
if (queueItems.Count == 0)
return 0;
// Calculate workload based on queue density per agent
var agentQueued = queueItems.Count(q =>
q.Name.Contains(agentId, StringComparison.OrdinalIgnoreCase)
|| q.Id.Contains(agentId, StringComparison.OrdinalIgnoreCase));
// Base workload from total queue pressure
var totalQueuePressure = Math.Min(queueItems.Count * 10, 60);
// Agent-specific queue items add extra weight
var agentPressure = agentQueued * 25;
return Math.Clamp(totalQueuePressure + agentPressure, 0, 100);
}
/// <summary>
/// Fetches the most recent assistant activity (last N messages) for a specific agent.
/// Returns entries with timestamp and truncated content text.
/// Falls back to an empty list if the session is unreachable.
/// </summary>
public async Task<List<AgentActivityEntry>> GetAgentActivityAsync(string agentId, int limit = 5)
{
var entries = new List<AgentActivityEntry>();
try
{
var sessionKey = $"agent:{agentId}:main";
var messages = await GetSessionHistoryAsync(sessionKey, Math.Clamp(limit * 2, 1, 100));
foreach (var msg in messages)
{
if (!string.Equals(msg.Role, "assistant", StringComparison.OrdinalIgnoreCase))
continue;
if (string.IsNullOrWhiteSpace(msg.Content))
continue;
if (msg.Content == "REPLY_SKIP" || msg.Content == "ANNOUNCE_SKIP")
continue;
// Truncate content to first 200 chars for compact display
var text = msg.Content.Length > 200
? msg.Content[..200] + "…"
: msg.Content;
var ts = ParseTimestamp(msg.Timestamp);
var timeAgo = FormatTimeAgo(ts);
entries.Add(new AgentActivityEntry(timeAgo, text));
}
}
catch
{
// Return empty list if gateway is unreachable
}
return entries.Take(Math.Clamp(limit, 1, 20)).ToList();
}
/// <summary>
/// Returns the list of available models by reading from the OpenClaw config,
/// with fallback to hardcoded list.
/// </summary>
public List<ModelOption> GetAvailableModels()
{
try
{
var configPath = configuration.GetValue<string>("AgentConfigPath")
?? "/home/node/.openclaw/openclaw.json";
if (!System.IO.File.Exists(configPath))
return GetDefaultModels();
var json = System.IO.File.ReadAllText(configPath);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
// Read models from agents.defaults.models
if (!root.TryGetProperty("agents", out var agentsEl))
return GetDefaultModels();
if (!agentsEl.TryGetProperty("defaults", out var defaultsEl))
return GetDefaultModels();
if (!defaultsEl.TryGetProperty("models", out var modelsEl))
return GetDefaultModels();
var models = new List<ModelOption>();
foreach (var modelProp in modelsEl.EnumerateObject())
{
var modelId = modelProp.Name;
var modelObj = modelProp.Value;
var name = modelId; // fallback: use model ID as name
var provider = ExtractProvider(modelId);
// Check for alias in the model object
if (modelObj.TryGetProperty("alias", out var aliasEl))
{
var alias = aliasEl.GetString();
if (!string.IsNullOrWhiteSpace(alias))
name = alias;
}
models.Add(new ModelOption(modelId, name, provider));
}
return models.Count > 0 ? models : GetDefaultModels();
}
catch
{
return GetDefaultModels();
}
}
private static List<ModelOption> GetDefaultModels()
=> new()
{
new ModelOption("openai/gpt-5.4", "GPT-5.4", "openai"),
new ModelOption("openai/gpt-5.5", "GPT-5.5", "openai"),
new ModelOption("deepseek/deepseek-v4-flash", "DeepSeek V4 Flash", "deepseek"),
new ModelOption("deepseek/deepseek-v4-pro", "DeepSeek V4 Pro", "deepseek")
};
private static string ExtractProvider(string modelId)
{
var slash = modelId.IndexOf('/');
return slash > 0 ? modelId[..slash] : "unknown";
}
private static string DeriveRole(string agentId) => agentId.ToLowerInvariant() switch
{
"iris" => "Chief of Staff",
+41
View File
@@ -15,12 +15,24 @@ services:
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
networks: [nexus]
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
api:
build:
context: ./backend
restart: unless-stopped
deploy:
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
window: 120s
environment:
ASPNETCORE_ENVIRONMENT: Production
ASPNETCORE_URLS: http://+:8080
@@ -40,7 +52,14 @@ services:
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
volumes:
- /opt/openclaw/data/openclaw/openclaw.json:/home/node/.openclaw/openclaw.json:ro
- /opt/openclaw/data/openclaw/workspace-iris:/mnt/workspace-iris
- /opt/openclaw/data/openclaw/workspace-programmer:/mnt/workspace-programmer
- /opt/openclaw/data/openclaw/workspace-reviewer:/mnt/workspace-reviewer
@@ -50,15 +69,37 @@ services:
networks:
- nexus
- openclaw_default
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
web:
build:
context: ./frontend
restart: unless-stopped
deploy:
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
window: 120s
ports:
- "127.0.0.1:18880:80"
depends_on: [api]
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:80/ || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
networks: [nexus]
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
networks:
nexus:
+3
View File
@@ -5,6 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#080a0f" />
<title>Nexus | Noveria Operations</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Manrope:wght@400;500;600;700;800&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="app"></div>
+1 -1
View File
@@ -40,7 +40,7 @@ onMounted(() => {
</script>
<template>
<RouterView v-if="route.name === 'Login'" />
<RouterView v-if="route.name === 'Login' || route.name === 'Dashboard'" />
<div v-else class="shell">
<AppSidebar
:active-view="activeView"
+47 -1
View File
@@ -4,6 +4,52 @@
@custom-variant dark (&:is(.dark *));
/* ── Nexus V2 Theme (Tailwind v4 @theme directive) ── */
@theme {
/* Font families */
--font-sans: 'Manrope', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-display: 'Space Grotesk', sans-serif;
--font-mono: 'JetBrains Mono', monospace;
/* Space surfaces */
--color-space-0: #050410;
--color-space-1: #0a0818;
--color-space-2: #0e0c20;
--color-space-3: #141130;
--color-space-4: #1b1742;
/* Glass */
--color-glass: rgba(20, 17, 48, 0.55);
--color-glass-2: rgba(28, 24, 64, 0.55);
/* Lines */
--color-line: rgba(150, 140, 255, 0.10);
--color-line-2: rgba(150, 140, 255, 0.18);
--color-line-3: rgba(150, 140, 255, 0.30);
/* Text */
--color-tx: #ece9ff;
--color-tx-2: #a8a3d6;
--color-tx-3: #6f6aa0;
/* Accent */
--color-a-blue: #4f7cff;
--color-a-purple: #b557f6;
--color-a-mid: #7c6cff;
/* Status */
--color-st-work: #3ddc97;
--color-st-think: #34d6f5;
--color-st-queue: #fbbf24;
--color-st-block: #fb7185;
--color-st-idle: #6b6796;
/* Border radius */
--radius-r: 14px;
--radius-r-sm: 10px;
--radius-r-lg: 20px;
}
:root {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
@@ -39,7 +85,7 @@
body {
background: hsl(var(--background));
color: hsl(var(--foreground));
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
font-family: 'Manrope', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
'Segoe UI', sans-serif;
margin: 0;
min-width: 320px;
+110
View File
@@ -0,0 +1,110 @@
/* ================================================================
nexus-tokens.css — Nexus Mission Control V2 Design Tokens
Geladen NACH main.css, überschreibt shadcn/v1-Standards.
================================================================ */
:root {
/* ── Surfaces ─────────────────────────────────────── */
--space-0: #050410;
--space-1: #0a0818;
--space-2: #0e0c20;
--space-3: #141130;
--space-4: #1b1742;
--glass: rgba(20, 17, 48, 0.55);
--glass-2: rgba(28, 24, 64, 0.55);
/* ── Lines ────────────────────────────────────────── */
--line: rgba(150, 140, 255, 0.10);
--line-2: rgba(150, 140, 255, 0.18);
--line-3: rgba(150, 140, 255, 0.30);
/* ── Text ─────────────────────────────────────────── */
--tx: #ece9ff;
--tx-2: #a8a3d6;
--tx-3: #6f6aa0;
/* ── Accent Gradient ──────────────────────────────── */
--a-blue: #4f7cff;
--a-purple: #b557f6;
--a-mid: #7c6cff;
--grad: linear-gradient(120deg, var(--a-blue), var(--a-purple));
--grad-soft: linear-gradient(120deg, rgba(79,124,255,.18), rgba(181,87,246,.18));
/* ── Status ───────────────────────────────────────── */
--st-work: #3ddc97;
--st-think: #34d6f5;
--st-queue: #fbbf24;
--st-block: #fb7185;
--st-idle: #6b6796;
/* ── Glows ────────────────────────────────────────── */
--glow: 0 0 0 1px rgba(124,108,255,.20), 0 0 28px -4px rgba(124,108,255,.55);
--glow-blue: 0 0 24px -2px rgba(79,124,255,.65);
--glow-purple: 0 0 24px -2px rgba(181,87,246,.60);
--glow-work: 0 0 16px -1px rgba(61,220,151,.70);
--glow-think: 0 0 16px -1px rgba(52,214,245,.65);
/* ── Radius ───────────────────────────────────────── */
--r: 14px;
--r-sm: 10px;
--r-lg: 20px;
/* ── Layout ───────────────────────────────────────── */
--sidebar-w: 248px;
--topbar-h: 62px;
--rail-w: 360px;
}
/* ── Glass card utility ────────────────────────────── */
.glass-panel {
background: var(--glass);
border: 1px solid var(--line);
border-radius: var(--r);
backdrop-filter: blur(12px);
}
/* ── Gradient text ─────────────────────────────────── */
.grad-text {
background: var(--grad);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
/* ── Status dot keyframes ──────────────────────────── */
@keyframes pulse-work {
0% { box-shadow: 0 0 0 0 rgba(61,220,151,.55); }
70% { box-shadow: 0 0 0 7px rgba(61,220,151,0); }
100% { box-shadow: 0 0 0 0 rgba(61,220,151,0); }
}
@keyframes pulse-think {
0% { box-shadow: 0 0 0 0 rgba(52,214,245,.55); }
70% { box-shadow: 0 0 0 7px rgba(52,214,245,0); }
100% { box-shadow: 0 0 0 0 rgba(52,214,245,0); }
}
@keyframes pulse-block {
0% { box-shadow: 0 0 0 0 rgba(251,113,133,.55); }
70% { box-shadow: 0 0 0 7px rgba(251,113,133,0); }
100% { box-shadow: 0 0 0 0 rgba(251,113,133,0); }
}
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(180%); }
}
/* ── V2 Scrollbars ─────────────────────────────────── */
.v2-scroll::-webkit-scrollbar { width: 9px; height: 9px; }
.v2-scroll::-webkit-scrollbar-thumb {
background: rgba(124,108,255,.22);
border-radius: 9px;
border: 2px solid transparent;
background-clip: padding-box;
}
.v2-scroll::-webkit-scrollbar-thumb:hover {
background: rgba(124,108,255,.4);
background-clip: padding-box;
}
.v2-scroll::-webkit-scrollbar-track { background: transparent; }
/* ── Typography helpers ────────────────────────────── */
.font-display { font-family: 'Space Grotesk', sans-serif; }
.font-mono-v2 { font-family: 'JetBrains Mono', monospace; font-variant-numeric: tabular-nums; }
@@ -0,0 +1,249 @@
<script setup lang="ts">
/**
* GalaxyBackground Canvas Starfield + Aurora Blobs
*
* Ported from assets/galaxy.js (design_handoff_nexus_v2).
* Auto-mounts onMounted; cleans up onUnmounted.
* Fixed overlay with z-index:0 and pointer-events:none.
*/
import { onMounted, onUnmounted, ref } from 'vue'
const rootRef = ref<HTMLElement | null>(null)
interface Star {
x: number
y: number
r: number
a: number
tw: number
ph: number
hue: number
dx: number
dy: number
}
interface Shoot {
x: number
y: number
len: number
sp: number
ang: number
life: number
}
let resizeObserver: ResizeObserver | null = null
let animFrameId = 0
let canvas: HTMLCanvasElement | null = null
let ctx: CanvasRenderingContext2D | null = null
function mount(root: HTMLElement) {
canvas = document.createElement('canvas')
root.appendChild(canvas)
ctx = canvas.getContext('2d')
if (!ctx) return
const dpr = Math.min(window.devicePixelRatio || 1, 2)
let stars: Star[] = []
let shoots: Shoot[] = []
let W = 0
let H = 0
let t = 0
function resize() {
const r = root.getBoundingClientRect()
W = r.width
H = r.height
canvas!.width = W * dpr
canvas!.height = H * dpr
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0)
const count = Math.round((W * H) / 5200)
stars = []
for (let i = 0; i < count; i++) {
stars.push({
x: Math.random() * W,
y: Math.random() * H,
r: Math.random() * 1.3 + 0.25,
a: Math.random() * 0.6 + 0.2,
tw: Math.random() * 0.025 + 0.004,
ph: Math.random() * Math.PI * 2,
hue: Math.random() < 0.5 ? 230 : 270,
dx: (Math.random() - 0.5) * 0.04,
dy: (Math.random() - 0.5) * 0.04,
})
}
}
function spawnShoot() {
const fromLeft = Math.random() < 0.6
shoots.push({
x: fromLeft ? -40 : W * (0.4 + Math.random() * 0.5),
y: Math.random() * H * 0.5,
len: 90 + Math.random() * 120,
sp: 6 + Math.random() * 5,
ang: Math.random() * 0.3 + 0.15,
life: 1,
})
}
function frame() {
ctx!.clearRect(0, 0, W, H)
t += 1
// Draw stars
for (let i = 0; i < stars.length; i++) {
const s = stars[i]
s.ph += s.tw
const a = s.a * (0.55 + 0.45 * Math.sin(s.ph))
s.x += s.dx
s.y += s.dy
if (s.x < 0) s.x = W
if (s.x > W) s.x = 0
if (s.y < 0) s.y = H
if (s.y > H) s.y = 0
ctx!.beginPath()
ctx!.fillStyle = `hsla(${s.hue},90%,82%,${a})`
ctx!.arc(s.x, s.y, s.r, 0, Math.PI * 2)
ctx!.fill()
// Glow for larger stars
if (s.r > 1) {
ctx!.beginPath()
ctx!.fillStyle = `hsla(${s.hue},95%,80%,${a * 0.12})`
ctx!.arc(s.x, s.y, s.r * 3.5, 0, Math.PI * 2)
ctx!.fill()
}
}
// Shooting stars
if (Math.random() < 0.004 && shoots.length < 2) spawnShoot()
for (let j = shoots.length - 1; j >= 0; j--) {
const sh = shoots[j]
sh.x += Math.cos(sh.ang) * sh.sp
sh.y += Math.sin(sh.ang) * sh.sp
sh.life -= 0.006
const ex = sh.x - Math.cos(sh.ang) * sh.len
const ey = sh.y - Math.sin(sh.ang) * sh.len
const g = ctx!.createLinearGradient(sh.x, sh.y, ex, ey)
g.addColorStop(0, `rgba(200,210,255,${0.9 * sh.life})`)
g.addColorStop(1, 'rgba(160,120,255,0)')
ctx!.strokeStyle = g
ctx!.lineWidth = 2
ctx!.lineCap = 'round'
ctx!.beginPath()
ctx!.moveTo(sh.x, sh.y)
ctx!.lineTo(ex, ey)
ctx!.stroke()
if (sh.life <= 0 || sh.x > W + 60 || sh.y > H + 60) shoots.splice(j, 1)
}
animFrameId = requestAnimationFrame(frame)
}
resizeObserver = new ResizeObserver(() => resize())
resizeObserver.observe(root)
resize()
frame()
}
function cleanup() {
if (resizeObserver) {
resizeObserver.disconnect()
resizeObserver = null
}
if (animFrameId) {
cancelAnimationFrame(animFrameId)
animFrameId = 0
}
if (canvas && canvas.parentNode) {
canvas.parentNode.removeChild(canvas)
}
canvas = null
ctx = null
}
onMounted(() => {
if (rootRef.value) mount(rootRef.value)
})
onUnmounted(() => {
cleanup()
})
</script>
<template>
<div ref="rootRef" class="galaxy-bg">
<div class="aurora a1"></div>
<div class="aurora a2"></div>
<div class="aurora a3"></div>
</div>
</template>
<style scoped>
.galaxy-bg {
position: fixed;
inset: 0;
z-index: 0;
overflow: hidden;
background:
radial-gradient(1200px 800px at 18% -8%, rgba(79,124,255,.20), transparent 60%),
radial-gradient(1000px 760px at 92% 8%, rgba(181,87,246,.18), transparent 60%),
radial-gradient(900px 700px at 60% 110%, rgba(70,60,180,.20), transparent 60%),
linear-gradient(180deg, #070512, #0a0818 60%, #060410);
pointer-events: none;
}
.galaxy-bg canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.aurora {
position: absolute;
border-radius: 50%;
filter: blur(70px);
opacity: 0.5;
mix-blend-mode: screen;
will-change: transform;
}
.a1 {
width: 46vw;
height: 46vw;
left: -8vw;
top: -14vw;
background: radial-gradient(circle, rgba(79,124,255,.55), transparent 65%);
animation: drift1 26s ease-in-out infinite;
}
.a2 {
width: 40vw;
height: 40vw;
right: -10vw;
top: 2vw;
background: radial-gradient(circle, rgba(181,87,246,.5), transparent 65%);
animation: drift2 32s ease-in-out infinite;
}
.a3 {
width: 42vw;
height: 42vw;
left: 34vw;
bottom: -18vw;
background: radial-gradient(circle, rgba(95,80,220,.45), transparent 65%);
animation: drift3 30s ease-in-out infinite;
}
@keyframes drift1 {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(8vw, 6vw) scale(1.12); }
}
@keyframes drift2 {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(-7vw, 5vw) scale(1.1); }
}
@keyframes drift3 {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(4vw, -6vw) scale(1.15); }
}
</style>
@@ -1,201 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Clock } from '@lucide/vue'
type InitiativeStatus = 'healthy' | 'attention' | 'blocked' | 'paused' | 'completed'
interface Initiative {
title: string
progress: number
openTasks: number
blockers: number
status: InitiativeStatus
lastActivity: string
}
const initiatives = ref<Initiative[]>([
{ title: 'OpenClaw Companion', progress: 55, openTasks: 7, blockers: 2, status: 'healthy', lastActivity: 'vor 8 Minuten' },
{ title: '2D Idle Game', progress: 42, openTasks: 4, blockers: 0, status: 'healthy', lastActivity: 'vor 2 Stunden' },
{ title: 'Deutsch B2', progress: 73, openTasks: 3, blockers: 0, status: 'attention', lastActivity: 'vor 1 Stunde' },
{ title: 'Nexus Dashboard', progress: 60, openTasks: 3, blockers: 0, status: 'healthy', lastActivity: 'vor 5 Minuten' },
])
const statusMeta: Record<InitiativeStatus, { label: string; color: string; bg: string }> = {
healthy: { label: 'Healthy', color: '#22c55e', bg: 'rgba(34,197,94,0.1)' },
attention: { label: 'Needs Attention', color: '#eab308', bg: 'rgba(234,179,8,0.1)' },
blocked: { label: 'Blocked', color: '#ef4444', bg: 'rgba(239,68,68,0.1)' },
paused: { label: 'Paused', color: '#6b7280', bg: 'rgba(107,114,128,0.1)' },
completed: { label: 'Completed', color: '#3b82f6', bg: 'rgba(59,130,246,0.1)' },
}
function onInitiativeClick(title: string) {
console.log('[Dashboard] Open initiative:', title)
}
</script>
<template>
<div class="initiatives-section">
<h2>Active Initiatives</h2>
<div class="initiatives-grid">
<div
v-for="(init, idx) in initiatives"
:key="idx"
:class="['initiative-card', 'status-' + init.status]"
@click="onInitiativeClick(init.title)"
role="button"
tabindex="0"
@keyup.enter="onInitiativeClick(init.title)"
>
<div class="init-head">
<h3>{{ init.title }}</h3>
<span
class="status-badge"
:style="{
color: statusMeta[init.status].color,
background: statusMeta[init.status].bg,
}"
>
{{ statusMeta[init.status].label }}
</span>
</div>
<div class="progress-bar">
<div
class="progress-fill"
:style="{ width: init.progress + '%' }"
></div>
</div>
<div class="progress-label">{{ init.progress }}%</div>
<div class="init-stats">
<span>{{ init.openTasks }} offene Aufgaben</span>
<span v-if="init.blockers">&middot; {{ init.blockers }} Blocker</span>
</div>
<div class="init-meta">
<Clock :size="11" />
<span>Letzte Aktivität {{ init.lastActivity }}</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.initiatives-section {
display: flex;
flex-direction: column;
gap: 10px;
padding: 18px;
background: rgba(22, 27, 34, 0.8);
border: 1px solid rgba(139, 124, 246, 0.12);
border-radius: 16px;
box-shadow: 0 4px 24px rgba(0,0,0,0.3);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
transition: all 0.2s ease;
}
.initiatives-section:hover {
border-color: rgba(139, 124, 246, 0.18);
}
.initiatives-section h2 {
font-size: 14px;
font-weight: 600;
margin: 0;
color: #e8eaf0;
}
.initiatives-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 10px;
}
.initiative-card {
background: rgba(13, 17, 23, 0.5);
border: 1px solid rgba(139, 124, 246, 0.08);
border-radius: 14px;
padding: 14px;
cursor: pointer;
transition: all 0.2s ease;
}
.initiative-card:hover {
transform: scale(1.02);
border-color: rgba(139, 124, 246, 0.2);
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
}
.initiative-card:focus-visible {
outline: 2px solid #a78bfa;
outline-offset: 2px;
}
.init-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
gap: 8px;
}
.init-head h3 {
font-size: 12px;
font-weight: 600;
margin: 0;
color: #e8eaf0;
}
.status-badge {
font-size: 8px;
font-weight: 700;
padding: 2px 7px;
border-radius: 12px;
white-space: nowrap;
flex-shrink: 0;
letter-spacing: 0.02em;
}
.progress-bar {
height: 4px;
background: rgba(139, 124, 246, 0.1);
border-radius: 4px;
margin-bottom: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 4px;
background: linear-gradient(90deg, #a78bfa, #8b5cf6);
transition: width 0.5s ease;
}
.initiative-card.status-attention .progress-fill {
background: linear-gradient(90deg, #eab308, #f59e0b);
}
.initiative-card.status-blocked .progress-fill {
background: linear-gradient(90deg, #ef4444, #dc2626);
}
.progress-label {
font-size: 10px;
color: #6b7385;
margin-bottom: 4px;
}
.init-stats {
font-size: 9px;
color: #6b7385;
margin-bottom: 4px;
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.init-meta {
display: flex;
align-items: center;
gap: 4px;
font-size: 9px;
color: #6b7385;
}
.init-meta svg {
flex-shrink: 0;
}
@media (max-width: 900px) {
.initiatives-grid {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 600px) {
.initiatives-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -1,229 +0,0 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { CheckCircle2, Circle, AlertTriangle } from '@lucide/vue'
interface AgendaItem {
text: string
time?: string
done?: boolean
overdue?: boolean
}
const STORAGE_KEY = 'nexus-agenda-done'
const agendaToday = ref<AgendaItem[]>([
{ text: 'Teammeeting', time: '14:00' },
{ text: 'Deutsch lernen', time: '18:00' },
{ text: 'Steuerunterlagen prüfen' },
{ text: 'Dungeon-Balance abschließen' },
])
const agendaTomorrow = ref<AgendaItem[]>([
{ text: 'GitHub Issue #23' },
{ text: 'Backup überprüfen' },
])
const agendaOverdue = ref<AgendaItem[]>([
{ text: 'Hangfire konfigurieren', overdue: true },
])
function loadDoneStates() {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return
const keys: string[] = JSON.parse(raw)
const set = new Set(keys)
const sections = [
{ items: agendaToday.value, prefix: 'today' },
{ items: agendaTomorrow.value, prefix: 'tomorrow' },
{ items: agendaOverdue.value, prefix: 'overdue' },
]
for (const { items, prefix } of sections) {
items.forEach((item, i) => {
if (set.has(`${prefix}-${i}`)) item.done = true
})
}
} catch { /* ignore malformed storage */ }
}
function saveDoneStates() {
const keys: string[] = []
const sections = [
{ items: agendaToday.value, prefix: 'today' },
{ items: agendaTomorrow.value, prefix: 'tomorrow' },
{ items: agendaOverdue.value, prefix: 'overdue' },
]
for (const { items, prefix } of sections) {
items.forEach((item, i) => {
if (item.done) keys.push(`${prefix}-${i}`)
})
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(keys))
}
function toggleAgendaItem(item: AgendaItem) {
item.done = !item.done
saveDoneStates()
}
onMounted(() => {
loadDoneStates()
})
</script>
<template>
<div class="agenda-panel">
<h2>Agenda</h2>
<div class="agenda-section">
<h3>Heute</h3>
<div
v-for="(item, idx) in agendaToday"
:key="'today-' + idx"
:class="['agenda-item', { done: item.done }]"
@click="toggleAgendaItem(item)"
>
<button class="agenda-check">
<CheckCircle2 v-if="item.done" :size="14" class="checked" />
<Circle v-else :size="14" />
</button>
<span class="agenda-text">{{ item.text }}</span>
<span v-if="item.time" class="agenda-time">{{ item.time }}</span>
</div>
</div>
<div class="agenda-section">
<h3>Morgen</h3>
<div
v-for="(item, idx) in agendaTomorrow"
:key="'tomorrow-' + idx"
:class="['agenda-item', { done: item.done }]"
@click="toggleAgendaItem(item)"
>
<button class="agenda-check">
<CheckCircle2 v-if="item.done" :size="14" class="checked" />
<Circle v-else :size="14" />
</button>
<span class="agenda-text">{{ item.text }}</span>
</div>
</div>
<div class="agenda-section">
<h3 class="overdue-heading">
<AlertTriangle :size="12" />
Überfällig
</h3>
<div
v-for="(item, idx) in agendaOverdue"
:key="'overdue-' + idx"
class="agenda-item overdue"
>
<button class="agenda-check">
<AlertTriangle :size="14" class="overdue-icon" />
</button>
<span class="agenda-text">{{ item.text }}</span>
<span class="agenda-sub">seit 2 Tagen</span>
</div>
</div>
</div>
</template>
<style scoped>
.agenda-panel {
display: flex;
flex-direction: column;
gap: 10px;
padding: 18px;
background: rgba(22, 27, 34, 0.8);
border: 1px solid rgba(139, 124, 246, 0.12);
border-radius: 16px;
box-shadow: 0 4px 24px rgba(0,0,0,0.3);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
transition: all 0.2s ease;
}
.agenda-panel:hover {
border-color: rgba(139, 124, 246, 0.18);
}
.agenda-panel h2 {
font-size: 14px;
font-weight: 600;
margin: 0;
color: #e8eaf0;
}
.agenda-section h3 {
display: flex;
align-items: center;
gap: 5px;
font-size: 10px;
font-weight: 700;
color: #6b7385;
text-transform: uppercase;
letter-spacing: 0.08em;
margin: 0 0 4px;
padding-bottom: 4px;
border-bottom: 1px solid rgba(139, 124, 246, 0.06);
}
.overdue-heading {
color: #ef4444 !important;
border-bottom-color: rgba(239, 68, 68, 0.15) !important;
}
.agenda-item {
display: flex;
align-items: center;
gap: 7px;
padding: 5px 6px;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
}
.agenda-item:hover {
background: rgba(139, 124, 246, 0.04);
}
.agenda-item.done {
opacity: 0.5;
}
.agenda-item.done .agenda-text {
text-decoration: line-through;
}
.agenda-check {
display: grid;
place-items: center;
background: none;
border: none;
color: #6b7385;
padding: 0;
cursor: pointer;
flex-shrink: 0;
}
.agenda-check .checked {
color: #22c55e;
}
.overdue .overdue-icon {
color: #ef4444;
}
.agenda-text {
flex: 1;
font-size: 10.5px;
color: #7e8799;
}
.agenda-time {
font-size: 9px;
color: #6b7385;
flex-shrink: 0;
}
.agenda-sub {
font-size: 8px;
color: #ef4444;
flex-shrink: 0;
}
.agenda-item.overdue {
background: rgba(239, 68, 68, 0.06);
}
@media (max-width: 900px) {
.agenda-panel {
order: 3;
}
}
</style>
@@ -1,441 +0,0 @@
<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'
const props = defineProps<{
agent: AgentNodeData
runtime: string
}>()
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>
<Teleport to="body">
<div class="modal-overlay" @click.self="$emit('close')">
<div class="modal-card" :style="{ '--agent-color': agent.color }">
<!-- Header -->
<div class="modal-header">
<div class="modal-title-row">
<div class="modal-avatar" :style="{ background: `${agent.color}18`, color: agent.color }">
<span class="avatar-letter">{{ agent.name.charAt(0) }}</span>
</div>
<div>
<h2>{{ agent.name }}</h2>
<span class="modal-role">{{ agent.role }}</span>
<a :href="`/agents/${agent.id}`" class="agent-link-btn" title="Open agent config">
<ExternalLink :size="12" />
</a>
</div>
</div>
<Button variant="ghost" size="icon" class="h-8 w-8" @click="$emit('close')" aria-label="Close">
<X :size="16" />
</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 v-if="agent.active" class="thinking-dots">
<span class="thinking-dot blue"></span>
<span class="thinking-dot violet"></span>
</span>
</p>
</section>
<!-- Goal + Progress -->
<section class="modal-section">
<h3 class="section-label">Goal</h3>
<p class="section-value">{{ agent.goal }}</p>
<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>
</div>
</section>
<!-- Model -->
<section class="modal-section">
<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"
>
{{ saving ? 'Saving...' : 'Save' }}
</Button>
</div>
</section>
<!-- Footer Stats -->
<div class="modal-footer">
<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>
</Teleport>
</template>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: grid;
place-items: center;
padding: 24px;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(4px);
animation: overlay-in 0.2s ease;
}
@keyframes overlay-in {
from { opacity: 0; }
to { opacity: 1; }
}
.modal-card {
width: min(480px, 100%);
max-height: 80vh;
overflow-y: auto;
padding: 24px;
background: rgba(18, 22, 30, 0.96);
border: 1px solid color-mix(in srgb, var(--agent-color) 25%, transparent);
border-radius: 16px;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5);
animation: card-in 0.25s ease;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
@keyframes card-in {
from { opacity: 0; transform: translateY(12px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.modal-card::-webkit-scrollbar {
width: 5px;
}
.modal-card::-webkit-scrollbar-track {
background: transparent;
}
.modal-card::-webkit-scrollbar-thumb {
background: rgba(139, 124, 246, 0.2);
border-radius: 3px;
}
.modal-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 16px;
}
.modal-title-row {
display: flex;
align-items: center;
gap: 14px;
}
.modal-avatar {
width: 44px;
height: 44px;
display: grid;
place-items: center;
border-radius: 12px;
flex-shrink: 0;
}
.avatar-letter {
font-size: 18px;
font-weight: 700;
}
.modal-title-row h2 {
margin: 0;
font-size: 18px;
font-weight: 700;
color: #e8eaf0;
}
.modal-role {
font-size: 10px;
color: #6b7385;
font-weight: 500;
}
.agent-link-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
margin-left: 4px;
border: none;
border-radius: 6px;
background: transparent;
color: #6b7385;
opacity: 0.4;
cursor: pointer;
transition: opacity 0.2s;
flex-shrink: 0;
text-decoration: none;
vertical-align: middle;
}
.agent-link-btn:hover {
opacity: 1;
color: var(--agent-color);
}
.modal-desc {
font-size: 11px;
line-height: 1.55;
color: #7e8799;
margin: 0 0 18px;
padding-bottom: 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.modal-section {
margin-bottom: 16px;
}
.section-label {
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #6b7385;
margin: 0 0 6px;
}
.section-value {
margin: 0;
font-size: 12px;
color: #e8eaf0;
line-height: 1.4;
display: flex;
align-items: center;
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;
align-items: center;
gap: 10px;
margin-top: 8px;
}
.progress-pct {
font-size: 11px;
font-weight: 600;
color: #7e8799;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
.progress-track {
flex: 1;
height: 4px;
background: rgba(255, 255, 255, 0.06);
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 4px;
background: var(--agent-color);
transition: width 0.5s ease;
}
/* Thinking Dots */
.thinking-dots {
display: inline-flex;
gap: 6px;
flex-shrink: 0;
}
.thinking-dot {
width: 7px;
height: 7px;
border-radius: 50%;
}
.thinking-dot.blue {
background: #3b82f6;
box-shadow: 0 0 8px #3b82f6;
animation: pulse-dot-blue 1.2s ease-in-out infinite;
}
.thinking-dot.violet {
background: #8b7cf6;
box-shadow: 0 0 8px #8b7cf6;
animation: pulse-dot-violet 1.8s ease-in-out infinite 0.3s;
}
@keyframes pulse-dot-blue {
0%, 100% { opacity: 0.4; transform: scale(0.7); }
50% { opacity: 1; transform: scale(1.3); }
}
@keyframes pulse-dot-violet {
0%, 100% { opacity: 0.3; transform: scale(0.6); }
50% { opacity: 1; transform: scale(1.4); }
}
/* Footer */
.modal-footer {
display: flex;
gap: 10px;
padding-top: 14px;
margin-top: 6px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.footer-badge {
font-size: 9px;
font-weight: 600;
padding: 4px 10px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.04);
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,90 +0,0 @@
<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,291 +0,0 @@
<script setup lang="ts">
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[]
irisBusy: boolean
irisFocus: string
}>()
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 dialogOpen = ref(false)
function sendMessage(): void {
if (!inputText.value.trim()) return
sendChatMessage(inputText.value)
inputText.value = ''
}
watch(
() => props.messages.length,
async () => {
await nextTick()
const el = dialogOpen.value ? chatModalListRef.value : chatListRef.value
if (el) {
el.scrollTop = el.scrollHeight
}
}
)
</script>
<template>
<!-- Inline Chat Panel -->
<div class="chat-panel">
<div class="chat-header">
<div class="chat-header-left">
<Bot :size="16" class="text-[#a78bfa]" />
<h2>Iris Chat</h2>
</div>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="dialogOpen = true" title="Open larger chat">
<Maximize2 :size="14" />
</Button>
</div>
<!-- Focus Bar -->
<div v-if="irisBusy && irisFocus" class="focus-bar">
<span class="focus-label">Current Focus</span>
<span class="focus-text">{{ irisFocus }}</span>
</div>
<!-- Messages -->
<div ref="chatListRef" class="chat-messages">
<ChatMessageList
:messages="messages"
:iris-busy="irisBusy"
:elapsed-seconds="elapsedSeconds"
/>
</div>
<!-- Input -->
<div class="chat-input-row">
<Textarea
v-model="inputText"
rows="1"
placeholder="Type a message..."
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
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>
</div>
</div>
<!-- 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">&times;</span>
</Button>
</DialogHeader>
<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 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>
.chat-panel {
display: flex;
flex-direction: column;
flex: 1;
min-height: 360px;
max-height: 480px;
background: rgba(22, 27, 34, 0.75);
border: 1px solid rgba(139, 124, 246, 0.12);
border-radius: 16px;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
transition: border-color 0.2s ease;
overflow: hidden;
}
.chat-panel:hover {
border-color: rgba(139, 124, 246, 0.18);
}
.chat-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.chat-header-left {
display: flex;
align-items: center;
gap: 8px;
}
.chat-header h2 {
margin: 0;
font-size: 13px;
font-weight: 600;
color: #e8eaf0;
}
/* Focus Bar */
.focus-bar {
display: flex;
flex-direction: column;
gap: 3px;
padding: 8px 16px;
background: rgba(234, 179, 8, 0.04);
border-bottom: 1px solid rgba(234, 179, 8, 0.08);
}
.focus-label {
font-size: 8px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #eab308;
}
.focus-text {
font-size: 10px;
color: #7e8799;
line-height: 1.3;
}
/* Messages */
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 12px 16px;
}
.chat-messages::-webkit-scrollbar {
width: 5px;
}
.chat-messages::-webkit-scrollbar-track {
background: transparent;
}
.chat-messages::-webkit-scrollbar-thumb {
background: rgba(139, 124, 246, 0.2);
border-radius: 3px;
}
/* Input */
.chat-input-row {
display: flex;
gap: 6px;
padding: 10px 12px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
/* ── 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>
@@ -1,260 +0,0 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ChevronLeft, ChevronRight, X } from '@lucide/vue'
import type { FeedEntry } from '../../composables/useDashboardData'
const props = defineProps<{
entries: FeedEntry[]
modelValue: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const selectedDayOffset = ref(0) // 0 = today, -1 = yesterday, etc.
function close() {
emit('update:modelValue', false)
}
function dayLabel(offset: number): string {
if (offset === 0) return 'Heute'
if (offset === -1) return 'Gestern'
if (offset === -2) return 'Vorgestern'
const d = new Date()
d.setDate(d.getDate() + offset)
return d.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long' })
}
function navigateDay(dir: -1 | 1) {
const next = selectedDayOffset.value + dir
if (next >= -6 && next <= 0) {
selectedDayOffset.value = next
}
}
const filteredEntries = computed(() => {
const targetDate = new Date()
targetDate.setDate(targetDate.getDate() + selectedDayOffset.value)
const targetStr = targetDate.toISOString().slice(0, 10)
return props.entries.filter(e => e.timestamp.slice(0, 10) === targetStr)
})
</script>
<template>
<Teleport to="body">
<div v-if="modelValue" class="feed-modal-overlay" @click.self="close">
<div class="feed-modal-card">
<div class="feed-modal-header">
<h2 class="feed-modal-title">Operations Log</h2>
<button class="feed-modal-close-btn" @click="close" aria-label="Close">
<X :size="16" />
</button>
</div>
<div class="feed-modal-nav">
<button
class="feed-nav-btn"
:disabled="selectedDayOffset <= -6"
@click="navigateDay(-1)"
aria-label="Previous day"
>
<ChevronLeft :size="14" />
</button>
<span class="feed-nav-label">{{ dayLabel(selectedDayOffset) }}</span>
<button
class="feed-nav-btn"
:disabled="selectedDayOffset >= 0"
@click="navigateDay(1)"
aria-label="Next day"
>
<ChevronRight :size="14" />
</button>
</div>
<div class="feed-modal-entries">
<div v-if="filteredEntries.length === 0" class="feed-modal-empty">
Keine Einträge für diesen Tag.
</div>
<div
v-for="(entry, idx) in filteredEntries"
:key="entry.timestamp + '-' + idx"
class="feed-modal-entry"
>
<span class="feed-time">{{ entry.time }}</span>
<span class="feed-bullet">&middot;</span>
<span class="feed-agent" :class="'agent-' + entry.agent.toLowerCase()">
{{ entry.agent }}
</span>
<span class="feed-action">{{ entry.action }}</span>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.feed-modal-overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: grid;
place-items: center;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
padding: 20px;
animation: feed-overlay-in 0.2s ease;
}
@keyframes feed-overlay-in {
from { opacity: 0; }
to { opacity: 1; }
}
.feed-modal-card {
background: #161b22;
border: 1px solid rgba(139, 124, 246, 0.15);
border-radius: 16px;
padding: 24px;
width: 100%;
max-width: 520px;
max-height: 80vh;
display: flex;
flex-direction: column;
gap: 16px;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
animation: feed-card-in 0.25s ease;
}
@keyframes feed-card-in {
from { opacity: 0; transform: translateY(12px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.feed-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.feed-modal-title {
margin: 0;
font-size: 15px;
font-weight: 600;
color: #e8eaf0;
}
.feed-modal-close-btn {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border: none;
background: rgba(255, 255, 255, 0.05);
border-radius: 6px;
color: #7e8799;
cursor: pointer;
transition: all 0.15s;
}
.feed-modal-close-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #e8eaf0;
}
.feed-modal-nav {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
}
.feed-nav-btn {
display: grid;
place-items: center;
width: 30px;
height: 30px;
border: 1px solid rgba(139, 124, 246, 0.15);
background: rgba(139, 124, 246, 0.08);
border-radius: 8px;
color: #a78bfa;
cursor: pointer;
transition: all 0.15s;
}
.feed-nav-btn:hover:not(:disabled) {
background: rgba(139, 124, 246, 0.16);
border-color: rgba(139, 124, 246, 0.3);
}
.feed-nav-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.feed-nav-label {
font-size: 12px;
font-weight: 600;
color: #d1d5db;
min-width: 100px;
text-align: center;
}
.feed-modal-entries {
display: flex;
flex-direction: column;
gap: 4px;
overflow-y: auto;
max-height: 50vh;
padding-right: 4px;
}
.feed-modal-empty {
text-align: center;
padding: 24px 0;
font-size: 11px;
color: #6b7385;
}
.feed-modal-entry {
display: flex;
align-items: center;
gap: 5px;
padding: 5px 6px;
border-radius: 6px;
font-size: 9.5px;
line-height: 1.3;
transition: background 0.15s;
}
.feed-modal-entry:hover {
background: rgba(255, 255, 255, 0.03);
}
.feed-time {
color: #6b7385;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
width: 32px;
}
.feed-bullet {
color: #6b7385;
flex-shrink: 0;
}
.feed-agent {
font-weight: 600;
flex-shrink: 0;
}
.agent-iris {
color: #a78bfa;
}
.agent-developer {
color: #3b82f6;
}
.agent-devops {
color: #eab308;
}
.agent-researcher {
color: #22c55e;
}
.agent-reviewer {
color: #a855f7;
}
.feed-action {
color: #7e8799;
white-space: normal;
word-break: break-word;
}
</style>
@@ -1,323 +0,0 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Bot, Sparkles, MessageSquareText, ListTodo, Zap, FileText, Send, Lightbulb } from '@lucide/vue'
import { useTime } from '../../composables/useTime'
import { useOperationsStore } from '../../stores/operations'
interface Suggestion {
text: string
}
const { greeting } = useTime()
const store = useOperationsStore()
const chatInput = ref('')
const meters = computed(() => {
const tasks = store.snapshot.tasks
return {
openTasks: store.snapshot.metrics.queuedTasks,
blocked: store.snapshot.metrics.incidents,
critical: tasks.filter(t => t.state === 'Blocked').length,
active: tasks.filter(t => t.state === 'In progress').length,
}
})
const suggestions = ref<Suggestion[]>([
{ text: 'Du solltest zuerst das Dungeon-System abschließen.' },
{ text: 'Die Dokumentation wurde seit 3 Tagen nicht aktualisiert.' },
{ text: 'Das Projekt OpenClaw benötigt Aufmerksamkeit.' },
])
function sendChat() {
if (!chatInput.value.trim()) return
console.log('[Iris] Chat received:', chatInput.value)
chatInput.value = ''
}
</script>
<template>
<aside class="iris-panel">
<div class="iris-profile">
<div class="iris-avatar">
<Bot :size="32" />
</div>
<div class="iris-name-block">
<h2>Iris</h2>
<span class="iris-role">Chief of Staff</span>
</div>
</div>
<p class="iris-greeting">{{ greeting }} Bao.</p>
<p class="iris-status">Du hast heute <strong>4 wichtige Punkte.</strong></p>
<div class="meters">
<div class="meter-item">
<span class="meter-value">{{ meters.openTasks }}</span>
<span class="meter-label">Offene Aufgaben</span>
</div>
<div class="meter-item">
<span class="meter-value meter-blocked">{{ meters.blocked }}</span>
<span class="meter-label">Blockiert</span>
</div>
<div class="meter-item">
<span class="meter-value meter-critical">{{ meters.critical }}</span>
<span class="meter-label">Kritisch</span>
</div>
<div class="meter-item">
<span class="meter-value meter-active">{{ meters.active }}</span>
<span class="meter-label">Aktiv</span>
</div>
</div>
<div class="suggestions">
<h3><Sparkles :size="14" /> Vorschläge</h3>
<div
v-for="(s, idx) in suggestions"
:key="idx"
class="suggestion-card"
>
<Lightbulb :size="14" class="bulb" />
<span>{{ s.text }}</span>
</div>
</div>
<div class="quick-actions">
<button class="qa-btn">
<MessageSquareText :size="14" /> Chat öffnen
</button>
<button class="qa-btn">
<ListTodo :size="14" /> Tagesplanung
</button>
<button class="qa-btn">
<Zap :size="14" /> Prioritäten setzen
</button>
<button class="qa-btn">
<FileText :size="14" /> Zusammenfassung
</button>
</div>
<div class="chat-box">
<div class="chat-input-row">
<input
v-model="chatInput"
type="text"
placeholder="Frag Iris etwas..."
@keyup.enter="sendChat"
/>
<button class="chat-send" @click="sendChat">
<Send :size="14" />
</button>
</div>
</div>
</aside>
</template>
<style scoped>
.iris-panel {
display: flex;
flex-direction: column;
gap: 12px;
padding: 18px;
background: rgba(22, 27, 34, 0.8);
border: 1px solid rgba(139, 124, 246, 0.12);
border-radius: 16px;
box-shadow: 0 4px 24px rgba(0,0,0,0.3);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
transition: all 0.2s ease;
}
.iris-panel:hover {
border-color: rgba(139, 124, 246, 0.18);
}
.iris-profile {
display: flex;
align-items: center;
gap: 12px;
}
.iris-avatar {
width: 48px;
height: 48px;
border-radius: 14px;
background: rgba(167, 139, 250, 0.15);
color: #a78bfa;
display: grid;
place-items: center;
flex-shrink: 0;
}
.iris-name-block h2 {
font-size: 18px;
font-weight: 700;
margin: 0;
line-height: 1.2;
color: #e8eaf0;
}
.iris-role {
font-size: 10px;
color: #a78bfa;
font-weight: 600;
letter-spacing: 0.04em;
}
.iris-greeting {
font-size: 15px;
font-weight: 600;
margin: 0;
color: #e8eaf0;
}
.iris-status {
font-size: 11px;
color: #7e8799;
margin: 0;
}
.iris-status strong {
color: #e8eaf0;
}
/* Meters */
.meters {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.meter-item {
background: rgba(139, 124, 246, 0.06);
border: 1px solid rgba(139, 124, 246, 0.08);
border-radius: 10px;
padding: 8px;
text-align: center;
transition: all 0.2s ease;
}
.meter-item:hover {
border-color: rgba(139, 124, 246, 0.18);
background: rgba(139, 124, 246, 0.1);
}
.meter-value {
display: block;
font-size: 20px;
font-weight: 700;
color: #e8eaf0;
}
.meter-blocked { color: #eab308; }
.meter-critical { color: #ef4444; }
.meter-active { color: #3b82f6; }
.meter-label {
display: block;
font-size: 8px;
color: #6b7385;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-top: 2px;
}
/* Suggestions */
.suggestions h3 {
display: flex;
align-items: center;
gap: 5px;
font-size: 10px;
font-weight: 700;
color: #a78bfa;
text-transform: uppercase;
letter-spacing: 0.08em;
margin: 0 0 6px;
}
.suggestion-card {
display: flex;
align-items: flex-start;
gap: 7px;
padding: 7px 8px;
margin-bottom: 3px;
border-radius: 8px;
cursor: default;
transition: all 0.2s ease;
}
.suggestion-card:hover {
background: rgba(139, 124, 246, 0.08);
}
.suggestion-card .bulb {
color: #eab308;
flex-shrink: 0;
margin-top: 1px;
}
.suggestion-card span {
font-size: 10.5px;
line-height: 1.4;
color: #7e8799;
}
/* Quick Actions */
.quick-actions {
display: flex;
flex-direction: column;
gap: 4px;
}
.qa-btn {
display: flex;
align-items: center;
gap: 7px;
width: 100%;
padding: 8px 10px;
border: 1px solid rgba(139, 124, 246, 0.1);
border-radius: 8px;
background: rgba(139, 124, 246, 0.04);
color: #7e8799;
font-size: 10.5px;
cursor: pointer;
transition: all 0.2s ease;
}
.qa-btn:hover {
background: rgba(139, 124, 246, 0.12);
border-color: rgba(139, 124, 246, 0.2);
color: #e8eaf0;
}
/* Chat Box */
.chat-box {
margin-top: auto;
}
.chat-input-row {
display: flex;
gap: 5px;
}
.chat-input-row input {
flex: 1;
padding: 7px 10px;
border: 1px solid rgba(139, 124, 246, 0.12);
border-radius: 8px;
background: rgba(13, 17, 23, 0.6);
color: #e8eaf0;
font-size: 10.5px;
font-family: inherit;
outline: none;
transition: border-color 0.2s;
}
.chat-input-row input:focus {
border-color: #a78bfa;
}
.chat-input-row input::placeholder {
color: #6b7385;
}
.chat-send {
display: grid;
place-items: center;
width: 32px;
height: 32px;
border: none;
border-radius: 8px;
background: #a78bfa;
color: #fff;
cursor: pointer;
flex-shrink: 0;
transition: opacity 0.2s;
}
.chat-send:hover {
opacity: 0.85;
}
@media (max-width: 900px) {
.iris-panel {
order: 1;
}
}
</style>
@@ -1,196 +0,0 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Activity } from '@lucide/vue'
import type { FeedEntry } from '../../composables/useDashboardData'
import FeedDetailModal from './FeedDetailModal.vue'
const props = defineProps<{
entries: FeedEntry[]
}>()
// ── Compact feed (5 items) ──
const compactEntries = computed(() => props.entries.slice(0, 5))
// ── Feed Detail Modal ──
const showDetailModal = ref(false)
function openDetailModal() {
showDetailModal.value = true
}
</script>
<template>
<div class="feed-panel">
<div class="feed-header">
<Activity :size="14" class="feed-icon" />
<h2>Operations Feed</h2>
</div>
<div class="feed-list">
<TransitionGroup name="feed">
<div
v-for="(entry, idx) in compactEntries"
:key="entry.timestamp + '-' + idx"
class="feed-entry"
>
<span class="feed-time">{{ entry.time }}</span>
<span class="feed-bullet">&middot;</span>
<span class="feed-agent" :class="'agent-' + entry.agent.toLowerCase()">
{{ entry.agent }}
</span>
<span class="feed-action">{{ entry.action }}</span>
</div>
</TransitionGroup>
<div v-if="entries.length === 0" class="feed-empty">
<span>No operations recorded yet.</span>
</div>
<button v-if="entries.length > 5" class="feed-more-btn" @click="openDetailModal">
Mehr anzeigen
</button>
</div>
<FeedDetailModal
:entries="entries"
:model-value="showDetailModal"
@update:model-value="showDetailModal = $event"
/>
</div>
</template>
<style scoped>
.feed-panel {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px;
background: rgba(22, 27, 34, 0.65);
border: 1px solid rgba(139, 124, 246, 0.08);
border-radius: 14px;
transition: border-color 0.2s ease;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.feed-panel:hover {
border-color: rgba(139, 124, 246, 0.15);
}
.feed-header {
display: flex;
align-items: center;
gap: 6px;
}
.feed-icon {
color: #a78bfa;
}
.feed-header h2 {
margin: 0;
font-size: 11px;
font-weight: 600;
color: #e8eaf0;
}
.feed-list {
display: flex;
flex-direction: column;
gap: 2px;
position: relative;
}
.feed-entry {
display: flex;
align-items: center;
gap: 5px;
padding: 5px 6px;
border-radius: 6px;
font-size: 9.5px;
line-height: 1.3;
transition: background 0.15s;
}
.feed-entry:hover {
background: rgba(255, 255, 255, 0.03);
}
.feed-time {
color: #6b7385;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
width: 32px;
}
.feed-bullet {
color: #6b7385;
flex-shrink: 0;
}
.feed-agent {
font-weight: 600;
flex-shrink: 0;
}
.agent-iris {
color: #a78bfa;
}
.agent-developer {
color: #3b82f6;
}
.agent-devops {
color: #eab308;
}
.agent-researcher {
color: #22c55e;
}
.agent-reviewer {
color: #a855f7;
}
.feed-action {
color: #7e8799;
white-space: normal;
word-break: break-word;
}
.feed-empty {
text-align: center;
padding: 12px 0;
font-size: 10px;
color: #6b7385;
}
.feed-more-btn {
display: block;
width: 100%;
padding: 8px;
margin-top: 4px;
background: rgba(139, 124, 246, 0.08);
border: 1px solid rgba(139, 124, 246, 0.12);
border-radius: 8px;
color: #a78bfa;
font-size: 9.5px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
text-align: center;
}
.feed-more-btn:hover {
background: rgba(139, 124, 246, 0.14);
border-color: rgba(139, 124, 246, 0.2);
}
/* TransitionGroup */
.feed-enter-active {
transition: all 0.3s ease;
}
.feed-leave-active {
transition: all 0.3s ease;
position: absolute;
}
.feed-enter-from {
opacity: 0;
transform: translateX(-10px);
}
.feed-leave-to {
opacity: 0;
transform: translateX(10px);
}
.feed-move {
transition: transform 0.3s ease;
}
</style>
@@ -1,293 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import {
ListTodo,
ChevronUp,
ChevronDown,
ArrowUp,
ArrowDown,
Trash2,
Zap,
RefreshCw,
} 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[]
}>()
const emit = defineEmits<{
remove: [id: string]
moveUp: [id: string]
moveDown: [id: string]
changePriority: [id: string, priority: QueueItem['priority']]
executeNow: [id: string]
}>()
const expanded = ref(true)
const priorityColor: Record<string, string> = {
high: '#ef4444',
medium: '#eab308',
low: '#6b7385',
}
const dragIndex = ref<number | null>(null)
const dragOverIndex = ref<number | null>(null)
function onDragStart(idx: number): void {
dragIndex.value = idx
}
function onDragOver(e: DragEvent, idx: number): void {
e.preventDefault()
dragOverIndex.value = idx
}
function onDrop(): void {
if (dragIndex.value !== null && dragOverIndex.value !== null && dragIndex.value !== dragOverIndex.value) {
const id = props.items[dragIndex.value]?.id
if (id) {
const targetId = props.items[dragOverIndex.value]?.id
if (targetId) {
if (dragIndex.value < dragOverIndex.value) {
for (let i = dragIndex.value; i < dragOverIndex.value; i++) {
emit('moveDown', props.items[i]!.id)
}
} else {
for (let i = dragIndex.value; i > dragOverIndex.value; i--) {
emit('moveUp', props.items[i]!.id)
}
}
}
}
}
dragIndex.value = null
dragOverIndex.value = null
}
function onDragEnd(): void {
dragIndex.value = null
dragOverIndex.value = null
}
</script>
<template>
<div class="queue-panel">
<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="text-[#a78bfa]" />
<h2>Chat Queue</h2>
<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 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>
</div>
<Transition name="queue-expand">
<div v-if="expanded" class="queue-list">
<div
v-for="(item, idx) in items"
:key="item.id"
class="queue-item"
:class="{
'drag-source': dragIndex === idx,
'drag-over': dragOverIndex === idx && dragIndex !== idx,
}"
draggable="true"
@dragstart="onDragStart(idx)"
@dragover="onDragOver($event, idx)"
@drop="onDrop"
@dragend="onDragEnd"
>
<div class="queue-item-body">
<div class="queue-item-head">
<span class="queue-source-badge" :class="item.source">{{ item.source }}</span>
<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`,
background: `${priorityColor[item.priority]}10`,
}"
>
{{ item.priority }}
</Badge>
<span class="queue-wait">{{ item.waitTime }}</span>
</div>
<p class="queue-text">{{ item.text }}</p>
</div>
<div class="queue-actions">
<Button variant="ghost" size="icon" class="h-5 w-5 text-[#6b7385] hover:text-[#e8eaf0]" title="Cycle priority" @click.stop="emit('changePriority', item.id, item.priority === 'high' ? 'medium' : item.priority === 'medium' ? 'low' : 'high')">
<RefreshCw :size="12" />
</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 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 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>
</div>
</div>
<div v-if="items.length === 0" class="queue-empty">
<p>Queue is empty</p>
</div>
</div>
</Transition>
</div>
</template>
<style scoped>
.queue-panel {
display: flex;
flex-direction: column;
background: rgba(22, 27, 34, 0.75);
border: 1px solid rgba(139, 124, 246, 0.12);
border-radius: 16px;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
transition: border-color 0.2s ease;
}
.queue-panel:hover {
border-color: rgba(139, 124, 246, 0.18);
}
.queue-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
cursor: pointer;
user-select: none;
}
.queue-header-left {
display: flex;
align-items: center;
gap: 7px;
}
.queue-header h2 {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #e8eaf0;
}
.queue-list {
display: flex;
flex-direction: column;
padding: 0 10px 10px;
gap: 4px;
}
.queue-item {
display: flex;
align-items: flex-start;
gap: 6px;
padding: 8px 10px;
border-radius: 8px;
transition: background 0.15s, opacity 0.15s;
cursor: grab;
}
.queue-item:hover {
background: rgba(255, 255, 255, 0.03);
}
.queue-item:active {
cursor: grabbing;
}
.drag-source {
opacity: 0.4;
}
.drag-over {
background: rgba(167, 139, 250, 0.08);
}
.queue-item-body {
flex: 1;
min-width: 0;
}
.queue-item-head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 3px;
}
.queue-wait {
font-size: 8px;
color: #6b7385;
font-variant-numeric: tabular-nums;
}
.queue-text {
margin: 0;
font-size: 9.5px;
color: #7e8799;
line-height: 1.3;
}
/* Actions */
.queue-actions {
display: flex;
gap: 2px;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.15s;
margin-top: 2px;
}
.queue-item:hover .queue-actions {
opacity: 1;
}
.queue-empty {
text-align: center;
padding: 16px 0;
}
.queue-empty p {
margin: 0;
font-size: 10px;
color: #6b7385;
}
/* Source badge */
.queue-source-badge {
font-size: 7px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
padding: 0 4px;
border-radius: 3px;
line-height: 14px;
}
.queue-source-badge.cron {
color: #22c55e;
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.2);
}
.queue-source-badge.task {
color: #a78bfa;
background: rgba(167, 139, 250, 0.1);
border: 1px solid rgba(167, 139, 250, 0.2);
}
/* Transition */
.queue-expand-enter-active,
.queue-expand-leave-active {
transition: all 0.2s ease;
}
.queue-expand-enter-from,
.queue-expand-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>
@@ -1,92 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
const recentlyFinished = ref([
'Docker Image gebaut',
'Memory Compression',
'Enemy AI verbessert',
'Daily Backup',
'TeamView deployt',
'Config-Editor live',
])
function onChipClick(item: string) {
console.log('[Dashboard] Recently finished:', item)
}
</script>
<template>
<div class="finished-section">
<h3>Recently Finished</h3>
<div class="finished-scroll">
<span
v-for="(item, idx) in recentlyFinished"
:key="idx"
class="finished-chip"
role="button"
tabindex="0"
@click="onChipClick(item)"
@keyup.enter="onChipClick(item)"
>
{{ item }}
</span>
</div>
</div>
</template>
<style scoped>
.finished-section {
display: flex;
align-items: center;
gap: 10px;
}
.finished-section h3 {
font-size: 10px;
font-weight: 700;
color: #7e8799;
text-transform: uppercase;
letter-spacing: 0.08em;
margin: 0;
white-space: nowrap;
flex-shrink: 0;
}
.finished-scroll {
display: flex;
gap: 6px;
overflow-x: auto;
scrollbar-width: none;
-ms-overflow-style: none;
padding-bottom: 2px;
}
.finished-scroll::-webkit-scrollbar {
display: none;
}
.finished-chip {
flex-shrink: 0;
padding: 5px 12px;
border: 1px solid rgba(139, 124, 246, 0.1);
border-radius: 20px;
background: rgba(139, 124, 246, 0.06);
color: #7e8799;
font-size: 9.5px;
cursor: pointer;
white-space: nowrap;
transition: all 0.2s ease;
}
.finished-chip:hover {
background: rgba(139, 124, 246, 0.12);
border-color: rgba(139, 124, 246, 0.2);
color: #e8eaf0;
}
.finished-chip:focus-visible {
outline: 2px solid #a78bfa;
outline-offset: 2px;
}
@media (max-width: 900px) {
.finished-section {
flex-direction: column;
align-items: flex-start;
}
}
</style>
@@ -1,219 +0,0 @@
<script setup lang="ts">
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[]
}>()
const emit = defineEmits<{
newTask: []
'go-board': []
}>()
const expandedId = ref<string | null>(null)
function toggleExpand(id: string) {
expandedId.value = expandedId.value === id ? null : id
}
</script>
<template>
<div class="task-card-panel">
<div class="task-header">
<h2 class="task-title">Offene Aufgaben</h2>
<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>
</div>
<div class="task-list">
<div v-if="tasks.length === 0" class="task-empty">
Keine offenen Aufgaben. Erstelle eine mit + New Task.
</div>
<TransitionGroup name="task">
<div
v-for="task in tasks"
:key="task.id"
class="task-item"
:class="{ expanded: expandedId === task.id }"
@click="toggleExpand(task.id)"
>
<div class="task-main">
<Circle
:size="8"
class="task-source-dot"
:class="task.source === 'iris' ? 'dot-iris' : 'dot-bao'"
fill="currentColor"
/>
<div class="task-content">
<div class="task-title-row">
<span class="task-name">{{ task.title }}</span>
<span class="task-time">{{ task.createdAt }}</span>
</div>
<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' }}
</Badge>
</div>
</div>
<div v-if="expandedId === task.id" class="task-detail">
{{ task.detail }}
</div>
</div>
</TransitionGroup>
</div>
<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>
</div>
</template>
<style scoped>
.task-card-panel {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px;
background: rgba(22, 27, 34, 0.65);
border: 1px solid rgba(139, 124, 246, 0.08);
border-radius: 14px;
transition: border-color 0.2s ease;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.task-card-panel:hover {
border-color: rgba(139, 124, 246, 0.15);
}
.task-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.task-title {
margin: 0;
font-size: 11px;
font-weight: 600;
color: #e8eaf0;
}
.task-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.task-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 10px;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s;
border: 1px solid transparent;
}
.task-item:hover {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(139, 124, 246, 0.08);
}
.task-item.expanded {
background: rgba(139, 124, 246, 0.04);
border-color: rgba(139, 124, 246, 0.1);
}
.task-main {
display: flex;
align-items: flex-start;
gap: 8px;
}
.task-source-dot {
margin-top: 4px;
flex-shrink: 0;
}
.dot-iris {
color: #a78bfa;
}
.dot-bao {
color: #3b82f6;
}
.task-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.task-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.task-name {
font-size: 10px;
font-weight: 500;
color: #d1d5db;
line-height: 1.35;
}
.task-time {
font-size: 8.5px;
color: #6b7385;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
.task-detail {
padding: 6px 10px;
margin: 0 0 2px 16px;
font-size: 9.5px;
color: #7e8799;
line-height: 1.45;
background: rgba(0, 0, 0, 0.15);
border-radius: 6px;
border-left: 2px solid rgba(139, 124, 246, 0.2);
}
.task-empty {
text-align: center;
padding: 16px 8px;
font-size: 10px;
color: #6b7385;
line-height: 1.5;
}
/* TransitionGroup */
.task-enter-active {
transition: all 0.3s ease;
}
.task-leave-active {
transition: all 0.3s ease;
position: absolute;
}
.task-enter-from {
opacity: 0;
transform: translateY(-6px);
}
.task-leave-to {
opacity: 0;
transform: translateY(6px);
}
.task-move {
transition: transform 0.3s ease;
}
</style>
@@ -1,541 +0,0 @@
<script setup lang="ts">
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'
const props = defineProps<{
agents: AgentNodeData[]
heroId?: string
activeAgents?: string[]
}>()
const emit = defineEmits<{
select: [id: string]
}>()
// ── Network ref ──
const networkRef = ref<HTMLDivElement | null>(null)
// ── Computed data ──
const heroId = computed(() => props.heroId ?? props.agents[0]?.id ?? '')
function isActive(id: string): boolean {
return props.activeAgents?.includes(id) ?? false
}
// ── SVG composable ──
const {
svgWidth,
svgHeight,
childAgents,
connectionPaths,
storePathRef,
storePulseRef,
storePulseRef2,
} = useTeamNetworkSvg(networkRef, toRef(props, 'agents'), heroId, isActive)
// ── Icon resolver ──
function resolveIcon(iconName: string) {
switch (iconName) {
case 'bot': return Bot
case 'code': return Code2
case 'server': return Server
case 'shield': return Shield
case 'search': return Search
case 'terminal': return Terminal
default: return Bot
}
}
// ── Runtime formatter ──
function formatRuntime(seconds: number): string {
const m = Math.floor(seconds / 60)
const s = seconds % 60
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>
<template>
<div ref="networkRef" class="ai-team-network">
<!-- SVG Connection Layer -->
<svg
v-if="svgWidth > 0 && svgHeight > 0"
class="network-svg"
:width="svgWidth"
:height="svgHeight"
:viewBox="`0 0 ${svgWidth} ${svgHeight}`"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<filter
v-for="agent in childAgents"
:key="`glow-${agent.id}`"
:id="`glow-${agent.id}`"
x="-30%" y="-30%" width="160%" height="160%"
>
<feGaussianBlur stdDeviation="4" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<!-- Connection lines for each agent -->
<template v-for="agent in childAgents" :key="agent.id">
<!-- Base line -->
<path
v-if="connectionPaths[agent.id]"
:ref="storePathRef(agent.id)"
:d="connectionPaths[agent.id]!.d"
:stroke="agent.color"
:stroke-width="isActive(agent.id) ? 2.5 : 1.5"
fill="none"
:opacity="isActive(agent.id) ? 0.7 : 0.25"
stroke-linecap="round"
/>
<!-- Glow line for active agent -->
<path
v-if="isActive(agent.id) && connectionPaths[agent.id]"
:d="connectionPaths[agent.id]!.d"
:stroke="agent.color"
stroke-width="4"
fill="none"
stroke-linecap="round"
:filter="`url(#glow-${agent.id})`"
opacity="0.5"
/>
<!-- Pulse line 1 (white dashed segment moving along) -->
<path
v-if="connectionPaths[agent.id]"
:ref="storePulseRef(agent.id)"
:d="connectionPaths[agent.id]!.d"
stroke="white"
stroke-width="3"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
:opacity="isActive(agent.id) ? 1 : 0.4"
/>
<!-- Pulse line 2 (offset by half cycle) -->
<path
v-if="connectionPaths[agent.id]"
:ref="storePulseRef2(agent.id)"
:d="connectionPaths[agent.id]!.d"
stroke="white"
stroke-width="3"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
:opacity="isActive(agent.id) ? 0.8 : 0.3"
/>
</template>
</svg>
<!-- Cards Layer (above SVG) -->
<div class="cards-layer">
<!-- Hero: Iris centered top -->
<div class="hero-slot" :data-agent-id="hero.id">
<article
class="agent-card hero-card"
:style="{
'--card-color': hero.color,
...(isActive(hero.id) ? {
boxShadow: `0 0 20px ${hero.color}44`,
borderColor: hero.color
} : {})
}"
@click="emit('select', hero.id)"
>
<div class="card-main">
<div class="card-icon-wrap" :style="{ background: `${hero.color}18`, color: hero.color }">
<component :is="resolveIcon(hero.icon)" :size="20" />
</div>
<div class="card-body">
<div class="card-name-row">
<h3 class="card-name">{{ hero.name }}</h3>
<span class="card-role-tag" :style="{ background: `${hero.color}18`, color: hero.color, borderColor: `${hero.color}30` }">{{ hero.role }}</span>
</div>
<p class="card-desc">{{ hero.description }}</p>
<div v-if="hero.currentTask" class="task-row">
<span class="node-task">
<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">{{ formatModel(hero.model) }}</span>
</div>
<div v-else class="idle-row">
<span class="idle-badge">Idle</span>
</div>
<div class="card-tags">
<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>
<div class="card-arrow">
<span class="arrow-icon">&rarr;</span>
</div>
</article>
</div>
<!-- Agent Grid: 2 columns x 2 rows -->
<div class="agent-grid">
<div
v-for="agent in childAgents"
:key="agent.id"
:data-agent-id="agent.id"
class="agent-slot"
>
<article
class="agent-card"
:style="{
'--card-color': agent.color,
...(isActive(agent.id) ? {
boxShadow: `0 0 14px ${agent.color}55, 0 0 30px ${agent.color}22`,
borderColor: agent.color
} : {})
}"
@click="emit('select', agent.id)"
>
<div class="card-main">
<div class="card-icon-wrap" :style="{ background: `${agent.color}18`, color: agent.color }">
<component :is="resolveIcon(agent.icon)" :size="18" />
</div>
<div class="card-body">
<div class="card-name-row">
<h3 class="card-name">{{ agent.name }}</h3>
<span class="card-role-tag" :style="{ background: `${agent.color}18`, color: agent.color, borderColor: `${agent.color}30` }">{{ agent.role }}</span>
</div>
<p class="card-desc">{{ agent.description }}</p>
<div v-if="agent.currentTask" class="task-row">
<span class="node-task">
<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">{{ formatModel(agent.model) }}</span>
</div>
<div v-else class="idle-row">
<span class="idle-badge">Idle</span>
</div>
<div class="card-tags">
<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>
<div class="card-arrow">
<span class="arrow-icon">&rarr;</span>
</div>
</article>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.ai-team-network {
position: relative;
width: 100%;
background: transparent;
}
.network-svg {
position: absolute;
top: 0;
left: 0;
z-index: 0;
pointer-events: none;
overflow: visible;
}
.cards-layer {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 64px;
}
.hero-slot {
width: 100%;
max-width: 520px;
transition: border-color 0.3s, box-shadow 0.3s;
}
.agent-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
width: 100%;
max-width: 820px;
}
.agent-slot {
width: 100%;
transition: border-color 0.3s, box-shadow 0.3s;
}
/* ── Agent Card ── */
.agent-card {
background: rgba(18, 22, 30, 0.45);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
padding: 18px;
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
overflow: hidden;
position: relative;
}
.agent-card:hover {
background: rgba(18, 22, 30, 0.65);
border-color: var(--card-color, #8b7cf6);
box-shadow: 0 0 16px color-mix(in srgb, var(--card-color, #8b7cf6) 10%, transparent);
}
.hero-card {
background: rgba(18, 22, 30, 0.45);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.06);
box-shadow: 0 0 20px rgba(139, 124, 246, 0.06);
}
.hero-card:hover {
background: rgba(18, 22, 30, 0.65);
border-color: #8b7cf6;
box-shadow: 0 0 24px rgba(139, 124, 246, 0.12);
}
.card-main {
display: flex;
gap: 14px;
align-items: flex-start;
}
.card-icon-wrap {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border-radius: 10px;
flex-shrink: 0;
}
.card-body {
flex: 1;
min-width: 0;
}
.card-name-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 5px;
flex-wrap: wrap;
}
.card-name {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #e8eaf0;
}
.card-role-tag {
display: inline-block;
font-size: 8.5px;
font-weight: 600;
padding: 2px 8px;
border-radius: 5px;
border: 1px solid transparent;
white-space: nowrap;
}
.card-desc {
font-size: 10.5px;
color: #7e8799;
line-height: 1.5;
margin: 0 0 8px;
}
/* ── Task + Runtime Row ── */
.task-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.node-task {
display: inline-flex;
align-items: center;
font-size: 10px;
color: #9ea5b3;
line-height: 1.4;
flex: 1;
min-width: 0;
}
.node-task-dot {
display: inline-block;
margin-right: 4px;
font-size: 8px;
vertical-align: middle;
}
.node-runtime {
font-size: 9px;
color: #6b7385;
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.node-model {
font-size: 8.5px;
color: #6b7385;
font-weight: 500;
flex-shrink: 0;
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;
flex-wrap: wrap;
gap: 4px;
}
.card-tag {
display: inline-block;
font-size: 9px;
font-weight: 600;
padding: 2px 8px;
border-radius: 5px;
letter-spacing: 0.02em;
}
.tag-overflow {
opacity: 0.7;
}
/* ── Hover Arrow ── */
.card-arrow {
position: absolute;
right: 12px;
bottom: 12px;
color: #6b7385;
opacity: 0;
transform: translateX(-6px);
transition: opacity 0.2s ease, transform 0.2s ease;
}
.agent-card:hover .card-arrow {
opacity: 1;
transform: translateX(0);
}
.arrow-icon {
font-size: 14px;
line-height: 1;
display: block;
}
/* ── 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: 24px;
}
}
</style>
@@ -0,0 +1,763 @@
<script setup lang="ts">
/**
* AgentDetailModal — Detailansicht für einen Agenten im V2 Dashboard
*
* Props:
* agent AgentDetail (s. Interface unten)
*
* Emits:
* close Modal schließen
* select(id) Zum nächsten/vorherigen Agenten springen
* changeModel(id, modelId) Modell wechseln
*/
import { ref, onMounted, onUnmounted, watch } from 'vue'
import type { ThinkingItem, AgentDetailData } from './types'
import { formatNumber } from '../../../utils/format'
/* ── Props ──────────────────────────────────────────── */
const props = defineProps<{
agent: AgentDetailData
// Agent-Liste für Pfeilnavigation (IDs in Anzeigereihenfolge)
agentOrder: string[]
}>()
const emit = defineEmits<{
close: []
select: [id: string]
changeModel: [agentId: string, modelId: string]
}>()
/* ── Model Dropdown ────────────────────────────────── */
const modelDropdownOpen = ref(false)
const selectedModel = ref(props.agent.model)
watch(
() => props.agent.id,
() => {
selectedModel.value = props.agent.model
modelDropdownOpen.value = false
}
)
function toggleModelDropdown() {
modelDropdownOpen.value = !modelDropdownOpen.value
}
function selectModel(modelId: string) {
selectedModel.value = modelId
modelDropdownOpen.value = false
emit('changeModel', props.agent.id, modelId)
}
/* ── Keyboard Navigation ──────────────────────────── */
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault()
emit('close')
return
}
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault()
const idx = props.agentOrder.indexOf(props.agent.id)
if (idx === -1) return
const nextIdx = e.key === 'ArrowRight'
? (idx + 1) % props.agentOrder.length
: (idx - 1 + props.agentOrder.length) % props.agentOrder.length
emit('select', props.agentOrder[nextIdx])
}
}
onMounted(() => {
document.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
})
/* ── Backdrop Click ───────────────────────────────── */
function onBackdropClick(e: MouseEvent) {
if ((e.target as HTMLElement).classList.contains('modal-backdrop')) {
emit('close')
}
}
/* ── Metrics Config ───────────────────────────────── */
interface MetricDef {
label: string
value: string
sub?: string
}
function getMetrics(a: AgentDetailData): MetricDef[] {
return [
{
label: 'Tasks aktiv',
value: String(a.activeTaskCount),
sub: 'aktuelle Aufgaben',
},
{
label: 'Tokens heute',
value: formatNumber(a.tokensToday),
sub: 'verbraucht',
},
{
label: 'Kosten',
value: '$' + a.costToday.toFixed(2),
sub: 'heute gesamt',
},
{
label: 'Workload',
value: a.workload + '%',
sub: a.workload > 70 ? 'Ausgelastet' : a.workload > 30 ? 'Moderat' : 'Gering',
},
{
label: 'Uptime',
value: a.uptime,
sub: 'aktuelle Session',
},
{
label: 'Letzte Aktivität',
value: a.lastActive,
sub: '',
},
]
}
/* ── Pretty Model Name ────────────────────────────── */
function modelLabel(alias: string): string {
return alias
}
/* ── Thinking type helpers ─────────────────────────── */
const typeConfig: Record<ThinkingItem['type'], { dotClass: string; label: string }> = {
thought: { dotClass: 'dot-thought', label: 'Thought' },
action: { dotClass: 'dot-action', label: 'Action' },
result: { dotClass: 'dot-result', label: 'Result' },
}
</script>
<template>
<div class="modal-backdrop" @click="onBackdropClick">
<div class="modal-panel" role="dialog" aria-modal="true">
<!-- Close Button -->
<button class="m-close" @click="emit('close')" aria-label="Schließen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="close-icon">
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
<!-- Header -->
<div class="m-head">
<!-- Avatar -->
<div :class="['m-av', { 'm-av-iris': agent.id === 'iris' }]">
<span>{{ agent.id === 'iris' ? 'IR' : agent.name.slice(0, 2).toUpperCase() }}</span>
</div>
<div class="m-head-info">
<div class="m-name">{{ agent.name }}</div>
<div class="m-sub">
<span class="m-role">{{ agent.role }}</span>
<span class="m-status-pill">
<span :class="['dot', `dot-${agent.status}`]"></span>
{{ agent.status === 'work' ? 'Arbeitet' : agent.status === 'think' ? 'Denkt' : 'Bereit' }}
</span>
</div>
</div>
<!-- Model Info + Dropdown -->
<div class="m-model-area">
<div class="m-model-current">
<span class="m-model-label">Model</span>
<button class="m-model-btn" @click="toggleModelDropdown">
<span class="m-model-name">{{ selectedModel }}</span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="chevron-down">
<path d="m6 9 6 6 6-6" />
</svg>
</button>
</div>
<!-- Dropdown Menu -->
<div v-if="modelDropdownOpen" class="m-model-dropdown">
<button
v-for="m in agent.availableModels"
:key="m.id"
:class="['m-model-option', { active: m.alias === selectedModel }]"
@click="selectModel(m.alias)"
>
<span class="option-check" v-if="m.alias === selectedModel">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="check-icon">
<path d="M20 6 9 17l-5-5" />
</svg>
</span>
{{ modelLabel(m.alias) }}
</button>
</div>
</div>
</div>
<!-- Metrics Grid (3 columns, 6 items) -->
<div class="m-metrics">
<div v-for="(metric, idx) in getMetrics(agent)" :key="idx" class="m-metric">
<div class="m-metric-label">{{ metric.label }}</div>
<div class="m-metric-value">{{ metric.value }}</div>
<div v-if="metric.sub" class="m-metric-sub">{{ metric.sub }}</div>
</div>
</div>
<!-- Thinking Feed -->
<div v-if="agent.thinking.length > 0" class="m-feed">
<div class="m-feed-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="feed-icon">
<circle cx="12" cy="12" r="9" />
<path d="M12 8v4l3 3" />
</svg>
Live Thinking
</div>
<div class="m-feed-items">
<div
v-for="(item, idx) in agent.thinking"
:key="idx"
class="m-feed-item"
>
<div class="feed-item-header">
<span :class="['feed-dot', typeConfig[item.type].dotClass]"></span>
<span class="feed-type-label">{{ typeConfig[item.type].label }}</span>
</div>
<div class="feed-item-text">{{ item.text }}</div>
<div class="feed-item-ts">{{ item.ts }}</div>
</div>
</div>
</div>
<!-- Empty Thinking State -->
<div v-else class="m-feed">
<div class="m-feed-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="feed-icon">
<circle cx="12" cy="12" r="9" />
<path d="M12 8v4l3 3" />
</svg>
Live Thinking
</div>
<div class="m-feed-empty">
<span class="empty-text">Keine aktiven Gedanken</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* ── Backdrop ──────────────────────────────────────── */
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
background: rgba(4, 2, 12, 0.72);
backdrop-filter: blur(8px);
animation: backdrop-in 0.2s ease-out;
}
@keyframes backdrop-in {
from { opacity: 0; }
to { opacity: 1; }
}
/* ── Panel ─────────────────────────────────────────── */
.modal-panel {
width: min(680px, 92vw);
max-height: 86vh;
overflow-y: auto;
border-radius: var(--r);
position: relative;
background: linear-gradient(145deg, rgba(14, 12, 28, 0.96), rgba(8, 6, 20, 0.96));
border: 1px solid var(--line);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6);
animation: panel-in 0.22s cubic-bezier(0.2, 0.8, 0.3, 1);
}
@keyframes panel-in {
from {
opacity: 0;
transform: scale(0.94) translateY(12px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.modal-panel::-webkit-scrollbar {
width: 7px;
}
.modal-panel::-webkit-scrollbar-thumb {
background: rgba(124, 108, 255, 0.22);
border-radius: 7px;
border: 2px solid transparent;
background-clip: padding-box;
}
.modal-panel::-webkit-scrollbar-thumb:hover {
background: rgba(124, 108, 255, 0.4);
background-clip: padding-box;
}
.modal-panel::-webkit-scrollbar-track {
background: transparent;
}
/* ── Close Button ──────────────────────────────────── */
.m-close {
position: absolute;
top: 16px;
right: 16px;
width: 32px;
height: 32px;
border-radius: 8px;
border: none;
background: transparent;
color: var(--tx-3);
cursor: pointer;
display: grid;
place-items: center;
transition: background 0.15s, color 0.15s;
z-index: 3;
}
.m-close:hover {
background: rgba(124, 108, 255, 0.12);
color: var(--tx);
}
.close-icon {
width: 18px;
height: 18px;
}
/* ── Header ────────────────────────────────────────── */
.m-head {
display: flex;
align-items: flex-start;
gap: 16px;
padding: 22px 24px 16px;
padding-right: 56px; /* Platz für Close-Button */
}
.m-av {
width: 40px;
height: 40px;
border-radius: 11px;
flex: 0 0 auto;
display: grid;
place-items: center;
font-family: 'JetBrains Mono', monospace;
font-weight: 700;
font-size: 13px;
background: var(--grad-soft);
border: 1px solid var(--line-2);
color: var(--tx);
}
.m-av-iris {
background: var(--grad);
color: #fff;
box-shadow: var(--glow-purple);
}
.m-av span {
line-height: 1;
}
.m-head-info {
flex: 1;
min-width: 0;
}
.m-name {
font-family: 'Space Grotesk', sans-serif;
font-weight: 700;
font-size: 16px;
color: var(--tx);
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.m-sub {
display: flex;
align-items: center;
gap: 10px;
margin-top: 6px;
flex-wrap: wrap;
}
.m-role {
font-family: 'Manrope', sans-serif;
font-size: 12px;
color: var(--tx-3);
}
.m-status-pill {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: 'Manrope', sans-serif;
font-size: 11px;
font-weight: 600;
color: var(--tx-2);
}
/* Status dots in pill */
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex: 0 0 auto;
}
.dot-work {
background: var(--st-work);
box-shadow: 0 0 0 0 rgba(61, 220, 151, 0.55);
animation: pulse-work 1.8s infinite;
}
.dot-think {
background: var(--st-think);
box-shadow: 0 0 0 0 rgba(52, 214, 245, 0.55);
animation: pulse-think 1.6s infinite;
}
.dot-idle {
background: var(--st-idle);
}
/* ── Model Area ────────────────────────────────────── */
.m-model-area {
flex: 0 0 auto;
position: relative;
}
.m-model-current {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
.m-model-label {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--tx-3);
letter-spacing: 0.05em;
}
.m-model-btn {
display: inline-flex;
align-items: center;
gap: 5px;
height: 28px;
padding: 0 10px;
border-radius: 7px;
border: 1px solid var(--line);
background: rgba(124, 108, 255, 0.06);
color: var(--tx-2);
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
font-weight: 500;
white-space: nowrap;
}
.m-model-btn:hover {
background: rgba(124, 108, 255, 0.12);
border-color: var(--line-3);
color: var(--tx);
}
.m-model-name {
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chevron-down {
width: 13px;
height: 13px;
flex: 0 0 auto;
opacity: 0.6;
}
/* ── Model Dropdown ────────────────────────────────── */
.m-model-dropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
min-width: 180px;
background: linear-gradient(145deg, rgba(16, 14, 32, 0.98), rgba(10, 8, 22, 0.98));
border: 1px solid var(--line-2);
border-radius: 10px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.6);
padding: 4px;
z-index: 10;
animation: dropdown-in 0.12s ease-out;
}
@keyframes dropdown-in {
from {
opacity: 0;
transform: translateY(-6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.m-model-option {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
border-radius: 7px;
background: transparent;
border: none;
color: var(--tx-2);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
cursor: pointer;
transition: background 0.12s, color 0.12s;
text-align: left;
white-space: nowrap;
}
.m-model-option:hover {
background: rgba(124, 108, 255, 0.10);
color: var(--tx);
}
.m-model-option.active {
color: var(--tx);
background: rgba(124, 108, 255, 0.14);
}
.option-check {
width: 16px;
height: 16px;
flex: 0 0 auto;
display: grid;
place-items: center;
}
.check-icon {
width: 14px;
height: 14px;
color: var(--a-mid);
}
/* ── Metrics Grid ──────────────────────────────────── */
.m-metrics {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
padding: 0 24px 18px;
}
.m-metric {
background: var(--glass);
border: 1px solid var(--line);
border-radius: 10px;
padding: 14px;
display: flex;
flex-direction: column;
gap: 2px;
transition: border-color 0.15s;
}
.m-metric:hover {
border-color: var(--line-2);
}
.m-metric-label {
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
color: var(--tx-3);
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 600;
}
.m-metric-value {
font-family: 'Space Grotesk', sans-serif;
font-weight: 700;
font-size: 20px;
color: var(--tx);
line-height: 1.1;
margin-top: 4px;
font-variant-numeric: tabular-nums;
}
.m-metric-sub {
font-family: 'Manrope', sans-serif;
font-size: 11px;
color: var(--tx-3);
margin-top: 2px;
}
/* ── Feed ──────────────────────────────────────────── */
.m-feed {
padding: 0 24px 22px;
}
.m-feed-title {
display: flex;
align-items: center;
gap: 8px;
font-family: 'Space Grotesk', sans-serif;
font-weight: 600;
font-size: 12px;
color: var(--tx);
margin-bottom: 12px;
}
.feed-icon {
width: 15px;
height: 15px;
color: var(--a-mid);
flex: 0 0 auto;
}
.m-feed-items {
display: flex;
flex-direction: column;
gap: 6px;
overflow-y: auto;
max-height: 340px;
padding-right: 4px;
}
.m-feed-items::-webkit-scrollbar {
width: 5px;
}
.m-feed-items::-webkit-scrollbar-thumb {
background: rgba(124, 108, 255, 0.18);
border-radius: 5px;
border: 1px solid transparent;
background-clip: padding-box;
}
.m-feed-items::-webkit-scrollbar-track {
background: transparent;
}
.m-feed-item {
padding: 11px 14px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.04);
border-radius: 8px;
transition: background 0.15s;
}
.m-feed-item:hover {
background: rgba(255, 255, 255, 0.04);
}
.feed-item-header {
display: flex;
align-items: center;
gap: 7px;
margin-bottom: 5px;
}
.feed-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex: 0 0 auto;
display: block;
}
.dot-thought {
background: var(--st-think);
box-shadow: 0 0 5px rgba(52, 214, 245, 0.4);
}
.dot-action {
background: var(--a-purple);
box-shadow: 0 0 5px rgba(181, 87, 246, 0.4);
}
.dot-result {
background: var(--st-work);
box-shadow: 0 0 5px rgba(61, 220, 151, 0.4);
}
.feed-type-label {
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.dot-thought ~ .feed-type-label {
color: var(--st-think);
}
.dot-action ~ .feed-type-label {
color: var(--a-purple);
}
.dot-result ~ .feed-type-label {
color: var(--st-work);
}
.feed-item-text {
font-family: 'Manrope', sans-serif;
font-size: 12px;
line-height: 1.5;
color: var(--tx);
}
.feed-item-ts {
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
color: var(--tx-3);
margin-top: 4px;
font-variant-numeric: tabular-nums;
}
/* Empty state */
.m-feed-empty {
padding: 20px 0;
text-align: center;
}
.empty-text {
font-family: 'Manrope', sans-serif;
font-size: 12px;
color: var(--tx-3);
font-style: italic;
}
</style>
@@ -0,0 +1,272 @@
<script setup lang="ts">
/**
* AgentNode — Einzelner Agenten-Knoten im FlowCanvas
*
* Props:
* agent AgentNodeData
* left x-Position in % (0100)
* top y-Position in % (0100)
* entering true wenn Node gerade frisch ins DOM kam (Enter-Animation)
*
* Emits:
* select Agent ausgewählt (id)
*/
import type { AgentNodeData } from '../../../composables/useFlowLayout'
const props = defineProps<{
agent: AgentNodeData
left: number
top: number
entering?: boolean
}>()
defineEmits<{
select: [id: string]
}>()
</script>
<template>
<div
:class="[
'node',
agent.id === 'iris' ? 'is-iris' : `is-${agent.status}`,
{ entering }
]"
:style="{ left: left + '%', top: top + '%' }"
@click="$emit('select', agent.id)"
>
<div class="ncard">
<!-- Header: Avatar + Name + Role + Status-Dot -->
<div class="nc-top">
<div :class="['nc-av', { 'iris-av': agent.id === 'iris' }]">
<span v-html="agent.avatar === '</>' ? '&lt;/&gt;' : agent.avatar"></span>
</div>
<div class="nc-info">
<div class="nc-name">{{ agent.name }}</div>
<div class="nc-role">{{ agent.role }}</div>
</div>
<span :class="['nc-stat', 'dot', agent.status]"></span>
</div>
<!-- Task (2-line clamp) -->
<div class="nc-task">{{ agent.task || 'Bereit · ' + agent.next }}</div>
<!-- Progress Bar -->
<div class="nc-bar">
<i :style="{ width: (agent.progress || 3) + '%' }"></i>
</div>
<!-- Meta-Zeile -->
<div class="nc-meta">
<span
class="st"
:style="{ color: `var(--st-${agent.status})` }"
>
{{ agent.statusLabel }}
</span>
<span>{{ agent.task ? (agent.progress + '% · ' + agent.elapsed) : agent.model }}</span>
</div>
</div>
</div>
</template>
<style scoped>
.node {
position: absolute;
transform: translate(-50%, -50%);
z-index: 3;
width: 188px;
transition:
left 0.55s cubic-bezier(.4, 0, .2, 1),
top 0.55s cubic-bezier(.4, 0, .2, 1),
opacity 0.35s,
scale 0.35s;
cursor: pointer;
user-select: none;
}
.node.entering {
opacity: 0;
scale: 0.7;
}
.ncard {
padding: 11px 12px;
border-radius: 13px;
background: var(--glass-2);
border: 1px solid var(--line-2);
backdrop-filter: blur(10px);
transition: transform 0.18s;
}
.ncard:hover {
transform: translateY(-2px);
}
/* Status-glow border */
.node.is-work .ncard {
border-color: rgba(61, 220, 151, 0.45);
box-shadow: 0 0 0 1px rgba(61, 220, 151, 0.2), 0 0 26px -6px rgba(61, 220, 151, 0.6);
}
.node.is-think .ncard {
border-color: rgba(52, 214, 245, 0.45);
box-shadow: 0 0 0 1px rgba(52, 214, 245, 0.2), 0 0 26px -6px rgba(52, 214, 245, 0.55);
}
.node.is-block .ncard {
border-color: rgba(251, 113, 133, 0.45);
box-shadow: 0 0 0 1px rgba(251, 113, 133, 0.2), 0 0 26px -6px rgba(251, 113, 133, 0.55);
}
.node.is-iris .ncard {
border-color: rgba(124, 108, 255, 0.55);
box-shadow: var(--glow);
background: linear-gradient(160deg, rgba(124, 108, 255, 0.2), rgba(28, 24, 64, 0.6));
}
.node.is-idle .ncard {
opacity: 0.7;
}
/* ── Card Content ────────────────────────────── */
.nc-top {
display: flex;
align-items: center;
gap: 9px;
}
.nc-av {
width: 32px;
height: 32px;
border-radius: 9px;
flex: 0 0 auto;
display: grid;
place-items: center;
font-family: 'JetBrains Mono', monospace;
font-weight: 600;
font-size: 11px;
background: var(--grad-soft);
border: 1px solid var(--line-2);
color: var(--tx);
}
.nc-av.iris-av {
background: var(--grad);
color: #fff;
box-shadow: var(--glow-purple);
}
.nc-av :deep(svg) {
display: block;
}
.nc-info {
min-width: 0;
flex: 1;
}
.nc-name {
font-family: 'Space Grotesk', sans-serif;
font-weight: 600;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--tx);
}
.nc-role {
font-size: 10px;
color: var(--tx-3);
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.nc-stat {
margin-left: auto;
flex: 0 0 auto;
}
.dot {
width: 9px;
height: 9px;
border-radius: 50%;
flex: 0 0 auto;
display: block;
}
.dot.work {
background: var(--st-work);
box-shadow: 0 0 0 0 rgba(61, 220, 151, 0.55);
animation: pulse-work 1.8s infinite;
}
.dot.think {
background: var(--st-think);
box-shadow: 0 0 0 0 rgba(52, 214, 245, 0.55);
animation: pulse-think 1.8s infinite;
}
.dot.idle {
background: var(--st-idle);
}
.dot.block {
background: var(--st-block);
box-shadow: 0 0 0 0 rgba(251, 113, 133, 0.55);
animation: pulse-block 1.8s infinite;
}
/* ── Task ────────────────────────────────────── */
.nc-task {
font-size: 11px;
color: var(--tx-2);
margin-top: 8px;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: 28px;
}
/* ── Progress Bar ────────────────────────────── */
.nc-bar {
height: 4px;
border-radius: 4px;
background: rgba(124, 108, 255, 0.12);
overflow: hidden;
margin-top: 7px;
}
.nc-bar i {
display: block;
height: 100%;
border-radius: 4px;
background: var(--grad);
transition: width 0.4s ease;
}
.node.is-work .nc-bar i {
background: linear-gradient(90deg, #2bb87f, #3ddc97);
}
/* ── Meta ────────────────────────────────────── */
.nc-meta {
display: flex;
justify-content: space-between;
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--tx-3);
margin-top: 5px;
font-variant-numeric: tabular-nums;
}
.nc-meta .st {
font-weight: 600;
}
</style>
@@ -0,0 +1,171 @@
<script setup lang="ts">
/**
* AlertBar — Status-Übersicht im V2 Dashboard
*
* Props:
* activeCount Agents mit status 'work'
* thinkCount Agents mit status 'think'
* idleCount Agents mit status 'idle'
* blockerCount Blocker-Anzahl
* todayCost Kosten heute (z.B. "$6.40")
* todayTokens Token heute (z.B. "282k")
*/
import { icons } from '../../../composables/icons'
defineProps<{
activeCount: number
thinkCount: number
idleCount: number
blockerCount: number
todayCost: string
todayTokens: string
}>()
defineEmits<{
blockerClick: []
}>()
</script>
<template>
<div class="alertbar glass-panel">
<!-- Active (arbeitet) -->
<div class="seg">
<span class="dot work"></span>
<span class="seg-label">{{ activeCount }} arbeiten</span>
</div>
<!-- Think (plant) -->
<div class="seg">
<span class="dot think"></span>
<span class="seg-label">{{ thinkCount }} planen</span>
</div>
<!-- Idle (bereit) -->
<div class="seg">
<span class="dot idle"></span>
<span class="seg-label">{{ idleCount }} bereit</span>
</div>
<!-- Separator -->
<div class="sep"></div>
<!-- Kosten heute -->
<div class="seg tx2">
<span class="seg-icon" v-html="icons.coin || ''"></span>
heute <span class="cost-value">{{ todayCost }}</span> · {{ todayTokens }}
</div>
<!-- Blocker Alert (rechts) -->
<button
v-if="blockerCount > 0"
class="blk"
@click="$emit('blockerClick')"
>
<span class="dot block"></span>
{{ blockerCount }} Blocker
</button>
</div>
</template>
<style scoped>
.alertbar {
display: flex;
align-items: center;
gap: 14px;
padding: 11px 16px;
border-radius: var(--r);
flex-wrap: wrap;
}
.seg {
display: flex;
align-items: center;
gap: 8px;
font-size: 12.5px;
font-weight: 600;
color: var(--tx);
white-space: nowrap;
}
.seg-label {
color: var(--tx-2);
}
.seg-icon :deep(svg) {
width: 14px;
height: 14px;
flex: 0 0 auto;
color: var(--a-mid);
}
.tx2 .seg-icon :deep(svg) {
color: var(--tx-3);
}
.dot {
width: 9px;
height: 9px;
border-radius: 50%;
flex: 0 0 auto;
}
.dot.work {
background: var(--st-work);
box-shadow: 0 0 0 0 rgba(61,220,151,.55);
animation: pulse-work 1.8s infinite;
}
.dot.think {
background: var(--st-think);
box-shadow: 0 0 0 0 rgba(52,214,245,.55);
animation: pulse-think 1.8s infinite;
}
.dot.idle {
background: var(--st-idle);
}
.dot.block {
background: var(--st-block);
box-shadow: 0 0 0 0 rgba(251,113,133,.55);
animation: pulse-block 1.8s infinite;
}
.sep {
width: 1px;
height: 20px;
background: var(--line-2);
flex: 0 0 auto;
}
.cost-value {
font-family: 'JetBrains Mono', monospace;
font-variant-numeric: tabular-nums;
background: var(--grad);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.blk {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 9px;
padding: 6px 12px;
border-radius: 9px;
background: rgba(251,113,133,.12);
border: 1px solid rgba(251,113,133,.3);
font-size: 12.5px;
font-weight: 600;
color: #fda4b0;
cursor: pointer;
transition: background .15s;
font-family: 'Manrope', sans-serif;
white-space: nowrap;
}
.blk:hover {
background: rgba(251,113,133,.22);
}
</style>
@@ -0,0 +1,484 @@
<script setup lang="ts">
/**
* FlowCanvas — SVG-Kanten + Auto-Layout + AgentNode-Karten
*
* Props:
* agents Liste der AgentNodeData
* positions Record<id, {x,y}> mit aktuellen Positionen
*
* Emits:
* select Agent ausgewählt (id)
* add Neuen Agent hinzufügen
* updatePositions Positionsänderung
*/
import { computed, onMounted, onUnmounted, ref, nextTick, watch } from 'vue'
import type { AgentNodeData } from '../../../composables/useFlowLayout'
import { autoLayout, buildEdges, curve } from '../../../composables/useFlowLayout'
import { icons } from '../../../composables/icons'
import AgentNode from './AgentNode.vue'
const props = defineProps<{
agents: AgentNodeData[]
positions: Record<string, { x: number; y: number }>
enteringIds: string[]
}>()
const emit = defineEmits<{
select: [id: string]
add: []
resetLayout: []
updatePositions: [positions: Record<string, { x: number; y: number }>]
}>()
/* ── Refs ───────────────────────────────────────── */
const flowRef = ref<HTMLElement | null>(null)
const svgRef = ref<SVGSVGElement | null>(null)
const edgesDefs = ref('')
const edgesPaths = ref('')
const edgesPulses = ref('')
/* ── Computed ───────────────────────────────────── */
const agentCount = computed(() => props.agents.length)
const autoPositions = computed(() => autoLayout(props.agents))
// Layout label
const layoutLabel = computed(() => {
const n = props.agents.length - 1
if (n <= 0) return `${props.agents.length} Agents`
const maxPerRow = n <= 2 ? 2 : n <= 6 ? 3 : n <= 9 ? 3 : 4
const rows = Math.ceil(n / maxPerRow)
const hasCustom = Object.keys(props.positions).length > 0
if (hasCustom) {
return `✦ Eigenes Layout · ${props.agents.length} Agents gesamt`
}
return `Layout: ${rows} ${rows === 1 ? 'Reihe' : 'Reihen'} × ${maxPerRow} · ${props.agents.length} Agents gesamt`
})
/* ── Edge Rendering ────────────────────────────── */
function isActive(status: string) {
return status === 'work' || status === 'think'
}
function renderEdges() {
const flow = flowRef.value
if (!flow) return
const fr = flow.getBoundingClientRect()
const svg = svgRef.value
if (!svg) return
svg.setAttribute('width', String(fr.width))
svg.setAttribute('height', String(fr.height))
svg.setAttribute('viewBox', `0 0 ${fr.width} ${fr.height}`)
// Node centers in pixel coordinates
function center(id: string): { x: number; y: number } | null {
const el = flow.querySelector(`.node[data-id="${id}"]`) as HTMLElement | null
if (!el) return null
const nr = el.getBoundingClientRect()
return {
x: nr.left - fr.left + nr.width / 2,
y: nr.top - fr.top + nr.height / 2,
}
}
const edgeList = buildEdges(props.agents)
let defs = `<defs><linearGradient id="eg2" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#4f7cff"/><stop offset="1" stop-color="#b557f6"/></linearGradient></defs>`
let paths = ''
let pulses = ''
let idCounter = 0
edgeList.forEach((e) => {
const c1 = center(e.a)
const c2 = center(e.b)
if (!c1 || !c2) return
const d = curve(c1, c2)
const a1Status = props.agents.find(a => a.id === e.a)?.status || 'idle'
const a2Status = props.agents.find(a => a.id === e.b)?.status || 'idle'
const live = isActive(a1Status) && isActive(a2Status)
const pathId = `ep${idCounter++}`
if (e.kind === 'flow' && live) {
// Active flow: gradient stroke + animate pulse
paths += `<path id="${pathId}" d="${d}" fill="none" stroke="url(#eg2)" stroke-width="2.2" opacity="0.85"/>`
paths += `<path d="${d}" fill="none" stroke="#3ddc97" stroke-width="2.2" stroke-dasharray="5 20" opacity="0.8" style="animation:dashmove 1.1s linear infinite"/>`
pulses += `<circle r="3.4" fill="#eafff6"><animateMotion dur="2s" repeatCount="indefinite" rotate="auto"><mpath href="#${pathId}"/></animateMotion></circle>`
} else if (e.kind === 'flow') {
// Inactive flow
paths += `<path id="${pathId}" d="${d}" fill="none" stroke="url(#eg2)" stroke-width="1.8" opacity="0.45"/>`
pulses += `<circle r="2.8" fill="#c9b8ff" opacity="0.7"><animateMotion dur="3s" repeatCount="indefinite"><mpath href="#${pathId}"/></animateMotion></circle>`
} else {
// Orchestration (Iris → Agent)
const targetAgent = props.agents.find(a => a.id === e.b)
const op = targetAgent && isActive(targetAgent.status) ? 0.45 : 0.18
paths += `<path d="${d}" fill="none" stroke="#7c6cff" stroke-width="1.2" stroke-dasharray="2 6" opacity="${op}"/>`
}
})
edgesDefs.value = defs
edgesPaths.value = paths
edgesPulses.value = pulses
}
/* ── Resize Observer ──────────────────────────── */
let resizeObserver: ResizeObserver | null = null
function setupObserver() {
if (!flowRef.value) return
resizeObserver = new ResizeObserver(() => {
// Debounce via requestAnimationFrame
if (!debounceRaf) debounceRaf = requestAnimationFrame(() => {
debounceRaf = null
renderEdges()
})
})
resizeObserver.observe(flowRef.value)
}
let debounceRaf: number | null = null
function teardownObserver() {
if (resizeObserver) {
resizeObserver.disconnect()
resizeObserver = null
}
if (debounceRaf) {
cancelAnimationFrame(debounceRaf)
debounceRaf = null
}
}
onMounted(() => {
setupObserver()
// Initial render after DOM settles
requestAnimationFrame(() => renderEdges())
})
onUnmounted(() => {
teardownObserver()
})
// Re-render edges when agents or positions change
watch(
() => [props.agents.length, props.positions],
() => {
// Wait for DOM update (AgentNode transitions)
setTimeout(() => renderEdges(), 200)
},
{ deep: true }
)
/* ── Drag & Drop ──────────────────────────────── */
const DRAG_THRESHOLD = 5
interface DragState {
id: string
startX: number
startY: number
ox: number
oy: number
moved: boolean
raf: number | null
}
let drag: DragState | null = null
function onPointerDown(e: PointerEvent) {
const node = (e.target as HTMLElement).closest('.node') as HTMLElement | null
if (!node) return
e.preventDefault()
const nr = node.getBoundingClientRect()
drag = {
id: node.dataset.id || '',
startX: e.clientX,
startY: e.clientY,
ox: e.clientX - (nr.left + nr.width / 2),
oy: e.clientY - (nr.top + nr.height / 2),
moved: false,
raf: null,
}
node.setPointerCapture(e.pointerId)
}
function onPointerMove(e: PointerEvent) {
if (!drag) return
const dist = Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY)
if (!drag.moved && dist < DRAG_THRESHOLD) return
if (!drag.moved) {
drag.moved = true
const node = flowRef.value?.querySelector(`.node[data-id="${drag.id}"]`) as HTMLElement | null
if (node) node.classList.add('dragging')
}
const flow = flowRef.value
if (!flow) return
const fr = flow.getBoundingClientRect()
const x = Math.max(8, Math.min(92, ((e.clientX - drag.ox - fr.left) / fr.width) * 100))
const y = Math.max(10, Math.min(92, ((e.clientY - drag.oy - fr.top) / fr.height) * 100))
// Direct DOM manipulation for responsiveness
const node = flow.querySelector(`.node[data-id="${drag.id}"]`) as HTMLElement | null
if (node) {
node.style.left = x + '%'
node.style.top = y + '%'
}
// Update positions state
const newPos = { ...props.positions }
newPos[drag.id] = { x, y }
emit('updatePositions', newPos)
// Debounced edge re-render
if (!drag.raf) {
drag.raf = requestAnimationFrame(() => {
renderEdges()
if (drag) drag.raf = null
})
}
}
function onPointerUp() {
if (!drag) return
const node = flowRef.value?.querySelector(`.node[data-id="${drag.id}"]`) as HTMLElement | null
if (node) node.classList.remove('dragging')
if (!drag.moved) {
// Was a click — emit select
emit('select', drag.id)
}
drag = null
}
/* ── Keyboard handler for Enter key on buttons ── */
function handleReset() {
emit('resetLayout')
nextTick(() => renderEdges())
}
</script>
<template>
<div
ref="flowRef"
class="flow"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerUp"
>
<!-- Header -->
<div class="flow-h">
<span class="header-icon" v-html="icons.flow || ''"></span>
<h3>Live-Orchestrierung</h3>
<span class="flow-count">{{ agentCount }} Agents</span>
<button
class="reset-btn"
title="Auto-Layout wiederherstellen"
@click="handleReset"
>
<span class="btn-icon" v-html="icons.flow || ''"></span>
Reset
</button>
<button class="add-btn" @click="emit('add')">
<span class="btn-icon" v-html="icons.plus || ''"></span>
Agent hinzufügen
</button>
</div>
<!-- SVG Layer -->
<svg
ref="svgRef"
class="edges"
v-html="edgesDefs + edgesPaths + edgesPulses"
></svg>
<!-- Agent Nodes -->
<AgentNode
v-for="agent in agents"
:key="agent.id"
:agent="agent"
:left="(positions[agent.id] || autoPositions[agent.id] || { x: 50, y: 50 }).x"
:top="(positions[agent.id] || autoPositions[agent.id] || { x: 50, y: 50 }).y"
:entering="enteringIds.includes(agent.id)"
:data-id="agent.id"
@select="(id: string) => emit('select', id)"
/>
<!-- Layout Label -->
<div class="layout-label">{{ layoutLabel }}</div>
</div>
</template>
<style scoped>
.flow {
flex: 1;
position: relative;
border-radius: var(--r);
overflow: hidden;
min-height: 0;
border: 1px solid var(--line);
background:
radial-gradient(120% 90% at 50% 0%, rgba(124, 108, 255, 0.10), transparent 60%);
}
.flow-h {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 4;
display: flex;
align-items: center;
gap: 9px;
padding: 13px 16px;
}
.flow-h h3 {
margin: 0;
font-family: 'Space Grotesk', sans-serif;
font-weight: 600;
font-size: 14.5px;
color: var(--tx);
}
.header-icon :deep(svg) {
width: 18px;
height: 18px;
color: var(--a-mid);
}
.flow-count {
font-family: 'JetBrains Mono', monospace;
font-size: 11.5px;
font-weight: 600;
padding: 3px 10px;
border-radius: 20px;
background: rgba(124, 108, 255, 0.14);
color: var(--tx-2);
font-variant-numeric: tabular-nums;
}
.reset-btn {
height: 30px;
padding: 0 11px;
border-radius: 9px;
background: rgba(124, 108, 255, 0.1);
border: 1px solid var(--line-2);
color: var(--tx-2);
font-family: 'Manrope', sans-serif;
font-size: 12px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
transition: background 0.15s;
}
.reset-btn:hover {
background: rgba(124, 108, 255, 0.18);
color: var(--tx);
}
.reset-btn .btn-icon :deep(svg) {
width: 13px;
height: 13px;
}
.add-btn {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
height: 34px;
padding: 0 14px;
border-radius: 10px;
background: var(--grad);
border: none;
color: #fff;
font-family: 'Manrope', sans-serif;
font-size: 13px;
font-weight: 600;
cursor: pointer;
box-shadow: var(--glow-purple);
transition: filter 0.15s;
}
.add-btn:hover {
filter: brightness(1.1);
}
.add-btn .btn-icon :deep(svg) {
width: 15px;
height: 15px;
}
/* ── SVG Layer ────────────────────────────────── */
.edges {
position: absolute;
inset: 0;
z-index: 1;
width: 100%;
height: 100%;
pointer-events: none;
}
/* ── Layout Label ─────────────────────────────── */
.layout-label {
position: absolute;
bottom: 14px;
left: 50%;
transform: translateX(-50%);
z-index: 5;
font-family: 'JetBrains Mono', monospace;
font-size: 10.5px;
color: var(--tx-3);
background: rgba(10, 8, 24, 0.7);
padding: 5px 14px;
border-radius: 20px;
border: 1px solid var(--line);
backdrop-filter: blur(8px);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
/* ── Drag state ───────────────────────────────── */
:deep(.node.dragging) {
cursor: grabbing;
transition: none !important;
z-index: 10;
}
:deep(.node.dragging .ncard) {
box-shadow: 0 0 0 2px var(--a-mid), 0 0 36px -2px rgba(124, 108, 255, 0.9) !important;
transform: scale(1.04);
}
/* Dash animation */
@keyframes dashmove {
to {
stroke-dashoffset: -28;
}
}
/* Node cursor */
:deep(.node) {
cursor: grab;
}
:deep(.node.dragging) {
cursor: grabbing;
}
</style>
@@ -0,0 +1,483 @@
<script setup lang="ts">
/**
* IrisChat — Rechte Seitenleiste (Rail) im V2 Dashboard
*
* Container: 368px breit, border-left 1px var(--line), flex column
*
* Props:
* messages ChatMessage[]
* isThinking zeigt "thinking…" Indicator an
*
* Emits:
* send(text) Nachricht absenden
*/
import { ref, computed, nextTick, watch } from 'vue'
import { icons } from '../../../composables/icons'
import type { ChatMessage } from './types'
const props = defineProps<{
messages: ChatMessage[]
isThinking: boolean
error?: string | null
}>()
const emit = defineEmits<{
send: [text: string]
}>()
/* ── Input ────────────────────────────────────────── */
const inputText = ref('')
const msgContainer = ref<HTMLElement | null>(null)
const inputRef = ref<HTMLInputElement | null>(null)
function handleSend() {
const text = inputText.value.trim()
if (!text) return
emit('send', text)
inputText.value = ''
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
/* ── Reversed messages (newest first in DOM for column-reverse) ── */
const reversedMessages = computed(() => [...props.messages].reverse())
/* ── Auto-scroll: column-reverse means scrollTop=0 = bottom (newest) ── */
watch(
() => props.messages.length,
() => {
nextTick(() => {
if (msgContainer.value) {
msgContainer.value.scrollTop = 0
}
})
}
)
</script>
<template>
<div class="irischat">
<!-- Header -->
<div class="chat-header">
<div class="chat-header-left">
<span class="header-icon" v-html="icons.bot || ''"></span>
<div class="header-text">
<span class="header-title">Live-Orchestrierung</span>
<span class="header-subtitle">Iris Chat</span>
</div>
</div>
<button class="ask-btn" type="button" @click="inputRef?.focus()">
<span class="ask-icon" v-html="icons.spark || ''"></span>
Ask Iris
</button>
</div>
<!-- Messages (flex column-reverse neueste unten) -->
<div ref="msgContainer" class="messages">
<!-- Error Banner -->
<div v-if="error" class="chat-error">
<span class="error-icon"></span>
<span>Chat unavailable: {{ error }}</span>
</div>
<!-- Thinking Indicator -->
<div v-if="isThinking" class="thinking-indicator">
<span class="thinking-dots">
<span class="dot-1"></span>
<span class="dot-2"></span>
<span class="dot-3"></span>
</span>
<span class="thinking-text">thinking</span>
</div>
<!-- Empty State -->
<div v-if="!messages.length && !isThinking" class="chat-empty">
<span class="empty-text">No messages yet. Ask Iris something.</span>
</div>
<!-- Messages (reverse order newest first in DOM, column-reverse flips) -->
<template v-for="(msg, i) in reversedMessages" :key="i">
<!-- Iris Bubble -->
<div v-if="msg.sender === 'iris'" class="bubble iris-bubble">
<div class="bubble-text">{{ msg.text }}</div>
<!-- Tool-Call-Indikator -->
<div v-if="msg.tool" class="tool-indicator">
<span class="tool-icon" v-html="icons.search || ''"></span>
<span class="tool-label">{{ msg.tool }}</span>
</div>
<div class="bubble-meta">{{ msg.ts }}</div>
</div>
<!-- User Bubble -->
<div v-else class="bubble user-bubble">
<div class="bubble-text">{{ msg.text }}</div>
<div class="bubble-meta">{{ msg.ts }}</div>
</div>
</template>
</div>
<!-- Input Area -->
<div class="chat-input-area">
<div class="input-wrap">
<input
ref="inputRef"
v-model="inputText"
class="chat-input"
type="text"
placeholder="Nachricht an Iris…"
@keydown="onKeydown"
/>
<button
class="send-btn"
type="button"
:disabled="!inputText.trim()"
@click="handleSend"
:aria-label="'Send message'"
>
<span v-html="icons.send || ''"></span>
</button>
</div>
</div>
</div>
</template>
<style scoped>
.irischat {
width: 368px;
flex: 0 0 368px;
align-self: stretch;
display: flex;
flex-direction: column;
border-left: 1px solid var(--line);
background: linear-gradient(180deg, rgba(14, 12, 32, 0.92), rgba(8, 6, 20, 0.92));
overflow: hidden;
}
/* ── Header ───────────────────────────────────────── */
.chat-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--line);
flex: 0 0 auto;
}
.chat-header-left {
display: flex;
align-items: center;
gap: 10px;
}
.header-icon :deep(svg) {
width: 20px;
height: 20px;
color: var(--a-mid);
}
.header-text {
display: flex;
flex-direction: column;
}
.header-title {
font-family: 'Space Grotesk', sans-serif;
font-weight: 600;
font-size: 14.5px;
color: var(--tx);
line-height: 1.3;
}
.header-subtitle {
font-family: 'Space Grotesk', sans-serif;
font-weight: 600;
font-size: 13px;
color: var(--tx-3);
line-height: 1.3;
}
.ask-btn {
display: inline-flex;
align-items: center;
gap: 7px;
height: 29px;
padding: 0 14px;
border-radius: 8px;
border: none;
background: var(--grad);
color: #fff;
font-family: 'Manrope', sans-serif;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: filter 0.15s;
white-space: nowrap;
}
.ask-btn:hover {
filter: brightness(1.1);
}
.ask-icon :deep(svg) {
width: 14px;
height: 14px;
}
/* ── Messages ─────────────────────────────────────── */
.messages {
flex: 1;
display: flex;
flex-direction: column-reverse;
overflow-y: auto;
padding: 12px;
gap: 10px;
min-height: 0;
}
.messages::-webkit-scrollbar {
width: 6px;
}
.messages::-webkit-scrollbar-thumb {
background: rgba(124, 108, 255, 0.22);
border-radius: 6px;
border: 1px solid transparent;
background-clip: padding-box;
}
.messages::-webkit-scrollbar-thumb:hover {
background: rgba(124, 108, 255, 0.4);
background-clip: padding-box;
}
.messages::-webkit-scrollbar-track {
background: transparent;
}
/* ── Bubbles ──────────────────────────────────────── */
.bubble {
padding: 10px 13px;
max-width: 86%;
animation: bubble-in 0.2s ease-out;
}
@keyframes bubble-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.iris-bubble {
align-self: flex-start;
background: rgba(124, 108, 255, 0.14);
border-left: 2px solid var(--a-mid);
border-radius: 0 10px 10px 10px;
}
.user-bubble {
align-self: flex-end;
background: rgba(255, 255, 255, 0.06);
border-right: 2px solid var(--tx-3);
border-radius: 10px 0 10px 10px;
}
.bubble-text {
font-family: 'Manrope', sans-serif;
font-size: 12px;
line-height: 1.6;
color: var(--tx);
white-space: pre-wrap;
word-wrap: break-word;
}
.bubble-meta {
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
color: var(--tx-3);
margin-top: 4px;
font-variant-numeric: tabular-nums;
}
/* ── Tool-Call-Indikator ──────────────────────────── */
.tool-indicator {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 6px;
padding: 3px 9px;
border-radius: 6px;
background: rgba(52, 214, 245, 0.10);
border: 1px solid rgba(52, 214, 245, 0.18);
}
.tool-icon :deep(svg) {
width: 11px;
height: 11px;
color: var(--st-think);
flex: 0 0 auto;
}
.tool-label {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--st-think);
}
/* ── Error Banner ─────────────────────────────────── */
.chat-error {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 13px;
background: rgba(251, 113, 133, 0.12);
border: 1px solid rgba(251, 113, 133, 0.25);
border-radius: 10px;
font-family: 'Manrope', sans-serif;
font-size: 11px;
color: #fda4b0;
}
.error-icon {
flex: 0 0 auto;
font-size: 14px;
}
/* ── Empty State ──────────────────────────────────── */
.chat-empty {
display: flex;
align-items: center;
justify-content: center;
padding: 32px 16px;
}
.chat-empty .empty-text {
font-family: 'Manrope', sans-serif;
font-size: 12px;
color: var(--tx-3);
font-style: italic;
}
/* ── Thinking Indicator ────────────────────────────── */
.thinking-indicator {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0;
}
.thinking-dots {
display: flex;
gap: 2px;
font-size: 6px;
color: var(--a-mid);
}
.thinking-dots span {
animation: think-pop 1.2s ease-in-out infinite;
}
.thinking-dots .dot-2 {
animation-delay: 0.2s;
}
.thinking-dots .dot-3 {
animation-delay: 0.4s;
}
@keyframes think-pop {
0%, 80%, 100% {
opacity: 0.3;
transform: scale(0.7);
}
40% {
opacity: 1;
transform: scale(1);
}
}
.thinking-text {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--tx-3);
font-style: italic;
}
/* ── Input Area ───────────────────────────────────── */
.chat-input-area {
flex: 0 0 auto;
padding: 10px 12px 12px;
border-top: 1px solid var(--line);
}
.input-wrap {
display: flex;
align-items: center;
gap: 8px;
height: 44px;
padding: 0 8px 0 13px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--line);
transition: border-color 0.15s, box-shadow 0.15s;
}
.input-wrap:focus-within {
border-color: var(--line-3);
box-shadow: 0 0 0 3px rgba(124, 108, 255, 0.12);
}
.chat-input {
flex: 1;
background: transparent;
border: none;
outline: none;
font-family: 'Manrope', sans-serif;
font-size: 12px;
color: var(--tx);
line-height: 1.4;
}
.chat-input::placeholder {
color: var(--tx-3);
}
.send-btn {
flex: 0 0 32px;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 8px;
background: var(--grad);
color: #fff;
cursor: pointer;
transition: filter 0.15s, opacity 0.15s;
}
.send-btn:disabled {
opacity: 0.35;
cursor: default;
}
.send-btn:not(:disabled):hover {
filter: brightness(1.1);
}
.send-btn :deep(svg) {
width: 16px;
height: 16px;
}
</style>
@@ -0,0 +1,226 @@
<script setup lang="ts">
/**
* TaskStrip — Untere Leiste im V2 Dashboard Stage
*
* Props:
* tasks TaskItem[]
*/
import type { TaskItem } from './types'
defineProps<{
tasks: TaskItem[]
loading?: boolean
error?: string | null
}>()
</script>
<template>
<div class="taskstrip v2-scroll">
<!-- Loading skeleton -->
<template v-if="loading">
<div v-for="n in 3" :key="'sk-' + n" class="taskcard skeleton" />
</template>
<!-- Error -->
<div v-else-if="error" class="task-error">
<span class="error-icon"></span> {{ error }}
</div>
<!-- Empty -->
<div v-else-if="!tasks.length" class="task-empty">
No active tasks
</div>
<!-- Tasks -->
<div
v-for="task in tasks"
:key="task.id"
class="taskcard"
:class="`task-${task.status}`"
>
<!-- Priority Badge -->
<span class="prio-badge" :class="`prio-${task.priority}`">
{{ task.priority === 'high' ? 'P0' : task.priority === 'medium' ? 'P1' : 'P2' }}
</span>
<!-- Title -->
<div class="task-title">{{ task.title }}</div>
<!-- Agent -->
<div class="task-agent">{{ task.agent }}</div>
<!-- Progress Bar -->
<div class="task-progress">
<div class="bar-track">
<div
class="bar-fill"
:style="{ width: task.progress + '%' }"
></div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.taskstrip {
display: flex;
flex-direction: row;
gap: 10px;
padding: 0 16px 14px;
overflow-x: auto;
min-height: 0;
flex: 0 0 auto;
}
/* ── Task Card ────────────────────────────────────── */
.taskcard {
min-width: 196px;
max-width: 220px;
flex: 0 0 auto;
background: var(--glass);
border: 1px solid var(--line);
border-radius: var(--r);
padding: 12px 13px;
display: flex;
flex-direction: column;
gap: 6px;
position: relative;
transition: border-color 0.15s, background 0.15s;
}
/* ── Status Variants ──────────────────────────────── */
.task-active {
border-left: 2px solid var(--st-work);
background: rgba(61, 220, 151, 0.04);
}
.task-pending {
border-left: 2px solid var(--st-think);
background: rgba(52, 214, 245, 0.04);
}
.task-blocked {
border-left: 2px solid var(--st-block);
background: rgba(255, 106, 106, 0.04);
}
/* ── Priority Badge ───────────────────────────────── */
.prio-badge {
display: inline-block;
align-self: flex-start;
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
font-weight: 600;
padding: 1px 7px;
border-radius: 20px;
line-height: 1.5;
}
.prio-high {
background: rgba(255, 106, 106, 0.18);
color: var(--st-block);
}
.prio-medium {
background: rgba(124, 108, 255, 0.14);
color: var(--a-mid);
}
.prio-low {
background: rgba(255, 255, 255, 0.06);
color: var(--tx-3);
}
/* ── Title ─────────────────────────────────────────── */
.task-title {
font-family: 'Manrope', sans-serif;
font-size: 12px;
font-weight: 600;
color: var(--tx);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* ── Agent ─────────────────────────────────────────── */
.task-agent {
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
color: var(--tx-3);
font-variant-numeric: tabular-nums;
}
/* ── Progress Bar ──────────────────────────────────── */
.task-progress {
margin-top: 2px;
}
.bar-track {
height: 3px;
background: rgba(255, 255, 255, 0.06);
border-radius: 2px;
overflow: hidden;
position: relative;
}
.bar-fill {
height: 100%;
border-radius: 2px;
transition: width 0.4s ease;
}
/* Status-specific bar colors */
.task-active .bar-fill {
background: var(--grad);
}
.task-pending .bar-fill {
background: var(--grad);
opacity: 0.45;
}
.task-blocked .bar-fill {
background: var(--st-block);
opacity: 0.55;
}
/* ── Skeleton ─────────────────────────────────── */
.taskcard.skeleton {
height: 98px;
background: var(--glass);
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 0.5; }
50% { opacity: 0.8; }
}
/* ── Error ────────────────────────────────────── */
.task-error {
display: flex;
align-items: center;
gap: 8px;
font-family: 'Manrope', sans-serif;
font-size: 11px;
color: #fda4b0;
padding: 12px;
white-space: nowrap;
}
.error-icon { flex: 0 0 auto; font-size: 14px; }
/* ── Empty ────────────────────────────────────── */
.task-empty {
font-family: 'Manrope', sans-serif;
font-size: 11px;
color: var(--tx-3);
font-style: italic;
padding: 12px;
white-space: nowrap;
}
</style>
@@ -0,0 +1,44 @@
/**
* Shared types for V2 Dashboard components
*/
export interface ChatMessage {
sender: 'iris' | 'user'
text: string
ts: string
tool?: string
}
export interface TaskItem {
id: string
title: string
agent: string
priority: 'high' | 'medium' | 'low'
status: 'active' | 'pending' | 'blocked'
progress: number // 0100
}
/* ── Agent Detail Modal Types ─────────────────── */
export interface ThinkingItem {
type: 'thought' | 'action' | 'result'
text: string
ts: string
}
/** Dashboard view-model for an agent detail modal (distinct from types/agent.ts AgentDetail) */
export interface AgentDetailData {
id: string
name: string
role: string
model: string
status: 'work' | 'think' | 'idle'
tokensToday: number
costToday: number
workload: number
uptime: string
lastActive: string
activeTaskCount: number
thinking: ThinkingItem[]
availableModels: { id: string; alias: string }[]
}
@@ -8,6 +8,7 @@ import {
} from '@lucide/vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../../stores/auth'
import { initials } from '../../utils/format'
const props = defineProps<{
activeView: string
@@ -23,7 +24,9 @@ const emit = defineEmits<{
const auth = useAuthStore()
const router = useRouter()
const ownerInitials = computed(() => auth.user?.displayName.split(' ').map(part => part[0]).join('').slice(0, 2).toUpperCase() ?? 'OW')
const ownerInitials = computed(() =>
auth.user?.displayName ? initials(auth.user.displayName) : 'OW'
)
const navigation = [
{ label: 'Dashboard', icon: LayoutDashboard },
@@ -0,0 +1,36 @@
<script setup lang="ts">
import type { NavItemDef } from '../../composables/icons'
import NavItem from './NavItem.vue'
defineProps<{
label: string
items: NavItemDef[]
}>()
</script>
<template>
<div class="nav-group">
<div class="nav-group-label">{{ label }}</div>
<NavItem
v-for="item in items"
:key="item.label"
:icon="item.icon"
:label="item.label"
:route="item.route"
:count="item.count"
:active="item.active"
/>
</div>
</template>
<style scoped>
.nav-group-label {
font-size: 10px;
letter-spacing: .18em;
text-transform: uppercase;
color: var(--tx-3);
font-weight: 700;
padding: 16px 10px 7px;
font-family: 'Manrope', sans-serif;
}
</style>
+126
View File
@@ -0,0 +1,126 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { icons } from '../../composables/icons'
const props = defineProps<{
icon: string
label: string
route?: string
count?: string
active?: boolean
}>()
const router = useRouter()
const route = useRoute()
const isActive = computed(() => {
if (props.active) return true
if (props.route && route.path === props.route) return true
return false
})
function navigate() {
if (props.route) {
router.push(props.route)
}
}
</script>
<template>
<button
:class="['nav-item', { active: isActive }]"
@click="navigate"
>
<!-- Icon -->
<span class="nav-icon" v-html="icons[icon] || ''"></span>
<!-- Label -->
<span class="nav-label">{{ label }}</span>
<!-- Count badge -->
<span v-if="count !== undefined" class="count">{{ count }}</span>
</button>
</template>
<style scoped>
.nav-item {
display: flex;
align-items: center;
gap: 11px;
padding: 9px 11px;
border-radius: 10px;
border: none;
background: transparent;
color: var(--tx-2);
font-family: 'Manrope', sans-serif;
font-size: 13.5px;
font-weight: 500;
cursor: pointer;
position: relative;
transition: background .16s, color .16s;
text-decoration: none;
width: 100%;
text-align: left;
}
.nav-item:hover {
background: rgba(124,108,255,.08);
color: var(--tx);
}
.nav-item.active {
color: #fff;
background: linear-gradient(90deg, rgba(124,108,255,.22), rgba(124,108,255,.04));
box-shadow: inset 0 0 0 1px rgba(124,108,255,.25);
}
.nav-item.active::before {
content: '';
position: absolute;
left: -12px;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 20px;
border-radius: 3px;
background: var(--grad);
box-shadow: var(--glow-purple);
}
.nav-icon {
display: flex;
align-items: center;
justify-content: center;
width: 17px;
height: 17px;
flex: 0 0 auto;
opacity: .85;
}
.nav-icon :deep(svg) {
width: 17px;
height: 17px;
}
.nav-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.count {
margin-left: auto;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
font-weight: 600;
padding: 1px 8px;
border-radius: 20px;
background: rgba(124,108,255,.16);
color: var(--tx);
line-height: 1.4;
flex-shrink: 0;
}
</style>
+207
View File
@@ -0,0 +1,207 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../../stores/auth'
import { useAgentStore } from '../../stores/agents'
import { useTaskStore } from '../../stores/tasks'
import { navigation, icons } from '../../composables/icons'
import type { NavGroupDef } from '../../composables/icons'
import NavGroup from './NavGroup.vue'
import { initials } from '../../utils/format'
const auth = useAuthStore()
const router = useRouter()
const agentStore = useAgentStore()
const taskStore = useTaskStore()
const ownerInitials = computed(() =>
auth.user?.displayName ? initials(auth.user.displayName) : 'OW'
)
function logout() {
auth.logout()
router.replace('/login')
}
/**
* Dynamische Nav-Item-Counts aus den Stores.
* Überschreibt die hartcodierten `count`-Werte im navigation-Array.
*/
const dynamicNavigation = computed<NavGroupDef[]>(() => {
// Deep-clone: Jede Gruppe und jedes Item neu erstellen
return navigation.map(group => ({
...group,
items: group.items.map(item => {
let dynamicCount: string | undefined
switch (item.label) {
case 'Agenten':
case 'Hosts · OpenClaw':
dynamicCount = String(agentStore.agentList.length)
break
case 'Task Board':
dynamicCount = String(taskStore.taskList.length)
break
case 'Kosten & Tokens':
dynamicCount = agentStore.todayCost
break
case 'Docs & .md':
dynamicCount = '0'
break
case 'Incidents':
dynamicCount = '0'
break
}
return {
...item,
count: dynamicCount ?? item.count,
}
}),
}))
})
</script>
<template>
<aside class="sidebar">
<!-- Brand -->
<div class="side-top">
<div class="brand-mark" v-html="icons.command || ''"></div>
<div>
<div class="brand-name">NEXUS</div>
<div class="brand-sub">Mission Control</div>
</div>
</div>
<!-- Navigation -->
<nav class="nav">
<NavGroup
v-for="(group, idx) in dynamicNavigation"
:key="idx"
:label="group.group"
:items="group.items"
/>
</nav>
<!-- Footer -->
<div class="side-foot">
<div class="avatar">{{ ownerInitials }}</div>
<div class="owner-info">
<div class="owner-name">{{ auth.user?.displayName ?? 'Owner' }}</div>
<div class="owner-role">{{ auth.user?.role ?? 'Owner' }}</div>
</div>
</div>
</aside>
</template>
<style scoped>
.sidebar {
width: 248px;
flex: 0 0 248px;
height: 100vh;
display: flex;
flex-direction: column;
background: linear-gradient(180deg, rgba(14,12,32,.92), rgba(8,6,20,.92));
border-right: 1px solid var(--line);
backdrop-filter: blur(14px);
padding: 0;
position: relative;
z-index: 2;
}
.side-top {
display: flex;
align-items: center;
gap: 11px;
padding: 18px 18px 16px;
}
.brand-mark {
width: 38px;
height: 38px;
border-radius: 11px;
display: grid;
place-items: center;
background: var(--grad);
box-shadow: var(--glow-purple);
flex: 0 0 auto;
}
.brand-mark :deep(svg) {
width: 20px;
height: 20px;
color: #fff;
}
.brand-name {
font-family: 'Space Grotesk', sans-serif;
font-weight: 700;
font-size: 17px;
letter-spacing: .14em;
line-height: 1;
}
.brand-sub {
font-size: 10.5px;
color: var(--tx-3);
letter-spacing: .05em;
margin-top: 3px;
}
.nav {
flex: 1;
overflow-y: auto;
padding: 6px 12px 12px;
display: flex;
flex-direction: column;
gap: 2px;
}
.side-foot {
padding: 12px;
border-top: 1px solid var(--line);
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
transition: background .15s;
}
.side-foot:hover {
background: rgba(124,108,255,.06);
}
.avatar {
width: 34px;
height: 34px;
border-radius: 10px;
background: var(--grad-soft);
border: 1px solid var(--line-2);
display: grid;
place-items: center;
font-weight: 700;
font-size: 13px;
color: var(--tx);
flex-shrink: 0;
}
.owner-info {
min-width: 0;
}
.owner-name {
font-size: 12px;
font-weight: 600;
color: var(--tx);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.owner-role {
font-size: 10px;
color: var(--tx-3);
margin-top: 1px;
text-transform: capitalize;
}
</style>
+141
View File
@@ -0,0 +1,141 @@
<script setup lang="ts">
import { icons } from '../../composables/icons'
defineProps<{
connected?: boolean
}>()
</script>
<template>
<header class="topbar">
<!-- Search -->
<div class="search">
<span class="search-icon" v-html="icons.search || ''"></span>
<span class="search-placeholder">Operationen, Agents oder Tasks suchen</span>
</div>
<!-- Spacer -->
<div class="spacer"></div>
<!-- Status Pill -->
<span :class="['pill', connected ? 'live' : 'preview']">
<span class="status-dot" :class="connected ? 'on' : 'off'"></span>
{{ connected ? 'Verbunden' : 'Preview' }}
</span>
<!-- Ask Iris Button -->
<button class="btn btn-primary">
<span class="btn-icon" v-html="icons.spark || ''"></span>
Ask Iris
</button>
</header>
</template>
<style scoped>
.topbar {
height: 62px;
flex: 0 0 62px;
display: flex;
align-items: center;
gap: 14px;
padding: 0 22px;
border-bottom: 1px solid var(--line);
background: rgba(8,6,20,.5);
backdrop-filter: blur(14px);
}
.search {
flex: 1;
max-width: 560px;
display: flex;
align-items: center;
gap: 10px;
height: 38px;
padding: 0 14px;
border-radius: 11px;
background: rgba(124,108,255,.06);
border: 1px solid var(--line);
color: var(--tx-3);
font-size: 13.5px;
font-family: 'Manrope', sans-serif;
}
.search-icon :deep(svg) {
width: 16px;
height: 16px;
flex: 0 0 auto;
}
.search-placeholder {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.spacer {
flex: 1;
}
.pill {
display: inline-flex;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 11px;
border-radius: 20px;
font-size: 11.5px;
font-weight: 600;
font-family: 'Manrope', sans-serif;
border: 1px solid var(--line-2);
background: rgba(124,108,255,.07);
color: var(--tx-2);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex: 0 0 auto;
}
.status-dot.on {
background: var(--st-work);
box-shadow: 0 0 0 0 rgba(61,220,151,.5);
animation: pulse-work 1.8s infinite;
}
.status-dot.off {
background: var(--st-idle);
}
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
height: 36px;
padding: 0 14px;
border-radius: 10px;
font-family: 'Manrope', sans-serif;
font-weight: 600;
font-size: 13px;
cursor: pointer;
border: none;
transition: filter .16s;
}
.btn-primary {
background: var(--grad);
color: #fff;
box-shadow: var(--glow-purple);
}
.btn-primary:hover {
filter: brightness(1.08);
}
.btn-icon :deep(svg) {
width: 15px;
height: 15px;
}
</style>
+89
View File
@@ -0,0 +1,89 @@
/**
* Inline SVG icons for Nexus V2
* All stroke-based, currentColor, viewBox 0 0 24 24
*/
export const icons: Record<string, string> = {
grid: `<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>`,
cpu: `<rect x="5" y="5" width="14" height="14" rx="2"/><rect x="9" y="9" width="6" height="6" rx="1"/><path d="M9 2v3M15 2v3M9 19v3M15 19v3M2 9h3M2 15h3M19 9h3M19 15h3"/>`,
list: `<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/>`,
flow: `<circle cx="6" cy="6" r="2.5"/><circle cx="18" cy="6" r="2.5"/><circle cx="12" cy="18" r="2.5"/><path d="M7.5 7.5 11 16M16.5 7.5 13 16"/>`,
brain: `<path d="M9 3a3 3 0 0 0-3 3 3 3 0 0 0-1 5.8A3 3 0 0 0 8 17a3 3 0 0 0 4 1 3 3 0 0 0 4-1 3 3 0 0 0 3-5.2A3 3 0 0 0 18 6a3 3 0 0 0-3-3 3 3 0 0 0-3 1.5A3 3 0 0 0 9 3Z"/>`,
doc: `<path d="M14 3v5h5M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>`,
search: `<circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/>`,
server: `<rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01M7 16.5h.01"/>`,
model: `<path d="M12 2 3 7l9 5 9-5-9-5ZM3 12l9 5 9-5M3 17l9 5 9-5"/>`,
activity: `<path d="M3 12h4l3 8 4-16 3 8h4"/>`,
coin: `<circle cx="12" cy="12" r="9"/><path d="M12 7v10M9.5 9.5h4a1.5 1.5 0 0 1 0 3h-3a1.5 1.5 0 0 0 0 3h4"/>`,
shield: `<path d="M12 3 5 6v5c0 4 3 7 7 9 4-2 7-5 7-9V6z"/>`,
alert: `<path d="M12 3 2 20h20zM12 9v5M12 17h.01"/>`,
send: `<path d="M22 2 11 13M22 2 15 22l-4-9-9-4z"/>`,
spark: `<path d="M12 3v4M12 17v4M3 12h4M17 12h4M6 6l2.5 2.5M15.5 15.5 18 18M18 6l-2.5 2.5M8.5 15.5 6 18"/>`,
expand: `<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/>`,
bot: `<rect x="4" y="7" width="16" height="12" rx="3"/><path d="M12 7V4M9 13h.01M15 13h.01M8 19v2M16 19v2"/>`,
clock: `<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>`,
target: `<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="1.5"/>`,
arrow: `<path d="M5 12h14M13 6l6 6-6 6"/>`,
plus: `<path d="M12 5v14M5 12h14"/>`,
command: `<path d="M7 4a3 3 0 0 0-3 3v10a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V7a3 3 0 0 0-3-3H7z"/><path d="M12 8v8M8 12h8"/>`,
chevron_left: `<path d="m15 18-6-6 6-6"/>`,
chevron_right: `<path d="m9 18 6-6-6-6"/>`,
dots: `<circle cx="12" cy="12" r="1.5"/><circle cx="19" cy="12" r="1.5"/><circle cx="5" cy="12" r="1.5"/>`,
}
export function svg(name: string, cls = ''): string {
const inner = icons[name]
if (!inner) return ''
return `<svg class="${cls}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${inner}</svg>`
}
export interface NavItemDef {
icon: string
label: string
route?: string
count?: string
active?: boolean
}
export interface NavGroupDef {
group: string
items: NavItemDef[]
}
/**
* Navigation structure matching NEXUS.nav from agents.js
*/
export const navigation: NavGroupDef[] = [
{
group: 'Operations',
items: [
{ icon: 'grid', label: 'Dashboard', route: '/dashboard', active: true },
{ icon: 'cpu', label: 'Agenten', route: '/agents' },
{ icon: 'list', label: 'Task Board', route: '/tasks' },
{ icon: 'flow', label: 'Orchestrierung', route: '/orchestration' },
],
},
{
group: 'Knowledge',
items: [
{ icon: 'brain', label: 'Memory', route: '/memory' },
{ icon: 'doc', label: 'Docs & .md', route: '/docs' },
{ icon: 'search', label: 'Research', route: '/research' },
],
},
{
group: 'Infrastructure',
items: [
{ icon: 'server', label: 'Hosts · OpenClaw', route: '/hosts' },
{ icon: 'model', label: 'Modelle', route: '/models' },
{ icon: 'activity', label: 'Activity Log', route: '/activity' },
],
},
{
group: 'Governance',
items: [
{ icon: 'coin', label: 'Kosten & Tokens', route: '/costs' },
{ icon: 'shield', label: 'Security', route: '/security' },
{ icon: 'alert', label: 'Incidents', route: '/incidents' },
],
},
]
@@ -1,617 +0,0 @@
import { ref, computed } from 'vue'
// ── Shared State (singleton: same state regardless of how many times useDashboardData() is called) ──
const sessionStart = Date.now()
// Intervals registry for cleanup
const intervals: ReturnType<typeof setInterval>[] = []
let cleanupRegistered = false
// ── Interfaces (exported for components) ──
export interface AgentNodeData {
id: string
name: string
role: string
description: string
tags: string[]
color: string
icon: string
model?: string
hero?: boolean
currentTask: string
goal: string
progress: number
workload: number // 0-100
active: boolean
runtimeSeconds: number
workingFeed: Array<{ time: string; text: string }>
thinkingStream?: Array<{ time: string; text: string }>
}
export interface OpenTask {
id: string
title: string
detail: string
source: 'bao' | 'iris'
createdAt: string
}
export interface FeedEntry {
time: string
agent: string
action: string
timestamp: string
}
export interface ChatMessage {
id: string
sender: 'user' | 'iris'
text: string
timestamp: number
}
export interface QueueItem {
id: string
text: string
priority: 'high' | 'medium' | 'low'
waitTime: string
source: 'cron' | 'task'
}
// ── API Response Interfaces ──
interface DashboardStatusResponse {
gatewayOk: boolean
irisStatus: string
activeAgents: number
pendingTasks: number
}
interface DashboardAgentInfo {
id: string
name: string
role: string
model: string
isActive: boolean
currentTask: string
description?: string
tags?: string[]
}
interface DashboardOperationEntry {
agent: string
action: string
timestamp: string
time: string
}
interface DashboardChatMessage {
role: 'user' | 'assistant'
content: string
timestamp: string
}
interface DashboardSendResponse {
ok: boolean
reply?: string
error?: string
}
interface DashboardQueueItem {
id: string
name: string
status: string
priority: string
source: string
waitTime: string
}
interface DashboardTaskResponse {
id: string
title: string
detail: string | null
source: string
state: string
priority: string
assignedTo: string | null
createdAt: string
updatedAt: string
}
// ── Agent Catalog (static enrichment) ──
const AGENT_CATALOG: Record<string, Partial<AgentNodeData>> = {
iris: {
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: '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: [],
},
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: 'Nexus Dashboard & Dungeon System',
progress: 70,
workload: 65,
workingFeed: [],
thinkingStream: [],
},
}
function enrichAgent(api: DashboardAgentInfo): AgentNodeData {
const catalog = AGENT_CATALOG[api.id] ?? AGENT_CATALOG['reviewer']
return {
id: api.id,
name: api.name,
role: api.role,
model: api.model,
currentTask: api.currentTask ?? 'Idle',
active: api.isActive,
description: api.description ?? catalog.description ?? '',
tags: api.tags ?? catalog.tags ?? [],
color: catalog.color ?? '#6b7385',
icon: catalog.icon ?? 'bot',
hero: catalog.hero ?? false,
goal: catalog.goal ?? 'No goal set',
progress: catalog.progress ?? 0,
workload: catalog.workload ?? 0,
runtimeSeconds: 0,
workingFeed: catalog.workingFeed ?? [],
thinkingStream: catalog.thinkingStream ?? [],
}
}
// ── Helper: API Fetch with auth ──
async function apiFetch(path: string, init: RequestInit = {}): Promise<Response> {
const base = '' // same-origin proxy
return fetch(`${base}${path}`, {
...init,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(init.headers as Record<string, string> ?? {}),
},
})
}
// ── State ──
// Status
const gatewayOk = ref(true)
const irisStatus = ref('Active')
const activeAgents = ref(0)
const pendingTasks = ref(0)
// Agents
const agents = ref<AgentNodeData[]>([])
// Chat
const chatMessages = ref<ChatMessage[]>([])
const irisBusy = ref(false)
const irisFocus = ref('')
const busySince = ref(0)
// Operations Feed
const feedEntries = ref<FeedEntry[]>([])
// Open Tasks (fetched from API)
const openTasks = ref<OpenTask[]>([])
// Queue
const queue = ref<QueueItem[]>([])
// Runtime
const runtimeSeconds = ref(0)
let runtimeInterval: ReturnType<typeof setInterval> | null = null
// ── Fetch Functions ──
async function fetchStatus(): Promise<void> {
try {
const res = await apiFetch('/api/dashboard/status')
if (!res.ok) return
const data: DashboardStatusResponse = await res.json()
gatewayOk.value = data.gatewayOk
irisStatus.value = data.irisStatus
activeAgents.value = data.activeAgents
pendingTasks.value = data.pendingTasks
} catch {
// API unreachable keep current values
}
}
async function fetchAgents(): Promise<void> {
try {
const res = await apiFetch('/api/dashboard/agents')
if (!res.ok) return
const data: DashboardAgentInfo[] = await res.json()
agents.value = data.map(enrichAgent)
} catch {
// API unreachable keep current values
}
}
async function fetchOperations(): Promise<void> {
try {
const res = await apiFetch('/api/dashboard/operations?limit=20')
if (!res.ok) return
const data: DashboardOperationEntry[] = await res.json()
feedEntries.value = data.map((entry) => ({
time: entry.time,
agent: entry.agent,
action: entry.action,
timestamp: entry.timestamp,
}))
} catch {
// API unreachable keep current values
}
}
async function fetchChatMessages(): Promise<void> {
try {
const res = await apiFetch('/api/dashboard/chat/messages?limit=50')
if (!res.ok) return
const data: DashboardChatMessage[] = await res.json()
// 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
}
}
async function fetchQueue(): Promise<void> {
try {
const res = await apiFetch('/api/dashboard/queue')
if (!res.ok) return
const data: DashboardQueueItem[] = await res.json()
queue.value = data.map((item) => ({
id: item.id,
text: item.name,
priority: (item.priority === 'high' || item.priority === 'medium' || item.priority === 'low')
? item.priority as 'high' | 'medium' | 'low'
: 'medium',
waitTime: item.waitTime || '--',
source: (item.source === 'cron' || item.source === 'task') ? item.source as 'cron' | 'task' : 'cron',
}))
} catch {
// API unreachable keep current values
}
}
async function fetchTasks(): Promise<void> {
try {
const res = await apiFetch('/api/dashboard/tasks')
if (!res.ok) return
const data: DashboardTaskResponse[] = await res.json()
openTasks.value = data.map(mapTaskResponse)
} catch {
// API unreachable keep current values
}
}
function mapTaskResponse(t: DashboardTaskResponse): OpenTask {
const source: OpenTask['source'] = t.source === 'iris' ? 'iris' : 'bao'
// Format createdAt as relative time string (like "22:30")
const created = new Date(t.createdAt)
const now = new Date()
const diffMs = now.getTime() - created.getTime()
const diffMins = Math.floor(diffMs / 60000)
let createdAt: string
if (diffMins < 1) {
createdAt = 'just now'
} else if (diffMins < 60) {
createdAt = `${diffMins}m`
} else if (diffMins < 1440) {
createdAt = `${Math.floor(diffMins / 60)}h`
} else {
createdAt = created.toLocaleDateString('de-DE', { month: 'short', day: 'numeric' })
}
return {
id: t.id,
title: t.title,
detail: t.detail ?? '',
source,
createdAt,
}
}
// ── Chat Send ──
async function sendChatMessage(text: string): Promise<void> {
if (!text.trim()) return
// Optimistic add
chatMessages.value.push({
id: `user-${Date.now()}`,
sender: 'user',
text: text.trim(),
timestamp: Date.now(),
})
irisBusy.value = true
busySince.value = Date.now()
try {
const res = await apiFetch('/api/dashboard/chat/send', {
method: 'POST',
body: JSON.stringify({ message: text.trim() }),
})
const data: DashboardSendResponse = await res.json()
if (data.ok && data.reply) {
chatMessages.value.push({
id: `iris-${Date.now()}`,
sender: 'iris',
text: data.reply,
timestamp: Date.now(),
})
} else if (data.error) {
chatMessages.value.push({
id: `error-${Date.now()}`,
sender: 'iris',
text: `⚠️ ${data.error}`,
timestamp: Date.now(),
})
}
} catch {
chatMessages.value.push({
id: `error-${Date.now()}`,
sender: 'iris',
text: '⚠️ Connection error. Please try again.',
timestamp: Date.now(),
})
} finally {
irisBusy.value = false
busySince.value = 0
irisFocus.value = text.trim()
}
}
// ── Queue Operations ──
async function removeQueueItem(id: string): Promise<void> {
const item = queue.value.find(q => q.id === id)
if (!item) return
try {
const res = await apiFetch(`/api/dashboard/queue/${encodeURIComponent(id)}?source=${item.source}`, {
method: 'DELETE',
})
if (!res.ok) {
console.warn('[Dashboard] Failed to remove queue item', id, res.status)
return
}
// Remove from local state on success
const idx = queue.value.findIndex(q => q.id === id)
if (idx !== -1) queue.value.splice(idx, 1)
} catch {
console.warn('[Dashboard] Error removing queue item', id)
}
}
function moveQueueItem(fromIdx: number, toIdx: number): void {
if (toIdx < 0 || toIdx >= queue.value.length) return
const [item] = queue.value.splice(fromIdx, 1)
queue.value.splice(toIdx, 0, item)
}
async function changeQueuePriority(id: string, priority: QueueItem['priority']): Promise<void> {
const item = queue.value.find(q => q.id === id)
if (!item) return
// For cron jobs, just update locally (gateway manages its own priorities)
if (item.source === 'cron') {
item.priority = priority
return
}
try {
const res = await apiFetch(`/api/dashboard/queue/${encodeURIComponent(id)}/priority`, {
method: 'PUT',
})
if (!res.ok) {
console.warn('[Dashboard] Failed to change priority', id, res.status)
return
}
// Update priority from API response
const data = await res.json()
if (data.priority) {
const normalized = data.priority.toLowerCase()
if (normalized === 'high' || normalized === 'medium' || normalized === 'low') {
item.priority = normalized as 'high' | 'medium' | 'low'
} else {
// Fallback: cycle locally
item.priority = priority
}
} else {
item.priority = priority
}
} catch {
console.warn('[Dashboard] Error changing priority', id)
}
}
// ── Runtime ──
function startRuntime(): void {
const startTs = sessionStart
runtimeSeconds.value = Math.floor((Date.now() - startTs) / 1000)
runtimeInterval = setInterval(() => {
runtimeSeconds.value = Math.floor((Date.now() - startTs) / 1000)
}, 1000)
}
function stopRuntime(): void {
if (runtimeInterval) {
clearInterval(runtimeInterval)
runtimeInterval = null
}
}
const formatRuntime = (seconds: number): string => {
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
}
const irisRuntime = computed(() => formatRuntime(runtimeSeconds.value))
const getAgentRuntime = (_id: string): string => {
// Could be extended to track per-agent runtimes from API
return formatRuntime(runtimeSeconds.value)
}
// ── Polling starten (nur einmal) ──
function startPolling(): void {
if (cleanupRegistered) return
cleanupRegistered = true
// Initial fetches
fetchStatus()
fetchAgents()
fetchOperations()
fetchChatMessages()
fetchQueue()
fetchTasks()
// Polling intervals
intervals.push(setInterval(fetchStatus, 5000))
intervals.push(setInterval(fetchAgents, 10000))
intervals.push(setInterval(fetchOperations, 10000))
intervals.push(setInterval(fetchChatMessages, 3000))
intervals.push(setInterval(fetchQueue, 10000))
intervals.push(setInterval(fetchTasks, 15000))
}
function stopPolling(): void {
for (const interval of intervals) {
clearInterval(interval)
}
intervals.length = 0
cleanupRegistered = false
}
// ── Composable Export ──
export function useDashboardData() {
// Start polling on first call
startPolling()
return {
// State
agents,
openTasks,
feedEntries,
chatMessages,
irisBusy,
irisFocus,
busySince,
irisRuntime,
queue,
gatewayOk,
irisStatus,
pendingTasks,
activeAgents,
// Runtime
runtimeSeconds,
getAgentRuntime,
startRuntime,
stopRuntime,
formatRuntime,
// Actions
sendChatMessage,
removeQueueItem,
moveQueueItem,
changeQueuePriority,
// Fetch (for manual refresh)
fetchStatus,
fetchAgents,
fetchOperations,
fetchChatMessages,
fetchQueue,
fetchTasks,
}
}
+196
View File
@@ -0,0 +1,196 @@
/**
* useFlowLayout — Auto-Layout und Edge-Formeln für das V2 FlowCanvas
*
* Portiert von agents.js (design_handoff_nexus_v2).
* Enthält alle Positionslogik, Edge-Erzeugung und den Typ AgentNodeData.
*/
export interface Point {
x: number
y: number
}
export interface AgentNodeData {
id: string
name: string
role: string
roleBadge?: string
model: string
avatar: string
status: 'work' | 'think' | 'idle' | 'block'
statusLabel: string
task: string | null
goal: string | null
progress: number
elapsed: string
next: string
tokens: string
cost: string
think: string | null
handoff?: string
from?: string
links?: string[]
md?: string
}
export interface EdgeData {
a: string
b: string
kind: 'orch' | 'flow'
}
/**
* Auto-Layout Algorithmus
* Iris immer top-center (x:50%, y:14%).
* Andere Agenten in Reihen (maxPerRow variiert nach Gesamtzahl).
*/
export function autoLayout(agents: AgentNodeData[]): Record<string, Point> {
const positions: Record<string, Point> = { iris: { x: 50, y: 14 } }
const others = agents.filter(a => a.id !== 'iris')
const n = others.length
if (n === 0) return positions
const maxPerRow = n <= 2 ? 2 : n <= 6 ? 3 : n <= 9 ? 3 : 4
const numRows = Math.ceil(n / maxPerRow)
const yStart = n <= 3 ? 58 : 30
const yEnd = 86
const yVals = numRows === 1
? [yStart]
: Array.from({ length: numRows }, (_, i) => yStart + (yEnd - yStart) * i / (numRows - 1))
let idx = 0
yVals.forEach(y => {
const rowN = Math.min(maxPerRow, n - idx)
const xSpan = Math.min(72, 24 * (rowN - 1) + 18)
const xOff = 50 - xSpan / 2
const xStep = rowN > 1 ? xSpan / (rowN - 1) : 0
for (let ci = 0; ci < rowN; ci++, idx++) {
positions[others[idx].id] = { x: xOff + ci * xStep, y }
}
})
return positions
}
/**
* Erzeugt die Kantenliste basierend auf Agenten-Daten
* (gleiche Logik wie buildEdges in agents.js).
*/
export function buildEdges(agents: AgentNodeData[]): EdgeData[] {
const edges: EdgeData[] = []
// Orchestrierung: Iris → jeder Agent
agents.filter(a => a.id !== 'iris').forEach(a => {
edges.push({ a: 'iris', b: a.id, kind: 'orch' })
})
// Spezifische Flows (wenn Agent existiert)
const hasId = (id: string) => agents.some(a => a.id === id)
if (hasId('dev') && hasId('rev')) edges.push({ a: 'dev', b: 'rev', kind: 'flow' })
if (hasId('arch') && hasId('exec')) edges.push({ a: 'arch', b: 'exec', kind: 'flow' })
if (hasId('res')) edges.push({ a: 'res', b: 'iris', kind: 'flow' })
if (hasId('qa') && hasId('rev')) edges.push({ a: 'qa', b: 'rev', kind: 'flow' })
if (hasId('security')) edges.push({ a: 'security', b: 'iris', kind: 'flow' })
if (hasId('pm')) edges.push({ a: 'pm', b: 'iris', kind: 'flow' })
if (hasId('devops') && hasId('exec')) edges.push({ a: 'exec', b: 'devops', kind: 'flow' })
return edges
}
/**
* Bézier-Kurve zwischen zwei Punkten
* Verwendet die README.p1 und p2)) Formel:
* M p1 Q Kontrollpunkt p2
* mx,my = Mittelpunkt; off = min(50, hypot*0.14)
* len = hypot(-dy, dx) (Normale)
* cp = (mx + (-dy/len)*off, my + (dx/len)*off)
*/
export function curve(p1: Point, p2: Point): string {
const mx = (p1.x + p2.x) / 2
const my = (p1.y + p2.y) / 2
const dx = p2.x - p1.x
const dy = p2.y - p1.y
const off = Math.min(50, Math.hypot(dx, dy) * 0.14)
const len = Math.hypot(-dy, dx) || 1
const cx = mx + (-dy / len) * off
const cy = my + (dx / len) * off
return `M${p1.x},${p1.y} Q${cx},${cy} ${p2.x},${p2.y}`
}
/**
* Extra agents that can be added to the FlowBoard dynamically
*/
export const extraAgentPool: AgentNodeData[] = [
{
id: 'qa',
name: 'QA Automator',
role: 'Test Automation',
roleBadge: 'badge-cyan',
avatar: 'QA',
status: 'idle',
statusLabel: 'Bereit',
task: 'End-to-End Tests schreiben',
goal: '100% Coverage für auth/',
progress: 0,
elapsed: '—',
next: 'Testplan erstellen',
model: 'Deepseek V4 Flash',
tokens: '0',
cost: '0.00',
think: null,
},
{
id: 'devops',
name: 'DevOps',
role: 'CI/CD Pipeline',
roleBadge: 'badge-amber',
avatar: 'DO',
status: 'idle',
statusLabel: 'Bereit',
task: 'GitHub Actions Workflow',
goal: 'Automatisches Deploy auf merge',
progress: 0,
elapsed: '—',
next: 'Pipeline konfigurieren',
model: 'Deepseek V4 Pro',
tokens: '0',
cost: '0.00',
think: null,
},
{
id: 'security',
name: 'Security Scanner',
role: 'Security Analysis',
roleBadge: 'badge-rose',
avatar: 'SC',
status: 'think',
statusLabel: 'Scannt',
task: 'Dependency-Audit durchführen',
goal: 'CVEs in api/ aufdecken',
progress: 18,
elapsed: '00:01:44',
next: 'Report an Iris',
model: 'Deepseek V4 Pro',
tokens: '9k',
cost: '0.18',
think: 'Analysiere package-lock.json auf bekannte Vulnerabilities…',
},
{
id: 'pm',
name: 'Project Manager',
role: 'Coordination',
roleBadge: 'badge-purple',
avatar: 'PM',
status: 'think',
statusLabel: 'Plant',
task: 'Sprint-Retrospektive vorbereiten',
goal: 'Blockers identifizieren',
progress: 35,
elapsed: '00:05:10',
next: 'Meeting-Summary an Team',
model: 'Deepseek V4 Flash',
tokens: '14k',
cost: '0.24',
think: 'Analysiere Velocity-Daten der letzten 3 Sprints…',
},
]
@@ -1,266 +0,0 @@
import { ref, computed, onMounted, onUnmounted, nextTick, type Ref } from 'vue'
import type { AgentNodeData } from './useDashboardData'
export interface CardBox {
left: number
right: number
top: number
bottom: number
cx: number
cy: number
width: number
height: number
}
export interface ConnectionPath {
d: string
length: number
}
export function useTeamNetworkSvg(
networkRef: Ref<HTMLElement | null>,
agents: Ref<AgentNodeData[]>,
heroId: Ref<string>,
isActive: (id: string) => boolean,
) {
// ── Layout ──
const cardPositions = ref<Record<string, CardBox>>({})
const svgWidth = ref(0)
const svgHeight = ref(0)
const childAgents = computed(() => agents.value.filter(a => a.id !== heroId.value))
function updatePositions() {
const el = networkRef.value
if (!el) return
const rect = el.getBoundingClientRect()
svgWidth.value = rect.width
svgHeight.value = rect.height
const cards = el.querySelectorAll('[data-agent-id]')
const positions: Record<string, CardBox> = {}
cards.forEach(card => {
const id = card.getAttribute('data-agent-id')
if (!id) return
const r = card.getBoundingClientRect()
positions[id] = {
left: r.left - rect.left,
right: r.left + r.width - rect.left,
top: r.top - rect.top,
bottom: r.top + r.height - rect.top,
cx: r.left + r.width / 2 - rect.left,
cy: r.top + r.height / 2 - rect.top,
width: r.width,
height: r.height,
}
})
cardPositions.value = positions
}
// ── Connection paths ──
const connectionPaths = computed<Record<string, ConnectionPath | null>>(() => {
const result: Record<string, ConnectionPath | null> = {}
const pos = cardPositions.value
const iris = pos[heroId.value]
if (!iris) return result
const children = childAgents.value
const total = children.length
if (total === 0) return result
for (let idx = 0; idx < total; idx++) {
const agent = children[idx]
const agentPos = pos[agent.id]
if (!agentPos) {
result[agent.id] = null
continue
}
// Spread start points across Iris bottom edge (30%-70% range)
const t = total > 1 ? idx / (total - 1) : 0.5
const startX = iris.left + iris.width * (0.38 + t * 0.24)
const startY = iris.bottom - 1
// Determine column: left or right of Iris center
const isLeftColumn = agentPos.cx < iris.cx
// End point: approach from side, 8px before card edge
const endX = isLeftColumn ? agentPos.right - 8 : agentPos.left + 8
const endY = agentPos.cy
// Bézier control points
const cp1x = startX
const cp1y = startY + 70
const cp2x = endX + (isLeftColumn ? 35 : -35)
const cp2y = endY - 10
const d = `M ${startX} ${startY} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${endX} ${endY}`
result[agent.id] = { d, length: 0 }
}
return result
})
// ── Path refs (template ref functions) ──
const pathElements = ref<Record<string, SVGPathElement | null>>({})
const pulseElements = ref<Record<string, SVGPathElement | null>>({})
const pulseElements2 = ref<Record<string, SVGPathElement | null>>({})
const pulseOffsets = ref<Record<string, number>>({})
const pulseOffsets2 = ref<Record<string, number>>({})
function storePathRef(id: string) {
return (el: SVGPathElement | null) => {
pathElements.value[id] = el
}
}
function storePulseRef(id: string) {
return (el: SVGPathElement | null) => {
pulseElements.value[id] = el
}
}
function storePulseRef2(id: string) {
return (el: SVGPathElement | null) => {
pulseElements2.value[id] = el
}
}
// ── Pulse animation ──
let animFrameId: number | null = null
let lastAnimTime = 0
const speeds: Record<string, number> = {}
function refreshPathLengths() {
for (const id of childAgents.value.map(a => a.id)) {
const pathEl = pathElements.value[id]
const pulseEl = pulseElements.value[id]
const p = connectionPaths.value[id]
if (pathEl && p) {
p.length = pathEl.getTotalLength()
}
if (pulseEl && p && p.length > 0) {
if (pulseOffsets.value[id] === undefined) {
pulseOffsets.value[id] = 0
}
pulseEl.setAttribute('stroke-dasharray', `40 ${p.length}`)
pulseEl.setAttribute('stroke-dashoffset', String(-pulseOffsets.value[id]))
}
const pulseEl2 = pulseElements2.value[id]
if (pulseEl2 && p && p.length > 0) {
if (pulseOffsets2.value[id] === undefined) {
pulseOffsets2.value[id] = 0
}
pulseEl2.setAttribute('stroke-dasharray', `40 ${p.length}`)
pulseEl2.setAttribute('stroke-dashoffset', String(-pulseOffsets2.value[id]))
}
}
}
function startPulseAnimation() {
refreshPathLengths()
for (const id of childAgents.value.map(a => a.id)) {
const p = connectionPaths.value[id]
if (p && p.length > 0) {
speeds[id] = p.length / 3000
if (pulseOffsets.value[id] === undefined) pulseOffsets.value[id] = 0
if (pulseOffsets2.value[id] === undefined) pulseOffsets2.value[id] = 0
}
}
lastAnimTime = performance.now()
function tick(now: number) {
const dt = now - lastAnimTime
lastAnimTime = now
const children = childAgents.value
for (let i = 0; i < children.length; i++) {
const id = children[i].id
const pathEl = pathElements.value[id]
const pulseEl = pulseElements.value[id]
const pulseEl2 = pulseElements2.value[id]
const p = connectionPaths.value[id]
if (!pathEl || !pulseEl || !p) continue
const len = p.length
if (len <= 0) continue
const speed = speeds[id] ?? len / 3000
const cycleLen = len + 40
// Pulse 1
const currentOffset = pulseOffsets.value[id] ?? 0
const newOffset = currentOffset + speed * dt
pulseOffsets.value[id] = newOffset > cycleLen ? newOffset % cycleLen : newOffset
pulseEl.setAttribute('stroke-dashoffset', String(-pulseOffsets.value[id]))
// Pulse 2 (offset by half cycle)
if (pulseEl2) {
const offset2 = (pulseOffsets.value[id] + cycleLen / 2) % cycleLen
pulseOffsets2.value[id] = offset2
pulseEl2.setAttribute('stroke-dashoffset', String(-offset2))
}
}
animFrameId = requestAnimationFrame(tick)
}
animFrameId = requestAnimationFrame(tick)
}
function stopPulseAnimation() {
if (animFrameId !== null) {
cancelAnimationFrame(animFrameId)
animFrameId = null
}
}
// ── Lifecycle ──
let resizeObserver: ResizeObserver | null = null
onMounted(async () => {
await nextTick()
updatePositions()
// Wait for SVG to render so path refs are populated
await nextTick()
updatePositions()
refreshPathLengths()
startPulseAnimation()
resizeObserver = new ResizeObserver(() => {
updatePositions()
requestAnimationFrame(() => {
refreshPathLengths()
})
})
if (networkRef.value) {
resizeObserver.observe(networkRef.value)
}
})
onUnmounted(() => {
stopPulseAnimation()
resizeObserver?.disconnect()
})
return {
cardPositions,
svgWidth,
svgHeight,
childAgents,
connectionPaths,
pathElements,
pulseElements,
pulseElements2,
pulseOffsets,
pulseOffsets2,
storePathRef,
storePulseRef,
storePulseRef2,
updatePositions,
refreshPathLengths,
}
}
+76
View File
@@ -0,0 +1,76 @@
import type { AgentNodeData } from '../types/agentNode'
export const EXTRA_AGENT_POOL: AgentNodeData[] = [
{
id: 'qa',
name: 'QA Automator',
role: 'Test Automation',
roleBadge: 'badge-cyan',
avatar: 'QA',
status: 'idle',
statusLabel: 'Bereit',
task: 'End-to-End Tests schreiben',
goal: '100% Coverage für auth/',
progress: 0,
elapsed: '—',
next: 'Testplan erstellen',
model: 'Deepseek V4 Flash',
tokens: '0',
cost: '0.00',
think: null,
},
{
id: 'devops',
name: 'DevOps',
role: 'CI/CD Pipeline',
roleBadge: 'badge-amber',
avatar: 'DO',
status: 'idle',
statusLabel: 'Bereit',
task: 'GitHub Actions Workflow',
goal: 'Automatisches Deploy auf merge',
progress: 0,
elapsed: '—',
next: 'Pipeline konfigurieren',
model: 'Deepseek V4 Pro',
tokens: '0',
cost: '0.00',
think: null,
},
{
id: 'security',
name: 'Security Scanner',
role: 'Security Analysis',
roleBadge: 'badge-rose',
avatar: 'SC',
status: 'think',
statusLabel: 'Scannt',
task: 'Dependency-Audit durchführen',
goal: 'CVEs in api/ aufdecken',
progress: 18,
elapsed: '00:01:44',
next: 'Report an Iris',
model: 'Deepseek V4 Pro',
tokens: '9k',
cost: '0.18',
think: 'Analysiere package-lock.json auf bekannte Vulnerabilities…',
},
{
id: 'pm',
name: 'Project Manager',
role: 'Coordination',
roleBadge: 'badge-purple',
avatar: 'PM',
status: 'think',
statusLabel: 'Plant',
task: 'Sprint-Retrospektive vorbereiten',
goal: 'Blockers identifizieren',
progress: 35,
elapsed: '00:05:10',
next: 'Meeting-Summary an Team',
model: 'Deepseek V4 Flash',
tokens: '14k',
cost: '0.24',
think: 'Analysiere Velocity-Daten der letzten 3 Sprints…',
},
]
+49
View File
@@ -0,0 +1,49 @@
<script setup lang="ts">
/**
* NexusLayout — V2 Dashboard Shell
* Flex row, 100vh, overflow hidden.
* Sidebar (248px) + Main (flex:1, flex-column)
*/
import { RouterView } from 'vue-router'
import GalaxyBackground from '../components/background/GalaxyBackground.vue'
import Sidebar from '../components/layout/Sidebar.vue'
import Topbar from '../components/layout/Topbar.vue'
</script>
<template>
<div class="nexus-layout">
<GalaxyBackground />
<Sidebar />
<main class="nexus-main">
<Topbar />
<div class="nexus-content">
<RouterView />
</div>
</main>
</div>
</template>
<style scoped>
.nexus-layout {
display: flex;
flex-direction: row;
height: 100vh;
overflow: hidden;
position: relative;
}
.nexus-main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
position: relative;
z-index: 1;
}
.nexus-content {
flex: 1;
overflow-y: auto;
padding: 18px 20px;
}
</style>
+1
View File
@@ -4,6 +4,7 @@ import App from './App.vue'
import router from './router'
import { useAuthStore } from './stores/auth'
import './assets/main.css'
import './assets/nexus-tokens.css'
const pinia = createPinia()
+123
View File
@@ -0,0 +1,123 @@
import type { AgentNodeData } from '../types/agentNode'
import type { AgentDetailData, ThinkingItem } from '../types/agentDetail'
import type { DashboardAgentDto, ModelDto } from '../services/agentService'
const STATUS_LABELS: Record<AgentNodeData['status'], string> = {
work: 'Arbeitet',
think: 'Plant',
idle: 'Bereit',
block: 'Blockiert',
}
interface CatalogEntry {
elapsed: string
think: string | null
next: string
}
const AGENT_CATALOG: Record<string, CatalogEntry> = {
iris: { elapsed: '--', think: null, next: 'Standby' },
programmer: { elapsed: '--', think: null, next: 'Standby' },
developer: { elapsed: '--', think: null, next: 'Standby' },
architekt: { elapsed: '--', think: null, next: 'Standby' },
reviewer: { elapsed: '--', think: null, next: 'Standby' },
executor: { elapsed: '--', think: null, next: 'Standby' },
researcher: { elapsed: '--', think: null, next: 'Standby' },
}
function resolveStatus(isActive: boolean, currentTask: string | null): AgentNodeData['status'] {
if (!isActive) return 'idle'
if (currentTask && currentTask !== 'Idle') return 'work'
return 'think'
}
function resolveAvatar(id: string, name: string): string {
if (id === 'iris') return 'IR'
if (id === 'programmer' || id === 'developer') return '</>'
return name.slice(0, 2).toUpperCase()
}
function buildThinkingItems(data: AgentNodeData): ThinkingItem[] {
if (!data.think) return []
const now = new Date()
const ts = (ago: number) => {
const d = new Date(now.getTime() - ago * 1000)
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
const sentences = data.think.split(/[.…!?]+/).filter(s => s.trim().length > 5)
if (sentences.length >= 2) {
const items: ThinkingItem[] = [
{ type: 'thought', text: sentences[0].trim() + '.', ts: ts(30) },
{ type: 'action', text: sentences[1].trim() + '…', ts: ts(18) },
]
const lastSentence = sentences.length >= 3
? sentences[sentences.length - 1].trim() + '.'
: 'Verarbeitung abgeschlossen.'
items.push({ type: 'result', text: lastSentence, ts: ts(3) })
return items
}
if (sentences.length === 1) {
return [
{ type: 'thought', text: sentences[0].trim(), ts: ts(15) },
{ type: 'action', text: 'Analysiere Daten und erstelle nächsten Schritt…', ts: ts(6) },
]
}
return [{ type: 'thought', text: data.think, ts: ts(10) }]
}
export function toAgentNode(dto: DashboardAgentDto): AgentNodeData {
const cat = AGENT_CATALOG[dto.id] ?? AGENT_CATALOG['reviewer']!
const status = resolveStatus(dto.isActive, dto.currentTask)
return {
id: dto.id,
name: dto.name,
role: dto.role,
model: dto.model,
avatar: resolveAvatar(dto.id, dto.name),
status,
statusLabel: STATUS_LABELS[status],
task: dto.currentTask,
goal: dto.goal ?? null,
progress: dto.progress ?? 0,
elapsed: cat.elapsed,
next: cat.next,
tokens: '0',
cost: '0.00',
think: cat.think,
}
}
export function toModelAlias(dtos: ModelDto[]): { id: string; alias: string }[] {
return dtos.map(m => ({ id: m.id, alias: m.name }))
}
export function toAgentDetail(
data: AgentNodeData,
models: { id: string; alias: string }[]
): AgentDetailData {
const tokenNum = parseFloat(data.tokens?.replace(/[^0-9.]/g, '') || '0')
const tokenMultiplier = data.tokens?.includes('M')
? 1_000_000
: data.tokens?.includes('k') ? 1_000 : 1
const tokensToday = Math.round(tokenNum * tokenMultiplier)
const matchingModel = models.find(m => m.id === data.model || m.alias === data.model)
const displayModel = matchingModel?.alias ?? data.model
return {
id: data.id,
name: data.name,
role: data.role,
model: displayModel,
status: data.status === 'block' ? 'idle' : data.status,
tokensToday,
costToday: parseFloat(data.cost || '0'),
workload: data.progress,
uptime: data.elapsed || '—',
lastActive: data.elapsed !== '—' ? 'Vor ' + data.elapsed : 'Nicht aktiv',
activeTaskCount: data.task ? 1 : 0,
thinking: buildThinkingItems(data),
availableModels: models,
}
}
+35
View File
@@ -0,0 +1,35 @@
import type { TaskItem } from '../types/task'
import type { TaskDto } from '../services/taskService'
function toPriority(raw: string): TaskItem['priority'] {
const p = raw.toLowerCase()
if (p === 'high' || p === 'critical' || p === 'urgent') return 'high'
if (p === 'low' || p === 'minor') return 'low'
return 'medium'
}
function toStatus(raw: string): TaskItem['status'] {
const s = raw.toLowerCase()
if (s === 'in progress' || s === 'active' || s === 'working') return 'active'
if (s === 'blocked' || s === 'block') return 'blocked'
return 'pending'
}
function toProgress(raw: string): number {
const s = raw.toLowerCase()
if (s === 'in progress' || s === 'active' || s === 'working') return 50
if (s === 'done') return 100
if (s === 'blocked') return 30
return 0
}
export function toTaskItem(dto: TaskDto): TaskItem {
return {
id: dto.id,
title: dto.title,
agent: dto.assignedTo ?? '—',
priority: toPriority(dto.priority),
status: toStatus(dto.state),
progress: toProgress(dto.state),
}
}
+12 -2
View File
@@ -9,12 +9,22 @@ import AgentsIndexView from './views/AgentsIndexView.vue'
import SecurityView from './views/SecurityView.vue'
import IncidentsView from './views/IncidentsView.vue'
import CalendarView from './views/CalendarView.vue'
import DashboardView from './views/DashboardView.vue'
import NexusLayout from './layouts/NexusLayout.vue'
import FlowBoard from './views/Dashboard/FlowBoard.vue'
const routes = [
{ path: '/login', name: 'Login', component: LoginView, meta: { public: true } },
{ path: '/', redirect: '/dashboard' },
{ path: '/dashboard', name: 'Dashboard', component: DashboardView },
// V2 Dashboard (neues NexusLayout + FlowBoard)
{
path: '/dashboard',
component: NexusLayout,
children: [
{ path: '', name: 'Dashboard', component: FlowBoard },
],
},
{ path: '/memory', name: 'Memory', component: MemoryView },
{ path: '/docs', name: 'Docs', component: DocsView },
{ path: '/agents/:id', name: 'AgentDetail', component: AgentDetailView },
+275
View File
@@ -0,0 +1,275 @@
/**
* Agent Store V2 Dashboard
*
* Fetches agents from /api/dashboard/agents and available models
* from /api/dashboard/models. Enriches raw API data with catalog
* metadata (color, icon, description, hero) and maps into
* AgentNodeData (for FlowCanvas) and AgentDetail (for Modal).
*
* Auto-refresh: every 30 seconds.
*/
import { defineStore } from 'pinia'
import { apiFetch } from '../services/api'
import type { AgentNodeData } from '../composables/useFlowLayout'
import type { AgentDetailData, ThinkingItem } from '../components/dashboard/v2/types'
/* ── API Response Shapes ──────────────────────────── */
interface DashboardAgentInfo {
id: string
name: string
role: string
model: string
isActive: boolean
currentTask: string | null
description?: string
tags?: string[]
progress?: number
workload?: number
goal?: string | null
}
interface ModelOption {
id: string
name: string
provider: string
}
/* ── Agent Catalog (static enrichment) ────────────── */
// Type-safe catalog for static AgentNodeData fields not provided by API
interface AgentCatalogEntry {
elapsed: string;
think: string | null;
next: string;
}
const AGENT_CATALOG: Record<string, AgentCatalogEntry> = {
iris: { elapsed: '--', think: null, next: 'Standby' },
programmer: { elapsed: '--', think: null, next: 'Standby' },
developer: { elapsed: '--', think: null, next: 'Standby' },
architekt: { elapsed: '--', think: null, next: 'Standby' },
reviewer: { elapsed: '--', think: null, next: 'Standby' },
executor: { elapsed: '--', think: null, next: 'Standby' },
researcher: { elapsed: '--', think: null, next: 'Standby' },
}
/* ── Status Mapping ───────────────────────────────── */
function mapStatus(isActive: boolean, currentTask: string | null): AgentNodeData['status'] {
if (!isActive) return 'idle'
if (currentTask && currentTask !== 'Idle') return 'work'
return 'think'
}
const STATUS_LABELS: Record<AgentNodeData['status'], string> = {
work: 'Arbeitet',
think: 'Plant',
idle: 'Bereit',
block: 'Blockiert',
}
function avatarFor(id: string, name: string): string {
if (id === 'iris') return 'IR'
if (id === 'programmer' || id === 'developer') return '</>'
return name.slice(0, 2).toUpperCase()
}
/* ── Enrich API Agent → AgentNodeData ─────────────── */
function enrichAgent(api: DashboardAgentInfo): AgentNodeData {
const cat = AGENT_CATALOG[api.id] ?? AGENT_CATALOG['reviewer']!
const status = mapStatus(api.isActive, api.currentTask)
return {
id: api.id,
name: api.name,
role: api.role,
model: api.model,
avatar: avatarFor(api.id, api.name),
status,
statusLabel: STATUS_LABELS[status],
task: api.currentTask,
goal: api.goal ?? null,
progress: api.progress ?? 0,
elapsed: cat.elapsed ?? '--',
next: cat.next ?? 'Standby',
tokens: '0',
cost: '0.00',
think: cat.think ?? null,
}
}
/* ── Build AgentDetail from AgentNodeData ─────────── */
function buildThinkingItems(data: AgentNodeData): ThinkingItem[] {
if (!data.think) return []
const now = new Date()
const ts = (ago: number) => {
const d = new Date(now.getTime() - ago * 1000)
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
const sentences = data.think.split(/[.…!?]+/).filter(s => s.trim().length > 5)
const items: ThinkingItem[] = []
if (sentences.length >= 2) {
items.push({ type: 'thought', text: sentences[0].trim() + '.', ts: ts(30) })
items.push({ type: 'action', text: sentences[1].trim() + '…', ts: ts(18) })
if (sentences.length >= 3) {
items.push({ type: 'result', text: sentences[sentences.length - 1].trim() + '.', ts: ts(3) })
} else {
items.push({ type: 'result', text: 'Verarbeitung abgeschlossen.', ts: ts(3) })
}
} else if (sentences.length === 1) {
items.push({ type: 'thought', text: sentences[0].trim(), ts: ts(15) })
items.push({ type: 'action', text: 'Analysiere Daten und erstelle nächsten Schritt…', ts: ts(6) })
} else {
items.push({ type: 'thought', text: data.think, ts: ts(10) })
}
return items
}
export function buildAgentDetail(data: AgentNodeData, models: { id: string; alias: string }[]): AgentDetailData {
const tokenNum = parseFloat(data.tokens?.replace(/[^0-9.]/g, '') || '0')
const tokenMultiplier = data.tokens?.includes('M') ? 1_000_000 : data.tokens?.includes('k') ? 1_000 : 1
const tokensToday = Math.round(tokenNum * tokenMultiplier)
const costNum = parseFloat(data.cost || '0')
const progress = data.progress || 0
// Map model ID to display name for the modal dropdown (which uses alias for comparison)
const matchingModel = models.find(m => m.id === data.model || m.alias === data.model)
const displayModel = matchingModel?.alias ?? data.model
return {
id: data.id,
name: data.name,
role: data.role,
model: displayModel,
status: data.status === 'block' ? 'idle' : data.status,
tokensToday,
costToday: costNum,
workload: progress,
uptime: data.elapsed || '—',
lastActive: data.elapsed !== '—' ? 'Vor ' + data.elapsed : 'Nicht aktiv',
activeTaskCount: data.task ? 1 : 0,
thinking: buildThinkingItems(data),
availableModels: models,
}
}
export const useAgentStore = defineStore('agents', {
state: () => ({
agents: [] as AgentNodeData[],
models: [] as { id: string; alias: string }[],
loading: false,
error: null as string | null,
selectedAgentId: null as string | null,
refreshInterval: null as ReturnType<typeof setInterval> | null,
}),
getters: {
/** AgentNodeData list for FlowCanvas */
agentList: (state) => state.agents,
/** Agent IDs in display order (Iris first) */
agentOrder: (state) => {
const ordered = state.agents.filter(a => a.id === 'iris')
state.agents.forEach(a => { if (a.id !== 'iris') ordered.push(a) })
return ordered.map(a => a.id)
},
/** Selected agent detail for modal */
selectedAgent(state): AgentDetailData | null {
if (!state.selectedAgentId) return null
const data = state.agents.find(a => a.id === state.selectedAgentId)
if (!data) return null
return buildAgentDetail(data, state.models)
},
/** Is the modal open? */
modalOpen: (state) => state.selectedAgentId !== null,
/* ── AlertBar Metrics ────────────────────────── */
activeCount: (state) => state.agents.filter(a => a.status === 'work').length,
thinkCount: (state) => state.agents.filter(a => a.status === 'think').length,
idleCount: (state) => state.agents.filter(a => a.status === 'idle').length,
blockerCount: (state) => state.agents.filter(a => a.status === 'block').length,
todayCost: (state) => {
const total = state.agents.reduce((s, a) => s + parseFloat(a.cost || '0'), 0)
return '$' + total.toFixed(2)
},
todayTokens: (state) => {
const total = state.agents.reduce((s, a) => {
const raw = a.tokens?.replace(/[^0-9.]/g, '') || '0'
const v = parseFloat(raw)
return Number.isFinite(v) ? s + v : s
}, 0)
return total >= 1000 ? Math.round(total / 1000) + 'k' : Math.round(total) + ''
},
},
actions: {
/* ── API: Fetch agents ──────────────────────── */
async fetchAgents() {
try {
const res = await apiFetch('/api/dashboard/agents')
if (!res.ok) return
const data: DashboardAgentInfo[] = await res.json()
this.agents = data.map(enrichAgent)
} catch (err) {
console.warn('[AgentStore] fetchAgents failed', err)
}
},
/* ── API: Fetch available models ────────────── */
async fetchModels() {
try {
const res = await apiFetch('/api/dashboard/models')
if (!res.ok) return
const data: ModelOption[] = await res.json()
this.models = data.map(m => ({ id: m.id, alias: m.name }))
} catch (err) {
console.warn('[AgentStore] fetchModels failed', err)
}
},
/* ── API: Change agent model ────────────────── */
async changeModel(agentId: string, modelId: string) {
// Optimistic update
const agent = this.agents.find(a => a.id === agentId)
if (agent) agent.model = modelId
try {
await apiFetch(`/api/dashboard/agents/${encodeURIComponent(agentId)}/model`, {
method: 'PUT',
body: JSON.stringify({ model: modelId }),
})
} catch (err) {
console.warn('[AgentStore] changeModel failed', err)
// Refetch to revert on failure
await this.fetchAgents()
}
},
/* ── Selection ───────────────────────────────── */
selectAgent(id: string | null) {
this.selectedAgentId = id
},
/* ── Polling ─────────────────────────────────── */
startPolling() {
if (this.refreshInterval) return
this.fetchAgents()
this.fetchModels()
this.refreshInterval = setInterval(() => {
this.fetchAgents()
this.fetchModels()
}, 30000)
},
stopPolling() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval)
this.refreshInterval = null
}
},
},
})
+160
View File
@@ -0,0 +1,160 @@
/**
* Chat Store V2 Dashboard
*
* Fetches chat messages from /api/dashboard/chat/messages and
* sends new messages via /api/dashboard/chat/send.
*
* Auto-refresh: every 10 seconds (incoming Iris messages).
*/
import { defineStore } from 'pinia'
import { apiFetch } from '../services/api'
import type { ChatMessage } from '../components/dashboard/v2/types'
/* ── API Response Shapes ──────────────────────────── */
interface MessageEntry {
role: string
content: string
timestamp: string
}
interface ChatResponse {
ok: boolean
reply: string | null
error: string | null
}
export const useChatStore = defineStore('chat', {
state: () => ({
messages: [] as ChatMessage[],
isThinking: false,
error: null as string | null,
refreshInterval: null as ReturnType<typeof setInterval> | null,
/** Tracks last process timestamp to avoid duplicates */
lastProcessedTs: 0,
}),
getters: {
messageList: (state) => state.messages,
},
actions: {
/* ── API: Fetch history ─────────────────────── */
async fetchHistory() {
try {
const res = await apiFetch('/api/dashboard/chat/messages?limit=50')
if (!res.ok) return
const data: MessageEntry[] = await res.json()
// Merge new messages (avoid duplicates)
let mostRecentTs = this.lastProcessedTs
for (const msg of data) {
const msgTs = new Date(msg.timestamp).getTime()
if (msgTs <= this.lastProcessedTs) continue
if (msgTs > mostRecentTs) mostRecentTs = msgTs
const sender = msg.role === 'assistant' ? 'iris' as const : 'user' as const
const tsFormatted = new Date(msg.timestamp).toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
})
// Avoid appending duplicates already present
const exists = this.messages.some(
m => m.sender === sender && m.text === msg.content && m.ts === tsFormatted
)
if (exists) continue
this.messages.push({
sender,
text: msg.content,
ts: tsFormatted,
})
}
if (mostRecentTs > this.lastProcessedTs) {
this.lastProcessedTs = mostRecentTs
}
} catch (err) {
console.warn('[ChatStore] fetchHistory failed', err)
}
},
/* ── API: Send message ──────────────────────── */
async sendMessage(text: string) {
if (!text.trim()) return
const tsFormatted = new Date().toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
})
// Optimistic add: user message
this.messages.push({
sender: 'user',
text: text.trim(),
ts: tsFormatted,
})
this.isThinking = true
this.error = null
try {
const res = await apiFetch('/api/dashboard/chat/send', {
method: 'POST',
body: JSON.stringify({ message: text.trim() }),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data: ChatResponse = await res.json()
if (data.ok && data.reply) {
this.messages.push({
sender: 'iris',
text: data.reply,
ts: new Date().toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
}),
})
} else if (data.error) {
this.messages.push({
sender: 'iris',
text: `⚠️ ${data.error}`,
ts: new Date().toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
}),
})
}
} catch (err) {
console.warn('[ChatStore] sendMessage failed', err)
this.messages.push({
sender: 'iris',
text: '⚠️ Connection error. Please try again.',
ts: new Date().toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
}),
})
} finally {
this.isThinking = false
}
},
/* ── Polling ─────────────────────────────────── */
startPolling() {
if (this.refreshInterval) return
this.fetchHistory()
this.refreshInterval = setInterval(() => {
this.fetchHistory()
}, 10000) // 10s for chat (more responsive)
},
stopPolling() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval)
this.refreshInterval = null
}
},
},
})
+7 -28
View File
@@ -4,36 +4,15 @@ import { apiFetch } from '../services/api'
const fallback: OperationsSnapshot = {
generatedAt: new Date().toISOString(),
runtime: { runtime: 'OpenClaw', status: 'Online', detail: 'Gateway responding' },
models: [
{ provider: 'OpenClaw', model: 'deepseek/deepseek-v4-flash', status: 'Online', isLocal: false, detail: 'Programmer agent' },
{ provider: 'OpenClaw', model: 'deepseek/deepseek-v4-pro', status: 'Online', isLocal: false, detail: 'Reviewer agent' },
{ provider: 'OpenClaw', model: 'openai/gpt-5.3-chat-latest', status: 'Online', isLocal: false, detail: 'Iris orchestrator' },
],
metrics: { activeAgents: 3, queuedTasks: 7, successRate: 98.4, incidents: 0 },
projects: [
{ id: 'nexus', name: 'Nexus', status: 'Active', progress: 18 },
{ id: 'openclaw', name: 'OpenClaw Runtime', status: 'Online', progress: 100 },
{ id: 'infra', name: 'Noveria Infrastructure', status: 'Stable', progress: 74 },
],
tasks: [
{ id: 'preview-foundation', title: 'Nexus foundation', state: 'In progress', priority: 'Critical', updatedAt: new Date().toISOString() },
{ id: 'preview-runtime', title: 'Connect OpenClaw adapter', state: 'In progress', priority: 'High', updatedAt: new Date().toISOString() },
{ id: 'preview-routing', title: 'Configure model routing', state: 'In progress', priority: 'High', updatedAt: new Date().toISOString() },
{ id: 'preview-auth', title: 'Owner authentication', state: 'Done', priority: 'Critical', updatedAt: new Date().toISOString() },
],
activity: [
{ type: 'runtime', message: 'OpenClaw runtime health checked', at: new Date().toISOString() },
{ type: 'deploy', message: 'Nexus foundation initialized', at: new Date(Date.now() - 720000).toISOString() },
{ type: 'deploy', message: 'Model routing configured for DeepSeek agents', at: new Date(Date.now() - 1140000).toISOString() },
],
runtime: { runtime: 'OpenClaw', status: 'Unknown', detail: 'Awaiting connection…' },
models: [],
metrics: { activeAgents: 0, queuedTasks: 0, successRate: 0, incidents: 0 },
projects: [],
tasks: [],
activity: [],
}
const fallbackRouting: RoutingTarget[] = [
{ priority: 1, provider: 'OpenClaw', model: 'deepseek/deepseek-v4-flash', purpose: 'Programmer agent', status: 'Online', detail: 'Routed through OpenClaw' },
{ priority: 2, provider: 'OpenClaw', model: 'deepseek/deepseek-v4-pro', purpose: 'Reviewer agent', status: 'Online', detail: 'Routed through OpenClaw' },
{ priority: 3, provider: 'OpenClaw', model: 'openai/gpt-5.3-chat-latest', purpose: 'Iris orchestrator', status: 'Online', detail: 'Routed through OpenClaw' },
]
const fallbackRouting: RoutingTarget[] = []
export const useOperationsStore = defineStore('operations', {
state: () => ({
+143
View File
@@ -0,0 +1,143 @@
/**
* Task Store V2 Dashboard
*
* Fetches tasks from /api/dashboard/tasks and maps them into
* TaskItem[] format for the TaskStrip component.
*
* Auto-refresh: every 30 seconds.
*/
import { defineStore } from 'pinia'
import { apiFetch } from '../services/api'
import type { TaskItem } from '../components/dashboard/v2/types'
/* ── API Response Shapes ──────────────────────────── */
interface DashboardTaskDto {
id: string
title: string
detail: string | null
source: string
state: string
priority: string
assignedTo: string | null
createdAt: string
updatedAt: string
}
/* ── State Mapping ────────────────────────────────── */
function mapPriority(priority: string): TaskItem['priority'] {
const p = priority.toLowerCase()
if (p === 'high' || p === 'critical' || p === 'urgent') return 'high'
if (p === 'low' || p === 'minor') return 'low'
return 'medium'
}
function mapState(state: string): TaskItem['status'] {
const s = state.toLowerCase()
if (s === 'in progress' || s === 'active' || s === 'working') return 'active'
if (s === 'blocked' || s === 'block') return 'blocked'
return 'pending'
}
function mapProgress(state: string): number {
const s = state.toLowerCase()
if (s === 'in progress' || s === 'active' || s === 'working') return 50
if (s === 'done') return 100
if (s === 'blocked') return 30
return 0
}
function mapTask(t: DashboardTaskDto): TaskItem {
return {
id: t.id,
title: t.title,
agent: t.assignedTo ?? '—',
priority: mapPriority(t.priority),
status: mapState(t.state),
progress: mapProgress(t.state),
}
}
export const useTaskStore = defineStore('tasks', {
state: () => ({
tasks: [] as TaskItem[],
loading: false,
error: null as string | null,
refreshInterval: null as ReturnType<typeof setInterval> | null,
}),
getters: {
taskList: (state) => state.tasks,
},
actions: {
/* ── API: Fetch tasks ───────────────────────── */
async fetchTasks() {
this.loading = true
try {
const res = await apiFetch('/api/dashboard/tasks')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data: DashboardTaskDto[] = await res.json()
this.tasks = data.map(mapTask)
this.error = null
} catch (err) {
console.warn('[TaskStore] fetchTasks failed', err)
this.error = 'Tasks could not be loaded'
} finally {
this.loading = false
}
},
/* ── API: Add task ──────────────────────────── */
async addTask(title: string, detail?: string, priority?: string, assignedTo?: string) {
try {
const res = await apiFetch('/api/dashboard/tasks', {
method: 'POST',
body: JSON.stringify({
title,
detail: detail ?? null,
priority: priority ?? null,
assignedTo: assignedTo ?? null,
source: 'bao',
}),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
// Refresh task list
await this.fetchTasks()
} catch (err) {
console.warn('[TaskStore] addTask failed', err)
}
},
/* ── API: Update task ───────────────────────── */
async updateTask(id: string, updates: { title?: string; detail?: string; priority?: string; assignedTo?: string }) {
try {
const res = await apiFetch(`/api/dashboard/tasks/${id}`, {
method: 'PUT',
body: JSON.stringify(updates),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
await this.fetchTasks()
} catch (err) {
console.warn('[TaskStore] updateTask failed', err)
}
},
/* ── Polling ─────────────────────────────────── */
startPolling() {
if (this.refreshInterval) return
this.fetchTasks()
this.refreshInterval = setInterval(() => {
this.fetchTasks()
}, 30000)
},
stopPolling() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval)
this.refreshInterval = null
}
},
},
})
+30
View File
@@ -0,0 +1,30 @@
/**
* Shared formatting utilities for Nexus Dashboard
*/
/** Format a number with SI suffixes (k, M) */
export function formatNumber(n: number): string {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'k'
return String(n)
}
/** Format currency with $ prefix */
export function formatCurrency(n: number): string {
return '$' + n.toFixed(2)
}
/** Format a Date as German locale time HH:MM:SS */
export function formatTime(d: Date): string {
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
/** Format a Date as German locale time HH:MM */
export function formatTimeShort(d: Date): string {
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
}
/** Extract initials (max 2 chars) from a display name */
export function initials(name: string): string {
return name.split(' ').map(p => p[0]).join('').slice(0, 2).toUpperCase()
}
+178
View File
@@ -0,0 +1,178 @@
<script setup lang="ts">
/**
* FlowBoard Das neue V2 Dashboard
*
* Layout:
* Stage (AlertBar + FlowCanvas) + Rail (IrisChat) + TaskStrip (unten)
*
* Datenquellen:
* - AgentStore: agents, models, AlertBar-Metriken, Modal-Status
* - ChatStore: messages, isThinking, sendMessage()
* - TaskStore: tasks
*
* Polling startet bei Mount, stoppt bei Unmount.
*/
import { ref, onMounted, onUnmounted } from 'vue'
import { useAgentStore } from '../../stores/agents'
import { useChatStore } from '../../stores/chat'
import { useTaskStore } from '../../stores/tasks'
import AlertBar from '../../components/dashboard/v2/AlertBar.vue'
import FlowCanvas from '../../components/dashboard/v2/FlowCanvas.vue'
import IrisChat from '../../components/dashboard/v2/IrisChat.vue'
import TaskStrip from '../../components/dashboard/v2/TaskStrip.vue'
import AgentDetailModal from '../../components/dashboard/v2/AgentDetailModal.vue'
import type { AgentNodeData } from '../../composables/useFlowLayout'
import { extraAgentPool } from '../../composables/useFlowLayout'
/* ── Stores ──────────────────────────────────────── */
const agentStore = useAgentStore()
const chatStore = useChatStore()
const taskStore = useTaskStore()
/* ── Agent Layout State ───────────────────────────── */
const agentPositions = ref<Record<string, { x: number; y: number }>>({})
const enteringIds = ref<string[]>([])
const localAgentPool = ref<AgentNodeData[]>([...extraAgentPool])
/* ── Event Handlers ───────────────────────────────── */
function handleSelect(id: string) {
agentStore.selectAgent(id)
}
function handleCloseModal() {
agentStore.selectAgent(null)
}
function handleChangeModel(agentId: string, modelAlias: string) {
// Modal emits the alias (display name); resolve to model ID for the API
const model = agentStore.models.find(m => m.alias === modelAlias)
const modelId = model?.id ?? modelAlias
agentStore.changeModel(agentId, modelId)
}
function handleAdd() {
const pool = localAgentPool.value
if (pool.length === 0) return
const next = pool.shift()!
enteringIds.value.push(next.id)
agentStore.agents.push(next)
setTimeout(() => {
const idx = enteringIds.value.indexOf(next.id)
if (idx !== -1) enteringIds.value.splice(idx, 1)
}, 600)
}
function handleResetLayout() {
agentPositions.value = {}
}
function handleUpdatePositions(pos: Record<string, { x: number; y: number }>) {
agentPositions.value = { ...pos }
}
function handleBlockerClick() {
console.log('[FlowBoard] blocker clicked')
}
function handleChatSend(text: string) {
chatStore.sendMessage(text)
}
/* ── Lifecycle ────────────────────────────────────── */
onMounted(() => {
agentStore.startPolling()
chatStore.startPolling()
taskStore.startPolling()
})
onUnmounted(() => {
agentStore.stopPolling()
chatStore.stopPolling()
taskStore.stopPolling()
})
</script>
<template>
<div class="flow-board">
<!-- Stage + Rail row -->
<div class="board-body">
<!-- Stage: AlertBar + FlowCanvas + TaskStrip -->
<div class="stage">
<AlertBar
:active-count="agentStore.activeCount"
:think-count="agentStore.thinkCount"
:idle-count="agentStore.idleCount"
:blocker-count="agentStore.blockerCount"
:today-cost="agentStore.todayCost"
:today-tokens="agentStore.todayTokens"
@blocker-click="handleBlockerClick"
/>
<FlowCanvas
:agents="agentStore.agentList"
:positions="agentPositions"
:entering-ids="enteringIds"
@select="handleSelect"
@add="handleAdd"
@reset-layout="handleResetLayout"
@update-positions="handleUpdatePositions"
/>
<TaskStrip :tasks="taskStore.taskList" :loading="taskStore.loading" :error="taskStore.error" />
</div>
<!-- Rail: IrisChat -->
<IrisChat
:messages="chatStore.messageList"
:is-thinking="chatStore.isThinking"
:error="chatStore.error"
@send="handleChatSend"
/>
</div>
<!-- Agent Detail Modal -->
<AgentDetailModal
v-if="agentStore.modalOpen && agentStore.selectedAgent"
:agent="agentStore.selectedAgent"
:agent-order="agentStore.agentOrder"
@close="handleCloseModal"
@select="handleSelect"
@change-model="handleChangeModel"
/>
</div>
</template>
<style scoped>
.flow-board {
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
animation: fade-in 0.35s ease-out;
}
@keyframes fade-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.board-body {
flex: 1;
display: flex;
flex-direction: row;
gap: 0;
min-height: 0;
}
.stage {
flex: 1;
display: flex;
flex-direction: column;
gap: 14px;
padding: 0 18px 0 0;
min-height: 0;
min-width: 0;
}
</style>
-243
View File
@@ -1,243 +0,0 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import TaskCard from '../components/dashboard/TaskCard.vue'
import OperationsFeed from '../components/dashboard/OperationsFeed.vue'
import TeamNetwork from '../components/dashboard/TeamNetwork.vue'
import ChatPanel from '../components/dashboard/ChatPanel.vue'
import QueuePanel from '../components/dashboard/QueuePanel.vue'
import AgentModal from '../components/dashboard/AgentModal.vue'
import { useDashboardData } from '../composables/useDashboardData'
import type { AgentNodeData } from '../composables/useDashboardData'
const {
agents, openTasks, feedEntries, chatMessages,
irisBusy, irisFocus, queue,
getAgentRuntime, startRuntime, stopRuntime,
sendChatMessage, removeQueueItem, moveQueueItem, changeQueuePriority,
} = useDashboardData()
const selectedAgent = ref<AgentNodeData | null>(null)
function onAgentSelect(id: string) {
const agent = agents.value.find(a => a.id === id)
if (agent) selectedAgent.value = agent
}
onMounted(startRuntime)
onUnmounted(stopRuntime)
function onQueueMoveUp(id: string): void {
const idx = queue.value.findIndex(q => q.id === id)
if (idx > 0) moveQueueItem(idx, idx - 1)
}
function onQueueMoveDown(id: string): void {
const idx = queue.value.findIndex(q => q.id === id)
if (idx < queue.value.length - 1) moveQueueItem(idx, idx + 1)
}
function onQueueExecuteNow(id: string): void {
const item = queue.value.find(q => q.id === id)
if (item) console.log('[Dashboard] Execute now:', item.text)
}
</script>
<template>
<div class="dashboard">
<div class="col-left">
<section class="missions-section">
<TaskCard :tasks="openTasks" @new-task="console.log('New task requested')" @go-board="console.log('Go to Task Board')" />
</section>
<OperationsFeed :entries="feedEntries" />
</div>
<div class="col-center">
<!-- Quote Pill -->
<div class="quote-pill">
<span class="quote-text">"An autonomous organization of AI agents that does work for me and produces value 24/7"</span>
</div>
<!-- Header -->
<div class="team-header">
<h1 class="team-title">AI Team Network</h1>
<p class="team-subtitle">{{ agents.length }} AI agents, connected in real-time.</p>
<p class="team-description">Mission Control orchestriert ein Team spezialisierter Agenten jeder mit eigener Identität, eigenem Workspace und klaren Verantwortlichkeiten. Die Pulse zeigen aktive Kommunikationsflüsse.</p>
</div>
<TeamNetwork
hero-id="iris"
:agents="agents"
@select="onAgentSelect"
/>
<!-- Legend -->
<div class="legend-row">
<div class="legend-item">
<span class="legend-dot active-pulse"></span>
<span>Aktive Verbindung</span>
</div>
<div class="legend-item">
<span class="legend-dot idle-pulse"></span>
<span>Idle</span>
</div>
<div class="legend-item">
<span class="legend-dot pulse-dot"></span>
<span>Datenfluss (Pulse)</span>
</div>
</div>
</div>
<div class="col-right">
<ChatPanel :messages="chatMessages" :iris-busy="irisBusy" :iris-focus="irisFocus" />
<QueuePanel :items="queue" @remove="removeQueueItem" @move-up="onQueueMoveUp" @move-down="onQueueMoveDown" @change-priority="changeQueuePriority" @execute-now="onQueueExecuteNow" />
</div>
<AgentModal
v-if="selectedAgent"
:agent="selectedAgent"
:runtime="getAgentRuntime(selectedAgent.id)"
@close="selectedAgent = null"
/>
</div>
</template>
<style scoped>
.dashboard {
display: grid;
grid-template-columns: 280px 1fr 320px;
gap: 14px;
height: 100%; min-height: 0;
animation: fade-in 0.35s ease-out;
}
@keyframes fade-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.dashboard ::-webkit-scrollbar { width: 5px; height: 5px; }
.dashboard ::-webkit-scrollbar-track { background: transparent; }
.dashboard ::-webkit-scrollbar-thumb { background: rgba(139,124,246,0.2); border-radius: 3px; }
.dashboard ::-webkit-scrollbar-thumb:hover { background: rgba(139,124,246,0.35); }
.col-left { display: flex; flex-direction: column; gap: 12px; overflow-y: auto; padding-right: 4px; }
.col-center { overflow-y: auto; padding: 0 4px; min-height: 0; display: flex; flex-direction: column; gap: 12px; }
/* Quote Pill */
.quote-pill {
background: var(--nx-panel);
border: 1px solid rgba(139, 124, 246, 0.25);
border-radius: 14px;
padding: 14px 22px;
box-shadow: 0 0 18px rgba(139, 124, 246, 0.06), inset 0 0 18px rgba(139, 124, 246, 0.03);
text-align: center;
}
.quote-text {
font-style: italic;
font-size: 12px;
color: #9ea5b3;
line-height: 1.5;
}
/* Team Header */
.team-header {
text-align: center;
}
.team-title {
font-size: 26px;
font-weight: 600;
color: #e8eaf0;
margin: 0 0 6px;
}
.team-subtitle {
font-size: 12px;
color: #7e8799;
margin: 0 0 4px;
}
.team-description {
font-size: 10.5px;
color: #6b7385;
margin: 0;
max-width: 560px;
margin-left: auto;
margin-right: auto;
}
/* Legend */
.legend-row {
display: flex;
align-items: center;
justify-content: center;
gap: 24px;
padding: 12px 20px;
background: var(--nx-panel);
border: 1px solid var(--nx-line);
border-radius: 10px;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 10px;
color: #7e8799;
}
.legend-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.active-pulse {
background: #51d49a;
box-shadow: 0 0 6px rgba(81, 212, 154, 0.6);
}
.idle-pulse {
background: #3a3f4b;
}
.pulse-dot {
background: white;
width: 6px;
height: 6px;
animation: legend-pulse 1.5s ease-in-out infinite;
}
@keyframes legend-pulse {
0%, 100% { opacity: 0.3; transform: scale(0.8); }
50% { opacity: 1; transform: scale(1.2); }
}
.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 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>
+11 -4
View File
@@ -3,16 +3,23 @@ import { setActivePinia, createPinia } from 'pinia'
import { useOperationsStore } from '../src/stores/operations'
describe('operations store', () => {
it('initializes with fallback data', () => {
it('initializes with safe fallback structure', () => {
setActivePinia(createPinia())
const store = useOperationsStore()
expect(store.snapshot.metrics.activeAgents).toBeGreaterThan(0)
// Fallback provides a valid structure even when API is down
expect(store.snapshot).toBeDefined()
expect(store.snapshot.runtime.runtime).toBe('OpenClaw')
expect(store.snapshot.metrics).toBeDefined()
expect(Array.isArray(store.snapshot.projects)).toBe(true)
expect(Array.isArray(store.snapshot.tasks)).toBe(true)
expect(Array.isArray(store.snapshot.activity)).toBe(true)
expect(Array.isArray(store.snapshot.models)).toBe(true)
})
it('has routing targets', () => {
it('initializes routing as empty array', () => {
setActivePinia(createPinia())
const store = useOperationsStore()
expect(store.routing.length).toBeGreaterThan(0)
expect(Array.isArray(store.routing)).toBe(true)
expect(store.routing.length).toBe(0)
})
})
+8 -7
View File
@@ -12,18 +12,21 @@ echo "Verzeichnis: $NEXUS_DIR"
cd "$NEXUS_DIR"
echo ""
echo "[1/3] Prüfe Konfiguration..."
echo "[1/4] Prüfe Konfiguration..."
docker compose config --quiet && echo " ✅ Konfiguration gültig"
echo ""
echo "[2/3] Starte Stack..."
docker compose up -d
echo "[2/4] Starte Stack (mit Healthchecks)..."
docker compose up -d --wait
echo ""
echo "[3/3] Warte auf Services..."
sleep 5
echo "[3/4] Status nach Deployment..."
docker compose ps
echo ""
echo "[4/4] Verifikation..."
curl -fsS http://localhost:18880/health && echo " ✅ Health-Check bestanden"
echo ""
echo "=== Fertig ==="
echo "Nexus Web: http://nexus.noveria.net:18880"
@@ -32,5 +35,3 @@ echo "Passwort: wird beim ersten Start im Container-Log ausgegeben"
echo ""
echo "Logs: docker compose logs api | grep 'Initial owner'"
echo "Status: docker compose ps"
# Patch für compose.yaml
sed -i 's/${OWNER_PASSWORD:?Set OWNER_PASSWORD in .env}/${OWNER_PASSWORD:-}/' "$NEXUS_DIR/compose.yaml"