Files
nexus/frontend/src/components/config/AgentWorkspaceBrowser.vue
T
AzuTear f5552218bc
CI - Build & Test / Backend (.NET) (push) Successful in 42s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 2m46s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Successful in 56s
feat: ship agent-first mission control v0.2.57
2026-07-31 22:39:47 +02:00

220 lines
8.9 KiB
Vue

<script setup lang="ts">
import {
ArrowLeft,
CircleAlert,
FileText,
Folder,
Loader2,
RefreshCw,
X,
} from '@lucide/vue'
import { computed, onMounted, ref } from 'vue'
import { apiFetch } from '../../services/api'
interface WorkspaceEntry {
path: string
name: string
kind: string
size: number | null
updatedAt: string | null
}
interface WorkspaceCollection {
agentId: string
path: string
parentPath: string | null
entries: WorkspaceEntry[]
totalEntries: number
offset: number
checkedAt: string
}
interface WorkspaceFile {
agentId: string
path: string
name: string
size: number
updatedAt: string | null
mimeType: string
encoding: string
content: string
contentHash: string
checkedAt: string
}
const props = defineProps<{ agentId: string }>()
const collection = ref<WorkspaceCollection | null>(null)
const selectedFile = ref<WorkspaceFile | null>(null)
const loading = ref(false)
const error = ref('')
const currentLabel = computed(() => collection.value?.path || 'Workspace root')
async function loadDirectory(path = '') {
loading.value = true
error.value = ''
selectedFile.value = null
try {
const query = new URLSearchParams({ path, offset: '0', limit: '250' })
const response = await apiFetch(
`/api/v1/openclaw/agents/${encodeURIComponent(props.agentId)}/workspace?${query}`,
)
const payload = await response.json().catch(() => null) as
| WorkspaceCollection
| { message?: string }
| null
if (!response.ok) throw new Error((payload as { message?: string } | null)?.message || `HTTP ${response.status}`)
collection.value = payload as WorkspaceCollection
} catch (cause) {
error.value = cause instanceof Error ? cause.message : 'Workspace konnte nicht geladen werden.'
} finally {
loading.value = false
}
}
async function openEntry(entry: WorkspaceEntry) {
if (entry.kind === 'directory' || entry.kind === 'folder') {
await loadDirectory(entry.path)
return
}
loading.value = true
error.value = ''
try {
const query = new URLSearchParams({ path: entry.path })
const response = await apiFetch(
`/api/v1/openclaw/agents/${encodeURIComponent(props.agentId)}/workspace/file?${query}`,
)
const payload = await response.json().catch(() => null) as
| WorkspaceFile
| { message?: string }
| null
if (!response.ok) throw new Error((payload as { message?: string } | null)?.message || `HTTP ${response.status}`)
selectedFile.value = payload as WorkspaceFile
} catch (cause) {
error.value = cause instanceof Error ? cause.message : 'Workspace-Datei konnte nicht geladen werden.'
} finally {
loading.value = false
}
}
function formatSize(size: number | null) {
if (size === null) return '—'
if (size < 1024) return `${size} B`
return `${(size / 1024).toFixed(1)} KB`
}
onMounted(() => loadDirectory())
</script>
<template>
<section class="workspace-browser" aria-labelledby="workspace-browser-title">
<header>
<div>
<span class="eyebrow">READ-ONLY WORKSPACE</span>
<h3 id="workspace-browser-title">Zusätzliche Agent-Dateien</h3>
<p>Custom-Dokumente werden sicher über OpenClaw gelesen und bleiben unverändert.</p>
</div>
<button
type="button"
class="nexus-button"
:disabled="loading"
@click="loadDirectory(collection?.path || '')"
>
<Loader2 v-if="loading" :size="14" class="spin" aria-hidden="true" />
<RefreshCw v-else :size="14" aria-hidden="true" />
Aktualisieren
</button>
</header>
<div class="workspace-path">
<button
type="button"
class="nexus-button"
:disabled="!collection?.parentPath && collection?.path === ''"
@click="loadDirectory(collection?.parentPath || '')"
>
<ArrowLeft :size="14" aria-hidden="true" />
Eine Ebene hoch
</button>
<code>{{ currentLabel }}</code>
<span>{{ collection?.totalEntries ?? 0 }} Einträge</span>
</div>
<div v-if="error" class="workspace-state workspace-state--error" role="alert">
<CircleAlert :size="17" aria-hidden="true" />
<p>{{ error }}</p>
<button type="button" class="nexus-button" @click="loadDirectory(collection?.path || '')">Erneut laden</button>
</div>
<div v-else-if="loading && !collection" class="workspace-state" role="status">
<Loader2 :size="17" class="spin" aria-hidden="true" />
<p>Workspace wird geladen</p>
</div>
<div v-else-if="collection?.entries.length" class="workspace-list">
<button
v-for="entry in collection.entries"
:key="entry.path"
type="button"
class="workspace-entry"
@click="openEntry(entry)"
>
<Folder v-if="entry.kind === 'directory' || entry.kind === 'folder'" :size="16" aria-hidden="true" />
<FileText v-else :size="16" aria-hidden="true" />
<span><strong>{{ entry.name }}</strong><code>{{ entry.path }}</code></span>
<small>{{ formatSize(entry.size) }}</small>
</button>
</div>
<div v-else class="workspace-state">
<Folder :size="17" aria-hidden="true" />
<p>OpenClaw meldet in diesem Ordner keine lesbaren Einträge.</p>
</div>
<div v-if="selectedFile" class="workspace-preview">
<header>
<div>
<strong>{{ selectedFile.name }}</strong>
<code>{{ selectedFile.path }} · sha256:{{ selectedFile.contentHash.slice(0, 12) }}</code>
</div>
<button type="button" aria-label="Dateivorschau schließen" @click="selectedFile = null">
<X :size="16" aria-hidden="true" />
</button>
</header>
<pre tabindex="0">{{ selectedFile.content }}</pre>
</div>
</section>
</template>
<style scoped>
.workspace-browser { display: grid; gap: 12px; margin-top: 16px; padding: 15px; border: 1px solid var(--line); border-radius: var(--r); background: var(--glass); }
.workspace-browser > header, .workspace-path, .workspace-entry, .workspace-preview > header { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.workspace-browser h3 { margin: 3px 0 0; font-family: var(--font-display); font-size: 15px; }
.workspace-browser header p { margin: 4px 0 0; color: var(--tx-3); line-height: 1.5; }
.workspace-path { min-width: 0; padding: 9px; border: 1px solid var(--line); border-radius: var(--r-sm); background: var(--accent-wash); }
.workspace-path code { min-width: 0; overflow: hidden; color: var(--tx-2); font-family: var(--font-mono-v2); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.workspace-path > span { flex: 0 0 auto; color: var(--tx-3); font-size: 11px; }
.workspace-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; }
.workspace-entry { min-width: 0; padding: 10px; border: 1px solid var(--line); border-radius: var(--r-sm); background: var(--accent-wash); color: var(--tx-2); text-align: left; cursor: pointer; }
.workspace-entry:hover { border-color: var(--line-3); }
.workspace-entry > svg { flex: 0 0 auto; color: var(--a-mid); }
.workspace-entry > span { display: grid; flex: 1; gap: 3px; min-width: 0; }
.workspace-entry strong, .workspace-entry code { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.workspace-entry code, .workspace-entry small { color: var(--tx-3); font-family: var(--font-mono-v2); font-size: 10px; }
.workspace-state { display: flex; align-items: center; gap: 9px; min-height: 58px; padding: 12px; border: 1px dashed var(--line-2); border-radius: var(--r-sm); color: var(--tx-3); }
.workspace-state p { flex: 1; margin: 0; }
.workspace-state--error { border-style: solid; border-color: var(--status-block-line); background: var(--status-block-bg); color: var(--st-block); }
.workspace-preview { overflow: hidden; border: 1px solid var(--line-2); border-radius: var(--r-sm); }
.workspace-preview > header { padding: 11px 13px; border-bottom: 1px solid var(--line); background: var(--accent-wash); }
.workspace-preview header div { display: grid; gap: 3px; min-width: 0; }
.workspace-preview header code { overflow-wrap: anywhere; color: var(--tx-3); font-family: var(--font-mono-v2); font-size: 10px; }
.workspace-preview header button { width: 34px; height: 34px; display: grid; place-items: center; flex: 0 0 auto; border: 1px solid var(--line); border-radius: var(--r-sm); background: var(--glass); color: var(--tx-2); cursor: pointer; }
.workspace-preview pre { max-height: 420px; margin: 0; overflow: auto; padding: 14px; background: var(--field-surface); color: var(--tx-2); font-family: var(--font-mono-v2); font-size: 11px; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }
.spin { animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 680px) {
.workspace-browser > header, .workspace-path { align-items: flex-start; flex-direction: column; }
.workspace-list { grid-template-columns: 1fr; }
.workspace-path code { white-space: normal; overflow-wrap: anywhere; }
}
</style>