220 lines
8.9 KiB
Vue
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>
|