Files
llm-in-text/src/composables/useFileSystem.js
T

547 lines
15 KiB
JavaScript

import { computed, ref } from 'vue'
import {
createDocFolder,
createDocTextFile,
deleteDocNode,
fetchDocBlob,
fetchDocNodes,
replaceDocBlob,
updateDocNode,
uploadDocFile,
} from '../utils/docsApi'
const MAX_FILE_SIZE = 1024 * 1024 * 1024
const MAX_NODES = 5000
function getExtension(name = '') {
const parts = String(name).split('.')
return parts.length > 1 ? parts.pop().toLowerCase() : ''
}
function isTextExtension(ext) {
const textExtensions = [
'md', 'markdown', 'txt', 'json', 'js', 'jsx', 'ts', 'tsx',
'css', 'scss', 'less', 'html', 'htm', 'py', 'vue', 'xml',
'yaml', 'yml', 'csv', 'log', 'sql', 'toml', 'ini', 'cfg',
'conf', 'sh', 'bat', 'ps1', 'java', 'c', 'cpp', 'h', 'hpp',
'go', 'rs', 'swift', 'kt', 'rb', 'php', 'pl', 'r', 'scala',
'gradle', 'properties', 'env', 'gitignore', 'dockerfile'
]
return textExtensions.includes(ext)
}
function isBinaryExtension(ext) {
const binaryExtensions = [
'exe', 'dll', 'so', 'dylib', 'bin', 'dat', 'obj', 'o', 'a',
'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp',
'pdf', 'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz',
'png', 'jpg', 'jpeg', 'gif', 'bmp', 'ico', 'webp', 'svg',
'mp3', 'mp4', 'wav', 'avi', 'mov', 'mkv', 'flv', 'wmv',
'ttf', 'otf', 'woff', 'woff2', 'eot',
'class', 'pyc', 'pyo', 'jar', 'war', 'ear',
'db', 'sqlite', 'mdb', 'accdb',
'pem', 'key', 'crt', 'cer', 'p12', 'pfx', 'jks',
'msg', 'eml', 'pst', 'ost',
'dwg', 'dxf', 'step', 'stl', 'obj', 'fbx', '3ds', 'blend'
]
return binaryExtensions.includes(ext.toLowerCase())
}
function inferMimeType(name, fallback = '') {
const ext = getExtension(name)
const map = {
avi: 'video/x-msvideo',
md: 'text/markdown',
markdown: 'text/markdown',
mkv: 'video/x-matroska',
mov: 'video/quicktime',
mp4: 'video/mp4',
txt: 'text/plain',
json: 'application/json',
js: 'text/javascript',
jsx: 'text/javascript',
ts: 'text/typescript',
tsx: 'text/typescript',
css: 'text/css',
html: 'text/html',
htm: 'text/html',
py: 'text/x-python',
vue: 'text/plain',
xml: 'application/xml',
yaml: 'text/yaml',
yml: 'text/yaml',
csv: 'text/csv',
log: 'text/plain',
sql: 'text/plain',
toml: 'text/plain',
ini: 'text/plain',
cfg: 'text/plain',
conf: 'text/plain',
sh: 'text/plain',
bat: 'text/plain',
ps1: 'text/plain',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
flv: 'video/x-flv',
m4v: 'video/x-m4v',
png: 'image/png',
gif: 'image/gif',
webp: 'image/webp',
svg: 'image/svg+xml',
pdf: 'application/pdf',
ogg: 'video/ogg',
ogv: 'video/ogg',
webm: 'video/webm',
wmv: 'video/x-ms-wmv'
}
return fallback || map[ext] || 'application/octet-stream'
}
function isTextFile(record) {
const ext = getExtension(record?.name)
if (isBinaryExtension(ext)) return false
const mime = String(record?.mimeType || '')
return isTextExtension(ext) || mime.startsWith('text/') || mime.includes('json') || mime.includes('xml')
}
function buildTree(records) {
const map = new Map()
const roots = []
for (const record of records) {
map.set(record.id, {
...record,
children: record.type === 'folder' ? [] : undefined
})
}
for (const record of records) {
const current = map.get(record.id)
if (!record.parentId) {
roots.push(current)
continue
}
const parent = map.get(record.parentId)
if (parent?.type === 'folder') parent.children.push(current)
else roots.push(current)
}
const sorter = (a, b) => {
if (a.type !== b.type) return a.type === 'folder' ? -1 : 1
return a.name.localeCompare(b.name, 'zh-CN', { sensitivity: 'base' })
}
const sortChildren = (nodes) => {
nodes.sort(sorter)
for (const node of nodes) {
if (node.type === 'folder' && Array.isArray(node.children)) sortChildren(node.children)
}
}
sortChildren(roots)
return roots
}
function findNode(nodes, id) {
for (const node of nodes) {
if (node.id === id) return node
if (node.type === 'folder') {
const found = findNode(node.children || [], id)
if (found) return found
}
}
return null
}
function getPath(nodes, id, path = []) {
for (const node of nodes) {
if (node.id === id) return [...path, node]
if (node.type === 'folder') {
const found = getPath(node.children || [], id, [...path, node])
if (found) return found
}
}
return null
}
function estimateRecordSize(record) {
if (typeof record?.size === 'number') return record.size
if (typeof record?.content === 'string') return new Blob([record.content]).size
if (typeof record?.previewText === 'string') return new Blob([record.previewText]).size
return 0
}
function cloneNode(node) {
return JSON.parse(JSON.stringify(node))
}
let singleton = null
export function useFileSystem() {
if (singleton) return singleton
const records = ref([])
const selectedId = ref(null)
const expandedIds = ref(new Set())
const clipboard = ref(null)
const contextMenu = ref(null)
const error = ref(null)
const loading = ref(false)
const blobCache = new Map()
const tree = computed(() => buildTree(records.value))
const stats = computed(() => {
let fileCount = 0
let folderCount = 0
let usedBytes = 0
for (const record of records.value) {
if (record.type === 'folder') folderCount += 1
else fileCount += 1
usedBytes += estimateRecordSize(record)
}
return { fileCount, folderCount, usedBytes }
})
function clearBlobCache(id = null) {
if (id) {
blobCache.delete(id)
return
}
blobCache.clear()
}
function upsertRecord(nextRecord) {
const index = records.value.findIndex((item) => item.id === nextRecord.id)
if (index === -1) records.value = [...records.value, nextRecord]
else {
const next = [...records.value]
next[index] = nextRecord
records.value = next
}
return nextRecord
}
// --- Async operations with unified error handling ---
async function load() {
loading.value = true; records.value = []
try { records.value = await fetchDocNodes() }
catch (err) { error.value = err?.message || '读取文档空间失败,请稍后重试' }
finally { loading.value = false }
}
async function createFile(parentId, name, content = '') {
if (records.value.length >= MAX_NODES) {
error.value = `文件数量不能超过 ${MAX_NODES} 个`
return false
}
try {
const node = await createDocTextFile(name, parentId || null, content)
upsertRecord(node); selectedId.value = node.id
if (parentId) { expandedIds.value = new Set([...expandedIds.value, parentId]) }
return true
} catch (err) { error.value = err?.message || '创建文件失败'; return false }
}
async function updateFile(id, nextValue, options = {}) {
const file = records.value.find((item) => item.id === id && item.type === 'file')
if (!file) return false
try {
const node = nextValue instanceof Blob
? await replaceDocBlob(id, nextValue instanceof File ? nextValue : new File([nextValue], options.name || file.name, { type: options.mimeType || nextValue.type || '' }))
: await updateDocNode(id, { name: options.name || file.name, content: String(options.content ?? nextValue) })
upsertRecord(node); clearBlobCache(id); return true
} catch (err) { error.value = err?.message || '保存文件失败'; return false }
}
async function createFolder(parentId, name) {
if (records.value.length >= MAX_NODES) {
error.value = `目录项数量不能超过 ${MAX_NODES} 个`
return false
}
try {
const node = await createDocFolder(name, parentId || null)
upsertRecord(node); selectedId.value = node.id
if (parentId) expandedIds.value = new Set([...expandedIds.value, parentId])
return true
} catch (err) { error.value = err?.message || '创建文件夹失败'; return false }
}
async function rename(id, newName) {
const node = records.value.find((item) => item.id === id)
if (!node) return false
try {
const updated = await updateDocNode(id, { name: newName })
upsertRecord(updated); clearBlobCache(id); return true
} catch (err) { error.value = err?.message || '重命名失败'; return false }
}
function collectDescendantIds(id) {
const ids = new Set([id])
let changed = true
while (changed) {
changed = false
for (const record of records.value) {
if (record.parentId && ids.has(record.parentId) && !ids.has(record.id)) {
ids.add(record.id)
changed = true
}
}
}
return [...ids]
}
async function remove(id) {
const ids = new Set(collectDescendantIds(id))
try {
await deleteDocNode(id)
records.value = records.value.filter((item) => !ids.has(item.id))
if (selectedId.value && ids.has(selectedId.value)) selectedId.value = null
if (clipboard.value?.nodeId && ids.has(clipboard.value.nodeId)) clipboard.value = null
if (clipboard.value?.node?.id && ids.has(clipboard.value.node.id)) clipboard.value = null
ids.forEach((currentId) => clearBlobCache(currentId))
error.value = null
return true
} catch (err) {
error.value = err instanceof Error && err.message ? err.message : '删除文件失败'
return false
}
}
function select(id) {
selectedId.value = id
}
function toggleFolder(id) {
const node = records.value.find((item) => item.id === id)
if (!node || node.type !== 'folder') return
const next = new Set(expandedIds.value)
if (next.has(id)) next.delete(id)
else next.add(id)
expandedIds.value = next
}
function copy(id) {
const node = findNode(tree.value, id)
if (!node) return
clipboard.value = { mode: 'copy', node: cloneNode(node) }
}
function cut(id) {
const node = records.value.find((item) => item.id === id)
if (!node) return
clipboard.value = { mode: 'cut', nodeId: node.id }
}
function isDescendantOf(sourceId, targetParentId) {
let current = records.value.find((item) => item.id === targetParentId)
while (current) {
if (current.parentId === sourceId) return true
current = current.parentId ? records.value.find((item) => item.id === current.parentId) : null
}
return false
}
async function duplicateNode(node, targetParentId) {
let created
if (node.type === 'folder') {
created = await createDocFolder(node.name, targetParentId)
upsertRecord(created)
for (const child of node.children || []) {
await duplicateNode(child, created.id)
}
return created
}
if (node.storageKind === 'blob') {
const blob = await getFileBlob(node)
const upload = new File([blob], node.name, { type: node.mimeType || blob.type || inferMimeType(node.name) })
created = await uploadDocFile(upload, targetParentId)
} else {
created = await createDocTextFile(node.name, targetParentId, node.content || node.previewText || '')
}
upsertRecord(created)
return created
}
async function paste(targetParentId) {
if (!clipboard.value) return
try {
if (clipboard.value.mode === 'cut') {
const node = records.value.find((item) => item.id === clipboard.value.nodeId)
if (!node) {
clipboard.value = null
return
}
if (node.id === targetParentId || (targetParentId && isDescendantOf(node.id, targetParentId))) {
error.value = '不能移动到自身或子目录中'
return
}
const updated = await updateDocNode(node.id, { parentId: targetParentId || null })
upsertRecord(updated)
clipboard.value = null
error.value = null
return
}
const source = clipboard.value.node
if (!source) return
if (records.value.length >= MAX_NODES) {
error.value = `目录项数量不能超过 ${MAX_NODES} 个`
return
}
await duplicateNode(source, targetParentId || null)
error.value = null
} catch (err) {
error.value = err instanceof Error && err.message ? err.message : '粘贴失败'
}
}
function canPaste() {
return clipboard.value !== null
}
function clearClipboard() {
clipboard.value = null
}
function getSelectedNode() {
return selectedId.value ? findNode(tree.value, selectedId.value) : null
}
function getBreadcrumbPath(id) {
return getPath(tree.value, id) || []
}
function showContextMenu(x, y, node) {
contextMenu.value = { x, y, node }
}
function hideContextMenu() {
contextMenu.value = null
}
function getFileIcon(name) {
const ext = getExtension(name)
const iconMap = {
avi: 'video',
md: 'markdown',
markdown: 'markdown',
mkv: 'video',
mov: 'video',
mp4: 'video',
txt: 'text',
json: 'json',
js: 'javascript',
jsx: 'javascript',
ts: 'typescript',
tsx: 'typescript',
css: 'css',
html: 'html',
htm: 'html',
py: 'python',
vue: 'vue',
xml: 'xml',
yaml: 'yaml',
yml: 'yaml',
csv: 'csv',
log: 'log',
sql: 'sql',
jpg: 'image',
jpeg: 'image',
png: 'image',
gif: 'image',
flv: 'video',
m4v: 'video',
webp: 'image',
svg: 'image',
ogg: 'video',
ogv: 'video',
pdf: 'pdf',
doc: 'word',
docx: 'word',
ppt: 'ppt',
pptx: 'ppt',
webm: 'video',
wmv: 'video',
xls: 'excel',
xlsx: 'excel',
zip: 'zip'
}
return iconMap[ext] || 'file'
}
async function uploadFiles(files, parentId = null) {
const source = Array.from(files || [])
if (source.length === 0) return { success: 0, failed: [] }
const failed = []
let success = 0
for (const file of source) {
if (file.size > MAX_FILE_SIZE) {
failed.push({ name: file.name, reason: '单个文件不能超过 1GB' })
continue
}
try {
const node = await uploadDocFile(file, parentId)
upsertRecord(node)
success += 1
} catch (err) {
failed.push({
name: file.name,
reason: err instanceof Error && err.message ? err.message : '上传文件失败',
})
}
}
if (success > 0 && parentId) {
const next = new Set(expandedIds.value)
next.add(parentId)
expandedIds.value = next
}
return { success, failed }
}
async function getFileBlob(node) {
if (!node || node.type !== 'file') return null
if (blobCache.has(node.id)) return blobCache.get(node.id)
if (node.storageKind === 'text' && typeof node.content === 'string' && node.content && !node.isTruncatedPreview) {
const blob = new Blob([node.content], { type: inferMimeType(node.name, node.mimeType) })
blobCache.set(node.id, blob)
return blob
}
const blob = await fetchDocBlob(node.id)
blobCache.set(node.id, blob)
return blob
}
singleton = {
tree,
selectedId,
expandedIds,
clipboard,
contextMenu,
error,
loading,
stats,
load,
createFile,
updateFile,
createFolder,
rename,
remove,
select,
toggleFolder,
copy,
cut,
paste,
canPaste,
clearClipboard,
getSelectedNode,
getBreadcrumbPath,
showContextMenu,
hideContextMenu,
getFileIcon,
getExtension,
getFileBlob,
isTextFile,
uploadFiles,
MAX_FILE_SIZE,
MAX_NODES
}
return singleton
}