fix(v2): reviewer bugfixes — scroll, block-status, NaN guard, dead code cleanup
This commit is contained in:
@@ -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 // 0–100
|
progress: number // 0–100
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 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 }[]
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user