4ad0f9e493
## Backend — Service Layer & Repository Refactoring ### Neue Services (21 neue Dateien) **Interfaces & Implementierungen:** - `IOpenClawGatewayClient` — Interface für OpenClawGatewayClient (DIP-Fix: DashboardController hing an konkreter Klasse) - `IAgentConfigService` / `AgentConfigService` — Agent-Config-File-I/O aus AgentsController extrahiert - `IProjectService` / `ProjectService` — Projekt-CRUD + Activity-Logging (SRP) - `ITaskService` / `TaskService` — Task-State-Machine, Approve/Reject, Dashboard-Operationen (eliminiert Duplikation zwischen TasksController und DashboardController) - `IDashboardService` / `DashboardService` — Queue-Aggregation, Priority-Normalisierung, Gateway-Delegation - `IOperationsService` / `OperationsService` — Metriken-Berechnung aus OperationsController - `ITeamService` / `TeamService` — IDENTITY.md-Lesen aus TeamController - `IMemoryService` / `MemoryService` — File-I/O aus MemoryController - `IIncidentService` / `IncidentService` — File-Parsing (Regex-Source-Generatoren) aus IncidentsController - `IDocService` / `DocService` — Directory-Scan aus DocsController - `ICalendarService` / `CalendarService` — Gateway-HTTP-Calls + Fallback-Daten aus CalendarController ### Repository-Fixes **IUserRepository / UserRepository:** - `SaveChangesAsync` entfernt (leaky abstraction — Caller sollten nie SaveChanges steuern) - `RevokeTokenAsync(tokenHash)` — atomares Token-Revoke inkl. SaveChanges - `RevokeFamilyAsync(familyId)` — Batch-Revoke einer Token-Familie inkl. SaveChanges - `RemoveExpiredTokensAsync` speichert jetzt selbst (war vorher dependent auf nachfolgenden Save) ### AuthService-Fixes - `GetUserAsync`: unnötiges `Task.Run` entfernt → direkt `_users.GetByIdAsync().AsTask()` - `RevokeAsync`: delegiert jetzt an `IUserRepository.RevokeTokenAsync` - `RefreshAsync`: Token-Reuse-Detection delegiert an `IUserRepository.RevokeFamilyAsync` ### Bug-Fix - `OpenClawGatewayClient.ReadAgentGoalAsync`: pre-existing `CS1656` behoben (`reader` war `using`-Variable und wurde neu zugewiesen — in `reader2` umbenannt) ### Controller (16 Stück — alle slim) Alle Controller reduziert auf: Input validieren → Service aufrufen → HTTP-Result zurückgeben. Kein Business-Logic, kein File-I/O, keine direkte Repository-Nutzung (außer AgentsController für Activity-Log). **Program.cs — neue Registrierungen:** - `AddHttpClient<IOpenClawGatewayClient, OpenClawGatewayClient>` (war vorher konkrete Klasse) - Scoped: IDashboardService, IProjectService, ITaskService, IOperationsService, ITeamService, ICalendarService - Singleton: IAgentConfigService, IMemoryService, IIncidentService, IDocService --- ## Frontend — Dashboard V2 Components **AgentDetailModal.vue, IrisChat.vue, TaskStrip.vue:** - V2 Design-System: Dark Space Theme, Glass-Panels, Gradient-Akzente - Stores (agents, chat, tasks) nutzen Service + Mapper-Pattern - NexusLayout, FlowBoard, Topbar — Layoutfixes für fullHeight-Route-Meta Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
570 lines
14 KiB
Vue
570 lines
14 KiB
Vue
<script setup lang="ts">
|
||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||
import type { AgentDetailData } from './types'
|
||
import { icons } from '../../../composables/icons'
|
||
|
||
const props = defineProps<{
|
||
agent: AgentDetailData
|
||
agentOrder: string[]
|
||
}>()
|
||
|
||
const emit = defineEmits<{
|
||
close: []
|
||
select: [id: string]
|
||
changeModel: [agentId: string, modelId: string]
|
||
}>()
|
||
|
||
/* ── Progress animation ────────────────────────── */
|
||
const displayProgress = ref(0)
|
||
|
||
function animateProgress() {
|
||
displayProgress.value = 0
|
||
setTimeout(() => { displayProgress.value = props.agent.progress }, 60)
|
||
}
|
||
|
||
/* ── Typewriter ────────────────────────────────── */
|
||
const thinkDisplay = ref('')
|
||
let thinkTimer: ReturnType<typeof setInterval> | null = null
|
||
|
||
function startTypewriter() {
|
||
if (thinkTimer) clearInterval(thinkTimer)
|
||
thinkDisplay.value = ''
|
||
if (!props.agent.think) return
|
||
const text = props.agent.think
|
||
let i = 0
|
||
thinkTimer = setInterval(() => {
|
||
thinkDisplay.value = text.slice(0, i)
|
||
i = i >= text.length ? 0 : i + 1
|
||
}, 38)
|
||
}
|
||
|
||
/* ── Selected model ────────────────────────────── */
|
||
const selectedModel = ref(props.agent.model)
|
||
|
||
watch(() => props.agent.id, () => {
|
||
selectedModel.value = props.agent.model
|
||
animateProgress()
|
||
startTypewriter()
|
||
})
|
||
|
||
function selectModel(alias: string) {
|
||
selectedModel.value = alias
|
||
emit('changeModel', props.agent.id, alias)
|
||
}
|
||
|
||
/* ── Keyboard / Backdrop ───────────────────────── */
|
||
function handleKeydown(e: KeyboardEvent) {
|
||
if (e.key === 'Escape') { 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 next = e.key === 'ArrowRight'
|
||
? (idx + 1) % props.agentOrder.length
|
||
: (idx - 1 + props.agentOrder.length) % props.agentOrder.length
|
||
emit('select', props.agentOrder[next])
|
||
}
|
||
}
|
||
|
||
function onBackdrop(e: MouseEvent) {
|
||
if ((e.target as HTMLElement).classList.contains('modal-ov')) emit('close')
|
||
}
|
||
|
||
onMounted(() => {
|
||
document.addEventListener('keydown', handleKeydown)
|
||
animateProgress()
|
||
startTypewriter()
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
document.removeEventListener('keydown', handleKeydown)
|
||
if (thinkTimer) clearInterval(thinkTimer)
|
||
})
|
||
|
||
const statusColors: Record<string, string> = {
|
||
work: 'var(--st-work)',
|
||
think: 'var(--st-think)',
|
||
idle: 'var(--st-idle)',
|
||
block: 'var(--st-block)',
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="modal-ov" @click="onBackdrop">
|
||
<div class="modal-card">
|
||
|
||
<!-- Close -->
|
||
<button class="m-close" @click="emit('close')">×</button>
|
||
|
||
<!-- Header -->
|
||
<div class="m-head">
|
||
<div :class="['m-av', { iris: agent.id === 'iris' }]">
|
||
{{ agent.id === 'iris' ? 'IR' : agent.name.slice(0, 2).toUpperCase() }}
|
||
</div>
|
||
<div style="flex:1; min-width:0">
|
||
<div class="m-name">{{ agent.name }}</div>
|
||
<div class="m-sub">
|
||
<span :class="['badge', agent.roleBadge]">{{ agent.role }}</span>
|
||
<span class="m-pill">{{ selectedModel }}</span>
|
||
<span class="m-status" :style="{ color: statusColors[agent.status] }">
|
||
<span class="dot" :class="agent.status"></span>
|
||
{{ agent.statusLabel }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Aktuelle Aufgabe -->
|
||
<div v-if="agent.task" class="m-sec">
|
||
<h4>Aktuelle Aufgabe</h4>
|
||
<div class="m-task">{{ agent.task }}</div>
|
||
<div class="m-goal">
|
||
<span v-html="icons.target || ''"></span>
|
||
Ziel: {{ agent.goal }}
|
||
</div>
|
||
<div class="m-bar" :class="{ work: agent.status === 'work' }">
|
||
<i :style="{ width: displayProgress + '%' }"></i>
|
||
</div>
|
||
<div class="m-pct-row">
|
||
<div class="m-pct grad-tx">{{ displayProgress }}%</div>
|
||
<div class="m-next">→ {{ agent.next }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Metriken -->
|
||
<div class="m-sec">
|
||
<h4>Metriken</h4>
|
||
<div class="m-metrics">
|
||
<div class="m-metric">
|
||
<div class="mk">Elapsed</div>
|
||
<div class="mv">{{ agent.elapsed }}</div>
|
||
</div>
|
||
<div class="m-metric">
|
||
<div class="mk">Token</div>
|
||
<div class="mv">{{ agent.tokens }}</div>
|
||
</div>
|
||
<div class="m-metric">
|
||
<div class="mk">Kosten</div>
|
||
<div class="mv grad-tx">${{ agent.cost }}</div>
|
||
</div>
|
||
<div class="m-metric">
|
||
<div class="mk">Fortschritt</div>
|
||
<div class="mv">{{ agent.progress }}%</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Live Thinking -->
|
||
<div v-if="agent.think" class="m-sec">
|
||
<h4>Live Thinking</h4>
|
||
<div class="m-think">{{ thinkDisplay }}<span class="caret"></span></div>
|
||
</div>
|
||
|
||
<!-- Modell wählen -->
|
||
<div class="m-sec">
|
||
<h4>Modell wählen</h4>
|
||
<div class="m-models">
|
||
<button
|
||
v-for="m in agent.availableModels"
|
||
:key="m.id"
|
||
:class="['m-model-btn', { active: m.alias === selectedModel }]"
|
||
@click="selectModel(m.alias)"
|
||
>{{ m.alias }}</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- MD Footer -->
|
||
<div v-if="agent.md" class="m-md">
|
||
<span class="dot work"></span>
|
||
Synced: <span class="m-md-path">{{ agent.md }}</span>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* ── Overlay ─────────────────────────────────── */
|
||
.modal-ov {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 100;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: rgba(5,4,16,.78);
|
||
backdrop-filter: blur(16px);
|
||
animation: ov-in .2s ease-out;
|
||
}
|
||
|
||
@keyframes ov-in {
|
||
from { opacity: 0; }
|
||
to { opacity: 1; }
|
||
}
|
||
|
||
/* ── Card ────────────────────────────────────── */
|
||
.modal-card {
|
||
width: 640px;
|
||
max-height: 88vh;
|
||
overflow-y: auto;
|
||
border-radius: 20px;
|
||
position: relative;
|
||
background: linear-gradient(160deg, rgba(22,18,50,.97), rgba(10,8,28,.97));
|
||
border: 1px solid rgba(150,140,255,.28);
|
||
box-shadow:
|
||
0 0 0 1px rgba(124,108,255,.15),
|
||
0 32px 80px -16px rgba(0,0,0,.8),
|
||
0 0 60px -10px rgba(124,108,255,.35);
|
||
animation: card-in .24s cubic-bezier(.2,.8,.3,1);
|
||
}
|
||
|
||
@keyframes card-in {
|
||
from { opacity: 0; transform: scale(.94) translateY(12px); }
|
||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||
}
|
||
|
||
.modal-card::-webkit-scrollbar { width: 7px; }
|
||
.modal-card::-webkit-scrollbar-thumb {
|
||
background: rgba(124,108,255,.22);
|
||
border-radius: 7px;
|
||
border: 2px solid transparent;
|
||
background-clip: padding-box;
|
||
}
|
||
.modal-card::-webkit-scrollbar-track { background: transparent; }
|
||
|
||
/* ── Close ───────────────────────────────────── */
|
||
.m-close {
|
||
position: absolute;
|
||
top: 16px;
|
||
right: 16px;
|
||
width: 32px;
|
||
height: 32px;
|
||
border-radius: 9px;
|
||
border: 1px solid var(--line-2);
|
||
background: rgba(124,108,255,.07);
|
||
color: var(--tx-2);
|
||
font-size: 20px;
|
||
line-height: 1;
|
||
cursor: pointer;
|
||
display: grid;
|
||
place-items: center;
|
||
transition: .15s;
|
||
z-index: 2;
|
||
}
|
||
|
||
.m-close:hover {
|
||
background: rgba(124,108,255,.18);
|
||
color: var(--tx);
|
||
}
|
||
|
||
/* ── Head ────────────────────────────────────── */
|
||
.m-head {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 16px;
|
||
padding: 26px 26px 20px;
|
||
}
|
||
|
||
.m-av {
|
||
width: 56px;
|
||
height: 56px;
|
||
border-radius: 15px;
|
||
flex: 0 0 auto;
|
||
display: grid;
|
||
place-items: center;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-weight: 700;
|
||
font-size: 16px;
|
||
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-name {
|
||
font-family: 'Space Grotesk', sans-serif;
|
||
font-size: 21px;
|
||
font-weight: 700;
|
||
line-height: 1.1;
|
||
color: var(--tx);
|
||
}
|
||
|
||
.m-sub {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 9px;
|
||
margin-top: 7px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
/* ── Badges ──────────────────────────────────── */
|
||
.badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 3px 9px;
|
||
border-radius: 7px;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
border: 1px solid transparent;
|
||
}
|
||
.badge-blue { background:rgba(79,124,255,.14); color:#9db6ff; border-color:rgba(79,124,255,.3); }
|
||
.badge-purple { background:rgba(181,87,246,.14); color:#d7a8ff; border-color:rgba(181,87,246,.3); }
|
||
.badge-amber { background:rgba(251,191,36,.13); color:#fcd34d; border-color:rgba(251,191,36,.3); }
|
||
.badge-green { background:rgba(61,220,151,.13); color:#7ef0bd; border-color:rgba(61,220,151,.3); }
|
||
.badge-cyan { background:rgba(52,214,245,.13); color:#8ee9fb; border-color:rgba(52,214,245,.3); }
|
||
.badge-rose { background:rgba(251,113,133,.13); color:#fda4b0; border-color:rgba(251,113,133,.3); }
|
||
.badge-slate { background:rgba(150,140,255,.08); color:var(--tx-2); border-color:var(--line-2); }
|
||
|
||
.m-pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
height: 26px;
|
||
padding: 0 11px;
|
||
border-radius: 20px;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
border: 1px solid var(--line-2);
|
||
background: rgba(124,108,255,.07);
|
||
color: var(--tx-2);
|
||
}
|
||
|
||
.m-status {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 12.5px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.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,.5); animation: pulse-work 1.8s infinite; }
|
||
.dot.think { background: var(--st-think); box-shadow: 0 0 0 0 rgba(52,214,245,.5); animation: pulse-think 1.6s infinite; }
|
||
.dot.idle { background: var(--st-idle); }
|
||
.dot.block { background: var(--st-block); }
|
||
|
||
/* ── Section ─────────────────────────────────── */
|
||
.m-sec {
|
||
padding: 16px 26px;
|
||
border-top: 1px solid var(--line);
|
||
}
|
||
|
||
.m-sec h4 {
|
||
font-size: 10px;
|
||
letter-spacing: .18em;
|
||
text-transform: uppercase;
|
||
color: var(--tx-3);
|
||
font-weight: 700;
|
||
margin: 0 0 13px;
|
||
}
|
||
|
||
/* ── Task ────────────────────────────────────── */
|
||
.m-task {
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
margin-bottom: 8px;
|
||
color: var(--tx);
|
||
}
|
||
|
||
.m-goal {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 7px;
|
||
font-size: 13px;
|
||
color: var(--tx-2);
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
.m-goal :deep(svg) {
|
||
width: 14px;
|
||
height: 14px;
|
||
color: var(--a-mid);
|
||
flex: 0 0 auto;
|
||
}
|
||
|
||
/* ── Progress Bar ────────────────────────────── */
|
||
.m-bar {
|
||
height: 10px;
|
||
border-radius: 10px;
|
||
background: rgba(124,108,255,.12);
|
||
overflow: hidden;
|
||
position: relative;
|
||
}
|
||
|
||
.m-bar i {
|
||
display: block;
|
||
height: 100%;
|
||
border-radius: 10px;
|
||
background: var(--grad);
|
||
box-shadow: 0 0 14px -2px rgba(124,108,255,.8);
|
||
transition: width .6s ease;
|
||
position: relative;
|
||
}
|
||
|
||
.m-bar.work i {
|
||
background: linear-gradient(90deg, #2bb87f, #3ddc97);
|
||
box-shadow: var(--glow-work);
|
||
}
|
||
|
||
.m-bar i::after {
|
||
content: '';
|
||
position: absolute;
|
||
inset: 0;
|
||
background: linear-gradient(90deg, transparent, rgba(255,255,255,.4), transparent);
|
||
animation: shimmer 2s linear infinite;
|
||
}
|
||
|
||
@keyframes shimmer {
|
||
0% { transform: translateX(-100%); }
|
||
100% { transform: translateX(180%); }
|
||
}
|
||
|
||
.m-pct-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.m-pct {
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 22px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.grad-tx {
|
||
background: var(--grad);
|
||
-webkit-background-clip: text;
|
||
background-clip: text;
|
||
color: transparent;
|
||
}
|
||
|
||
.m-next {
|
||
font-size: 12px;
|
||
color: var(--tx-3);
|
||
font-family: 'JetBrains Mono', monospace;
|
||
}
|
||
|
||
/* ── Metrics ─────────────────────────────────── */
|
||
.m-metrics {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, 1fr);
|
||
gap: 10px;
|
||
}
|
||
|
||
.m-metric {
|
||
padding: 11px 13px;
|
||
border-radius: 12px;
|
||
background: rgba(124,108,255,.06);
|
||
border: 1px solid var(--line);
|
||
}
|
||
|
||
.mk {
|
||
font-size: 10px;
|
||
color: var(--tx-3);
|
||
font-weight: 700;
|
||
letter-spacing: .06em;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.mv {
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
margin-top: 5px;
|
||
color: var(--tx);
|
||
}
|
||
|
||
/* ── Live Thinking ───────────────────────────── */
|
||
.m-think {
|
||
background: rgba(5,20,36,.7);
|
||
border: 1px solid rgba(52,214,245,.2);
|
||
border-radius: 13px;
|
||
padding: 14px 16px;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 12px;
|
||
line-height: 1.7;
|
||
color: #9fe8fb;
|
||
min-height: 72px;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.m-think::before {
|
||
content: '▶ thinking';
|
||
position: absolute;
|
||
top: 10px;
|
||
right: 14px;
|
||
font-size: 9px;
|
||
color: var(--st-think);
|
||
letter-spacing: .12em;
|
||
opacity: .7;
|
||
}
|
||
|
||
.caret::after {
|
||
content: '▍';
|
||
animation: blink 1s steps(1) infinite;
|
||
color: var(--st-think);
|
||
}
|
||
|
||
@keyframes blink { 50% { opacity: 0; } }
|
||
|
||
/* ── Models ──────────────────────────────────── */
|
||
.m-models {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.m-model-btn {
|
||
height: 34px;
|
||
padding: 0 14px;
|
||
border-radius: 9px;
|
||
font-family: 'Manrope', sans-serif;
|
||
font-size: 12.5px;
|
||
font-weight: 600;
|
||
border: 1px solid var(--line-2);
|
||
background: rgba(124,108,255,.06);
|
||
color: var(--tx-2);
|
||
cursor: pointer;
|
||
transition: .15s;
|
||
}
|
||
|
||
.m-model-btn:hover {
|
||
border-color: var(--line-3);
|
||
color: var(--tx);
|
||
}
|
||
|
||
.m-model-btn.active {
|
||
background: var(--grad);
|
||
border: none;
|
||
color: #fff;
|
||
box-shadow: var(--glow-purple);
|
||
}
|
||
|
||
/* ── MD Footer ───────────────────────────────── */
|
||
.m-md {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 9px;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 11.5px;
|
||
color: var(--tx-2);
|
||
padding: 12px 26px;
|
||
border-top: 1px solid var(--line);
|
||
}
|
||
|
||
.m-md .dot { width: 7px; height: 7px; }
|
||
.m-md-path { color: var(--tx-3); }
|
||
</style>
|