feat(v2): FlowCanvas, AgentNode, AlertBar, useFlowLayout composable
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* AgentNode — Einzelner Agenten-Knoten im FlowCanvas
|
||||
*
|
||||
* Props:
|
||||
* agent – AgentNodeData
|
||||
* left – x-Position in % (0–100)
|
||||
* top – y-Position in % (0–100)
|
||||
* entering – true wenn Node gerade frisch ins DOM kam (Enter-Animation)
|
||||
*
|
||||
* Emits:
|
||||
* select – Agent ausgewählt (id)
|
||||
*/
|
||||
import type { AgentNodeData } from '../../../composables/useFlowLayout'
|
||||
|
||||
const props = defineProps<{
|
||||
agent: AgentNodeData
|
||||
left: number
|
||||
top: number
|
||||
entering?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
select: [id: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'node',
|
||||
agent.id === 'iris' ? 'is-iris' : `is-${agent.status}`,
|
||||
{ entering }
|
||||
]"
|
||||
:style="{ left: left + '%', top: top + '%' }"
|
||||
@click="$emit('select', agent.id)"
|
||||
>
|
||||
<div class="ncard">
|
||||
<!-- Header: Avatar + Name + Role + Status-Dot -->
|
||||
<div class="nc-top">
|
||||
<div :class="['nc-av', { 'iris-av': agent.id === 'iris' }]">
|
||||
<span v-html="agent.avatar === '</>' ? '</>' : agent.avatar"></span>
|
||||
</div>
|
||||
<div class="nc-info">
|
||||
<div class="nc-name">{{ agent.name }}</div>
|
||||
<div class="nc-role">{{ agent.role }}</div>
|
||||
</div>
|
||||
<span :class="['nc-stat', 'dot', agent.status]"></span>
|
||||
</div>
|
||||
|
||||
<!-- Task (2-line clamp) -->
|
||||
<div class="nc-task">{{ agent.task || 'Bereit · ' + agent.next }}</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div class="nc-bar">
|
||||
<i :style="{ width: (agent.progress || 3) + '%' }"></i>
|
||||
</div>
|
||||
|
||||
<!-- Meta-Zeile -->
|
||||
<div class="nc-meta">
|
||||
<span
|
||||
class="st"
|
||||
:style="{ color: `var(--st-${agent.status})` }"
|
||||
>
|
||||
{{ agent.statusLabel }}
|
||||
</span>
|
||||
<span>{{ agent.task ? (agent.progress + '% · ' + agent.elapsed) : agent.model }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.node {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 3;
|
||||
width: 188px;
|
||||
transition:
|
||||
left 0.55s cubic-bezier(.4, 0, .2, 1),
|
||||
top 0.55s cubic-bezier(.4, 0, .2, 1),
|
||||
opacity 0.35s,
|
||||
scale 0.35s;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.node.entering {
|
||||
opacity: 0;
|
||||
scale: 0.7;
|
||||
}
|
||||
|
||||
.ncard {
|
||||
padding: 11px 12px;
|
||||
border-radius: 13px;
|
||||
background: var(--glass-2);
|
||||
border: 1px solid var(--line-2);
|
||||
backdrop-filter: blur(10px);
|
||||
transition: transform 0.18s;
|
||||
}
|
||||
|
||||
.ncard:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Status-glow border */
|
||||
.node.is-work .ncard {
|
||||
border-color: rgba(61, 220, 151, 0.45);
|
||||
box-shadow: 0 0 0 1px rgba(61, 220, 151, 0.2), 0 0 26px -6px rgba(61, 220, 151, 0.6);
|
||||
}
|
||||
|
||||
.node.is-think .ncard {
|
||||
border-color: rgba(52, 214, 245, 0.45);
|
||||
box-shadow: 0 0 0 1px rgba(52, 214, 245, 0.2), 0 0 26px -6px rgba(52, 214, 245, 0.55);
|
||||
}
|
||||
|
||||
.node.is-iris .ncard {
|
||||
border-color: rgba(124, 108, 255, 0.55);
|
||||
box-shadow: var(--glow);
|
||||
background: linear-gradient(160deg, rgba(124, 108, 255, 0.2), rgba(28, 24, 64, 0.6));
|
||||
}
|
||||
|
||||
.node.is-idle .ncard {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ── Card Content ────────────────────────────── */
|
||||
.nc-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.nc-av {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 9px;
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
background: var(--grad-soft);
|
||||
border: 1px solid var(--line-2);
|
||||
color: var(--tx);
|
||||
}
|
||||
|
||||
.nc-av.iris-av {
|
||||
background: var(--grad);
|
||||
color: #fff;
|
||||
box-shadow: var(--glow-purple);
|
||||
}
|
||||
|
||||
.nc-av :deep(svg) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.nc-info {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nc-name {
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--tx);
|
||||
}
|
||||
|
||||
.nc-role {
|
||||
font-size: 10px;
|
||||
color: var(--tx-3);
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.nc-stat {
|
||||
margin-left: auto;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
flex: 0 0 auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.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.8s infinite;
|
||||
}
|
||||
|
||||
.dot.idle {
|
||||
background: var(--st-idle);
|
||||
}
|
||||
|
||||
.dot.block {
|
||||
background: var(--st-block);
|
||||
box-shadow: 0 0 0 0 rgba(251, 113, 133, 0.55);
|
||||
animation: pulse-block 1.8s infinite;
|
||||
}
|
||||
|
||||
/* ── Task ────────────────────────────────────── */
|
||||
.nc-task {
|
||||
font-size: 11px;
|
||||
color: var(--tx-2);
|
||||
margin-top: 8px;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
/* ── Progress Bar ────────────────────────────── */
|
||||
.nc-bar {
|
||||
height: 4px;
|
||||
border-radius: 4px;
|
||||
background: rgba(124, 108, 255, 0.12);
|
||||
overflow: hidden;
|
||||
margin-top: 7px;
|
||||
}
|
||||
|
||||
.nc-bar i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
background: var(--grad);
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
.node.is-work .nc-bar i {
|
||||
background: linear-gradient(90deg, #2bb87f, #3ddc97);
|
||||
}
|
||||
|
||||
/* ── Meta ────────────────────────────────────── */
|
||||
.nc-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
color: var(--tx-3);
|
||||
margin-top: 5px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.nc-meta .st {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@keyframes pulse-work {
|
||||
0% { box-shadow: 0 0 0 0 rgba(61, 220, 151, 0.55); }
|
||||
70% { box-shadow: 0 0 0 7px 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 7px rgba(52, 214, 245, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(52, 214, 245, 0); }
|
||||
}
|
||||
|
||||
@keyframes pulse-block {
|
||||
0% { box-shadow: 0 0 0 0 rgba(251, 113, 133, 0.55); }
|
||||
70% { box-shadow: 0 0 0 7px rgba(251, 113, 133, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(251, 113, 133, 0); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,171 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* AlertBar — Status-Übersicht im V2 Dashboard
|
||||
*
|
||||
* Props:
|
||||
* activeCount – Agents mit status 'work'
|
||||
* thinkCount – Agents mit status 'think'
|
||||
* idleCount – Agents mit status 'idle'
|
||||
* blockerCount – Blocker-Anzahl
|
||||
* todayCost – Kosten heute (z.B. "$6.40")
|
||||
* todayTokens – Token heute (z.B. "282k")
|
||||
*/
|
||||
import { icons } from '../../../composables/icons'
|
||||
|
||||
defineProps<{
|
||||
activeCount: number
|
||||
thinkCount: number
|
||||
idleCount: number
|
||||
blockerCount: number
|
||||
todayCost: string
|
||||
todayTokens: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
blockerClick: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="alertbar glass-panel">
|
||||
<!-- Active (arbeitet) -->
|
||||
<div class="seg">
|
||||
<span class="dot work"></span>
|
||||
<span class="seg-label">{{ activeCount }} arbeiten</span>
|
||||
</div>
|
||||
|
||||
<!-- Think (plant) -->
|
||||
<div class="seg">
|
||||
<span class="dot think"></span>
|
||||
<span class="seg-label">{{ thinkCount }} planen</span>
|
||||
</div>
|
||||
|
||||
<!-- Idle (bereit) -->
|
||||
<div class="seg">
|
||||
<span class="dot idle"></span>
|
||||
<span class="seg-label">{{ idleCount }} bereit</span>
|
||||
</div>
|
||||
|
||||
<!-- Separator -->
|
||||
<div class="sep"></div>
|
||||
|
||||
<!-- Kosten heute -->
|
||||
<div class="seg tx2">
|
||||
<span class="seg-icon" v-html="icons.coin || ''"></span>
|
||||
heute <span class="cost-value">{{ todayCost }}</span> · {{ todayTokens }}
|
||||
</div>
|
||||
|
||||
<!-- Blocker Alert (rechts) -->
|
||||
<button
|
||||
v-if="blockerCount > 0"
|
||||
class="blk"
|
||||
@click="$emit('blockerClick')"
|
||||
>
|
||||
<span class="dot block"></span>
|
||||
{{ blockerCount }} {{ blockerCount === 1 ? 'Blocker' : 'Blocker' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.alertbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 11px 16px;
|
||||
border-radius: var(--r);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.seg {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--tx);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.seg-label {
|
||||
color: var(--tx-2);
|
||||
}
|
||||
|
||||
.seg-icon :deep(svg) {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex: 0 0 auto;
|
||||
color: var(--a-mid);
|
||||
}
|
||||
|
||||
.tx2 .seg-icon :deep(svg) {
|
||||
color: var(--tx-3);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.dot.work {
|
||||
background: var(--st-work);
|
||||
box-shadow: 0 0 0 0 rgba(61,220,151,.55);
|
||||
animation: pulse-work 1.8s infinite;
|
||||
}
|
||||
|
||||
.dot.think {
|
||||
background: var(--st-think);
|
||||
box-shadow: 0 0 0 0 rgba(52,214,245,.55);
|
||||
animation: pulse-think 1.8s infinite;
|
||||
}
|
||||
|
||||
.dot.idle {
|
||||
background: var(--st-idle);
|
||||
}
|
||||
|
||||
.dot.block {
|
||||
background: var(--st-block);
|
||||
box-shadow: 0 0 0 0 rgba(251,113,133,.55);
|
||||
animation: pulse-block 1.8s infinite;
|
||||
}
|
||||
|
||||
.sep {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: var(--line-2);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.cost-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
background: var(--grad);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.blk {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 9px;
|
||||
background: rgba(251,113,133,.12);
|
||||
border: 1px solid rgba(251,113,133,.3);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: #fda4b0;
|
||||
cursor: pointer;
|
||||
transition: background .15s;
|
||||
font-family: 'Manrope', sans-serif;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.blk:hover {
|
||||
background: rgba(251,113,133,.22);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,485 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* FlowCanvas — SVG-Kanten + Auto-Layout + AgentNode-Karten
|
||||
*
|
||||
* Props:
|
||||
* agents – Liste der AgentNodeData
|
||||
* positions – Record<id, {x,y}> mit aktuellen Positionen
|
||||
*
|
||||
* Emits:
|
||||
* select – Agent ausgewählt (id)
|
||||
* add – Neuen Agent hinzufügen
|
||||
* updatePositions – Positionsänderung
|
||||
*/
|
||||
import { computed, onMounted, onUnmounted, ref, nextTick, watch } from 'vue'
|
||||
import type { AgentNodeData } from '../../../composables/useFlowLayout'
|
||||
import { autoLayout, buildEdges, curve } from '../../../composables/useFlowLayout'
|
||||
import { icons } from '../../../composables/icons'
|
||||
import AgentNode from './AgentNode.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
agents: AgentNodeData[]
|
||||
positions: Record<string, { x: number; y: number }>
|
||||
enteringIds: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [id: string]
|
||||
add: []
|
||||
resetLayout: []
|
||||
updatePositions: [positions: Record<string, { x: number; y: number }>]
|
||||
}>()
|
||||
|
||||
/* ── Refs ───────────────────────────────────────── */
|
||||
const flowRef = ref<HTMLElement | null>(null)
|
||||
const svgRef = ref<SVGSVGElement | null>(null)
|
||||
const edgesDefs = ref('')
|
||||
const edgesPaths = ref('')
|
||||
const edgesPulses = ref('')
|
||||
|
||||
/* ── Computed ───────────────────────────────────── */
|
||||
const agentCount = computed(() => props.agents.length)
|
||||
|
||||
const autoPositions = computed(() => autoLayout(props.agents))
|
||||
|
||||
// Layout label
|
||||
const layoutLabel = computed(() => {
|
||||
const n = props.agents.length - 1
|
||||
if (n <= 0) return `${props.agents.length} Agents`
|
||||
const maxPerRow = n <= 2 ? 2 : n <= 6 ? 3 : n <= 9 ? 3 : 4
|
||||
const rows = Math.ceil(n / maxPerRow)
|
||||
const hasCustom = Object.keys(props.positions).length > 0
|
||||
if (hasCustom) {
|
||||
return `✦ Eigenes Layout · ${props.agents.length} Agents gesamt`
|
||||
}
|
||||
return `Layout: ${rows} ${rows === 1 ? 'Reihe' : 'Reihen'} × ${maxPerRow} · ${props.agents.length} Agents gesamt`
|
||||
})
|
||||
|
||||
/* ── Edge Rendering ────────────────────────────── */
|
||||
function isActive(status: string) {
|
||||
return status === 'work' || status === 'think'
|
||||
}
|
||||
|
||||
function renderEdges() {
|
||||
const flow = flowRef.value
|
||||
if (!flow) return
|
||||
|
||||
const fr = flow.getBoundingClientRect()
|
||||
const svg = svgRef.value
|
||||
if (!svg) return
|
||||
|
||||
svg.setAttribute('width', String(fr.width))
|
||||
svg.setAttribute('height', String(fr.height))
|
||||
svg.setAttribute('viewBox', `0 0 ${fr.width} ${fr.height}`)
|
||||
|
||||
// Node centers in pixel coordinates
|
||||
function center(id: string): { x: number; y: number } | null {
|
||||
const el = flow.querySelector(`.node[data-id="${id}"]`) as HTMLElement | null
|
||||
if (!el) return null
|
||||
const nr = el.getBoundingClientRect()
|
||||
return {
|
||||
x: nr.left - fr.left + nr.width / 2,
|
||||
y: nr.top - fr.top + nr.height / 2,
|
||||
}
|
||||
}
|
||||
|
||||
const edgeList = buildEdges(props.agents)
|
||||
|
||||
let defs = `<defs><linearGradient id="eg2" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#4f7cff"/><stop offset="1" stop-color="#b557f6"/></linearGradient></defs>`
|
||||
let paths = ''
|
||||
let pulses = ''
|
||||
let idCounter = 0
|
||||
|
||||
edgeList.forEach((e) => {
|
||||
const c1 = center(e.a)
|
||||
const c2 = center(e.b)
|
||||
if (!c1 || !c2) return
|
||||
|
||||
const d = curve(c1, c2)
|
||||
const a1Status = props.agents.find(a => a.id === e.a)?.status || 'idle'
|
||||
const a2Status = props.agents.find(a => a.id === e.b)?.status || 'idle'
|
||||
const live = isActive(a1Status) && isActive(a2Status)
|
||||
const pathId = `ep${idCounter++}`
|
||||
|
||||
if (e.kind === 'flow' && live) {
|
||||
// Active flow: gradient stroke + animate pulse
|
||||
paths += `<path id="${pathId}" d="${d}" fill="none" stroke="url(#eg2)" stroke-width="2.2" opacity="0.85"/>`
|
||||
paths += `<path d="${d}" fill="none" stroke="#3ddc97" stroke-width="2.2" stroke-dasharray="5 20" opacity="0.8" style="animation:dashmove 1.1s linear infinite"/>`
|
||||
pulses += `<circle r="3.4" fill="#eafff6"><animateMotion dur="2s" repeatCount="indefinite" rotate="auto"><mpath href="#${pathId}"/></animateMotion></circle>`
|
||||
} else if (e.kind === 'flow') {
|
||||
// Inactive flow
|
||||
paths += `<path id="${pathId}" d="${d}" fill="none" stroke="url(#eg2)" stroke-width="1.8" opacity="0.45"/>`
|
||||
pulses += `<circle r="2.8" fill="#c9b8ff" opacity="0.7"><animateMotion dur="3s" repeatCount="indefinite"><mpath href="#${pathId}"/></animateMotion></circle>`
|
||||
} else {
|
||||
// Orchestration (Iris → Agent)
|
||||
const targetAgent = props.agents.find(a => a.id === e.b)
|
||||
const op = targetAgent && isActive(targetAgent.status) ? 0.45 : 0.18
|
||||
paths += `<path d="${d}" fill="none" stroke="#7c6cff" stroke-width="1.2" stroke-dasharray="2 6" opacity="${op}"/>`
|
||||
}
|
||||
})
|
||||
|
||||
edgesDefs.value = defs
|
||||
edgesPaths.value = paths
|
||||
edgesPulses.value = pulses
|
||||
}
|
||||
|
||||
/* ── Resize Observer ──────────────────────────── */
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
function setupObserver() {
|
||||
if (!flowRef.value) return
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
// Debounce via requestAnimationFrame
|
||||
if (!debounceRaf) debounceRaf = requestAnimationFrame(() => {
|
||||
debounceRaf = null
|
||||
renderEdges()
|
||||
})
|
||||
})
|
||||
resizeObserver.observe(flowRef.value)
|
||||
}
|
||||
|
||||
let debounceRaf: number | null = null
|
||||
|
||||
function teardownObserver() {
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect()
|
||||
resizeObserver = null
|
||||
}
|
||||
if (debounceRaf) {
|
||||
cancelAnimationFrame(debounceRaf)
|
||||
debounceRaf = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setupObserver()
|
||||
// Initial render after DOM settles
|
||||
requestAnimationFrame(() => renderEdges())
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
teardownObserver()
|
||||
})
|
||||
|
||||
// Re-render edges when agents or positions change
|
||||
watch(
|
||||
() => [props.agents.length, props.positions],
|
||||
() => {
|
||||
// Wait for DOM update (AgentNode transitions)
|
||||
setTimeout(() => renderEdges(), 200)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
/* ── Drag & Drop ──────────────────────────────── */
|
||||
const DRAG_THRESHOLD = 5
|
||||
|
||||
interface DragState {
|
||||
id: string
|
||||
startX: number
|
||||
startY: number
|
||||
ox: number
|
||||
oy: number
|
||||
moved: boolean
|
||||
raf: number | null
|
||||
}
|
||||
|
||||
let drag: DragState | null = null
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
const node = (e.target as HTMLElement).closest('.node') as HTMLElement | null
|
||||
if (!node) return
|
||||
|
||||
e.preventDefault()
|
||||
const nr = node.getBoundingClientRect()
|
||||
|
||||
drag = {
|
||||
id: node.dataset.id || '',
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
ox: e.clientX - (nr.left + nr.width / 2),
|
||||
oy: e.clientY - (nr.top + nr.height / 2),
|
||||
moved: false,
|
||||
raf: null,
|
||||
}
|
||||
|
||||
node.setPointerCapture(e.pointerId)
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!drag) return
|
||||
|
||||
const dist = Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY)
|
||||
if (!drag.moved && dist < DRAG_THRESHOLD) return
|
||||
|
||||
if (!drag.moved) {
|
||||
drag.moved = true
|
||||
const node = flowRef.value?.querySelector(`.node[data-id="${drag.id}"]`) as HTMLElement | null
|
||||
if (node) node.classList.add('dragging')
|
||||
}
|
||||
|
||||
const flow = flowRef.value
|
||||
if (!flow) return
|
||||
|
||||
const fr = flow.getBoundingClientRect()
|
||||
const x = Math.max(8, Math.min(92, ((e.clientX - drag.ox - fr.left) / fr.width) * 100))
|
||||
const y = Math.max(10, Math.min(92, ((e.clientY - drag.oy - fr.top) / fr.height) * 100))
|
||||
|
||||
// Direct DOM manipulation for responsiveness
|
||||
const node = flow.querySelector(`.node[data-id="${drag.id}"]`) as HTMLElement | null
|
||||
if (node) {
|
||||
node.style.left = x + '%'
|
||||
node.style.top = y + '%'
|
||||
}
|
||||
|
||||
// Update positions state
|
||||
const newPos = { ...props.positions }
|
||||
newPos[drag.id] = { x, y }
|
||||
emit('updatePositions', newPos)
|
||||
|
||||
// Debounced edge re-render
|
||||
if (!drag.raf) {
|
||||
drag.raf = requestAnimationFrame(() => {
|
||||
renderEdges()
|
||||
if (drag) drag.raf = null
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
if (!drag) return
|
||||
|
||||
const node = flowRef.value?.querySelector(`.node[data-id="${drag.id}"]`) as HTMLElement | null
|
||||
if (node) node.classList.remove('dragging')
|
||||
|
||||
if (!drag.moved) {
|
||||
// Was a click — emit select
|
||||
emit('select', drag.id)
|
||||
}
|
||||
|
||||
drag = null
|
||||
}
|
||||
|
||||
/* ── Keyboard handler for Enter key on buttons ── */
|
||||
function handleReset() {
|
||||
emit('resetLayout')
|
||||
nextTick(() => renderEdges())
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="flowRef"
|
||||
class="flow"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
@pointercancel="onPointerUp"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flow-h">
|
||||
<span class="header-icon" v-html="icons.flow || ''"></span>
|
||||
<h3>Live-Orchestrierung</h3>
|
||||
<span class="flow-count">{{ agentCount }} Agents</span>
|
||||
|
||||
<button
|
||||
class="reset-btn"
|
||||
title="Auto-Layout wiederherstellen"
|
||||
@click="handleReset"
|
||||
>
|
||||
<span class="btn-icon" v-html="icons.flow || ''"></span>
|
||||
Reset
|
||||
</button>
|
||||
|
||||
<button class="add-btn" @click="$emit('add')">
|
||||
<span class="btn-icon" v-html="icons.plus || ''"></span>
|
||||
Agent hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- SVG Layer -->
|
||||
<svg
|
||||
ref="svgRef"
|
||||
class="edges"
|
||||
v-html="edgesDefs + edgesPaths + edgesPulses"
|
||||
></svg>
|
||||
|
||||
<!-- Agent Nodes -->
|
||||
<div class="nodes-layer" v-html="''"></div>
|
||||
<AgentNode
|
||||
v-for="agent in agents"
|
||||
:key="agent.id"
|
||||
:agent="agent"
|
||||
:left="(positions[agent.id] || autoPositions[agent.id] || { x: 50, y: 50 }).x"
|
||||
:top="(positions[agent.id] || autoPositions[agent.id] || { x: 50, y: 50 }).y"
|
||||
:entering="enteringIds.includes(agent.id)"
|
||||
:data-id="agent.id"
|
||||
@select="(id: string) => $emit('select', id)"
|
||||
/>
|
||||
|
||||
<!-- Layout Label -->
|
||||
<div class="layout-label">{{ layoutLabel }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.flow {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
border-radius: var(--r);
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--line);
|
||||
background:
|
||||
radial-gradient(120% 90% at 50% 0%, rgba(124, 108, 255, 0.10), transparent 60%);
|
||||
}
|
||||
|
||||
.flow-h {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 13px 16px;
|
||||
}
|
||||
|
||||
.flow-h h3 {
|
||||
margin: 0;
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 14.5px;
|
||||
color: var(--tx);
|
||||
}
|
||||
|
||||
.header-icon :deep(svg) {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--a-mid);
|
||||
}
|
||||
|
||||
.flow-count {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
background: rgba(124, 108, 255, 0.14);
|
||||
color: var(--tx-2);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
height: 30px;
|
||||
padding: 0 11px;
|
||||
border-radius: 9px;
|
||||
background: rgba(124, 108, 255, 0.1);
|
||||
border: 1px solid var(--line-2);
|
||||
color: var(--tx-2);
|
||||
font-family: 'Manrope', sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.reset-btn:hover {
|
||||
background: rgba(124, 108, 255, 0.18);
|
||||
color: var(--tx);
|
||||
}
|
||||
|
||||
.reset-btn .btn-icon :deep(svg) {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 34px;
|
||||
padding: 0 14px;
|
||||
border-radius: 10px;
|
||||
background: var(--grad);
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-family: 'Manrope', sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--glow-purple);
|
||||
transition: filter 0.15s;
|
||||
}
|
||||
|
||||
.add-btn:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.add-btn .btn-icon :deep(svg) {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
/* ── SVG Layer ────────────────────────────────── */
|
||||
.edges {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Layout Label ─────────────────────────────── */
|
||||
.layout-label {
|
||||
position: absolute;
|
||||
bottom: 14px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 5;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10.5px;
|
||||
color: var(--tx-3);
|
||||
background: rgba(10, 8, 24, 0.7);
|
||||
padding: 5px 14px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--line);
|
||||
backdrop-filter: blur(8px);
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── Drag state ───────────────────────────────── */
|
||||
:deep(.node.dragging) {
|
||||
cursor: grabbing;
|
||||
transition: none !important;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
:deep(.node.dragging .ncard) {
|
||||
box-shadow: 0 0 0 2px var(--a-mid), 0 0 36px -2px rgba(124, 108, 255, 0.9) !important;
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
/* Dash animation */
|
||||
@keyframes dashmove {
|
||||
to {
|
||||
stroke-dashoffset: -28;
|
||||
}
|
||||
}
|
||||
|
||||
/* Node cursor */
|
||||
:deep(.node) {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
:deep(.node.dragging) {
|
||||
cursor: grabbing;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* useFlowLayout — Auto-Layout und Edge-Formeln für das V2 FlowCanvas
|
||||
*
|
||||
* Portiert von agents.js (design_handoff_nexus_v2).
|
||||
* Enthält alle Positionslogik, Edge-Erzeugung und den Typ AgentNodeData.
|
||||
*/
|
||||
|
||||
export interface Point {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface AgentNodeData {
|
||||
id: string
|
||||
name: string
|
||||
role: string
|
||||
roleBadge?: string
|
||||
model: string
|
||||
avatar: string
|
||||
status: 'work' | 'think' | 'idle' | 'block'
|
||||
statusLabel: string
|
||||
task: string | null
|
||||
goal: string | null
|
||||
progress: number
|
||||
elapsed: string
|
||||
next: string
|
||||
tokens: string
|
||||
cost: string
|
||||
think: string | null
|
||||
handoff?: string
|
||||
from?: string
|
||||
links?: string[]
|
||||
md?: string
|
||||
}
|
||||
|
||||
export interface EdgeData {
|
||||
a: string
|
||||
b: string
|
||||
kind: 'orch' | 'flow'
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-Layout Algorithmus
|
||||
* Iris immer top-center (x:50%, y:14%).
|
||||
* Andere Agenten in Reihen (maxPerRow variiert nach Gesamtzahl).
|
||||
*/
|
||||
export function autoLayout(agents: AgentNodeData[]): Record<string, Point> {
|
||||
const positions: Record<string, Point> = { iris: { x: 50, y: 14 } }
|
||||
const others = agents.filter(a => a.id !== 'iris')
|
||||
const n = others.length
|
||||
if (n === 0) return positions
|
||||
|
||||
const maxPerRow = n <= 2 ? 2 : n <= 6 ? 3 : n <= 9 ? 3 : 4
|
||||
const numRows = Math.ceil(n / maxPerRow)
|
||||
const yStart = n <= 3 ? 58 : 30
|
||||
const yEnd = 86
|
||||
const yVals = numRows === 1
|
||||
? [yStart]
|
||||
: Array.from({ length: numRows }, (_, i) => yStart + (yEnd - yStart) * i / (numRows - 1))
|
||||
|
||||
let idx = 0
|
||||
yVals.forEach(y => {
|
||||
const rowN = Math.min(maxPerRow, n - idx)
|
||||
const xSpan = Math.min(72, 24 * (rowN - 1) + 18)
|
||||
const xOff = 50 - xSpan / 2
|
||||
const xStep = rowN > 1 ? xSpan / (rowN - 1) : 0
|
||||
for (let ci = 0; ci < rowN; ci++, idx++) {
|
||||
positions[others[idx].id] = { x: xOff + ci * xStep, y }
|
||||
}
|
||||
})
|
||||
|
||||
return positions
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt die Kantenliste basierend auf Agenten-Daten
|
||||
* (gleiche Logik wie buildEdges in agents.js).
|
||||
*/
|
||||
export function buildEdges(agents: AgentNodeData[]): EdgeData[] {
|
||||
const edges: EdgeData[] = []
|
||||
|
||||
// Orchestrierung: Iris → jeder Agent
|
||||
agents.filter(a => a.id !== 'iris').forEach(a => {
|
||||
edges.push({ a: 'iris', b: a.id, kind: 'orch' })
|
||||
})
|
||||
|
||||
// Spezifische Flows (wenn Agent existiert)
|
||||
const hasId = (id: string) => agents.some(a => a.id === id)
|
||||
if (hasId('dev') && hasId('rev')) edges.push({ a: 'dev', b: 'rev', kind: 'flow' })
|
||||
if (hasId('arch') && hasId('exec')) edges.push({ a: 'arch', b: 'exec', kind: 'flow' })
|
||||
if (hasId('res')) edges.push({ a: 'res', b: 'iris', kind: 'flow' })
|
||||
if (hasId('qa') && hasId('rev')) edges.push({ a: 'qa', b: 'rev', kind: 'flow' })
|
||||
if (hasId('security')) edges.push({ a: 'security', b: 'iris', kind: 'flow' })
|
||||
if (hasId('pm')) edges.push({ a: 'pm', b: 'iris', kind: 'flow' })
|
||||
if (hasId('devops') && hasId('exec')) edges.push({ a: 'exec', b: 'devops', kind: 'flow' })
|
||||
|
||||
return edges
|
||||
}
|
||||
|
||||
/**
|
||||
* Bézier-Kurve zwischen zwei Punkten
|
||||
* Verwendet die README.p1 und p2)) Formel:
|
||||
* M p1 Q Kontrollpunkt p2
|
||||
* mx,my = Mittelpunkt; off = min(50, hypot*0.14)
|
||||
* len = hypot(-dy, dx) (Normale)
|
||||
* cp = (mx + (-dy/len)*off, my + (dx/len)*off)
|
||||
*/
|
||||
export function curve(p1: Point, p2: Point): string {
|
||||
const mx = (p1.x + p2.x) / 2
|
||||
const my = (p1.y + p2.y) / 2
|
||||
const dx = p2.x - p1.x
|
||||
const dy = p2.y - p1.y
|
||||
const off = Math.min(50, Math.hypot(dx, dy) * 0.14)
|
||||
const len = Math.hypot(-dy, dx) || 1
|
||||
const cx = mx + (-dy / len) * off
|
||||
const cy = my + (dx / len) * off
|
||||
return `M${p1.x},${p1.y} Q${cx},${cy} ${p2.x},${p2.y}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock-Daten (identisch zu NEXUS.agents aus agents.js)
|
||||
*/
|
||||
export const mockAgents: AgentNodeData[] = [
|
||||
{
|
||||
id: 'iris',
|
||||
name: 'Iris',
|
||||
role: 'Chief of Staff',
|
||||
roleBadge: 'badge-purple',
|
||||
model: 'Deepseek V4 Pro',
|
||||
avatar: 'IR',
|
||||
status: 'think',
|
||||
statusLabel: 'Orchestriert',
|
||||
task: 'Sprint-Planung koordinieren',
|
||||
goal: 'Tagesziele auf das Team verteilen',
|
||||
progress: 60,
|
||||
elapsed: '00:14:22',
|
||||
next: 'Standup-Report 17:00',
|
||||
tokens: '48.2k',
|
||||
cost: '1.84',
|
||||
md: 'mission/sprint-12.md',
|
||||
think: 'Priorisiere Deploy-Blocker vor Feature-Tasks; weise Healthcheck-Fehler dem Executor zu…',
|
||||
links: ['dev', 'rev', 'arch', 'exec', 'res'],
|
||||
},
|
||||
{
|
||||
id: 'dev',
|
||||
name: 'Full-Stack Developer',
|
||||
role: 'Implementation',
|
||||
roleBadge: 'badge-blue',
|
||||
model: 'Deepseek V4 Flash',
|
||||
avatar: '</>',
|
||||
status: 'work',
|
||||
statusLabel: 'Arbeitet',
|
||||
task: 'Auth-Refactor — JWT-Rotation',
|
||||
goal: 'Sichere Token-Rotation in api/auth',
|
||||
progress: 72,
|
||||
elapsed: '00:41:09',
|
||||
next: 'Unit-Tests schreiben',
|
||||
tokens: '121k',
|
||||
cost: '2.40',
|
||||
md: 'projects/auth/refactor.md',
|
||||
think: 'Erzeuge Refresh-Token-Middleware, prüfe Edge-Case bei abgelaufenem Token…',
|
||||
handoff: 'rev',
|
||||
},
|
||||
{
|
||||
id: 'rev',
|
||||
name: 'Reviewer',
|
||||
role: 'Code Quality',
|
||||
roleBadge: 'badge-cyan',
|
||||
model: 'Deepseek V4 Pro',
|
||||
avatar: 'RV',
|
||||
status: 'work',
|
||||
statusLabel: 'Arbeitet',
|
||||
task: 'Review PR #142 — Auth-Refactor',
|
||||
goal: 'Diff auf Regressionen prüfen',
|
||||
progress: 35,
|
||||
elapsed: '00:08:51',
|
||||
next: 'Feedback an Developer',
|
||||
tokens: '64k',
|
||||
cost: '1.10',
|
||||
md: 'reviews/pr-142.md',
|
||||
think: 'Analysiere Änderungen in auth/middleware.ts — fehlende Rate-Limit-Prüfung gefunden…',
|
||||
from: 'dev',
|
||||
},
|
||||
{
|
||||
id: 'arch',
|
||||
name: 'Architekt',
|
||||
role: 'Infrastructure',
|
||||
roleBadge: 'badge-amber',
|
||||
model: 'Deepseek V4 Pro',
|
||||
avatar: 'AR',
|
||||
status: 'think',
|
||||
statusLabel: 'Plant',
|
||||
task: 'VPS-Skalierung planen',
|
||||
goal: 'Lastspitzen abfedern, Kosten senken',
|
||||
progress: 20,
|
||||
elapsed: '00:03:30',
|
||||
next: 'Terraform-Plan an Executor',
|
||||
tokens: '31k',
|
||||
cost: '0.74',
|
||||
md: 'infra/scaling-q3.md',
|
||||
think: 'Vergleiche vertikale vs. horizontale Skalierung…',
|
||||
handoff: 'exec',
|
||||
},
|
||||
{
|
||||
id: 'exec',
|
||||
name: 'Executor',
|
||||
role: 'Host Executor',
|
||||
roleBadge: 'badge-green',
|
||||
model: 'Deepseek V4 Flash',
|
||||
avatar: 'EX',
|
||||
status: 'work',
|
||||
statusLabel: 'Deployt',
|
||||
task: 'Deploy nexus-api v2.3',
|
||||
goal: 'Zero-Downtime Rollout auf VPS',
|
||||
progress: 88,
|
||||
elapsed: '00:02:12',
|
||||
next: 'Healthcheck verifizieren',
|
||||
tokens: '18k',
|
||||
cost: '0.32',
|
||||
md: 'ops/deploy-v2.3.md',
|
||||
think: '$ docker compose up -d --no-deps api…',
|
||||
from: 'arch',
|
||||
},
|
||||
{
|
||||
id: 'res',
|
||||
name: 'Researcher',
|
||||
role: 'Research & Analysis',
|
||||
roleBadge: 'badge-slate',
|
||||
model: 'Deepseek V4 Flash',
|
||||
avatar: 'RS',
|
||||
status: 'idle',
|
||||
statusLabel: 'Bereit',
|
||||
task: null,
|
||||
goal: null,
|
||||
progress: 0,
|
||||
elapsed: '—',
|
||||
next: 'LLM-Cost-Benchmark (Queue)',
|
||||
tokens: '0',
|
||||
cost: '0.00',
|
||||
md: 'research/vector-db.md',
|
||||
think: null,
|
||||
handoff: 'iris',
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Padding-ähnliche Extra-Agenten zum Hinzufügen
|
||||
*/
|
||||
export const extraAgentPool: AgentNodeData[] = [
|
||||
{
|
||||
id: 'qa',
|
||||
name: 'QA Automator',
|
||||
role: 'Test Automation',
|
||||
roleBadge: 'badge-cyan',
|
||||
avatar: 'QA',
|
||||
status: 'idle',
|
||||
statusLabel: 'Bereit',
|
||||
task: 'End-to-End Tests schreiben',
|
||||
goal: '100% Coverage für auth/',
|
||||
progress: 0,
|
||||
elapsed: '—',
|
||||
next: 'Testplan erstellen',
|
||||
model: 'Deepseek V4 Flash',
|
||||
tokens: '0',
|
||||
cost: '0.00',
|
||||
think: null,
|
||||
},
|
||||
{
|
||||
id: 'devops',
|
||||
name: 'DevOps',
|
||||
role: 'CI/CD Pipeline',
|
||||
roleBadge: 'badge-amber',
|
||||
avatar: 'DO',
|
||||
status: 'idle',
|
||||
statusLabel: 'Bereit',
|
||||
task: 'GitHub Actions Workflow',
|
||||
goal: 'Automatisches Deploy auf merge',
|
||||
progress: 0,
|
||||
elapsed: '—',
|
||||
next: 'Pipeline konfigurieren',
|
||||
model: 'Deepseek V4 Pro',
|
||||
tokens: '0',
|
||||
cost: '0.00',
|
||||
think: null,
|
||||
},
|
||||
{
|
||||
id: 'security',
|
||||
name: 'Security Scanner',
|
||||
role: 'Security Analysis',
|
||||
roleBadge: 'badge-rose',
|
||||
avatar: 'SC',
|
||||
status: 'think',
|
||||
statusLabel: 'Scannt',
|
||||
task: 'Dependency-Audit durchführen',
|
||||
goal: 'CVEs in api/ aufdecken',
|
||||
progress: 18,
|
||||
elapsed: '00:01:44',
|
||||
next: 'Report an Iris',
|
||||
model: 'Deepseek V4 Pro',
|
||||
tokens: '9k',
|
||||
cost: '0.18',
|
||||
think: 'Analysiere package-lock.json auf bekannte Vulnerabilities…',
|
||||
},
|
||||
{
|
||||
id: 'pm',
|
||||
name: 'Project Manager',
|
||||
role: 'Coordination',
|
||||
roleBadge: 'badge-purple',
|
||||
avatar: 'PM',
|
||||
status: 'think',
|
||||
statusLabel: 'Plant',
|
||||
task: 'Sprint-Retrospektive vorbereiten',
|
||||
goal: 'Blockers identifizieren',
|
||||
progress: 35,
|
||||
elapsed: '00:05:10',
|
||||
next: 'Meeting-Summary an Team',
|
||||
model: 'Deepseek V4 Flash',
|
||||
tokens: '14k',
|
||||
cost: '0.24',
|
||||
think: 'Analysiere Velocity-Daten der letzten 3 Sprints…',
|
||||
},
|
||||
]
|
||||
Reference in New Issue
Block a user