Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 774a5a44f3 | |||
| b535fd1ab3 | |||
| 87e504a1b5 | |||
| 802d2cef3f | |||
| 7bee8bc23f | |||
| 84bf9b7fba | |||
| b0b95d2453 | |||
| cf00318f23 | |||
| 1085c14594 | |||
| df72fd9439 | |||
| 66b833b68b | |||
| 65b46386a1 | |||
| 09fb6c1ec0 |
@@ -1,6 +1,11 @@
|
|||||||
name: CI - Build & Test
|
name: CI - Build & Test
|
||||||
run-name: 🔍 CI ${{ gitea.ref_name }} by @${{ gitea.actor }}
|
run-name: 🔍 CI ${{ gitea.ref_name }} by @${{ gitea.actor }}
|
||||||
|
|
||||||
|
# ── Concurrency: cancel in-progress CI when new push arrives ──
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ gitea.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
@@ -49,8 +54,10 @@ jobs:
|
|||||||
corepack enable
|
corepack enable
|
||||||
corepack prepare pnpm@latest --activate
|
corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
|
# --prefer-offline: use cached packages if available in the runner image
|
||||||
|
# Lockfile IS committed — regenerated on changes via pnpm install.
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --no-frozen-lockfile
|
run: pnpm install --no-frozen-lockfile --prefer-offline
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|
||||||
- name: Type check
|
- name: Type check
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
name: Deploy to Production
|
name: Deploy to Production
|
||||||
run-name: 🚀 Deploy ${{ inputs.bump_version || 'patch' }} by @${{ gitea.actor }}
|
run-name: 🚀 Deploy ${{ inputs.bump_version || 'patch' }} by @${{ gitea.actor }}
|
||||||
|
|
||||||
|
# ── Concurrency: one deploy at a time, cancel queued ones ──
|
||||||
|
# Why: prevents race conditions when CI triggers deploy while
|
||||||
|
# a manual deploy is still running. The latest deploy wins.
|
||||||
|
concurrency:
|
||||||
|
group: deploy-production
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
# ───────────────────────────────────────────────────
|
# ───────────────────────────────────────────────────
|
||||||
# Trigger: automatic after CI success, or manual dispatch.
|
# Trigger: automatic after CI success, or manual dispatch.
|
||||||
# Runner: uses ubuntu-latest label (consistently present on
|
# Runner: uses ubuntu-latest label (consistently present on
|
||||||
@@ -153,29 +160,84 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
"
|
"
|
||||||
|
|
||||||
# ── Step 6: Health Check ──────────────────
|
# ── Step 6: Health Check (backoff) ────────
|
||||||
|
# Exponential-ish backoff: 1s, 2s, 3s, 5s, 8s, 13s (~32s total).
|
||||||
|
# Why: cold-start containers need variable warmup time;
|
||||||
|
# fixed 5s intervals either wait too long or give up too early.
|
||||||
- name: Health Check
|
- name: Health Check
|
||||||
run: |
|
run: |
|
||||||
sleep 5
|
|
||||||
echo "🏥 Health check..."
|
echo "🏥 Health check..."
|
||||||
for i in 1 2 3 4 5 6; do
|
RETRY=0
|
||||||
|
MAX=6
|
||||||
|
WAIT=1
|
||||||
|
while [ $RETRY -lt $MAX ]; do
|
||||||
|
RETRY=$((RETRY + 1))
|
||||||
if curl -sf --max-time 10 https://nexus.noveria.net/health; then
|
if curl -sf --max-time 10 https://nexus.noveria.net/health; then
|
||||||
echo ""
|
echo ""
|
||||||
echo "✅ Health check passed"
|
echo "✅ Health check passed (attempt $RETRY/$MAX)"
|
||||||
break
|
exit 0
|
||||||
fi
|
fi
|
||||||
echo "⏳ Retry $i/6..."
|
echo "⏳ Attempt $RETRY/$MAX failed, waiting ${WAIT}s..."
|
||||||
sleep 5
|
sleep $WAIT
|
||||||
|
# Fibonacci-ish backoff: 1,2,3,5,8,13
|
||||||
|
NEXT=$((WAIT + RETRY))
|
||||||
|
[ $NEXT -le 15 ] && WAIT=$NEXT || WAIT=15
|
||||||
done
|
done
|
||||||
|
echo "❌ Health check failed after $MAX attempts"
|
||||||
|
exit 1
|
||||||
|
|
||||||
# ── Step 7: Smoke test ────────────────────
|
# ── Step 7: Smoke test (multi-endpoint) ───
|
||||||
|
# Tests multiple endpoints to catch partial failures.
|
||||||
|
# Why: a single /dashboard check can miss backend-only outages;
|
||||||
|
# /health tests the API + database + runtime status.
|
||||||
- name: Verify (smoke test)
|
- name: Verify (smoke test)
|
||||||
run: |
|
run: |
|
||||||
echo "🔍 Smoke test..."
|
echo "🔍 Smoke test..."
|
||||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" https://nexus.noveria.net/dashboard)
|
PASS=0
|
||||||
echo "Dashboard: HTTP $HTTP_CODE"
|
FAIL=0
|
||||||
if [ "$HTTP_CODE" != "200" ]; then
|
BASE="https://nexus.noveria.net"
|
||||||
echo "❌ Dashboard not reachable!"
|
|
||||||
|
check() {
|
||||||
|
local path="$1" label="$2" expected="${3:-200}"
|
||||||
|
local code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "${BASE}${path}")
|
||||||
|
printf " %-25s HTTP %s" "${label}:" "${code}"
|
||||||
|
if [ "$code" = "$expected" ]; then
|
||||||
|
echo " ✅"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
else
|
||||||
|
echo " ❌ (expected $expected)"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check "/dashboard" "Dashboard" 200
|
||||||
|
check "/health" "Health API" 200
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Results: $PASS passed, $FAIL failed"
|
||||||
|
if [ "$FAIL" -gt 0 ]; then
|
||||||
|
echo "❌ Smoke test failed!"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "✅ Deployment verified"
|
echo "✅ Deployment verified"
|
||||||
|
|
||||||
|
# ── Step 8: Rollback hint ────────────────
|
||||||
|
# On any failure, prints the previous deploy tag for quick manual rollback.
|
||||||
|
# Why: reduces MTTR (mean time to recovery) by providing the exact
|
||||||
|
# git tag to roll back to without needing to look it up manually.
|
||||||
|
- name: Rollback hint
|
||||||
|
if: failure()
|
||||||
|
run: |
|
||||||
|
echo ""
|
||||||
|
echo "🔙 ─── Rollback Instructions ─── 🔙"
|
||||||
|
echo ""
|
||||||
|
echo " # 1. Checkout previous version:"
|
||||||
|
echo " git checkout tags/\$(git describe --tags --abbrev=0 2>/dev/null || echo 'unknown')"
|
||||||
|
echo ""
|
||||||
|
echo " # 2. Redeploy:"
|
||||||
|
echo " cd /opt/openclaw/data/openclaw/workspace/nexus"
|
||||||
|
echo " docker compose up -d --force-recreate"
|
||||||
|
echo ""
|
||||||
|
echo " # 3. Or trigger rollback via Gitea:"
|
||||||
|
echo " Trigger 'Deploy to Production' workflow with the previous tag"
|
||||||
|
echo ""
|
||||||
|
|||||||
+1
-2
@@ -30,5 +30,4 @@ docker-compose.override.yml
|
|||||||
*.tmp
|
*.tmp
|
||||||
*.bak
|
*.bak
|
||||||
|
|
||||||
# pnpm
|
# pnpm (lockfile IS committed for reproducible CI builds)
|
||||||
pnpm-lock.yaml
|
|
||||||
|
|||||||
Generated
+1442
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,417 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted, nextTick, computed } from 'vue'
|
||||||
|
import AgentCard from './AgentCard.vue'
|
||||||
|
|
||||||
|
interface AgentData {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
role: string
|
||||||
|
description: string
|
||||||
|
tags: string[]
|
||||||
|
color: string
|
||||||
|
icon: string
|
||||||
|
hero?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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.30 + t * 0.40)
|
||||||
|
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 + 40
|
||||||
|
const cp2x = endX + (isLeftColumn ? 50 : -50)
|
||||||
|
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
|
||||||
|
|
||||||
|
// Track path elements and animation offset per agent
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Refresh path lengths and pulse dasharrays from current SVG elements */
|
||||||
|
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 // full traversal in ~3s
|
||||||
|
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="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="iris">
|
||||||
|
<AgentCard
|
||||||
|
v-bind="hero"
|
||||||
|
:class="{ 'hero-active': isActive(hero.id) }"
|
||||||
|
:style="{
|
||||||
|
boxShadow: isActive(hero.id)
|
||||||
|
? `0 0 20px ${hero.color}44`
|
||||||
|
: undefined,
|
||||||
|
borderColor: isActive(hero.id) ? hero.color : undefined,
|
||||||
|
}"
|
||||||
|
@click="emit('select', hero.id)"
|
||||||
|
/>
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
<AgentCard
|
||||||
|
v-bind="agent"
|
||||||
|
:style="{
|
||||||
|
boxShadow: isActive(agent.id)
|
||||||
|
? `0 0 14px ${agent.color}55, 0 0 30px ${agent.color}22`
|
||||||
|
: undefined,
|
||||||
|
borderColor: isActive(agent.id) ? agent.color : undefined,
|
||||||
|
}"
|
||||||
|
@click="emit('select', agent.id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.team-network {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-slot {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
transition: border-color 0.3s, box-shadow 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-active {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.agent-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.cards-layer {
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+63
-152
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import AgentCard from '../components/team/AgentCard.vue'
|
import TeamNetwork from '../components/team/TeamNetwork.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@@ -32,25 +33,16 @@ const agents: AgentCardData[] = [
|
|||||||
role: 'Lead Developer',
|
role: 'Lead Developer',
|
||||||
description: 'Implementiert Features, schreibt Code, führt Builds und Tests aus. Arbeitet autonom im Scope.',
|
description: 'Implementiert Features, schreibt Code, führt Builds und Tests aus. Arbeitet autonom im Scope.',
|
||||||
tags: ['coding', 'development', 'builds'],
|
tags: ['coding', 'development', 'builds'],
|
||||||
color: '#4d8cf6',
|
color: '#3b82f6',
|
||||||
icon: 'code',
|
icon: 'code',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'architekt',
|
|
||||||
name: 'Architekt',
|
|
||||||
role: 'Infrastructure Engineer',
|
|
||||||
description: 'Verantwortlich für Docker, Nginx, Deployment und VPS-Infrastruktur.',
|
|
||||||
tags: ['infrastructure', 'deployment', 'docker'],
|
|
||||||
color: '#4da8f6',
|
|
||||||
icon: 'server',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'reviewer',
|
id: 'reviewer',
|
||||||
name: 'Reviewer',
|
name: 'Reviewer',
|
||||||
role: 'Code QA',
|
role: 'Code QA',
|
||||||
description: 'Prüft Code auf Bugs, Sicherheit und Wartbarkeit. Fixt Probleme eigenständig.',
|
description: 'Prüft Code auf Bugs, Sicherheit und Wartbarkeit. Fixt Probleme eigenständig.',
|
||||||
tags: ['Quality Assurance', 'Security', 'Code Review'],
|
tags: ['Quality Assurance', 'Security', 'Code Review'],
|
||||||
color: '#f6a84d',
|
color: '#a855f7',
|
||||||
icon: 'shield',
|
icon: 'shield',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -59,23 +51,21 @@ const agents: AgentCardData[] = [
|
|||||||
role: 'Research Analyst',
|
role: 'Research Analyst',
|
||||||
description: 'Recherchiert, analysiert Quellen, prüft Fakten. Nur Lese-Rechte, keine Aktionen.',
|
description: 'Recherchiert, analysiert Quellen, prüft Fakten. Nur Lese-Rechte, keine Aktionen.',
|
||||||
tags: ['Research', 'Analysis', 'Fact-Checking'],
|
tags: ['Research', 'Analysis', 'Fact-Checking'],
|
||||||
color: '#8b4df6',
|
color: '#22c55e',
|
||||||
icon: 'search',
|
icon: 'search',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'executor',
|
id: 'executor',
|
||||||
name: 'Executor',
|
name: 'DevOps',
|
||||||
role: 'Host Executor',
|
role: 'Host Executor',
|
||||||
description: 'Führt Host-Kommandos auf dem VPS aus. Nur auf Iris-Befehl, niemals eigeninitiativ.',
|
description: 'Führt Host-Kommandos auf dem VPS aus. Nur auf Iris-Befehl, niemals eigeninitiativ.',
|
||||||
tags: ['Execution', 'Docker', 'VPS'],
|
tags: ['Execution', 'Deployment', 'VPS'],
|
||||||
color: '#4df6d4',
|
color: '#eab308',
|
||||||
icon: 'terminal',
|
icon: 'terminal',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const heroAgent = agents.find(a => a.hero)!
|
const activeAgents = ref<string[]>(['programmer'])
|
||||||
const operationAgents = agents.filter(a => !a.hero && ['programmer', 'architekt'].includes(a.id))
|
|
||||||
const specialistAgents = agents.filter(a => ['reviewer', 'researcher', 'executor'].includes(a.id))
|
|
||||||
|
|
||||||
function goToAgent(id: string) {
|
function goToAgent(id: string) {
|
||||||
router.push(`/agents/${id}`)
|
router.push(`/agents/${id}`)
|
||||||
@@ -91,72 +81,42 @@ function goToAgent(id: string) {
|
|||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="team-header">
|
<div class="team-header">
|
||||||
<h1 class="team-title">Meet the Team</h1>
|
<h1 class="team-title">AI Team Network</h1>
|
||||||
<p class="team-subtitle">{{ agents.length }} AI agents, each with a real role and a real personality.</p>
|
<p class="team-subtitle">{{ agents.length }} AI agents, connected in real-time.</p>
|
||||||
<p class="team-description">Mission Control orchestriert ein Team spezialisierter Agenten — jeder mit eigener Identität, eigenem Workspace und klaren Verantwortlichkeiten.</p>
|
<p class="team-description">Mission Control orchestriert ein Team spezialisierter Agenten — jeder mit eigener Identität, eigenem Workspace und klaren Verantwortlichkeiten. Die Pulse zeigen aktive Kommunikationsflüsse.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Hero Card -->
|
<!-- Network Visualization -->
|
||||||
<div class="hero-section">
|
<div class="network-container">
|
||||||
<AgentCard
|
<TeamNetwork
|
||||||
v-bind="heroAgent"
|
:agents="agents"
|
||||||
@click="goToAgent"
|
hero-id="iris"
|
||||||
|
:active-agents="activeAgents"
|
||||||
|
@select="goToAgent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Section Divider -->
|
<!-- Legend -->
|
||||||
<div class="section-divider">
|
<div class="legend-row">
|
||||||
<div class="divider-line"></div>
|
<div class="legend-item">
|
||||||
<span class="divider-label">OPERATIONS</span>
|
<span class="legend-dot active-pulse"></span>
|
||||||
<div class="divider-line"></div>
|
<span>Aktive Verbindung</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
<!-- Operations Row -->
|
<span class="legend-dot idle-pulse"></span>
|
||||||
<div class="ops-row">
|
<span>Idle</span>
|
||||||
<AgentCard
|
|
||||||
v-for="agent in operationAgents"
|
|
||||||
:key="agent.id"
|
|
||||||
v-bind="agent"
|
|
||||||
@click="goToAgent"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
<!-- Connector Labels -->
|
<span class="legend-dot pulse-dot"></span>
|
||||||
<div class="connector-row">
|
<span>Datenfluss (Pulse)</span>
|
||||||
<div class="connector-left">
|
|
||||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
|
|
||||||
<path d="M6 0L6 10M6 10L2 6M6 10L10 6" stroke="#51d49a" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
<span>INPUT SIGNAL</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="connector-rail">
|
|
||||||
<div class="rail-line"></div>
|
|
||||||
<div class="rail-dot"></div>
|
|
||||||
<div class="rail-line"></div>
|
|
||||||
</div>
|
|
||||||
<div class="connector-right">
|
|
||||||
<span>OUTPUT ACTION</span>
|
|
||||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
|
|
||||||
<path d="M6 12L6 2M6 2L2 6M6 2L10 6" stroke="#4d8cf6" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Specialists Row -->
|
|
||||||
<div class="specialists-row">
|
|
||||||
<AgentCard
|
|
||||||
v-for="agent in specialistAgents"
|
|
||||||
:key="agent.id"
|
|
||||||
v-bind="agent"
|
|
||||||
@click="goToAgent"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.team-page {
|
.team-page {
|
||||||
max-width: 820px;
|
max-width: 920px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding-bottom: 40px;
|
padding-bottom: 40px;
|
||||||
}
|
}
|
||||||
@@ -201,99 +161,50 @@ function goToAgent(id: string) {
|
|||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-divider {
|
.network-container {
|
||||||
display: flex;
|
margin-top: 10px;
|
||||||
align-items: center;
|
padding: 0;
|
||||||
gap: 12px;
|
|
||||||
margin: 32px 0 24px;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.divider-line {
|
|
||||||
flex: 1;
|
|
||||||
height: 1px;
|
|
||||||
background: var(--line);
|
|
||||||
}
|
|
||||||
.divider-label {
|
|
||||||
font-size: 9.5px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
color: #6b7385;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-row {
|
.legend-row {
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.connector-row {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
justify-content: center;
|
||||||
margin: 10px 0;
|
gap: 24px;
|
||||||
padding: 0 6px;
|
margin-top: 28px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
.connector-left {
|
.legend-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 8px;
|
||||||
font-size: 8.5px;
|
font-size: 10px;
|
||||||
font-weight: 700;
|
color: #7e8799;
|
||||||
color: #51d49a;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
.connector-right {
|
.legend-dot {
|
||||||
display: flex;
|
width: 8px;
|
||||||
align-items: center;
|
height: 8px;
|
||||||
gap: 5px;
|
|
||||||
font-size: 8.5px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #4d8cf6;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.connector-rail {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
.rail-line {
|
|
||||||
flex: 1;
|
|
||||||
height: 1px;
|
|
||||||
background: var(--line);
|
|
||||||
}
|
|
||||||
.rail-dot {
|
|
||||||
width: 5px;
|
|
||||||
height: 5px;
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #5b5286;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
.active-pulse {
|
||||||
.specialists-row {
|
background: #51d49a;
|
||||||
display: grid;
|
box-shadow: 0 0 6px rgba(81, 212, 154, 0.6);
|
||||||
grid-template-columns: 1fr 1fr 1fr;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
}
|
||||||
|
.idle-pulse {
|
||||||
@media (max-width: 720px) {
|
background: #3a3f4b;
|
||||||
.ops-row {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
.specialists-row {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
.team-title {
|
|
||||||
font-size: 22px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.pulse-dot {
|
||||||
@media (min-width: 721px) and (max-width: 820px) {
|
background: white;
|
||||||
.specialists-row {
|
width: 6px;
|
||||||
grid-template-columns: 1fr 1fr;
|
height: 6px;
|
||||||
}
|
animation: legend-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes legend-pulse {
|
||||||
|
0%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||||||
|
50% { opacity: 1; transform: scale(1.2); }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user