feat: ship agent-first mission control v0.2.57
This commit is contained in:
@@ -1,39 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { onMounted, onUnmounted, ref, computed, watch } from 'vue'
|
||||
import { onBeforeRouteLeave, 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 {
|
||||
applyAgentFileWrite,
|
||||
useAgentActivityQuery,
|
||||
useAgentDetailQuery,
|
||||
useAgentFileQuery,
|
||||
useAgentFilesQuery,
|
||||
useAgentSummaryQuery,
|
||||
type AgentActivityDto,
|
||||
type AgentFileDto,
|
||||
type AgentFileWriteDto,
|
||||
} from '../api/agentDetail'
|
||||
import { queryClient, queryKeys } from '../api/queryClient'
|
||||
import ConfigTabs from '../components/config/ConfigTabs.vue'
|
||||
import ConfigEditor from '../components/config/ConfigEditor.vue'
|
||||
import { openDashboardLiveStream } from '../services/live'
|
||||
import AgentWorkspaceBrowser from '../components/config/AgentWorkspaceBrowser.vue'
|
||||
import StandingOrdersEditor from '../components/config/StandingOrdersEditor.vue'
|
||||
import { subscribeDomainEventState } from '../services/domainEvents'
|
||||
import { createMutationRequestContext } from '../services/mutationContext'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
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)
|
||||
let unsubscribeDomainState: (() => void) | null = null
|
||||
|
||||
interface EditorState {
|
||||
content: string
|
||||
@@ -45,56 +43,8 @@ interface EditorState {
|
||||
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
|
||||
}
|
||||
contentHash: string
|
||||
verified: boolean
|
||||
}
|
||||
|
||||
const editorState = ref<EditorState>({
|
||||
@@ -107,11 +57,32 @@ const editorState = ref<EditorState>({
|
||||
backupStatus: 'not_applicable',
|
||||
reloadStatus: 'not_supported',
|
||||
reloadMessage: '',
|
||||
contentHash: '',
|
||||
verified: false,
|
||||
})
|
||||
|
||||
const agentId = route.params.id as string
|
||||
|
||||
const orderedTabs = ['IDENTITY.md', 'SOUL.md', 'AGENTS.md', 'TOOLS.md', 'HEARTBEAT.md', 'USER.md']
|
||||
const canConfigure = computed(() => auth.user?.role === 'owner')
|
||||
const agentQuery = useAgentDetailQuery(agentId)
|
||||
const activityQuery = useAgentActivityQuery(agentId)
|
||||
const summaryQuery = useAgentSummaryQuery(agentId)
|
||||
const filesQuery = useAgentFilesQuery(agentId, canConfigure)
|
||||
const agent = computed(() => agentQuery.data.value ?? null)
|
||||
const activityItems = computed(() => activityQuery.data.value ?? [])
|
||||
const summary = computed(() => summaryQuery.data.value ?? null)
|
||||
const configFiles = computed(() => filesQuery.data.value?.files ?? [])
|
||||
const loading = computed(() => agentQuery.isPending.value)
|
||||
const activityLoading = computed(() => activityQuery.isFetching.value)
|
||||
const summaryLoading = computed(() => summaryQuery.isFetching.value)
|
||||
const error = computed(() =>
|
||||
agentQuery.error.value instanceof Error ? agentQuery.error.value.message : '',
|
||||
)
|
||||
const activityError = computed(() =>
|
||||
activityQuery.error.value instanceof Error ? activityQuery.error.value.message : '',
|
||||
)
|
||||
const summaryError = computed(() =>
|
||||
summaryQuery.error.value instanceof Error ? summaryQuery.error.value.message : '',
|
||||
)
|
||||
|
||||
const currentFile = computed(() => {
|
||||
if (!configFiles.value.length) return null
|
||||
@@ -120,8 +91,36 @@ const currentFile = computed(() => {
|
||||
})
|
||||
|
||||
const activeTabFileName = computed(() => {
|
||||
return orderedTabs[activeTab.value] || null
|
||||
return currentFile.value?.name || null
|
||||
})
|
||||
const requestedFileName = computed(() =>
|
||||
typeof route.query.file === 'string' ? route.query.file : '',
|
||||
)
|
||||
const fileQuery = useAgentFileQuery(
|
||||
agentId,
|
||||
computed(() => activeTabFileName.value ?? ''),
|
||||
canConfigure,
|
||||
)
|
||||
const configsLoading = computed(() =>
|
||||
canConfigure.value
|
||||
&& (
|
||||
filesQuery.isPending.value
|
||||
|| (Boolean(activeTabFileName.value) && fileQuery.isFetching.value)
|
||||
),
|
||||
)
|
||||
const configsError = computed(() => {
|
||||
if (!canConfigure.value) {
|
||||
return 'Nur Owner dürfen sensible OpenClaw-Agentdateien lesen und bearbeiten.'
|
||||
}
|
||||
const cause = fileQuery.error.value ?? filesQuery.error.value
|
||||
return cause instanceof Error ? cause.message : ''
|
||||
})
|
||||
const initLoading = computed(() =>
|
||||
loading.value
|
||||
|| activityQuery.isPending.value
|
||||
|| summaryQuery.isPending.value
|
||||
|| (canConfigure.value && filesQuery.isPending.value),
|
||||
)
|
||||
|
||||
const fallbackName = computed(() => {
|
||||
return agentId.charAt(0).toUpperCase() + agentId.slice(1)
|
||||
@@ -143,10 +142,10 @@ function formatModifiedAt(dateStr: string): string {
|
||||
|
||||
const statusColor = (status: string): string => {
|
||||
switch (status) {
|
||||
case 'Online': return '#51d49a'
|
||||
case 'Degraded': return '#e5b05e'
|
||||
case 'Offline': return '#e16e75'
|
||||
default: return '#7e8799'
|
||||
case 'Online': return 'var(--st-work)'
|
||||
case 'Degraded': return 'var(--st-queue)'
|
||||
case 'Offline': return 'var(--st-block)'
|
||||
default: return 'var(--st-idle)'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,7 +158,7 @@ function formatLastSeen(dateStr?: string): string {
|
||||
})
|
||||
}
|
||||
|
||||
function formatActivityTime(item: AgentActivityItem): string {
|
||||
function formatActivityTime(item: AgentActivityDto): string {
|
||||
if (item.relativeTime) return item.relativeTime
|
||||
const d = new Date(item.at)
|
||||
return d.toLocaleDateString('de-DE', {
|
||||
@@ -175,54 +174,14 @@ function activityTypeLabel(type: string): string {
|
||||
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()
|
||||
await Promise.allSettled([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agentActivity(agentId) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agentSummary(agentId) }),
|
||||
])
|
||||
}, 250)
|
||||
}
|
||||
|
||||
@@ -245,113 +204,80 @@ function summarySourceLabel(source: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleStreamReconnect() {
|
||||
if (liveStreamStopped || liveReconnectTimer) return
|
||||
liveReconnectTimer = setTimeout(() => {
|
||||
liveReconnectTimer = null
|
||||
void connectActivityStream()
|
||||
}, 1500)
|
||||
function onDomainEvent(event: Event) {
|
||||
const detail = (event as CustomEvent<{
|
||||
eventType?: string
|
||||
entity?: { type?: string }
|
||||
payload?: { agentIds?: unknown[] }
|
||||
}>).detail
|
||||
if (detail?.eventType !== 'activity.created' || detail.entity?.type !== 'activity') return
|
||||
const agentIds = Array.isArray(detail.payload?.agentIds)
|
||||
? detail.payload.agentIds.map(id => String(id).toLowerCase())
|
||||
: []
|
||||
if (agentIds.includes(agentId.toLowerCase())) scheduleActivityReload()
|
||||
}
|
||||
|
||||
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()
|
||||
function hydrateEditor(data: AgentFileDto) {
|
||||
if (editorState.value.dirty || editorState.value.saving) return
|
||||
editorState.value = {
|
||||
content: data.content ?? '',
|
||||
savedContent: data.content ?? '',
|
||||
saving: false,
|
||||
dirty: false,
|
||||
saveStatus: 'idle',
|
||||
saveMessage: data.missing ? 'Datei fehlt noch und wird beim ersten Speichern angelegt.' : '',
|
||||
backupStatus: 'not_applicable',
|
||||
reloadStatus: 'not_supported',
|
||||
reloadMessage: 'Nexus meldet erst nach erfolgreichem OpenClaw-Read-back „verifiziert“.',
|
||||
contentHash: data.contentHash,
|
||||
verified: false,
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
function refreshActivity(): void {
|
||||
void Promise.allSettled([
|
||||
activityQuery.refetch(),
|
||||
summaryQuery.refetch(),
|
||||
])
|
||||
}
|
||||
|
||||
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: '',
|
||||
}
|
||||
}
|
||||
}
|
||||
watch(
|
||||
() => fileQuery.data.value,
|
||||
data => {
|
||||
if (data) hydrateEditor(data)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => configFiles.value.length,
|
||||
length => {
|
||||
if (!length || activeTab.value >= length) activeTab.value = 0
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
[configFiles, requestedFileName],
|
||||
([files, requested]) => {
|
||||
if (!requested || editorState.value.dirty) return
|
||||
const index = files.findIndex(file => file.name === requested)
|
||||
if (index >= 0) activeTab.value = index
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function switchTab(index: number) {
|
||||
if (activeTab.value === index) return
|
||||
if (editorState.value.dirty &&
|
||||
!window.confirm('Ungespeicherte Änderungen verwerfen und eine andere Datei öffnen?')) {
|
||||
return
|
||||
}
|
||||
activeTab.value = index
|
||||
const fileName = orderedTabs[index]
|
||||
if (!fileName) return
|
||||
await loadFileContent(fileName)
|
||||
const fileName = configFiles.value[index]?.name
|
||||
if (fileName && route.query.file !== fileName) {
|
||||
await router.replace({ query: { ...route.query, file: fileName } })
|
||||
}
|
||||
}
|
||||
|
||||
function onContentChange(value: string) {
|
||||
@@ -369,36 +295,57 @@ async function saveFile() {
|
||||
editorState.value.backupStatus = 'not_applicable'
|
||||
editorState.value.reloadStatus = 'not_supported'
|
||||
editorState.value.reloadMessage = ''
|
||||
editorState.value.verified = false
|
||||
|
||||
try {
|
||||
const response = await apiFetch(`/api/v1/agents/${agentId}/config/${encodeURIComponent(fileName)}`, {
|
||||
const requestContext = createMutationRequestContext(
|
||||
'openclaw-agent-file-set',
|
||||
`${agentId}:${fileName}:${editorState.value.contentHash}`,
|
||||
)
|
||||
const response = await apiFetch(
|
||||
`/api/v1/openclaw/agents/${encodeURIComponent(agentId)}/files/${encodeURIComponent(fileName)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: editorState.value.content }),
|
||||
headers: requestContext.headers,
|
||||
body: JSON.stringify({
|
||||
content: editorState.value.content,
|
||||
expectedHash: editorState.value.contentHash,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}))
|
||||
const problem = err as { error?: string; errors?: Record<string, string[]> }
|
||||
const detail = problem.error
|
||||
const problem = err as {
|
||||
error?: string
|
||||
message?: string
|
||||
errors?: Record<string, string[]>
|
||||
currentHash?: string
|
||||
}
|
||||
const detail = problem.message
|
||||
|| problem.error
|
||||
|| Object.values(problem.errors ?? {}).flat().join(' ')
|
||||
|| 'Failed to save file'
|
||||
if (response.status === 409 && problem.currentHash) {
|
||||
editorState.value.reloadMessage = 'OpenClaw enthält eine neuere Version. Lade die Datei neu, bevor du erneut speicherst.'
|
||||
}
|
||||
throw new Error(detail)
|
||||
}
|
||||
|
||||
const result: SaveConfigResult = await response.json()
|
||||
const result: AgentFileWriteDto = await response.json()
|
||||
editorState.value.content = result.file.content ?? editorState.value.content
|
||||
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
|
||||
editorState.value.saveMessage = result.message
|
||||
editorState.value.backupStatus = 'not_applicable'
|
||||
editorState.value.reloadStatus = result.verified ? 'verified' : 'not_supported'
|
||||
editorState.value.reloadMessage = result.verified
|
||||
? 'OpenClaw hat den gespeicherten Inhalt identisch zurückgegeben.'
|
||||
: 'Der Runtime-Reload wurde nicht bestätigt.'
|
||||
editorState.value.contentHash = result.file.contentHash
|
||||
editorState.value.verified = result.verified
|
||||
|
||||
const idx = configFiles.value.findIndex(f => f.fileName === fileName)
|
||||
if (idx >= 0) {
|
||||
configFiles.value[idx] = { ...configFiles.value[idx], size: result.size, modifiedAt: result.modifiedAt }
|
||||
}
|
||||
await applyAgentFileWrite(queryClient, agentId, result)
|
||||
|
||||
setTimeout(() => {
|
||||
if (editorState.value.saveStatus === 'saved') {
|
||||
@@ -411,38 +358,47 @@ async function saveFile() {
|
||||
editorState.value.saveMessage = e instanceof Error ? e.message : 'Failed to save file'
|
||||
editorState.value.backupStatus = 'not_applicable'
|
||||
editorState.value.reloadStatus = 'not_supported'
|
||||
editorState.value.verified = false
|
||||
} finally {
|
||||
editorState.value.saving = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
liveStreamStopped = false
|
||||
initLoading.value = true
|
||||
await Promise.allSettled([
|
||||
loadAgent(),
|
||||
loadConfigFiles(),
|
||||
loadActivity(),
|
||||
loadSummary(),
|
||||
])
|
||||
connectActivityStream()
|
||||
initLoading.value = false
|
||||
onMounted(() => {
|
||||
window.addEventListener('nexus:domain-event', onDomainEvent)
|
||||
unsubscribeDomainState = subscribeDomainEventState(state => {
|
||||
liveConnected.value = state === 'open'
|
||||
liveUnavailable.value = state === 'unsupported' || state === 'error'
|
||||
})
|
||||
})
|
||||
|
||||
function onBeforeUnload(event: BeforeUnloadEvent) {
|
||||
if (!editorState.value.dirty) return
|
||||
event.preventDefault()
|
||||
event.returnValue = ''
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', onBeforeUnload)
|
||||
|
||||
onBeforeRouteLeave(() => {
|
||||
if (!editorState.value.dirty) return true
|
||||
return window.confirm('Ungespeicherte Agent-Konfiguration verwerfen und die Seite verlassen?')
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
liveStreamStopped = true
|
||||
liveAbort?.abort()
|
||||
liveAbort = null
|
||||
window.removeEventListener('nexus:domain-event', onDomainEvent)
|
||||
unsubscribeDomainState?.()
|
||||
unsubscribeDomainState = null
|
||||
if (activityReloadTimer) clearTimeout(activityReloadTimer)
|
||||
if (liveReconnectTimer) clearTimeout(liveReconnectTimer)
|
||||
window.removeEventListener('beforeunload', onBeforeUnload)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="detail-page">
|
||||
<button class="back-link" @click="router.push('/team')">
|
||||
<div class="detail-page nexus-page">
|
||||
<button type="button" class="back-link" @click="router.push('/agents')">
|
||||
<ArrowLeft :size="14" />
|
||||
Zurück zum Team
|
||||
Zurück zu Agents
|
||||
</button>
|
||||
|
||||
<div v-if="initLoading" class="status-message">
|
||||
@@ -482,13 +438,19 @@ onUnmounted(() => {
|
||||
<section class="thinking-section">
|
||||
<header class="section-head">
|
||||
<div>
|
||||
<span class="eyebrow">LIVE</span>
|
||||
<h2>Thinking <span :class="['live-dot', { on: liveConnected }]"></span></h2>
|
||||
<span class="eyebrow">AUTHORITATIVE ACTIVITY</span>
|
||||
<h2>Agent events <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()])">
|
||||
<button
|
||||
type="button"
|
||||
class="icon-button"
|
||||
aria-label="Agent activity and summary refresh"
|
||||
:disabled="activityLoading || summaryLoading"
|
||||
@click="refreshActivity"
|
||||
>
|
||||
<RefreshCw :size="14" :class="{ spin: activityLoading }" />
|
||||
</button>
|
||||
</header>
|
||||
@@ -572,15 +534,15 @@ onUnmounted(() => {
|
||||
|
||||
<template v-else>
|
||||
<ConfigTabs
|
||||
:tabs="orderedTabs"
|
||||
:tabs="configFiles.map(file => file.name)"
|
||||
:active-tab="activeTab"
|
||||
@switch-tab="switchTab"
|
||||
/>
|
||||
|
||||
<ConfigEditor
|
||||
:file-name="activeTabFileName"
|
||||
:file-size="currentFile ? formatFileSize(currentFile.size) : ''"
|
||||
:file-modified="currentFile ? formatModifiedAt(currentFile.modifiedAt) : ''"
|
||||
:file-size="currentFile?.size != null ? formatFileSize(Number(currentFile.size)) : 'Noch nicht angelegt'"
|
||||
:file-modified="currentFile?.updatedAt ? formatModifiedAt(currentFile.updatedAt) : 'Keine Änderung gemeldet'"
|
||||
:content="editorState.content"
|
||||
:dirty="editorState.dirty"
|
||||
:saving="editorState.saving"
|
||||
@@ -589,9 +551,19 @@ onUnmounted(() => {
|
||||
:backup-status="editorState.backupStatus"
|
||||
:reload-status="editorState.reloadStatus"
|
||||
:reload-message="editorState.reloadMessage"
|
||||
:content-hash="editorState.contentHash"
|
||||
:verified="editorState.verified"
|
||||
:read-only="!canConfigure"
|
||||
@update-content="onContentChange"
|
||||
@save="saveFile"
|
||||
/>
|
||||
<StandingOrdersEditor
|
||||
v-if="activeTabFileName === 'AGENTS.md'"
|
||||
:content="editorState.content"
|
||||
:read-only="!canConfigure"
|
||||
@apply="onContentChange"
|
||||
/>
|
||||
<AgentWorkspaceBrowser v-if="canConfigure" :agent-id="agentId" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -612,15 +584,15 @@ onUnmounted(() => {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
background: var(--panel);
|
||||
color: #7e8799;
|
||||
color: var(--tx-3);
|
||||
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;
|
||||
border-color: var(--line-3);
|
||||
color: var(--tx);
|
||||
}
|
||||
|
||||
.status-message {
|
||||
@@ -629,11 +601,11 @@ onUnmounted(() => {
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 48px;
|
||||
color: #7e8799;
|
||||
color: var(--tx-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
.status-message.error {
|
||||
color: #e16e75;
|
||||
color: var(--st-block);
|
||||
}
|
||||
.status-message.compact {
|
||||
padding: 20px;
|
||||
@@ -659,16 +631,16 @@ onUnmounted(() => {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 12px;
|
||||
background: rgba(139,124,246,.1);
|
||||
color: #8b7cf6;
|
||||
background: color-mix(in srgb, var(--a-mid) 10%, transparent);
|
||||
color: var(--a-mid);
|
||||
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-avatar.iris { background: color-mix(in srgb, var(--a-mid) 15%, transparent); color: var(--a-mid); }
|
||||
.agent-avatar.programmer { background: color-mix(in srgb, var(--a-blue) 15%, transparent); color: var(--a-blue); }
|
||||
.agent-avatar.architekt { background: color-mix(in srgb, var(--a-blue) 15%, transparent); color: var(--a-blue); }
|
||||
.agent-avatar.reviewer { background: color-mix(in srgb, var(--st-review) 15%, transparent); color: var(--st-review); }
|
||||
.agent-avatar.researcher { background: color-mix(in srgb, var(--a-mid) 15%, transparent); color: var(--a-mid); }
|
||||
.agent-avatar.executor { background: color-mix(in srgb, var(--st-think) 15%, transparent); color: var(--st-think); }
|
||||
|
||||
.agent-header-info {
|
||||
flex: 1;
|
||||
@@ -678,7 +650,7 @@ onUnmounted(() => {
|
||||
font-size: 8.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .12em;
|
||||
color: var(--accent, #7b6ef2);
|
||||
color: var(--a-mid);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
@@ -686,7 +658,7 @@ onUnmounted(() => {
|
||||
margin: 0 0 4px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #e8eaf0;
|
||||
color: var(--tx);
|
||||
}
|
||||
.agent-status-row {
|
||||
display: flex;
|
||||
@@ -702,14 +674,14 @@ onUnmounted(() => {
|
||||
}
|
||||
.status-label {
|
||||
font-size: 11px;
|
||||
color: #7e8799;
|
||||
color: var(--tx-3);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.status-label.muted { color: #6b7385; }
|
||||
.status-label.muted { color: var(--tx-3); }
|
||||
.status-label.mono { font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace; }
|
||||
.status-sep { color: #3d4152; font-size: 11px; }
|
||||
.status-sep { color: var(--line-3); font-size: 11px; }
|
||||
|
||||
.thinking-section {
|
||||
border: 1px solid var(--line);
|
||||
@@ -731,11 +703,11 @@ onUnmounted(() => {
|
||||
font-size: 8.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .12em;
|
||||
color: var(--accent, #7b6ef2);
|
||||
color: var(--a-mid);
|
||||
}
|
||||
.section-head h2 {
|
||||
margin: 2px 0 0;
|
||||
color: #e8eaf0;
|
||||
color: var(--tx);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
@@ -744,7 +716,7 @@ onUnmounted(() => {
|
||||
}
|
||||
.section-note {
|
||||
margin: 6px 0 0;
|
||||
color: #6f788b;
|
||||
color: var(--tx-3);
|
||||
font-size: 10px;
|
||||
line-height: 1.45;
|
||||
max-width: 560px;
|
||||
@@ -753,18 +725,18 @@ onUnmounted(() => {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: #6b7385;
|
||||
background: var(--tx-3);
|
||||
}
|
||||
.live-dot.on {
|
||||
background: #51d49a;
|
||||
background: var(--st-work);
|
||||
}
|
||||
.icon-button {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
background: rgba(255,255,255,.03);
|
||||
color: #9ba3b5;
|
||||
background: color-mix(in srgb, var(--tx) 3%, transparent);
|
||||
color: var(--tx-2);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
@@ -780,7 +752,7 @@ onUnmounted(() => {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1px;
|
||||
background: rgba(255,255,255,.05);
|
||||
background: color-mix(in srgb, var(--tx) 5%, transparent);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.summary-row > div {
|
||||
@@ -790,13 +762,13 @@ onUnmounted(() => {
|
||||
.summary-card small {
|
||||
display: block;
|
||||
margin-top: 7px;
|
||||
color: #6f788b;
|
||||
color: var(--tx-3);
|
||||
font-size: 9.5px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.summary-row span {
|
||||
display: block;
|
||||
color: #6f788b;
|
||||
color: var(--tx-3);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
@@ -804,14 +776,14 @@ onUnmounted(() => {
|
||||
}
|
||||
.summary-row p {
|
||||
margin: 0;
|
||||
color: #cbd0dc;
|
||||
color: var(--tx);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.thinking-item {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgba(255,255,255,.05);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--tx) 5%, transparent);
|
||||
}
|
||||
.thinking-item:last-child {
|
||||
border-bottom: 0;
|
||||
@@ -821,20 +793,20 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
color: #6f788b;
|
||||
color: var(--tx-3);
|
||||
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);
|
||||
border: 1px solid color-mix(in srgb, var(--a-mid) 24%, transparent);
|
||||
color: var(--tx-2);
|
||||
background: color-mix(in srgb, var(--a-mid) 8%, transparent);
|
||||
font-size: 9px;
|
||||
}
|
||||
.thinking-item p {
|
||||
margin: 0;
|
||||
color: #cbd0dc;
|
||||
color: var(--tx);
|
||||
font-size: 11px;
|
||||
line-height: 1.55;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
Reference in New Issue
Block a user