cd8c78d165
CI - Build & Test / Backend (.NET) (push) Successful in 45s
CI - Build & Test / Backend integration (PostgreSQL/Toxiproxy) (push) Failing after 1m0s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 2m49s
CI - Build & Test / Security Check (push) Successful in 7s
CI - Build & Test / Deploy Nexus (push) Has been skipped
907 lines
26 KiB
Vue
907 lines
26 KiB
Vue
<script setup lang="ts">
|
|
import { onMounted, onUnmounted, ref, computed, watch } from 'vue'
|
|
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
|
|
import { ArrowLeft, Bot, Activity, RefreshCw } from '@lucide/vue'
|
|
import { apiFetch } from '../services/api'
|
|
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 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'
|
|
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const auth = useAuthStore()
|
|
|
|
const activeTab = ref(0)
|
|
const liveConnected = ref(false)
|
|
const liveUnavailable = ref(false)
|
|
let activityReloadTimer: ReturnType<typeof setTimeout> | null = null
|
|
let unsubscribeDomainState: (() => void) | null = null
|
|
|
|
interface EditorState {
|
|
content: string
|
|
savedContent: string
|
|
saving: boolean
|
|
dirty: boolean
|
|
saveStatus: 'idle' | 'saved' | 'error'
|
|
saveMessage: string
|
|
backupStatus: string
|
|
reloadStatus: string
|
|
reloadMessage: string
|
|
contentHash: string
|
|
verified: boolean
|
|
}
|
|
|
|
const editorState = ref<EditorState>({
|
|
content: '',
|
|
savedContent: '',
|
|
saving: false,
|
|
dirty: false,
|
|
saveStatus: 'idle',
|
|
saveMessage: '',
|
|
backupStatus: 'not_applicable',
|
|
reloadStatus: 'not_supported',
|
|
reloadMessage: '',
|
|
contentHash: '',
|
|
verified: false,
|
|
})
|
|
|
|
const agentId = route.params.id as string
|
|
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 currentFile = computed(() => {
|
|
if (!configFiles.value.length) return null
|
|
const activeFile = configFiles.value[activeTab.value]
|
|
return activeFile || configFiles.value[0]
|
|
})
|
|
|
|
const activeTabFileName = computed(() => {
|
|
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,
|
|
)
|
|
|
|
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 'var(--st-work)'
|
|
case 'Degraded': return 'var(--st-queue)'
|
|
case 'Offline': return 'var(--st-block)'
|
|
default: return 'var(--st-idle)'
|
|
}
|
|
}
|
|
|
|
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: AgentActivityDto): 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'
|
|
}
|
|
|
|
function scheduleActivityReload() {
|
|
if (activityReloadTimer) return
|
|
activityReloadTimer = setTimeout(async () => {
|
|
activityReloadTimer = null
|
|
await Promise.allSettled([
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.agentActivity(agentId) }),
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.agentSummary(agentId) }),
|
|
])
|
|
}, 250)
|
|
}
|
|
|
|
function retryAgent() {
|
|
void agentQuery.refetch()
|
|
}
|
|
|
|
function retrySummary() {
|
|
void summaryQuery.refetch()
|
|
}
|
|
|
|
function retryActivity() {
|
|
void activityQuery.refetch()
|
|
}
|
|
|
|
function retryConfigs() {
|
|
void filesQuery.refetch()
|
|
if (activeTabFileName.value) void fileQuery.refetch()
|
|
}
|
|
|
|
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 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()
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|
|
|
|
function refreshActivity(): void {
|
|
void Promise.allSettled([
|
|
activityQuery.refetch(),
|
|
summaryQuery.refetch(),
|
|
])
|
|
}
|
|
|
|
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 = configFiles.value[index]?.name
|
|
if (fileName && route.query.file !== fileName) {
|
|
await router.replace({ query: { ...route.query, file: 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 = ''
|
|
editorState.value.verified = false
|
|
|
|
try {
|
|
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: 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 {
|
|
detail?: string
|
|
error?: string
|
|
message?: string
|
|
errors?: Record<string, string[]>
|
|
currentHash?: string
|
|
}
|
|
const detail = problem.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: 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 = 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
|
|
|
|
await applyAgentFileWrite(queryClient, agentId, result)
|
|
|
|
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'
|
|
editorState.value.verified = false
|
|
} finally {
|
|
editorState.value.saving = 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(() => {
|
|
window.removeEventListener('nexus:domain-event', onDomainEvent)
|
|
unsubscribeDomainState?.()
|
|
unsubscribeDomainState = null
|
|
if (activityReloadTimer) clearTimeout(activityReloadTimer)
|
|
window.removeEventListener('beforeunload', onBeforeUnload)
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="detail-page nexus-page">
|
|
<button type="button" class="back-link" @click="router.push('/agents')">
|
|
<ArrowLeft :size="14" />
|
|
Zurück zu Agents
|
|
</button>
|
|
|
|
<AsyncStatePanel
|
|
v-if="initLoading && !agent"
|
|
state="loading"
|
|
title="Agent wird geladen"
|
|
/>
|
|
|
|
<AsyncStatePanel
|
|
v-else-if="agentQuery.error.value && !agent"
|
|
state="error"
|
|
title="Agent nicht verfügbar"
|
|
:problem="agentQuery.error.value"
|
|
@action="retryAgent"
|
|
/>
|
|
|
|
<template v-else>
|
|
<AsyncStatePanel
|
|
v-if="agentQuery.error.value && agent"
|
|
state="stale"
|
|
title="Agentdaten möglicherweise veraltet"
|
|
:problem="agentQuery.error.value"
|
|
action-label="Aktualisieren"
|
|
compact
|
|
@action="retryAgent"
|
|
/>
|
|
<!-- 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>
|
|
</div>
|
|
|
|
<section class="thinking-section">
|
|
<header class="section-head">
|
|
<div>
|
|
<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
|
|
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>
|
|
|
|
<AsyncStatePanel
|
|
v-if="summaryLoading && !summary"
|
|
state="loading"
|
|
title="Zusammenfassungen werden geladen"
|
|
compact
|
|
/>
|
|
|
|
<AsyncStatePanel
|
|
v-else-if="summaryQuery.error.value && !summary"
|
|
state="error"
|
|
title="Zusammenfassungen nicht verfügbar"
|
|
:problem="summaryQuery.error.value"
|
|
compact
|
|
@action="retrySummary"
|
|
/>
|
|
|
|
<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>
|
|
|
|
<AsyncStatePanel
|
|
v-else
|
|
state="empty"
|
|
title="Keine Zusammenfassung"
|
|
message="OpenClaw hat für diesen Agenten noch keine bestätigte Zusammenfassung geliefert."
|
|
compact
|
|
/>
|
|
|
|
<AsyncStatePanel
|
|
v-if="summaryQuery.error.value && summary"
|
|
state="stale"
|
|
title="Zusammenfassung möglicherweise veraltet"
|
|
:problem="summaryQuery.error.value"
|
|
action-label="Aktualisieren"
|
|
compact
|
|
@action="retrySummary"
|
|
/>
|
|
|
|
<AsyncStatePanel
|
|
v-if="liveUnavailable"
|
|
state="stale"
|
|
title="Live-Stream wird neu verbunden"
|
|
message="Die letzte bestätigte Aktivität bleibt sichtbar."
|
|
action-label="Manuell aktualisieren"
|
|
compact
|
|
@action="refreshActivity"
|
|
/>
|
|
|
|
<AsyncStatePanel
|
|
v-if="activityLoading && !activityItems.length"
|
|
state="loading"
|
|
title="Aktivität wird geladen"
|
|
compact
|
|
/>
|
|
|
|
<AsyncStatePanel
|
|
v-else-if="activityQuery.error.value && !activityItems.length"
|
|
state="error"
|
|
title="Aktivität nicht verfügbar"
|
|
:problem="activityQuery.error.value"
|
|
compact
|
|
@action="retryActivity"
|
|
/>
|
|
|
|
<template v-else-if="activityItems.length">
|
|
<AsyncStatePanel
|
|
v-if="activityQuery.error.value"
|
|
state="stale"
|
|
title="Aktivitätsliste möglicherweise veraltet"
|
|
:problem="activityQuery.error.value"
|
|
action-label="Aktualisieren"
|
|
compact
|
|
@action="retryActivity"
|
|
/>
|
|
<div 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>
|
|
</template>
|
|
|
|
<AsyncStatePanel
|
|
v-else
|
|
state="empty"
|
|
title="Keine aktuelle Aktivität"
|
|
message="Für diesen Agenten liegen noch keine bestätigten Events vor."
|
|
compact
|
|
/>
|
|
</section>
|
|
|
|
<!-- Config section -->
|
|
<div class="config-section">
|
|
<AsyncStatePanel
|
|
v-if="configsLoading && !configFiles.length"
|
|
state="loading"
|
|
title="Agent-Dateien werden geladen"
|
|
compact
|
|
/>
|
|
|
|
<AsyncStatePanel
|
|
v-else-if="configsError && !configFiles.length"
|
|
state="error"
|
|
title="Agent-Dateien nicht verfügbar"
|
|
:message="configsError"
|
|
:problem="fileQuery.error.value ?? filesQuery.error.value"
|
|
:action-label="canConfigure ? 'Aktualisieren' : ''"
|
|
compact
|
|
@action="retryConfigs"
|
|
/>
|
|
|
|
<template v-else>
|
|
<AsyncStatePanel
|
|
v-if="configsError"
|
|
state="stale"
|
|
title="Agent-Dateiliste möglicherweise veraltet"
|
|
:message="configsError"
|
|
:problem="fileQuery.error.value ?? filesQuery.error.value"
|
|
action-label="Aktualisieren"
|
|
compact
|
|
@action="retryConfigs"
|
|
/>
|
|
<ConfigTabs
|
|
:tabs="configFiles.map(file => file.name)"
|
|
:active-tab="activeTab"
|
|
@switch-tab="switchTab"
|
|
/>
|
|
|
|
<ConfigEditor
|
|
:file-name="activeTabFileName"
|
|
: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"
|
|
:save-status="editorState.saveStatus"
|
|
:save-message="editorState.saveMessage"
|
|
: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>
|
|
</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: 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: var(--line-3);
|
|
color: var(--tx);
|
|
}
|
|
|
|
.status-message {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
padding: 48px;
|
|
color: var(--tx-3);
|
|
font-size: 12px;
|
|
}
|
|
.status-message.error {
|
|
color: var(--st-block);
|
|
}
|
|
.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: color-mix(in srgb, var(--a-mid) 10%, transparent);
|
|
color: var(--a-mid);
|
|
flex-shrink: 0;
|
|
}
|
|
.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;
|
|
}
|
|
.agent-header-info .eyebrow {
|
|
display: block;
|
|
font-size: 8.5px;
|
|
font-weight: 700;
|
|
letter-spacing: .12em;
|
|
color: var(--a-mid);
|
|
text-transform: uppercase;
|
|
margin-bottom: 2px;
|
|
}
|
|
.agent-header-info h1 {
|
|
margin: 0 0 4px;
|
|
font-size: 20px;
|
|
font-weight: 600;
|
|
color: var(--tx);
|
|
}
|
|
.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: var(--tx-3);
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
.status-label.muted { color: var(--tx-3); }
|
|
.status-label.mono { font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace; }
|
|
.status-sep { color: var(--line-3); 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(--a-mid);
|
|
}
|
|
.section-head h2 {
|
|
margin: 2px 0 0;
|
|
color: var(--tx);
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
.section-note {
|
|
margin: 6px 0 0;
|
|
color: var(--tx-3);
|
|
font-size: 10px;
|
|
line-height: 1.45;
|
|
max-width: 560px;
|
|
}
|
|
.live-dot {
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 999px;
|
|
background: var(--tx-3);
|
|
}
|
|
.live-dot.on {
|
|
background: var(--st-work);
|
|
}
|
|
.icon-button {
|
|
width: 30px;
|
|
height: 30px;
|
|
border: 1px solid var(--line);
|
|
border-radius: 7px;
|
|
background: color-mix(in srgb, var(--tx) 3%, transparent);
|
|
color: var(--tx-2);
|
|
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: color-mix(in srgb, var(--tx) 5%, transparent);
|
|
border-bottom: 1px solid var(--line);
|
|
}
|
|
.summary-row > div {
|
|
background: var(--panel);
|
|
padding: 12px 16px;
|
|
}
|
|
.summary-card small {
|
|
display: block;
|
|
margin-top: 7px;
|
|
color: var(--tx-3);
|
|
font-size: 9.5px;
|
|
line-height: 1.4;
|
|
}
|
|
.summary-row span {
|
|
display: block;
|
|
color: var(--tx-3);
|
|
font-size: 9px;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
margin-bottom: 5px;
|
|
}
|
|
.summary-row p {
|
|
margin: 0;
|
|
color: var(--tx);
|
|
font-size: 11px;
|
|
line-height: 1.5;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
.thinking-item {
|
|
padding: 12px 16px;
|
|
border-bottom: 1px solid color-mix(in srgb, var(--tx) 5%, transparent);
|
|
}
|
|
.thinking-item:last-child {
|
|
border-bottom: 0;
|
|
}
|
|
.thinking-meta {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-bottom: 6px;
|
|
color: var(--tx-3);
|
|
font-size: 10px;
|
|
}
|
|
.type-pill {
|
|
padding: 2px 6px;
|
|
border-radius: 999px;
|
|
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: var(--tx);
|
|
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>
|