feat: files preview

This commit is contained in:
Acbox
2026-02-28 21:45:30 +08:00
parent fab5ae6320
commit e365e5545a
11 changed files with 1181 additions and 1 deletions
+1
View File
@@ -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>
+31
View File
@@ -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.",
+31
View File
@@ -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>
+16 -1
View File
@@ -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 },
+3
View File
@@ -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))