feat(v2): Pinia stores (agents/tasks/chat) + live backend integration, remove mock data
CI - Build & Test / Backend (.NET) (push) Failing after 22s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 3s

This commit is contained in:
2026-06-12 00:57:28 +02:00
parent 9330de7af0
commit 676dbd7589
4 changed files with 648 additions and 227 deletions
+275
View File
@@ -0,0 +1,275 @@
/**
* Agent Store V2 Dashboard
*
* Fetches agents from /api/dashboard/agents and available models
* from /api/dashboard/models. Enriches raw API data with catalog
* metadata (color, icon, description, hero) and maps into
* AgentNodeData (for FlowCanvas) and AgentDetail (for Modal).
*
* Auto-refresh: every 30 seconds.
*/
import { defineStore } from 'pinia'
import { apiFetch } from '../services/api'
import type { AgentNodeData } from '../composables/useFlowLayout'
import type { AgentDetail, ThinkingItem } from '../components/dashboard/v2/types'
/* ── API Response Shapes ──────────────────────────── */
interface DashboardAgentInfo {
id: string
name: string
role: string
model: string
isActive: boolean
currentTask: string | null
description?: string
tags?: string[]
progress?: number
workload?: number
goal?: string | null
}
interface ModelOption {
id: string
name: string
provider: string
}
/* ── Agent Catalog (static enrichment) ────────────── */
// Type-safe catalog for static AgentNodeData fields not provided by API
interface AgentCatalogEntry {
elapsed: string;
think: string | null;
next: string;
}
const AGENT_CATALOG: Record<string, AgentCatalogEntry> = {
iris: { elapsed: '--', think: null, next: 'Standby' },
programmer: { elapsed: '--', think: null, next: 'Standby' },
developer: { elapsed: '--', think: null, next: 'Standby' },
architekt: { elapsed: '--', think: null, next: 'Standby' },
reviewer: { elapsed: '--', think: null, next: 'Standby' },
executor: { elapsed: '--', think: null, next: 'Standby' },
researcher: { elapsed: '--', think: null, next: 'Standby' },
}
/* ── Status Mapping ───────────────────────────────── */
function mapStatus(isActive: boolean, currentTask: string | null): AgentNodeData['status'] {
if (!isActive) return 'idle'
if (currentTask && currentTask !== 'Idle') return 'work'
return 'think'
}
const STATUS_LABELS: Record<AgentNodeData['status'], string> = {
work: 'Arbeitet',
think: 'Plant',
idle: 'Bereit',
block: 'Blockiert',
}
function avatarFor(id: string, name: string): string {
if (id === 'iris') return 'IR'
if (id === 'programmer' || id === 'developer') return '</>'
return name.slice(0, 2).toUpperCase()
}
/* ── Enrich API Agent → AgentNodeData ─────────────── */
function enrichAgent(api: DashboardAgentInfo): AgentNodeData {
const cat = AGENT_CATALOG[api.id] ?? AGENT_CATALOG['reviewer']!
const status = mapStatus(api.isActive, api.currentTask)
return {
id: api.id,
name: api.name,
role: api.role,
model: api.model,
avatar: avatarFor(api.id, api.name),
status,
statusLabel: STATUS_LABELS[status],
task: api.currentTask,
goal: api.goal ?? null,
progress: api.progress ?? 0,
elapsed: cat.elapsed ?? '--',
next: cat.next ?? 'Standby',
tokens: '0',
cost: '0.00',
think: cat.think ?? null,
}
}
/* ── Build AgentDetail from AgentNodeData ─────────── */
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' })
}
const sentences = data.think.split(/[.…!?]+/).filter(s => s.trim().length > 5)
const items: ThinkingItem[] = []
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
}
export function buildAgentDetail(data: AgentNodeData, models: { id: string; alias: string }[]): AgentDetail {
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
// Map model ID to display name for the modal dropdown (which uses alias for comparison)
const matchingModel = models.find(m => m.id === data.model || m.alias === data.model)
const displayModel = matchingModel?.alias ?? data.model
return {
id: data.id,
name: data.name,
role: data.role,
model: displayModel,
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: models,
}
}
export const useAgentStore = defineStore('agents', {
state: () => ({
agents: [] as AgentNodeData[],
models: [] as { id: string; alias: string }[],
loading: false,
error: null as string | null,
selectedAgentId: null as string | null,
refreshInterval: null as ReturnType<typeof setInterval> | null,
}),
getters: {
/** AgentNodeData list for FlowCanvas */
agentList: (state) => state.agents,
/** Agent IDs in display order (Iris first) */
agentOrder: (state) => {
const ordered = state.agents.filter(a => a.id === 'iris')
state.agents.forEach(a => { if (a.id !== 'iris') ordered.push(a) })
return ordered.map(a => a.id)
},
/** Selected agent detail for modal */
selectedAgent(state): AgentDetail | null {
if (!state.selectedAgentId) return null
const data = state.agents.find(a => a.id === state.selectedAgentId)
if (!data) return null
return buildAgentDetail(data, state.models)
},
/** Is the modal open? */
modalOpen: (state) => state.selectedAgentId !== null,
/* ── AlertBar Metrics ────────────────────────── */
activeCount: (state) => state.agents.filter(a => a.status === 'work').length,
thinkCount: (state) => state.agents.filter(a => a.status === 'think').length,
idleCount: (state) => state.agents.filter(a => a.status === 'idle').length,
blockerCount: (state) => state.agents.filter(a => a.status === 'block').length,
todayCost: (state) => {
const total = state.agents.reduce((s, a) => s + parseFloat(a.cost || '0'), 0)
return '$' + total.toFixed(2)
},
todayTokens: (state) => {
const total = state.agents.reduce((s, a) => {
const raw = a.tokens?.replace(/[^0-9.]/g, '') || '0'
const v = parseFloat(raw)
return Number.isFinite(v) ? s + v : s
}, 0)
return total >= 1000 ? Math.round(total / 1000) + 'k' : Math.round(total) + ''
},
},
actions: {
/* ── API: Fetch agents ──────────────────────── */
async fetchAgents() {
try {
const res = await apiFetch('/api/dashboard/agents')
if (!res.ok) return
const data: DashboardAgentInfo[] = await res.json()
this.agents = data.map(enrichAgent)
} catch (err) {
console.warn('[AgentStore] fetchAgents failed', err)
}
},
/* ── API: Fetch available models ────────────── */
async fetchModels() {
try {
const res = await apiFetch('/api/dashboard/models')
if (!res.ok) return
const data: ModelOption[] = await res.json()
this.models = data.map(m => ({ id: m.id, alias: m.name }))
} catch (err) {
console.warn('[AgentStore] fetchModels failed', err)
}
},
/* ── API: Change agent model ────────────────── */
async changeModel(agentId: string, modelId: string) {
// Optimistic update
const agent = this.agents.find(a => a.id === agentId)
if (agent) agent.model = modelId
try {
await apiFetch(`/api/dashboard/agents/${encodeURIComponent(agentId)}/model`, {
method: 'PUT',
body: JSON.stringify({ model: modelId }),
})
} catch (err) {
console.warn('[AgentStore] changeModel failed', err)
// Refetch to revert on failure
await this.fetchAgents()
}
},
/* ── Selection ───────────────────────────────── */
selectAgent(id: string | null) {
this.selectedAgentId = id
},
/* ── Polling ─────────────────────────────────── */
startPolling() {
if (this.refreshInterval) return
this.fetchAgents()
this.fetchModels()
this.refreshInterval = setInterval(() => {
this.fetchAgents()
this.fetchModels()
}, 30000)
},
stopPolling() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval)
this.refreshInterval = null
}
},
},
})
+160
View File
@@ -0,0 +1,160 @@
/**
* Chat Store V2 Dashboard
*
* Fetches chat messages from /api/dashboard/chat/messages and
* sends new messages via /api/dashboard/chat/send.
*
* Auto-refresh: every 10 seconds (incoming Iris messages).
*/
import { defineStore } from 'pinia'
import { apiFetch } from '../services/api'
import type { ChatMessage } from '../components/dashboard/v2/types'
/* ── API Response Shapes ──────────────────────────── */
interface MessageEntry {
role: string
content: string
timestamp: string
}
interface ChatResponse {
ok: boolean
reply: string | null
error: string | null
}
export const useChatStore = defineStore('chat', {
state: () => ({
messages: [] as ChatMessage[],
isThinking: false,
error: null as string | null,
refreshInterval: null as ReturnType<typeof setInterval> | null,
/** Tracks last process timestamp to avoid duplicates */
lastProcessedTs: 0,
}),
getters: {
messageList: (state) => state.messages,
},
actions: {
/* ── API: Fetch history ─────────────────────── */
async fetchHistory() {
try {
const res = await apiFetch('/api/dashboard/chat/messages?limit=50')
if (!res.ok) return
const data: MessageEntry[] = await res.json()
// Merge new messages (avoid duplicates)
let mostRecentTs = this.lastProcessedTs
for (const msg of data) {
const msgTs = new Date(msg.timestamp).getTime()
if (msgTs <= this.lastProcessedTs) continue
if (msgTs > mostRecentTs) mostRecentTs = msgTs
const sender = msg.role === 'assistant' ? 'iris' as const : 'user' as const
const tsFormatted = new Date(msg.timestamp).toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
})
// Avoid appending duplicates already present
const exists = this.messages.some(
m => m.sender === sender && m.text === msg.content && m.ts === tsFormatted
)
if (exists) continue
this.messages.push({
sender,
text: msg.content,
ts: tsFormatted,
})
}
if (mostRecentTs > this.lastProcessedTs) {
this.lastProcessedTs = mostRecentTs
}
} catch (err) {
console.warn('[ChatStore] fetchHistory failed', err)
}
},
/* ── API: Send message ──────────────────────── */
async sendMessage(text: string) {
if (!text.trim()) return
const tsFormatted = new Date().toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
})
// Optimistic add: user message
this.messages.push({
sender: 'user',
text: text.trim(),
ts: tsFormatted,
})
this.isThinking = true
this.error = null
try {
const res = await apiFetch('/api/dashboard/chat/send', {
method: 'POST',
body: JSON.stringify({ message: text.trim() }),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data: ChatResponse = await res.json()
if (data.ok && data.reply) {
this.messages.push({
sender: 'iris',
text: data.reply,
ts: new Date().toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
}),
})
} else if (data.error) {
this.messages.push({
sender: 'iris',
text: `⚠️ ${data.error}`,
ts: new Date().toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
}),
})
}
} catch (err) {
console.warn('[ChatStore] sendMessage failed', err)
this.messages.push({
sender: 'iris',
text: '⚠️ Connection error. Please try again.',
ts: new Date().toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
}),
})
} finally {
this.isThinking = false
}
},
/* ── Polling ─────────────────────────────────── */
startPolling() {
if (this.refreshInterval) return
this.fetchHistory()
this.refreshInterval = setInterval(() => {
this.fetchHistory()
}, 10000) // 10s for chat (more responsive)
},
stopPolling() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval)
this.refreshInterval = null
}
},
},
})
+143
View File
@@ -0,0 +1,143 @@
/**
* Task Store V2 Dashboard
*
* Fetches tasks from /api/dashboard/tasks and maps them into
* TaskItem[] format for the TaskStrip component.
*
* Auto-refresh: every 30 seconds.
*/
import { defineStore } from 'pinia'
import { apiFetch } from '../services/api'
import type { TaskItem } from '../components/dashboard/v2/types'
/* ── API Response Shapes ──────────────────────────── */
interface DashboardTaskDto {
id: string
title: string
detail: string | null
source: string
state: string
priority: string
assignedTo: string | null
createdAt: string
updatedAt: string
}
/* ── State Mapping ────────────────────────────────── */
function mapPriority(priority: string): TaskItem['priority'] {
const p = priority.toLowerCase()
if (p === 'high' || p === 'critical' || p === 'urgent') return 'high'
if (p === 'low' || p === 'minor') return 'low'
return 'medium'
}
function mapState(state: string): TaskItem['status'] {
const s = state.toLowerCase()
if (s === 'in progress' || s === 'active' || s === 'working') return 'active'
if (s === 'blocked' || s === 'block') return 'blocked'
return 'pending'
}
function mapProgress(state: string): number {
const s = state.toLowerCase()
if (s === 'in progress' || s === 'active' || s === 'working') return 50
if (s === 'done') return 100
if (s === 'blocked') return 30
return 0
}
function mapTask(t: DashboardTaskDto): TaskItem {
return {
id: t.id,
title: t.title,
agent: t.assignedTo ?? '—',
priority: mapPriority(t.priority),
status: mapState(t.state),
progress: mapProgress(t.state),
}
}
export const useTaskStore = defineStore('tasks', {
state: () => ({
tasks: [] as TaskItem[],
loading: false,
error: null as string | null,
refreshInterval: null as ReturnType<typeof setInterval> | null,
}),
getters: {
taskList: (state) => state.tasks,
},
actions: {
/* ── API: Fetch tasks ───────────────────────── */
async fetchTasks() {
this.loading = true
try {
const res = await apiFetch('/api/dashboard/tasks')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data: DashboardTaskDto[] = await res.json()
this.tasks = data.map(mapTask)
this.error = null
} catch (err) {
console.warn('[TaskStore] fetchTasks failed', err)
this.error = 'Tasks could not be loaded'
} finally {
this.loading = false
}
},
/* ── API: Add task ──────────────────────────── */
async addTask(title: string, detail?: string, priority?: string, assignedTo?: string) {
try {
const res = await apiFetch('/api/dashboard/tasks', {
method: 'POST',
body: JSON.stringify({
title,
detail: detail ?? null,
priority: priority ?? null,
assignedTo: assignedTo ?? null,
source: 'bao',
}),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
// Refresh task list
await this.fetchTasks()
} catch (err) {
console.warn('[TaskStore] addTask failed', err)
}
},
/* ── API: Update task ───────────────────────── */
async updateTask(id: string, updates: { title?: string; detail?: string; priority?: string; assignedTo?: string }) {
try {
const res = await apiFetch(`/api/dashboard/tasks/${id}`, {
method: 'PUT',
body: JSON.stringify(updates),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
await this.fetchTasks()
} catch (err) {
console.warn('[TaskStore] updateTask failed', err)
}
},
/* ── Polling ─────────────────────────────────── */
startPolling() {
if (this.refreshInterval) return
this.fetchTasks()
this.refreshInterval = setInterval(() => {
this.fetchTasks()
}, 30000)
},
stopPolling() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval)
this.refreshInterval = null
}
},
},
})