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

This commit is contained in:
2026-06-12 00:51:42 +02:00
parent 166c9f9051
commit 6023b5ea24
7 changed files with 941 additions and 22 deletions
@@ -0,0 +1,780 @@
<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, AgentDetail } from './types'
/* ── Props ──────────────────────────────────────────── */
const props = defineProps<{
agent: AgentDetail
// 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: AgentDetail): 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: '',
},
]
}
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)
}
/* ── 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', `status-${agent.status}`]">
<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);
}
@keyframes pulse-work {
0% { box-shadow: 0 0 0 0 rgba(61, 220, 151, 0.55); }
70% { box-shadow: 0 0 0 6px 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, 0.55); }
70% { box-shadow: 0 0 0 6px rgba(52, 214, 245, 0); }
100% { box-shadow: 0 0 0 0 rgba(52, 214, 245, 0); }
}
/* ── 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>
@@ -114,6 +114,11 @@ defineEmits<{
box-shadow: 0 0 0 1px rgba(52, 214, 245, 0.2), 0 0 26px -6px rgba(52, 214, 245, 0.55); 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 { .node.is-iris .ncard {
border-color: rgba(124, 108, 255, 0.55); border-color: rgba(124, 108, 255, 0.55);
box-shadow: var(--glow); box-shadow: var(--glow);
@@ -62,7 +62,7 @@ defineEmits<{
@click="$emit('blockerClick')" @click="$emit('blockerClick')"
> >
<span class="dot block"></span> <span class="dot block"></span>
{{ blockerCount }} {{ blockerCount === 1 ? 'Blocker' : 'Blocker' }} {{ blockerCount }} Blocker
</button> </button>
</div> </div>
</template> </template>
@@ -291,7 +291,7 @@ function handleReset() {
Reset Reset
</button> </button>
<button class="add-btn" @click="$emit('add')"> <button class="add-btn" @click="emit('add')">
<span class="btn-icon" v-html="icons.plus || ''"></span> <span class="btn-icon" v-html="icons.plus || ''"></span>
Agent hinzufügen Agent hinzufügen
</button> </button>
@@ -305,7 +305,6 @@ function handleReset() {
></svg> ></svg>
<!-- Agent Nodes --> <!-- Agent Nodes -->
<div class="nodes-layer" v-html="''"></div>
<AgentNode <AgentNode
v-for="agent in agents" v-for="agent in agents"
:key="agent.id" :key="agent.id"
@@ -314,7 +313,7 @@ function handleReset() {
:top="(positions[agent.id] || autoPositions[agent.id] || { x: 50, y: 50 }).y" :top="(positions[agent.id] || autoPositions[agent.id] || { x: 50, y: 50 }).y"
:entering="enteringIds.includes(agent.id)" :entering="enteringIds.includes(agent.id)"
:data-id="agent.id" :data-id="agent.id"
@select="(id: string) => $emit('select', id)" @select="(id: string) => emit('select', id)"
/> />
<!-- Layout Label --> <!-- Layout Label -->
@@ -12,7 +12,7 @@
* send(text) Nachricht absenden * send(text) Nachricht absenden
*/ */
import { ref, nextTick, watch } from 'vue' import { ref, computed, nextTick, watch } from 'vue'
import { icons } from '../../../composables/icons' import { icons } from '../../../composables/icons'
import type { ChatMessage } from './types' import type { ChatMessage } from './types'
@@ -28,13 +28,13 @@ const emit = defineEmits<{
/* ── Input ────────────────────────────────────────── */ /* ── Input ────────────────────────────────────────── */
const inputText = ref('') const inputText = ref('')
const msgContainer = ref<HTMLElement | null>(null) const msgContainer = ref<HTMLElement | null>(null)
const inputRef = ref<HTMLInputElement | null>(null)
function handleSend() { function handleSend() {
const text = inputText.value.trim() const text = inputText.value.trim()
if (!text) return if (!text) return
emit('send', text) emit('send', text)
inputText.value = '' inputText.value = ''
// Focus bleibt im Input
} }
function onKeydown(e: KeyboardEvent) { function onKeydown(e: KeyboardEvent) {
@@ -44,23 +44,20 @@ function onKeydown(e: KeyboardEvent) {
} }
} }
/* ── Auto-scroll nach unten ───────────────────────── */ /* ── 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( watch(
() => props.messages.length, () => props.messages.length,
() => { () => {
nextTick(() => { nextTick(() => {
if (msgContainer.value) { if (msgContainer.value) {
msgContainer.value.scrollTop = msgContainer.value.scrollHeight msgContainer.value.scrollTop = 0
} }
}) })
} }
) )
/* ── Format timestamp ─────────────────────────────── */
function formatTs(ts: string): string {
// Expects "HH:MM" or "HH:MM:SS"
return ts
}
</script> </script>
<template> <template>
@@ -74,7 +71,7 @@ function formatTs(ts: string): string {
<span class="header-subtitle">Iris Chat</span> <span class="header-subtitle">Iris Chat</span>
</div> </div>
</div> </div>
<button class="ask-btn" type="button"> <button class="ask-btn" type="button" @click="inputRef?.focus()">
<span class="ask-icon" v-html="icons.spark || ''"></span> <span class="ask-icon" v-html="icons.spark || ''"></span>
Ask Iris Ask Iris
</button> </button>
@@ -92,8 +89,8 @@ function formatTs(ts: string): string {
<span class="thinking-text">thinking</span> <span class="thinking-text">thinking</span>
</div> </div>
<!-- Messages (reverse order neueste zuerst) --> <!-- Messages (reverse order newest first in DOM, column-reverse flips) -->
<template v-for="(msg, i) in [...messages].reverse()" :key="i"> <template v-for="(msg, i) in reversedMessages" :key="i">
<!-- Iris Bubble --> <!-- Iris Bubble -->
<div v-if="msg.sender === 'iris'" class="bubble iris-bubble"> <div v-if="msg.sender === 'iris'" class="bubble iris-bubble">
<div class="bubble-text">{{ msg.text }}</div> <div class="bubble-text">{{ msg.text }}</div>
@@ -102,13 +99,13 @@ function formatTs(ts: string): string {
<span class="tool-icon" v-html="icons.search || ''"></span> <span class="tool-icon" v-html="icons.search || ''"></span>
<span class="tool-label">{{ msg.tool }}</span> <span class="tool-label">{{ msg.tool }}</span>
</div> </div>
<div class="bubble-meta">{{ formatTs(msg.ts) }}</div> <div class="bubble-meta">{{ msg.ts }}</div>
</div> </div>
<!-- User Bubble --> <!-- User Bubble -->
<div v-else class="bubble user-bubble"> <div v-else class="bubble user-bubble">
<div class="bubble-text">{{ msg.text }}</div> <div class="bubble-text">{{ msg.text }}</div>
<div class="bubble-meta">{{ formatTs(msg.ts) }}</div> <div class="bubble-meta">{{ msg.ts }}</div>
</div> </div>
</template> </template>
</div> </div>
@@ -117,6 +114,7 @@ function formatTs(ts: string): string {
<div class="chat-input-area"> <div class="chat-input-area">
<div class="input-wrap"> <div class="input-wrap">
<input <input
ref="inputRef"
v-model="inputText" v-model="inputText"
class="chat-input" class="chat-input"
type="text" type="text"
@@ -17,3 +17,27 @@ export interface TaskItem {
status: 'active' | 'pending' | 'blocked' status: 'active' | 'pending' | 'blocked'
progress: number // 0100 progress: number // 0100
} }
/* ── Agent Detail Modal Types ─────────────────── */
export interface ThinkingItem {
type: 'thought' | 'action' | 'result'
text: string
ts: string
}
export interface AgentDetail {
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 }[]
}
+116 -3
View File
@@ -15,6 +15,8 @@ import IrisChat from '../../components/dashboard/v2/IrisChat.vue'
import type { ChatMessage } from '../../components/dashboard/v2/types' import type { ChatMessage } from '../../components/dashboard/v2/types'
import TaskStrip from '../../components/dashboard/v2/TaskStrip.vue' import TaskStrip from '../../components/dashboard/v2/TaskStrip.vue'
import type { TaskItem } from '../../components/dashboard/v2/types' import type { TaskItem } from '../../components/dashboard/v2/types'
import AgentDetailModal from '../../components/dashboard/v2/AgentDetailModal.vue'
import type { AgentDetail, ThinkingItem } from '../../components/dashboard/v2/types'
import { mockAgents, extraAgentPool } from '../../composables/useFlowLayout' import { mockAgents, extraAgentPool } from '../../composables/useFlowLayout'
import type { AgentNodeData } from '../../composables/useFlowLayout' import type { AgentNodeData } from '../../composables/useFlowLayout'
@@ -25,8 +27,108 @@ const enteringIds = ref<string[]>([])
const agentPool = ref<AgentNodeData[]>(extraAgentPool) const agentPool = ref<AgentNodeData[]>(extraAgentPool)
/* ── Agent Detail Modal State ──────────────────────── */
const selectedAgentId = ref<string | null>(null)
const modalOpen = computed(() => selectedAgentId.value !== null)
const agentOrder = computed(() => agents.value.map(a => a.id))
const availableModels = [
{ id: 'deepseek-v4-flash', alias: 'Deepseek V4 Flash' },
{ id: 'deepseek-v4-pro', alias: 'Deepseek V4 Pro' },
{ id: 'gpt-4o', alias: 'GPT-4o' },
{ id: 'claude-35-sonnet', alias: 'Claude 3.5 Sonnet' },
]
/**
* Erzeugt simulierte Thinking-Items aus einem AgentNodeData.think-String.
*/
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' })
}
// Aus dem think-String mehrere simulierte Items erzeugen
const items: ThinkingItem[] = []
const sentences = data.think.split(/[.…!?]+/).filter(s => s.trim().length > 5)
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
}
/**
* Konvertiert AgentNodeData → AgentDetail für das Modal.
*/
function buildAgentDetail(data: AgentNodeData): AgentDetail {
// Aus tokens/cost die numerischen Werte extrahieren
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
return {
id: data.id,
name: data.name,
role: data.role,
model: data.model,
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,
}
}
const selectedAgent = computed<AgentDetail | null>(() => {
if (!selectedAgentId.value) return null
const data = agents.value.find(a => a.id === selectedAgentId.value)
if (!data) return null
return buildAgentDetail(data)
})
function handleSelect(id: string) { function handleSelect(id: string) {
console.log('[FlowBoard] selected agent:', id) selectedAgentId.value = id
}
function handleCloseModal() {
selectedAgentId.value = null
}
function handleAgentSelect(id: string) {
// Zum nächsten/vorherigen Agenten springen (Pfeiltasten)
selectedAgentId.value = id
}
function handleChangeModel(agentId: string, modelId: string) {
const agent = agents.value.find(a => a.id === agentId)
if (agent) {
agent.model = modelId
}
} }
function handleAdd() { function handleAdd() {
@@ -62,8 +164,9 @@ const todayCost = computed(() => {
}) })
const todayTokens = computed(() => { const todayTokens = computed(() => {
const total = agents.value.reduce((s, a) => { const total = agents.value.reduce((s, a) => {
const v = a.tokens?.replace(/[^0-9.]/g, '') const raw = a.tokens?.replace(/[^0-9.]/g, '') || '0'
return v ? s + parseFloat(v) : s const v = parseFloat(raw)
return Number.isFinite(v) ? s + v : s
}, 0) }, 0)
return total >= 1000 ? Math.round(total / 1000) + 'k' : Math.round(total) + '' return total >= 1000 ? Math.round(total / 1000) + 'k' : Math.round(total) + ''
}) })
@@ -213,6 +316,16 @@ const tasks = ref<TaskItem[]>([
@send="handleChatSend" @send="handleChatSend"
/> />
</div> </div>
<!-- Agent Detail Modal -->
<AgentDetailModal
v-if="modalOpen && selectedAgent"
:agent="selectedAgent"
:agent-order="agentOrder"
@close="handleCloseModal"
@select="handleAgentSelect"
@change-model="handleChangeModel"
/>
</div> </div>
</template> </template>