c2736d20c1
TeamNetwork: Footer→Arrow, Current Task+Runtime inline Missions→Offene Aufgaben (TaskCard) mit +New Task, Iris/Bao-Quelle OperationsFeed: Text-Wrap, 5 Items, Mehr-Button→Tag-Navigation-Modal
615 lines
16 KiB
Vue
615 lines
16 KiB
Vue
<script setup lang="ts">
|
|
import { ref, onMounted, onUnmounted, nextTick, computed } from 'vue'
|
|
import { Bot, Code2, Server, Shield, Search, Terminal } from '@lucide/vue'
|
|
|
|
interface AgentData {
|
|
id: string
|
|
name: string
|
|
role: string
|
|
description: string
|
|
tags: string[]
|
|
color: string
|
|
icon: string
|
|
hero?: boolean
|
|
task?: string
|
|
runtime?: string
|
|
}
|
|
|
|
const props = defineProps<{
|
|
agents: AgentData[]
|
|
heroId?: string
|
|
activeAgents?: string[]
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
select: [id: string]
|
|
}>()
|
|
|
|
// ── Layout refs ──
|
|
const networkRef = ref<HTMLDivElement | null>(null)
|
|
|
|
interface CardBox {
|
|
left: number
|
|
right: number
|
|
top: number
|
|
bottom: number
|
|
cx: number
|
|
cy: number
|
|
width: number
|
|
height: number
|
|
}
|
|
const cardPositions = ref<Record<string, CardBox>>({})
|
|
const svgWidth = ref(0)
|
|
const svgHeight = ref(0)
|
|
|
|
// ── Computed data ──
|
|
const hero = computed(() => props.agents.find(a => a.id === props.heroId) ?? props.agents[0])
|
|
const childAgents = computed(() => props.agents.filter(a => a.id !== props.heroId))
|
|
|
|
function isActive(id: string): boolean {
|
|
return props.activeAgents?.includes(id) ?? false
|
|
}
|
|
|
|
// ── 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
|
|
}
|
|
}
|
|
|
|
// ── Position measurement ──
|
|
function updatePositions() {
|
|
if (!networkRef.value) return
|
|
const rect = networkRef.value.getBoundingClientRect()
|
|
svgWidth.value = rect.width
|
|
svgHeight.value = rect.height
|
|
|
|
const cards = networkRef.value.querySelectorAll('[data-agent-id]')
|
|
const positions: Record<string, CardBox> = {}
|
|
cards.forEach(el => {
|
|
const id = el.getAttribute('data-agent-id')
|
|
if (!id) return
|
|
const r = el.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
|
|
}
|
|
|
|
// ── SVG path computation ──
|
|
interface ConnectionPath {
|
|
d: string
|
|
length: number
|
|
}
|
|
|
|
const connectionPaths = computed<Record<string, ConnectionPath | null>>(() => {
|
|
const result: Record<string, ConnectionPath | null> = {}
|
|
const pos = cardPositions.value
|
|
const heroEntry = props.agents.find(a => a.id === props.heroId)
|
|
const heroId = heroEntry?.id ?? ''
|
|
const iris = heroId ? pos[heroId] : undefined
|
|
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
|
|
})
|
|
|
|
// ── Pulse animation (JS-driven via requestAnimationFrame) ──
|
|
let animFrameId: number | null = null
|
|
let lastAnimTime = 0
|
|
|
|
const pathElements = ref<Record<string, SVGPathElement | null>>({})
|
|
const pulseElements = ref<Record<string, SVGPathElement | null>>({})
|
|
const pulseOffsets = 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 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', `10 ${p.length}`)
|
|
pulseEl.setAttribute('stroke-dashoffset', String(-pulseOffsets.value[id]))
|
|
}
|
|
}
|
|
}
|
|
|
|
function startPulseAnimation() {
|
|
const speeds: Record<string, number> = {}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
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 p = connectionPaths.value[id]
|
|
if (!pathEl || !pulseEl || !p) continue
|
|
|
|
const len = p.length
|
|
if (len <= 0) continue
|
|
|
|
const currentOffset = pulseOffsets.value[id] ?? 0
|
|
const newOffset = currentOffset + (speeds[id] ?? len / 3000) * dt
|
|
const cycleLen = len + 10
|
|
pulseOffsets.value[id] = newOffset > cycleLen ? newOffset % cycleLen : newOffset
|
|
|
|
pulseEl.setAttribute('stroke-dashoffset', String(-pulseOffsets.value[id]))
|
|
}
|
|
|
|
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()
|
|
// Paths changed — recalculate lengths and dasharrays
|
|
requestAnimationFrame(() => {
|
|
refreshPathLengths()
|
|
})
|
|
})
|
|
if (networkRef.value) {
|
|
resizeObserver.observe(networkRef.value)
|
|
}
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
stopPulseAnimation()
|
|
resizeObserver?.disconnect()
|
|
})
|
|
</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 (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"
|
|
/>
|
|
</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.task" class="task-row">
|
|
<span class="node-task">
|
|
<span class="node-task-dot">●</span>
|
|
{{ hero.task }}
|
|
</span>
|
|
<span v-if="hero.runtime" class="node-runtime">{{ hero.runtime }}</span>
|
|
</div>
|
|
<div class="card-tags">
|
|
<span v-for="tag in hero.tags" :key="tag" class="card-tag" :style="{ background: `${hero.color}18`, color: hero.color }">{{ tag }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="card-arrow">
|
|
<span class="arrow-icon">→</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.task" class="task-row">
|
|
<span class="node-task">
|
|
<span class="node-task-dot">●</span>
|
|
{{ agent.task }}
|
|
</span>
|
|
<span v-if="agent.runtime" class="node-runtime">{{ agent.runtime }}</span>
|
|
</div>
|
|
<div class="card-tags">
|
|
<span v-for="tag in agent.tags" :key="tag" class="card-tag" :style="{ background: `${agent.color}18`, color: agent.color }">{{ tag }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="card-arrow">
|
|
<span class="arrow-icon">→</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 (inlined from old AgentCard.vue) ── */
|
|
.agent-card {
|
|
background: var(--panel, #11141b);
|
|
border: 1px solid var(--line, #1f2330);
|
|
border-radius: 12px;
|
|
padding: 18px;
|
|
cursor: pointer;
|
|
transition: border-color 0.2s, box-shadow 0.2s;
|
|
overflow: hidden;
|
|
position: relative;
|
|
}
|
|
.agent-card:hover {
|
|
border-color: var(--card-color, #8b7cf6);
|
|
box-shadow: 0 0 16px color-mix(in srgb, var(--card-color, #8b7cf6) 10%, transparent);
|
|
}
|
|
.hero-card {
|
|
border-color: rgba(139, 124, 246, 0.2);
|
|
box-shadow: 0 0 20px rgba(139, 124, 246, 0.06);
|
|
}
|
|
.hero-card:hover {
|
|
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;
|
|
}
|
|
|
|
/* ── 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;
|
|
}
|
|
|
|
/* ── Hover Arrow (bottom-right) ── */
|
|
.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;
|
|
}
|
|
|
|
@media (max-width: 720px) {
|
|
.agent-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
.cards-layer {
|
|
gap: 20px;
|
|
}
|
|
}
|
|
</style>
|