Files
nexus/frontend/src/views/AgentDetailView.vue
T

855 lines
23 KiB
Vue

<script setup lang="ts">
import { onMounted, onUnmounted, ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Bot, Loader2, AlertCircle, Activity, RefreshCw } from '@lucide/vue'
import { apiFetch } from '../services/api'
import type { AgentDetail } from '../types'
import ConfigTabs from '../components/config/ConfigTabs.vue'
import ConfigEditor from '../components/config/ConfigEditor.vue'
import { openDashboardLiveStream } from '../services/live'
const route = useRoute()
const router = useRouter()
const agent = ref<AgentDetail | null>(null)
const loading = ref(false)
const error = ref('')
const configFiles = ref<ConfigFileInfo[]>([])
const activeTab = ref(0)
const configsLoading = ref(false)
const configsError = ref('')
const activityItems = ref<AgentActivityItem[]>([])
const activityLoading = ref(false)
const activityError = ref('')
const summaryLoading = ref(false)
const summaryError = ref('')
const summary = ref<AgentSummary | null>(null)
const liveConnected = ref(false)
const liveUnavailable = ref(false)
let liveAbort: AbortController | null = null
let activityReloadTimer: ReturnType<typeof setTimeout> | null = null
let liveReconnectTimer: ReturnType<typeof setTimeout> | null = null
let liveStreamStopped = false
let lastLiveSequence = 0
const initLoading = ref(true)
interface EditorState {
content: string
savedContent: string
saving: boolean
dirty: boolean
saveStatus: 'idle' | 'saved' | 'error'
saveMessage: string
backupStatus: string
reloadStatus: string
reloadMessage: string
}
interface ConfigFileInfo {
fileName: string
size: number
modifiedAt: string
}
interface ConfigFileDetail extends ConfigFileInfo {
content: string
}
interface AgentActivityItem {
id: number | null
type: string
message: string
at: string
source: string
relativeTime?: string | null
}
interface AgentSummary {
now: AgentSummaryItem
today: AgentSummaryItem
generatedAt: string
}
interface AgentSummaryItem {
text: string
source: string
timestamp?: string | null
}
interface SaveConfigResult {
fileName: string
size: number
modifiedAt: string
validation: {
status: string
fileKind: string
errors: string[]
}
backup: {
status: string
backupCreated: boolean
}
reloadCheck: {
status: string
message: string
}
}
const editorState = ref<EditorState>({
content: '',
savedContent: '',
saving: false,
dirty: false,
saveStatus: 'idle',
saveMessage: '',
backupStatus: 'not_applicable',
reloadStatus: 'not_supported',
reloadMessage: '',
})
const agentId = route.params.id as string
const orderedTabs = ['IDENTITY.md', 'SOUL.md', 'AGENTS.md', 'TOOLS.md', 'HEARTBEAT.md', 'USER.md']
const currentFile = computed(() => {
if (!configFiles.value.length) return null
const activeFile = configFiles.value[activeTab.value]
return activeFile || configFiles.value[0]
})
const activeTabFileName = computed(() => {
return orderedTabs[activeTab.value] || null
})
const fallbackName = computed(() => {
return agentId.charAt(0).toUpperCase() + agentId.slice(1)
})
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
function formatModifiedAt(dateStr: string): string {
const d = new Date(dateStr)
return d.toLocaleDateString('de-DE', {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit',
})
}
const statusColor = (status: string): string => {
switch (status) {
case 'Online': return '#51d49a'
case 'Degraded': return '#e5b05e'
case 'Offline': return '#e16e75'
default: return '#7e8799'
}
}
function formatLastSeen(dateStr?: string): string {
if (!dateStr) return 'N/A'
const d = new Date(dateStr)
return d.toLocaleDateString('de-DE', {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit',
})
}
function formatActivityTime(item: AgentActivityItem): string {
if (item.relativeTime) return item.relativeTime
const d = new Date(item.at)
return d.toLocaleDateString('de-DE', {
month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit',
})
}
function activityTypeLabel(type: string): string {
if (type === 'thinking') return 'Thinking'
if (type === 'handoff') return 'Handoff'
if (type === 'task') return 'Task'
return 'Activity'
}
async function loadAgent() {
loading.value = true
error.value = ''
try {
const response = await apiFetch(`/api/v1/agents/${agentId}`)
if (!response.ok) throw new Error(`Agent "${agentId}" not found`)
agent.value = await response.json()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load agent data'
} finally {
loading.value = false
}
}
async function loadActivity() {
activityLoading.value = true
activityError.value = ''
try {
const response = await apiFetch(`/api/v1/agents/${agentId}/activity`)
if (!response.ok) throw new Error('Failed to load activity')
activityItems.value = await response.json()
} catch (e) {
activityError.value = e instanceof Error ? e.message : 'Failed to load activity'
} finally {
activityLoading.value = false
}
}
async function loadSummary() {
summaryLoading.value = true
summaryError.value = ''
try {
const response = await apiFetch(`/api/v1/agents/${agentId}/summary`)
if (!response.ok) throw new Error('Failed to load summary')
summary.value = await response.json()
} catch (e) {
summaryError.value = e instanceof Error ? e.message : 'Failed to load summary'
} finally {
summaryLoading.value = false
}
}
function scheduleActivityReload() {
if (activityReloadTimer) return
activityReloadTimer = setTimeout(async () => {
activityReloadTimer = null
await loadActivity()
await loadSummary()
}, 250)
}
function formatSummaryTimestamp(value?: string | null): string {
if (!value) return 'No timestamp'
const d = new Date(value)
return d.toLocaleDateString('de-DE', {
month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit',
})
}
function summarySourceLabel(source: string): string {
switch (source) {
case 'nexus-activity': return 'Nexus activity'
case 'gateway-session-history': return 'Gateway history'
case 'derived-mixed': return 'Derived from mixed feed'
case 'none': return 'No data'
default: return source
}
}
function scheduleStreamReconnect() {
if (liveStreamStopped || liveReconnectTimer) return
liveReconnectTimer = setTimeout(() => {
liveReconnectTimer = null
void connectActivityStream()
}, 1500)
}
async function connectActivityStream() {
liveAbort?.abort()
liveAbort = new AbortController()
try {
const stream = await openDashboardLiveStream((event, data) => {
const cursor = (data as any)?.cursor
if (typeof cursor?.sequence === 'number') lastLiveSequence = cursor.sequence
if (event === 'snapshot') {
liveConnected.value = true
liveUnavailable.value = false
return
}
if (event !== 'update') return
const envelope = (data as any)?.envelope
if (envelope?.type !== 'activity.created') return
const agentIds = Array.isArray(envelope?.payload?.agentIds)
? envelope.payload.agentIds.map((id: unknown) => String(id).toLowerCase())
: []
if (agentIds.includes(agentId.toLowerCase())) {
scheduleActivityReload()
}
}, { signal: liveAbort.signal, afterSequence: lastLiveSequence || null })
await stream.closed
if (!liveStreamStopped) {
liveConnected.value = false
scheduleStreamReconnect()
}
} catch {
liveConnected.value = false
liveUnavailable.value = true
if (!liveStreamStopped) scheduleStreamReconnect()
}
}
async function loadConfigFiles() {
configsLoading.value = true
configsError.value = ''
try {
const response = await apiFetch(`/api/v1/agents/${agentId}/config`)
if (!response.ok) throw new Error('Failed to load config files')
configFiles.value = await response.json()
if (configFiles.value.length > 0) {
const fileName = configFiles.value[0].fileName
const tabIndex = orderedTabs.indexOf(fileName)
activeTab.value = tabIndex >= 0 ? tabIndex : 0
}
if (configFiles.value.length > 0) {
await loadFileContent(configFiles.value[0].fileName)
}
} catch (e) {
configsError.value = e instanceof Error ? e.message : 'Failed to load config files'
} finally {
configsLoading.value = false
}
}
async function loadFileContent(fileName: string) {
try {
const response = await apiFetch(`/api/v1/agents/${agentId}/config/${encodeURIComponent(fileName)}`)
if (!response.ok) throw new Error(`Failed to load ${fileName}`)
const data: ConfigFileDetail = await response.json()
editorState.value = {
content: data.content,
savedContent: data.content,
saving: false,
dirty: false,
saveStatus: 'idle',
saveMessage: '',
backupStatus: 'not_applicable',
reloadStatus: 'not_supported',
reloadMessage: '',
}
} catch (e) {
editorState.value = {
content: '',
savedContent: '',
saving: false,
dirty: false,
saveStatus: 'error',
saveMessage: e instanceof Error ? e.message : `Failed to load ${fileName}`,
backupStatus: 'not_applicable',
reloadStatus: 'not_supported',
reloadMessage: '',
}
}
}
async function switchTab(index: number) {
if (activeTab.value === index) return
activeTab.value = index
const fileName = orderedTabs[index]
if (!fileName) return
await loadFileContent(fileName)
}
function onContentChange(value: string) {
editorState.value.content = value
editorState.value.dirty = value !== editorState.value.savedContent
}
async function saveFile() {
const fileName = activeTabFileName.value
if (!fileName || !editorState.value.dirty) return
editorState.value.saving = true
editorState.value.saveStatus = 'idle'
editorState.value.saveMessage = ''
editorState.value.backupStatus = 'not_applicable'
editorState.value.reloadStatus = 'not_supported'
editorState.value.reloadMessage = ''
try {
const response = await apiFetch(`/api/v1/agents/${agentId}/config/${encodeURIComponent(fileName)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: editorState.value.content }),
})
if (!response.ok) {
const err = await response.json().catch(() => ({}))
const problem = err as { error?: string; errors?: Record<string, string[]> }
const detail = problem.error
|| Object.values(problem.errors ?? {}).flat().join(' ')
|| 'Failed to save file'
throw new Error(detail)
}
const result: SaveConfigResult = await response.json()
editorState.value.savedContent = editorState.value.content
editorState.value.dirty = false
editorState.value.saveStatus = 'saved'
editorState.value.saveMessage = `Gespeichert · Backup ${result.backup.status}`
editorState.value.backupStatus = result.backup.status
editorState.value.reloadStatus = result.reloadCheck.status
editorState.value.reloadMessage = result.reloadCheck.message
const idx = configFiles.value.findIndex(f => f.fileName === fileName)
if (idx >= 0) {
configFiles.value[idx] = { ...configFiles.value[idx], size: result.size, modifiedAt: result.modifiedAt }
}
setTimeout(() => {
if (editorState.value.saveStatus === 'saved') {
editorState.value.saveStatus = 'idle'
editorState.value.saveMessage = ''
}
}, 2000)
} catch (e) {
editorState.value.saveStatus = 'error'
editorState.value.saveMessage = e instanceof Error ? e.message : 'Failed to save file'
editorState.value.backupStatus = 'not_applicable'
editorState.value.reloadStatus = 'not_supported'
} finally {
editorState.value.saving = false
}
}
onMounted(async () => {
liveStreamStopped = false
initLoading.value = true
await Promise.allSettled([
loadAgent(),
loadConfigFiles(),
loadActivity(),
loadSummary(),
])
connectActivityStream()
initLoading.value = false
})
onUnmounted(() => {
liveStreamStopped = true
liveAbort?.abort()
liveAbort = null
if (activityReloadTimer) clearTimeout(activityReloadTimer)
if (liveReconnectTimer) clearTimeout(liveReconnectTimer)
})
</script>
<template>
<div class="detail-page">
<button class="back-link" @click="router.push('/team')">
<ArrowLeft :size="14" />
Zurück zum Team
</button>
<div v-if="initLoading" class="status-message">
<Loader2 :size="20" class="spin" />
Loading agent data...
</div>
<template v-else>
<!-- Agent header -->
<div class="agent-header">
<div class="agent-avatar" :class="agentId">
<Bot :size="24" />
</div>
<div class="agent-header-info">
<span class="eyebrow">{{ agent?.role?.toUpperCase() || 'AGENT' }}</span>
<h1>{{ agent?.name || fallbackName }}</h1>
<div v-if="agent" class="agent-status-row">
<span :style="{ background: statusColor(agent.status) }" class="status-dot"></span>
<span class="status-label">{{ agent.status }}</span>
<span class="status-sep">·</span>
<span class="status-label mono">{{ agent.model || 'N/A' }}</span>
<span v-if="agent.lastSeen" class="status-sep">·</span>
<span v-if="agent.lastSeen" class="status-label">
<Activity :size="11" />
{{ formatLastSeen(agent.lastSeen) }}
</span>
</div>
<div v-if="error && !agent" class="agent-status-row">
<span class="status-label muted">
<AlertCircle :size="11" />
{{ error }}
</span>
</div>
</div>
</div>
<section class="thinking-section">
<header class="section-head">
<div>
<span class="eyebrow">LIVE</span>
<h2>Thinking <span :class="['live-dot', { on: liveConnected }]"></span></h2>
<p class="section-note">
Nexus activity streams live. Gateway session history remains read-only fallback and refreshes when related Nexus events arrive or on manual reload.
</p>
</div>
<button class="icon-button" :disabled="activityLoading || summaryLoading" @click="Promise.allSettled([loadActivity(), loadSummary()])">
<RefreshCw :size="14" :class="{ spin: activityLoading }" />
</button>
</header>
<div v-if="summaryLoading && !summary" class="status-message compact">
<Loader2 :size="16" class="spin" />
Loading summaries...
</div>
<div v-else-if="summaryError && !summary" class="status-message compact error">
<AlertCircle :size="16" />
{{ summaryError }}
</div>
<div v-else-if="summary" class="summary-row">
<div class="summary-card">
<span>Now</span>
<p>{{ summary.now.text }}</p>
<small>{{ summarySourceLabel(summary.now.source) }} · {{ formatSummaryTimestamp(summary.now.timestamp) }}</small>
</div>
<div class="summary-card">
<span>Today</span>
<p>{{ summary.today.text }}</p>
<small>{{ summarySourceLabel(summary.today.source) }} · {{ formatSummaryTimestamp(summary.today.timestamp) }}</small>
</div>
</div>
<div v-else class="status-message compact">
No summary available.
</div>
<div v-if="summaryError && summary" class="status-message compact error summary-inline-error">
<AlertCircle :size="14" />
{{ summaryError }}
</div>
<div v-if="liveUnavailable" class="status-message compact">
Live stream reconnecting
</div>
<div v-if="activityLoading && !activityItems.length" class="status-message compact">
<Loader2 :size="16" class="spin" />
Loading activity...
</div>
<div v-else-if="activityError" class="status-message compact error">
<AlertCircle :size="16" />
{{ activityError }}
</div>
<div v-else-if="activityItems.length" class="thinking-list">
<article
v-for="item in activityItems"
:key="`${item.source}-${item.id ?? item.at}-${item.message}`"
class="thinking-item"
>
<div class="thinking-meta">
<span class="type-pill">{{ activityTypeLabel(item.type) }}</span>
<span>{{ formatActivityTime(item) }}</span>
</div>
<p>{{ item.message }}</p>
</article>
</div>
<div v-else class="status-message compact">
No recent activity.
</div>
</section>
<!-- Config section -->
<div class="config-section">
<div v-if="configsLoading" class="status-message">
<Loader2 :size="16" class="spin" />
Loading config files...
</div>
<div v-else-if="configsError" class="status-message error">
<AlertCircle :size="16" />
{{ configsError }}
</div>
<template v-else>
<ConfigTabs
:tabs="orderedTabs"
:active-tab="activeTab"
@switch-tab="switchTab"
/>
<ConfigEditor
:file-name="activeTabFileName"
:file-size="currentFile ? formatFileSize(currentFile.size) : ''"
:file-modified="currentFile ? formatModifiedAt(currentFile.modifiedAt) : ''"
:content="editorState.content"
:dirty="editorState.dirty"
:saving="editorState.saving"
:save-status="editorState.saveStatus"
:save-message="editorState.saveMessage"
:backup-status="editorState.backupStatus"
:reload-status="editorState.reloadStatus"
:reload-message="editorState.reloadMessage"
@update-content="onContentChange"
@save="saveFile"
/>
</template>
</div>
</template>
</div>
</template>
<style scoped>
.detail-page {
max-width: 960px;
margin: 0 auto;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: 1px solid var(--line);
border-radius: 7px;
background: var(--panel);
color: #7e8799;
font-size: 10.5px;
cursor: pointer;
margin-bottom: 20px;
transition: border-color 0.15s, color 0.15s;
}
.back-link:hover {
border-color: #443d7c;
color: #d8dbe3;
}
.status-message {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 48px;
color: #7e8799;
font-size: 12px;
}
.status-message.error {
color: #e16e75;
}
.status-message.compact {
padding: 20px;
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.agent-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
padding-bottom: 20px;
border-bottom: 1px solid var(--line);
}
.agent-avatar {
width: 52px;
height: 52px;
display: grid;
place-items: center;
border-radius: 12px;
background: rgba(139,124,246,.1);
color: #8b7cf6;
flex-shrink: 0;
}
.agent-avatar.iris { background: rgba(139,124,246,.15); color: #8b7cf6; }
.agent-avatar.programmer { background: rgba(77,140,246,.15); color: #4d8cf6; }
.agent-avatar.architekt { background: rgba(77,168,246,.15); color: #4da8f6; }
.agent-avatar.reviewer { background: rgba(246,168,77,.15); color: #f6a84d; }
.agent-avatar.researcher { background: rgba(139,77,246,.15); color: #8b4df6; }
.agent-avatar.executor { background: rgba(77,246,212,.15); color: #4df6d4; }
.agent-header-info {
flex: 1;
}
.agent-header-info .eyebrow {
display: block;
font-size: 8.5px;
font-weight: 700;
letter-spacing: .12em;
color: var(--accent, #7b6ef2);
text-transform: uppercase;
margin-bottom: 2px;
}
.agent-header-info h1 {
margin: 0 0 4px;
font-size: 20px;
font-weight: 600;
color: #e8eaf0;
}
.agent-status-row {
display: flex;
align-items: center;
gap: 5px;
flex-wrap: wrap;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.status-label {
font-size: 11px;
color: #7e8799;
display: inline-flex;
align-items: center;
gap: 4px;
}
.status-label.muted { color: #6b7385; }
.status-label.mono { font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace; }
.status-sep { color: #3d4152; font-size: 11px; }
.thinking-section {
border: 1px solid var(--line);
border-radius: 9px;
background: var(--panel);
margin-bottom: 16px;
overflow: hidden;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--line);
}
.section-head .eyebrow {
display: block;
font-size: 8.5px;
font-weight: 700;
letter-spacing: .12em;
color: var(--accent, #7b6ef2);
}
.section-head h2 {
margin: 2px 0 0;
color: #e8eaf0;
font-size: 13px;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 6px;
}
.section-note {
margin: 6px 0 0;
color: #6f788b;
font-size: 10px;
line-height: 1.45;
max-width: 560px;
}
.live-dot {
width: 6px;
height: 6px;
border-radius: 999px;
background: #6b7385;
}
.live-dot.on {
background: #51d49a;
}
.icon-button {
width: 30px;
height: 30px;
border: 1px solid var(--line);
border-radius: 7px;
background: rgba(255,255,255,.03);
color: #9ba3b5;
display: grid;
place-items: center;
cursor: pointer;
}
.icon-button:disabled {
opacity: .65;
cursor: default;
}
.thinking-list {
display: grid;
}
.summary-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1px;
background: rgba(255,255,255,.05);
border-bottom: 1px solid var(--line);
}
.summary-row > div {
background: var(--panel);
padding: 12px 16px;
}
.summary-card small {
display: block;
margin-top: 7px;
color: #6f788b;
font-size: 9.5px;
line-height: 1.4;
}
.summary-row span {
display: block;
color: #6f788b;
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
margin-bottom: 5px;
}
.summary-row p {
margin: 0;
color: #cbd0dc;
font-size: 11px;
line-height: 1.5;
overflow-wrap: anywhere;
}
.thinking-item {
padding: 12px 16px;
border-bottom: 1px solid rgba(255,255,255,.05);
}
.thinking-item:last-child {
border-bottom: 0;
}
.thinking-meta {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
color: #6f788b;
font-size: 10px;
}
.type-pill {
padding: 2px 6px;
border-radius: 999px;
border: 1px solid rgba(123,110,242,.24);
color: #aaa1ff;
background: rgba(123,110,242,.08);
font-size: 9px;
}
.thinking-item p {
margin: 0;
color: #cbd0dc;
font-size: 11px;
line-height: 1.55;
overflow-wrap: anywhere;
}
.summary-inline-error {
border-bottom: 1px solid var(--line);
}
@media (max-width: 640px) {
.detail-page {
max-width: 100%;
}
.summary-row {
grid-template-columns: 1fr;
}
}
</style>