mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
feat: files preview
This commit is contained in:
@@ -27,6 +27,7 @@
|
||||
"markstream-vue": "0.0.7-beta.2",
|
||||
"mermaid": "^11.12.2",
|
||||
"modern-css-reset": "^1.4.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"pinia": "^3.0.4",
|
||||
"pinia-plugin-persistedstate": "^4.7.1",
|
||||
"shiki": "^3.21.0",
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from '@memoh/ui'
|
||||
import type { HandlersFsFileInfo } from '@memoh/sdk'
|
||||
import { formatFileSize, formatRelativeTime } from './utils'
|
||||
|
||||
const props = defineProps<{
|
||||
entries: HandlersFsFileInfo[]
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
navigate: [path: string]
|
||||
open: [entry: HandlersFsFileInfo]
|
||||
download: [entry: HandlersFsFileInfo]
|
||||
rename: [entry: HandlersFsFileInfo]
|
||||
delete: [entry: HandlersFsFileInfo]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const sortedEntries = computed(() => {
|
||||
const dirs = props.entries
|
||||
.filter(e => e.isDir)
|
||||
.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''))
|
||||
const files = props.entries
|
||||
.filter(e => !e.isDir)
|
||||
.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''))
|
||||
return [...dirs, ...files]
|
||||
})
|
||||
|
||||
function handleClick(entry: HandlersFsFileInfo) {
|
||||
if (entry.isDir) {
|
||||
emit('navigate', entry.path ?? '')
|
||||
} else {
|
||||
emit('open', entry)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div
|
||||
v-if="loading"
|
||||
class="flex items-center justify-center py-16 text-muted-foreground"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
:icon="['fas', 'spinner']"
|
||||
class="mr-2 size-4 animate-spin"
|
||||
/>
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="sortedEntries.length === 0"
|
||||
class="flex flex-col items-center justify-center py-16 text-muted-foreground"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
:icon="['fas', 'folder-open']"
|
||||
class="mb-2 size-8 opacity-40"
|
||||
/>
|
||||
<span>{{ t('bots.files.empty') }}</span>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- Header row -->
|
||||
<div class="flex items-center border-b border-border px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
<div class="flex-1">
|
||||
{{ t('bots.files.name') }}
|
||||
</div>
|
||||
<div class="hidden w-20 text-right sm:block">
|
||||
{{ t('bots.files.size') }}
|
||||
</div>
|
||||
<div class="hidden w-28 text-right md:block">
|
||||
{{ t('bots.files.modified') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File rows -->
|
||||
<ContextMenu
|
||||
v-for="entry in sortedEntries"
|
||||
:key="entry.path"
|
||||
>
|
||||
<ContextMenuTrigger as-child>
|
||||
<div
|
||||
class="flex items-center border-b border-border/50 cursor-pointer px-3 py-2 text-sm transition-colors hover:bg-muted/50"
|
||||
@click="handleClick(entry)"
|
||||
>
|
||||
<div class="flex flex-1 items-center gap-2 min-w-0">
|
||||
<FontAwesomeIcon
|
||||
:icon="entry.isDir ? ['fas', 'folder'] : ['fas', 'file']"
|
||||
:class="entry.isDir ? 'text-blue-500' : 'text-muted-foreground'"
|
||||
class="size-4 shrink-0"
|
||||
/>
|
||||
<span class="truncate">{{ entry.name }}</span>
|
||||
</div>
|
||||
<div class="hidden w-20 shrink-0 text-right text-muted-foreground sm:block">
|
||||
{{ entry.isDir ? '' : formatFileSize(entry.size) }}
|
||||
</div>
|
||||
<div class="hidden w-28 shrink-0 text-right text-muted-foreground md:block">
|
||||
{{ formatRelativeTime(entry.modTime) }}
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
v-if="!entry.isDir"
|
||||
@select="emit('download', entry)"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
:icon="['fas', 'download']"
|
||||
class="mr-2 size-3.5"
|
||||
/>
|
||||
{{ t('bots.files.download') }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem @select="emit('rename', entry)">
|
||||
<FontAwesomeIcon
|
||||
:icon="['fas', 'pen']"
|
||||
class="mr-2 size-3.5"
|
||||
/>
|
||||
{{ t('bots.files.rename') }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
class="text-destructive focus:text-destructive"
|
||||
@select="emit('delete', entry)"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
:icon="['fas', 'trash']"
|
||||
class="mr-2 size-3.5"
|
||||
/>
|
||||
{{ t('bots.files.delete') }}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,233 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed, onBeforeUnmount } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
import { Button, Spinner } from '@memoh/ui'
|
||||
import {
|
||||
getBotsByBotIdContainerFsRead,
|
||||
postBotsByBotIdContainerFsWrite,
|
||||
getBotsByBotIdContainerFsDownload,
|
||||
} from '@memoh/sdk'
|
||||
import type { HandlersFsFileInfo } from '@memoh/sdk'
|
||||
import { resolveApiErrorMessage } from '@/utils/api-error'
|
||||
import MonacoEditor from '@/components/monaco-editor/index.vue'
|
||||
import { isTextFile, isImageFile, formatFileSize } from './utils'
|
||||
|
||||
const props = defineProps<{
|
||||
botId: string
|
||||
file: HandlersFsFileInfo
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const content = ref('')
|
||||
const originalContent = ref('')
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const imageUrl = ref('')
|
||||
|
||||
const filename = computed(() => props.file.name ?? '')
|
||||
const filePath = computed(() => props.file.path ?? '')
|
||||
const isText = computed(() => isTextFile(filename.value))
|
||||
const isImage = computed(() => isImageFile(filename.value))
|
||||
const isDirty = computed(() => content.value !== originalContent.value)
|
||||
|
||||
async function loadTextContent() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await getBotsByBotIdContainerFsRead({
|
||||
path: { bot_id: props.botId },
|
||||
query: { path: filePath.value },
|
||||
throwOnError: true,
|
||||
})
|
||||
content.value = data.content ?? ''
|
||||
originalContent.value = content.value
|
||||
} catch (error) {
|
||||
toast.error(resolveApiErrorMessage(error, t('bots.files.readFailed')))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadImageBlob() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await getBotsByBotIdContainerFsDownload({
|
||||
path: { bot_id: props.botId },
|
||||
query: { path: filePath.value },
|
||||
parseAs: 'blob',
|
||||
throwOnError: true,
|
||||
})
|
||||
const blob = response.data as unknown as Blob
|
||||
imageUrl.value = URL.createObjectURL(blob)
|
||||
} catch (error) {
|
||||
toast.error(resolveApiErrorMessage(error, t('bots.files.readFailed')))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!isDirty.value || saving.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
await postBotsByBotIdContainerFsWrite({
|
||||
path: { bot_id: props.botId },
|
||||
body: { path: filePath.value, content: content.value },
|
||||
throwOnError: true,
|
||||
})
|
||||
originalContent.value = content.value
|
||||
toast.success(t('bots.files.saveSuccess'))
|
||||
emit('saved')
|
||||
} catch (error) {
|
||||
toast.error(resolveApiErrorMessage(error, t('bots.files.saveFailed')))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
const url = `/api/bots/${props.botId}/container/fs/download?path=${encodeURIComponent(filePath.value)}`
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename.value
|
||||
a.click()
|
||||
}
|
||||
|
||||
function cleanupImageUrl() {
|
||||
if (imageUrl.value) {
|
||||
URL.revokeObjectURL(imageUrl.value)
|
||||
imageUrl.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.file.path, () => {
|
||||
cleanupImageUrl()
|
||||
content.value = ''
|
||||
originalContent.value = ''
|
||||
if (isText.value) {
|
||||
void loadTextContent()
|
||||
} else if (isImage.value) {
|
||||
void loadImageBlob()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanupImageUrl()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-border px-4 py-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<FontAwesomeIcon
|
||||
:icon="['fas', 'file']"
|
||||
class="size-3.5 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<span class="truncate text-sm font-medium">{{ filename }}</span>
|
||||
<span class="shrink-0 text-xs text-muted-foreground">{{ formatFileSize(file.size) }}</span>
|
||||
<span
|
||||
v-if="isDirty"
|
||||
class="shrink-0 text-xs text-orange-500"
|
||||
>{{ t('bots.files.unsaved') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Button
|
||||
v-if="isText && isDirty"
|
||||
size="sm"
|
||||
:disabled="saving"
|
||||
@click="handleSave"
|
||||
>
|
||||
<Spinner
|
||||
v-if="saving"
|
||||
class="mr-1"
|
||||
/>
|
||||
{{ t('bots.files.save') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="handleDownload"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
:icon="['fas', 'download']"
|
||||
class="mr-1 size-3"
|
||||
/>
|
||||
{{ t('bots.files.download') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="size-7 p-0"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
:icon="['fas', 'xmark']"
|
||||
class="size-4"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
<div
|
||||
v-if="loading"
|
||||
class="flex h-full items-center justify-center text-muted-foreground"
|
||||
>
|
||||
<Spinner class="mr-2" />
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
|
||||
<MonacoEditor
|
||||
v-else-if="isText"
|
||||
v-model="content"
|
||||
:filename="filename"
|
||||
class="h-full"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-else-if="isImage && imageUrl"
|
||||
class="flex h-full items-center justify-center overflow-auto p-4 bg-muted/30"
|
||||
>
|
||||
<img
|
||||
:src="imageUrl"
|
||||
:alt="filename"
|
||||
class="max-h-full max-w-full object-contain rounded"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="flex h-full flex-col items-center justify-center gap-3 text-muted-foreground"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
:icon="['fas', 'file']"
|
||||
class="size-12 opacity-30"
|
||||
/>
|
||||
<p class="text-sm">
|
||||
{{ t('bots.files.previewNotAvailable') }}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="handleDownload"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
:icon="['fas', 'download']"
|
||||
class="mr-1.5 size-3"
|
||||
/>
|
||||
{{ t('bots.files.download') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,431 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
Spinner,
|
||||
} from '@memoh/ui'
|
||||
import {
|
||||
getBotsByBotIdContainerFsList,
|
||||
postBotsByBotIdContainerFsUpload,
|
||||
postBotsByBotIdContainerFsMkdir,
|
||||
postBotsByBotIdContainerFsDelete,
|
||||
postBotsByBotIdContainerFsRename,
|
||||
} from '@memoh/sdk'
|
||||
import type { HandlersFsFileInfo } from '@memoh/sdk'
|
||||
import { resolveApiErrorMessage } from '@/utils/api-error'
|
||||
import { pathSegments, joinPath } from './utils'
|
||||
import FileList from './file-list.vue'
|
||||
import FileViewer from './file-viewer.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
botId: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const currentPath = ref('/data')
|
||||
const entries = ref<HandlersFsFileInfo[]>([])
|
||||
const listLoading = ref(false)
|
||||
const openFile = ref<HandlersFsFileInfo | null>(null)
|
||||
|
||||
const mkdirDialogOpen = ref(false)
|
||||
const mkdirName = ref('')
|
||||
const mkdirLoading = ref(false)
|
||||
|
||||
const renameDialogOpen = ref(false)
|
||||
const renameTarget = ref<HandlersFsFileInfo | null>(null)
|
||||
const renameNewName = ref('')
|
||||
const renameLoading = ref(false)
|
||||
|
||||
const deleteDialogOpen = ref(false)
|
||||
const deleteTarget = ref<HandlersFsFileInfo | null>(null)
|
||||
const deleteLoading = ref(false)
|
||||
|
||||
const uploadInputRef = ref<HTMLInputElement>()
|
||||
|
||||
async function loadDirectory(path: string) {
|
||||
listLoading.value = true
|
||||
try {
|
||||
const { data } = await getBotsByBotIdContainerFsList({
|
||||
path: { bot_id: props.botId },
|
||||
query: { path },
|
||||
throwOnError: true,
|
||||
})
|
||||
entries.value = data.entries ?? []
|
||||
currentPath.value = data.path ?? path
|
||||
} catch (error) {
|
||||
toast.error(resolveApiErrorMessage(error, t('bots.files.loadFailed')))
|
||||
} finally {
|
||||
listLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleNavigate(path: string) {
|
||||
openFile.value = null
|
||||
void loadDirectory(path)
|
||||
}
|
||||
|
||||
function handleOpenFile(entry: HandlersFsFileInfo) {
|
||||
openFile.value = entry
|
||||
}
|
||||
|
||||
function handleCloseViewer() {
|
||||
openFile.value = null
|
||||
}
|
||||
|
||||
function handleFileSaved() {
|
||||
void loadDirectory(currentPath.value)
|
||||
}
|
||||
|
||||
// Upload
|
||||
function triggerUpload() {
|
||||
uploadInputRef.value?.click()
|
||||
}
|
||||
|
||||
async function handleUpload(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
const destPath = joinPath(currentPath.value, file.name)
|
||||
try {
|
||||
await postBotsByBotIdContainerFsUpload({
|
||||
path: { bot_id: props.botId },
|
||||
body: { path: destPath, file } as never,
|
||||
throwOnError: true,
|
||||
})
|
||||
toast.success(t('bots.files.uploadSuccess'))
|
||||
void loadDirectory(currentPath.value)
|
||||
} catch (error) {
|
||||
toast.error(resolveApiErrorMessage(error, t('bots.files.uploadFailed')))
|
||||
} finally {
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// Mkdir
|
||||
function openMkdirDialog() {
|
||||
mkdirName.value = ''
|
||||
mkdirDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleMkdir() {
|
||||
const name = mkdirName.value.trim()
|
||||
if (!name || mkdirLoading.value) return
|
||||
|
||||
mkdirLoading.value = true
|
||||
try {
|
||||
await postBotsByBotIdContainerFsMkdir({
|
||||
path: { bot_id: props.botId },
|
||||
body: { path: joinPath(currentPath.value, name) },
|
||||
throwOnError: true,
|
||||
})
|
||||
mkdirDialogOpen.value = false
|
||||
toast.success(t('bots.files.mkdirSuccess'))
|
||||
void loadDirectory(currentPath.value)
|
||||
} catch (error) {
|
||||
toast.error(resolveApiErrorMessage(error, t('bots.files.mkdirFailed')))
|
||||
} finally {
|
||||
mkdirLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Rename
|
||||
function openRenameDialog(entry: HandlersFsFileInfo) {
|
||||
renameTarget.value = entry
|
||||
renameNewName.value = entry.name ?? ''
|
||||
renameDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleRename() {
|
||||
const target = renameTarget.value
|
||||
const newName = renameNewName.value.trim()
|
||||
if (!target || !newName || renameLoading.value) return
|
||||
|
||||
renameLoading.value = true
|
||||
try {
|
||||
await postBotsByBotIdContainerFsRename({
|
||||
path: { bot_id: props.botId },
|
||||
body: {
|
||||
oldPath: target.path,
|
||||
newPath: joinPath(currentPath.value, newName),
|
||||
},
|
||||
throwOnError: true,
|
||||
})
|
||||
renameDialogOpen.value = false
|
||||
if (openFile.value?.path === target.path) {
|
||||
openFile.value = null
|
||||
}
|
||||
toast.success(t('bots.files.renameSuccess'))
|
||||
void loadDirectory(currentPath.value)
|
||||
} catch (error) {
|
||||
toast.error(resolveApiErrorMessage(error, t('bots.files.renameFailed')))
|
||||
} finally {
|
||||
renameLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Delete
|
||||
function openDeleteDialog(entry: HandlersFsFileInfo) {
|
||||
deleteTarget.value = entry
|
||||
deleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
const target = deleteTarget.value
|
||||
if (!target || deleteLoading.value) return
|
||||
|
||||
deleteLoading.value = true
|
||||
try {
|
||||
await postBotsByBotIdContainerFsDelete({
|
||||
path: { bot_id: props.botId },
|
||||
body: { path: target.path, recursive: target.isDir },
|
||||
throwOnError: true,
|
||||
})
|
||||
deleteDialogOpen.value = false
|
||||
if (openFile.value?.path === target.path) {
|
||||
openFile.value = null
|
||||
}
|
||||
toast.success(t('bots.files.deleteSuccess'))
|
||||
void loadDirectory(currentPath.value)
|
||||
} catch (error) {
|
||||
toast.error(resolveApiErrorMessage(error, t('bots.files.deleteFailed')))
|
||||
} finally {
|
||||
deleteLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Download
|
||||
function handleDownload(entry: HandlersFsFileInfo) {
|
||||
const url = `/api/bots/${props.botId}/container/fs/download?path=${encodeURIComponent(entry.path ?? '')}`
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = entry.name ?? 'file'
|
||||
a.click()
|
||||
}
|
||||
|
||||
watch(() => props.botId, () => {
|
||||
openFile.value = null
|
||||
currentPath.value = '/data'
|
||||
void loadDirectory('/data')
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col overflow-hidden">
|
||||
<!-- Toolbar -->
|
||||
<div class="flex items-center gap-2 border-b border-border px-4 py-2">
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="flex min-w-0 flex-1 items-center gap-1 text-sm">
|
||||
<template
|
||||
v-for="(seg, idx) in pathSegments(currentPath)"
|
||||
:key="seg.path"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
v-if="idx > 0"
|
||||
:icon="['fas', 'chevron-right']"
|
||||
class="size-2.5 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<button
|
||||
class="truncate rounded px-1.5 py-0.5 hover:bg-muted transition-colors"
|
||||
:class="idx === pathSegments(currentPath).length - 1 ? 'font-medium text-foreground' : 'text-muted-foreground'"
|
||||
@click="handleNavigate(seg.path)"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
v-if="idx === 0"
|
||||
:icon="['fas', 'folder']"
|
||||
class="mr-1 size-3"
|
||||
/>
|
||||
{{ seg.name }}
|
||||
</button>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<input
|
||||
ref="uploadInputRef"
|
||||
type="file"
|
||||
class="hidden"
|
||||
@change="handleUpload"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="triggerUpload"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
:icon="['fas', 'upload']"
|
||||
class="mr-1.5 size-3"
|
||||
/>
|
||||
{{ t('bots.files.upload') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="openMkdirDialog"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
:icon="['fas', 'folder-plus']"
|
||||
class="mr-1.5 size-3"
|
||||
/>
|
||||
{{ t('bots.files.newFolder') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="size-8 p-0"
|
||||
:disabled="listLoading"
|
||||
@click="() => loadDirectory(currentPath)"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
:icon="['fas', 'rotate']"
|
||||
class="size-3.5"
|
||||
:class="{ 'animate-spin': listLoading }"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main content area -->
|
||||
<div class="flex flex-1 min-h-0 overflow-hidden">
|
||||
<!-- File list -->
|
||||
<div
|
||||
class="overflow-auto border-border transition-all"
|
||||
:class="openFile ? 'w-80 shrink-0 border-r' : 'w-full'"
|
||||
>
|
||||
<FileList
|
||||
:entries="entries"
|
||||
:loading="listLoading"
|
||||
@navigate="handleNavigate"
|
||||
@open="handleOpenFile"
|
||||
@download="handleDownload"
|
||||
@rename="openRenameDialog"
|
||||
@delete="openDeleteDialog"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- File viewer -->
|
||||
<div
|
||||
v-if="openFile"
|
||||
class="flex-1 overflow-hidden"
|
||||
>
|
||||
<FileViewer
|
||||
:bot-id="botId"
|
||||
:file="openFile"
|
||||
@close="handleCloseViewer"
|
||||
@saved="handleFileSaved"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mkdir dialog -->
|
||||
<Dialog v-model:open="mkdirDialogOpen">
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t('bots.files.newFolder') }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
v-model="mkdirName"
|
||||
:placeholder="t('bots.files.folderNamePlaceholder')"
|
||||
:disabled="mkdirLoading"
|
||||
@keydown.enter.prevent="handleMkdir"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="mkdirLoading"
|
||||
@click="mkdirDialogOpen = false"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="!mkdirName.trim() || mkdirLoading"
|
||||
@click="handleMkdir"
|
||||
>
|
||||
<Spinner
|
||||
v-if="mkdirLoading"
|
||||
class="mr-1"
|
||||
/>
|
||||
{{ t('common.confirm') }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- Rename dialog -->
|
||||
<Dialog v-model:open="renameDialogOpen">
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t('bots.files.rename') }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
v-model="renameNewName"
|
||||
:placeholder="t('bots.files.newNamePlaceholder')"
|
||||
:disabled="renameLoading"
|
||||
@keydown.enter.prevent="handleRename"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="renameLoading"
|
||||
@click="renameDialogOpen = false"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="!renameNewName.trim() || renameLoading"
|
||||
@click="handleRename"
|
||||
>
|
||||
<Spinner
|
||||
v-if="renameLoading"
|
||||
class="mr-1"
|
||||
/>
|
||||
{{ t('common.confirm') }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- Delete dialog -->
|
||||
<Dialog v-model:open="deleteDialogOpen">
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t('bots.files.confirmDelete') }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('bots.files.confirmDeleteMessage', { name: deleteTarget?.name ?? '' }) }}
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="deleteLoading"
|
||||
@click="deleteDialogOpen = false"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
:disabled="deleteLoading"
|
||||
@click="handleDelete"
|
||||
>
|
||||
<Spinner
|
||||
v-if="deleteLoading"
|
||||
class="mr-1"
|
||||
/>
|
||||
{{ t('bots.files.delete') }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,171 @@
|
||||
const EXTENSION_LANGUAGE_MAP: Record<string, string> = {
|
||||
js: 'javascript',
|
||||
mjs: 'javascript',
|
||||
cjs: 'javascript',
|
||||
jsx: 'javascript',
|
||||
ts: 'typescript',
|
||||
tsx: 'typescript',
|
||||
mts: 'typescript',
|
||||
cts: 'typescript',
|
||||
vue: 'html',
|
||||
html: 'html',
|
||||
htm: 'html',
|
||||
css: 'css',
|
||||
scss: 'scss',
|
||||
less: 'less',
|
||||
json: 'json',
|
||||
jsonc: 'json',
|
||||
md: 'markdown',
|
||||
markdown: 'markdown',
|
||||
xml: 'xml',
|
||||
svg: 'xml',
|
||||
yaml: 'yaml',
|
||||
yml: 'yaml',
|
||||
toml: 'ini',
|
||||
ini: 'ini',
|
||||
conf: 'ini',
|
||||
sh: 'shell',
|
||||
bash: 'shell',
|
||||
zsh: 'shell',
|
||||
fish: 'shell',
|
||||
py: 'python',
|
||||
rb: 'ruby',
|
||||
go: 'go',
|
||||
rs: 'rust',
|
||||
java: 'java',
|
||||
kt: 'kotlin',
|
||||
kts: 'kotlin',
|
||||
swift: 'swift',
|
||||
c: 'c',
|
||||
cpp: 'cpp',
|
||||
cc: 'cpp',
|
||||
cxx: 'cpp',
|
||||
h: 'c',
|
||||
hpp: 'cpp',
|
||||
cs: 'csharp',
|
||||
php: 'php',
|
||||
r: 'r',
|
||||
R: 'r',
|
||||
sql: 'sql',
|
||||
graphql: 'graphql',
|
||||
gql: 'graphql',
|
||||
dockerfile: 'dockerfile',
|
||||
makefile: 'makefile',
|
||||
lua: 'lua',
|
||||
perl: 'perl',
|
||||
pl: 'perl',
|
||||
scala: 'scala',
|
||||
groovy: 'groovy',
|
||||
dart: 'dart',
|
||||
elixir: 'elixir',
|
||||
ex: 'elixir',
|
||||
exs: 'elixir',
|
||||
clj: 'clojure',
|
||||
bat: 'bat',
|
||||
cmd: 'bat',
|
||||
ps1: 'powershell',
|
||||
psm1: 'powershell',
|
||||
tf: 'hcl',
|
||||
hcl: 'hcl',
|
||||
}
|
||||
|
||||
const TEXT_EXTENSIONS = new Set([
|
||||
...Object.keys(EXTENSION_LANGUAGE_MAP),
|
||||
'txt', 'log', 'env', 'gitignore', 'gitattributes',
|
||||
'editorconfig', 'prettierrc', 'eslintrc', 'babelrc',
|
||||
'npmrc', 'nvmrc', 'dockerignore', 'lock', 'csv', 'tsv',
|
||||
'properties', 'cfg', 'cmake',
|
||||
])
|
||||
|
||||
const TEXT_FILENAMES = new Set([
|
||||
'Makefile', 'Dockerfile', 'Vagrantfile', 'Gemfile',
|
||||
'Rakefile', 'Procfile', 'LICENSE', 'CHANGELOG',
|
||||
'README', 'AUTHORS', 'CONTRIBUTORS', '.gitignore',
|
||||
'.gitattributes', '.editorconfig', '.env', '.env.local',
|
||||
'.env.development', '.env.production', '.prettierrc',
|
||||
'.eslintrc', '.babelrc', '.npmrc', '.nvmrc',
|
||||
'.dockerignore',
|
||||
])
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set([
|
||||
'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'ico', 'bmp', 'avif',
|
||||
])
|
||||
|
||||
export function getExtension(filename: string): string {
|
||||
const dotIndex = filename.lastIndexOf('.')
|
||||
if (dotIndex === -1 || dotIndex === filename.length - 1) return ''
|
||||
return filename.slice(dotIndex + 1).toLowerCase()
|
||||
}
|
||||
|
||||
export function getLanguageByFilename(filename: string): string {
|
||||
const lower = filename.toLowerCase()
|
||||
if (lower === 'dockerfile' || lower.startsWith('dockerfile.')) return 'dockerfile'
|
||||
if (lower === 'makefile') return 'makefile'
|
||||
const ext = getExtension(filename)
|
||||
return EXTENSION_LANGUAGE_MAP[ext] ?? 'plaintext'
|
||||
}
|
||||
|
||||
export function isTextFile(filename: string): boolean {
|
||||
if (TEXT_FILENAMES.has(filename)) return true
|
||||
const ext = getExtension(filename)
|
||||
if (!ext) return false
|
||||
return TEXT_EXTENSIONS.has(ext)
|
||||
}
|
||||
|
||||
export function isImageFile(filename: string): boolean {
|
||||
const ext = getExtension(filename)
|
||||
return IMAGE_EXTENSIONS.has(ext)
|
||||
}
|
||||
|
||||
export function formatFileSize(bytes: number | undefined): string {
|
||||
if (bytes === undefined || bytes === null) return ''
|
||||
if (bytes === 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||
const size = bytes / Math.pow(1024, i)
|
||||
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`
|
||||
}
|
||||
|
||||
export function formatRelativeTime(dateStr: string | undefined): string {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffSec = Math.floor(diffMs / 1000)
|
||||
const diffMin = Math.floor(diffSec / 60)
|
||||
const diffHour = Math.floor(diffMin / 60)
|
||||
const diffDay = Math.floor(diffHour / 24)
|
||||
|
||||
if (diffDay > 30) {
|
||||
return date.toLocaleDateString()
|
||||
}
|
||||
if (diffDay > 0) return `${diffDay}d ago`
|
||||
if (diffHour > 0) return `${diffHour}h ago`
|
||||
if (diffMin > 0) return `${diffMin}m ago`
|
||||
return 'just now'
|
||||
}
|
||||
|
||||
export function joinPath(...parts: string[]): string {
|
||||
return parts
|
||||
.join('/')
|
||||
.replace(/\/+/g, '/')
|
||||
.replace(/\/$/, '') || '/'
|
||||
}
|
||||
|
||||
export function parentPath(path: string): string {
|
||||
if (path === '/' || path === '') return '/'
|
||||
const parts = path.replace(/\/$/, '').split('/')
|
||||
parts.pop()
|
||||
return parts.join('/') || '/'
|
||||
}
|
||||
|
||||
export function pathSegments(path: string): { name: string; path: string }[] {
|
||||
const parts = path.split('/').filter(Boolean)
|
||||
const segments: { name: string; path: string }[] = [{ name: '/', path: '/' }]
|
||||
let current = ''
|
||||
for (const part of parts) {
|
||||
current += '/' + part
|
||||
segments.push({ name: part, path: current })
|
||||
}
|
||||
return segments
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, watch, shallowRef } from 'vue'
|
||||
import * as monaco from 'monaco-editor'
|
||||
import { useSettingsStore } from '@/store/settings'
|
||||
import { getLanguageByFilename } from '@/components/file-manager/utils'
|
||||
|
||||
self.MonacoEnvironment = {
|
||||
getWorker() {
|
||||
return new Worker(
|
||||
new URL('monaco-editor/esm/vs/editor/editor.worker.js', import.meta.url),
|
||||
{ type: 'module' },
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: string
|
||||
language?: string
|
||||
readonly?: boolean
|
||||
filename?: string
|
||||
}>(), {
|
||||
language: undefined,
|
||||
readonly: false,
|
||||
filename: undefined,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
const editorInstance = shallowRef<monaco.editor.IStandaloneCodeEditor>()
|
||||
const settings = useSettingsStore()
|
||||
|
||||
function resolveLanguage(): string {
|
||||
if (props.language) return props.language
|
||||
if (props.filename) return getLanguageByFilename(props.filename)
|
||||
return 'plaintext'
|
||||
}
|
||||
|
||||
function resolveTheme(): string {
|
||||
return settings.theme === 'dark' ? 'vs-dark' : 'vs'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!containerRef.value) return
|
||||
|
||||
editorInstance.value = monaco.editor.create(containerRef.value, {
|
||||
value: props.modelValue,
|
||||
language: resolveLanguage(),
|
||||
theme: resolveTheme(),
|
||||
readOnly: props.readonly,
|
||||
automaticLayout: true,
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
fontSize: 13,
|
||||
lineNumbers: 'on',
|
||||
renderLineHighlight: 'line',
|
||||
tabSize: 2,
|
||||
wordWrap: 'on',
|
||||
padding: { top: 8, bottom: 8 },
|
||||
})
|
||||
|
||||
editorInstance.value.onDidChangeModelContent(() => {
|
||||
const value = editorInstance.value?.getValue() ?? ''
|
||||
emit('update:modelValue', value)
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
editorInstance.value?.dispose()
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
const editor = editorInstance.value
|
||||
if (!editor) return
|
||||
if (editor.getValue() !== newVal) {
|
||||
editor.setValue(newVal)
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.readonly, (val) => {
|
||||
editorInstance.value?.updateOptions({ readOnly: val })
|
||||
})
|
||||
|
||||
watch([() => props.language, () => props.filename], () => {
|
||||
const model = editorInstance.value?.getModel()
|
||||
if (model) {
|
||||
monaco.editor.setModelLanguage(model, resolveLanguage())
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => settings.theme, () => {
|
||||
monaco.editor.setTheme(resolveTheme())
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="h-full w-full overflow-hidden"
|
||||
/>
|
||||
</template>
|
||||
@@ -351,8 +351,39 @@
|
||||
"history": "History",
|
||||
"skills": "Skills",
|
||||
"email": "Email",
|
||||
"files": "Files",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"files": {
|
||||
"name": "Name",
|
||||
"size": "Size",
|
||||
"modified": "Modified",
|
||||
"empty": "This directory is empty",
|
||||
"upload": "Upload",
|
||||
"newFolder": "New Folder",
|
||||
"download": "Download",
|
||||
"rename": "Rename",
|
||||
"delete": "Delete",
|
||||
"save": "Save",
|
||||
"unsaved": "(unsaved)",
|
||||
"previewNotAvailable": "Preview not available for this file type",
|
||||
"folderNamePlaceholder": "Folder name",
|
||||
"newNamePlaceholder": "New name",
|
||||
"confirmDelete": "Confirm Delete",
|
||||
"confirmDeleteMessage": "Are you sure you want to delete \"{name}\"? This action cannot be undone.",
|
||||
"loadFailed": "Failed to load directory",
|
||||
"readFailed": "Failed to read file",
|
||||
"saveSuccess": "File saved",
|
||||
"saveFailed": "Failed to save file",
|
||||
"uploadSuccess": "File uploaded",
|
||||
"uploadFailed": "Failed to upload file",
|
||||
"mkdirSuccess": "Folder created",
|
||||
"mkdirFailed": "Failed to create folder",
|
||||
"renameSuccess": "Renamed successfully",
|
||||
"renameFailed": "Failed to rename",
|
||||
"deleteSuccess": "Deleted successfully",
|
||||
"deleteFailed": "Failed to delete"
|
||||
},
|
||||
"email": {
|
||||
"title": "Email",
|
||||
"subtitle": "Manage email provider bindings and view inbox / outbox.",
|
||||
|
||||
@@ -347,8 +347,39 @@
|
||||
"history": "对话历史",
|
||||
"skills": "技能",
|
||||
"email": "邮件",
|
||||
"files": "文件",
|
||||
"settings": "设置"
|
||||
},
|
||||
"files": {
|
||||
"name": "名称",
|
||||
"size": "大小",
|
||||
"modified": "修改时间",
|
||||
"empty": "此目录为空",
|
||||
"upload": "上传",
|
||||
"newFolder": "新建文件夹",
|
||||
"download": "下载",
|
||||
"rename": "重命名",
|
||||
"delete": "删除",
|
||||
"save": "保存",
|
||||
"unsaved": "(未保存)",
|
||||
"previewNotAvailable": "无法预览此文件类型",
|
||||
"folderNamePlaceholder": "文件夹名称",
|
||||
"newNamePlaceholder": "新名称",
|
||||
"confirmDelete": "确认删除",
|
||||
"confirmDeleteMessage": "确定要删除 \"{name}\" 吗?此操作不可撤销。",
|
||||
"loadFailed": "加载目录失败",
|
||||
"readFailed": "读取文件失败",
|
||||
"saveSuccess": "文件已保存",
|
||||
"saveFailed": "保存文件失败",
|
||||
"uploadSuccess": "文件已上传",
|
||||
"uploadFailed": "上传文件失败",
|
||||
"mkdirSuccess": "文件夹已创建",
|
||||
"mkdirFailed": "创建文件夹失败",
|
||||
"renameSuccess": "重命名成功",
|
||||
"renameFailed": "重命名失败",
|
||||
"deleteSuccess": "删除成功",
|
||||
"deleteFailed": "删除失败"
|
||||
},
|
||||
"email": {
|
||||
"title": "邮件",
|
||||
"subtitle": "管理邮件提供方绑定,查看收件箱和发件箱。",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import FileManager from '@/components/file-manager/index.vue'
|
||||
|
||||
defineProps<{
|
||||
botId: string
|
||||
botType?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FileManager
|
||||
:bot-id="botId"
|
||||
class="h-full"
|
||||
/>
|
||||
</template>
|
||||
@@ -127,7 +127,20 @@
|
||||
<template #sidebar-footer />
|
||||
|
||||
<template #detail>
|
||||
<ScrollArea class="max-h-full h-full">
|
||||
<template v-if="activeTab === 'files'">
|
||||
<div class="h-full">
|
||||
<KeepAlive>
|
||||
<BotFiles
|
||||
:bot-id="botId"
|
||||
:bot-type="bot?.type"
|
||||
/>
|
||||
</KeepAlive>
|
||||
</div>
|
||||
</template>
|
||||
<ScrollArea
|
||||
v-else
|
||||
class="max-h-full h-full"
|
||||
>
|
||||
<section class="p-4">
|
||||
<KeepAlive>
|
||||
<component
|
||||
@@ -246,6 +259,7 @@ import BotEmail from './components/bot-email.vue'
|
||||
import BotSubagents from './components/bot-subagents.vue'
|
||||
import BotOverview from './components/bot-overview.vue'
|
||||
import BotContainer from './components/bot-container.vue'
|
||||
import BotFiles from './components/bot-files.vue'
|
||||
import { resolveApiErrorMessage } from '@/utils/api-error'
|
||||
import { useAvatarInitials } from '@/composables/useAvatarInitials'
|
||||
import { useSyncedQueryParam } from '@/composables/useSyncedQueryParam'
|
||||
@@ -266,6 +280,7 @@ const tabList = [
|
||||
{ value: 'channels', label: 'bots.tabs.channels', component: BotChannels },
|
||||
{ value: 'email', label: 'bots.tabs.email', component: BotEmail },
|
||||
{ value: 'container', label: 'bots.tabs.container', component: BotContainer },
|
||||
{ value: 'files', label: 'bots.tabs.files', component: BotFiles },
|
||||
{ value: 'mcp', label: 'bots.tabs.mcp' ,component: BotMcp },
|
||||
{ value: 'subagents', label: 'bots.tabs.subagents',component: BotSubagents },
|
||||
{ value: 'heartbeat', label: 'bots.tabs.heartbeat',component: BotHeartbeat },
|
||||
|
||||
Generated
+3
@@ -345,6 +345,9 @@ importers:
|
||||
modern-css-reset:
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0
|
||||
monaco-editor:
|
||||
specifier: ^0.52.2
|
||||
version: 0.52.2
|
||||
pinia:
|
||||
specifier: ^3.0.4
|
||||
version: 3.0.4(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3))
|
||||
|
||||
Reference in New Issue
Block a user