Files
steven_li 2c5205b06e feat: 添加MinIO文件系统支持并优化外部连接器功能
- 添加MinIO用户文件系统配置选项(BEAVER_MINIO_ROOT_USER等)
- 更新外部连接器配置结构,包括BASE_URL和认证令牌设置
- 改进connector provider支持更多类型(official, feishu_bot等)
- 实现Mistral模型推理模式支持reasoning_effort参数
- 增强外部连接器策略配置和运行时配置管理
- 添加connector bridge事件验证和安全保护机制
- 优化任务路由逻辑,区分simple_chat和new_task场景
- 更新初始技能工具提示配置,分离authoring admin功能
2026-06-05 11:46:40 +08:00

565 lines
21 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
Trash2,
Download,
Loader2,
FolderOpen,
Folder,
FileText,
Upload,
FolderPlus,
ChevronRight,
Home,
RefreshCw,
FileImage,
FileCode,
FileArchive,
FileSpreadsheet,
} from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import {
browseUserFiles,
getUserFile,
getUserFileDownloadUrl,
uploadUserFile,
deleteUserFile,
createUserFileDir,
getAccessToken,
} from '@/lib/api';
import type { UserFileContent, UserFileItem } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { type AppLocale, pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { containedLongTextClass, containedPreservedLongTextClass } from '@/lib/text-wrapping';
const LOAD_RETRY_DELAYS_MS = [0, 600, 1200];
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
window.setTimeout(resolve, ms);
});
}
export default function FilesPage() {
const { locale } = useAppI18n();
const [items, setItems] = useState<UserFileItem[]>([]);
const [currentPath, setCurrentPath] = useState('');
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [showMkdir, setShowMkdir] = useState(false);
const [newDirName, setNewDirName] = useState('');
const [selectedFile, setSelectedFile] = useState<UserFileContent | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [previewError, setPreviewError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const mkdirInputRef = useRef<HTMLInputElement>(null);
const load = useCallback(async (path: string = currentPath) => {
let lastError: unknown = null;
try {
setLoading(true);
setLoadError(null);
for (const delay of LOAD_RETRY_DELAYS_MS) {
if (delay > 0) {
await sleep(delay);
}
try {
const data = await browseUserFiles(path);
setItems(data.items);
setCurrentPath(data.path);
setSelectedFile(null);
setPreviewError(null);
return;
} catch (err) {
lastError = err;
}
}
const message = lastError instanceof Error ? lastError.message : pickAppText(locale, '加载文件失败', 'Failed to load files');
setLoadError(message);
setItems([]);
} finally {
setLoading(false);
}
}, [currentPath, locale]);
useEffect(() => {
load('');
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const navigateTo = (path: string) => {
load(path);
};
const openFile = async (item: UserFileItem) => {
if (item.type !== 'file') return;
setPreviewLoading(true);
setPreviewError(null);
try {
setSelectedFile(await getUserFile(item.path));
} catch (err: any) {
setPreviewError(err.message || pickAppText(locale, '加载文件失败', 'Failed to load file'));
setSelectedFile(null);
} finally {
setPreviewLoading(false);
}
};
const handleDelete = async (item: UserFileItem) => {
const label = item.type === 'directory'
? pickAppText(locale, '文件夹', 'folder')
: pickAppText(locale, '文件', 'file');
if (!confirm(pickAppText(
locale,
`确定删除${label} "${item.name}"${item.type === 'directory' ? '(包含所有子文件)' : ''}`,
`Delete ${label} "${item.name}"?${item.type === 'directory' ? ' (including all nested files)' : ''}`
))) {
return;
}
try {
await deleteUserFile(item.path);
setItems((prev) => prev.filter((i) => i.path !== item.path));
if (selectedFile?.path === item.path) {
setSelectedFile(null);
}
} catch (err: any) {
setLoadError(err.message || pickAppText(locale, '删除失败', 'Delete failed'));
}
};
const handleDownload = async (item: UserFileItem) => {
const url = getUserFileDownloadUrl(item.path);
const token = getAccessToken();
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
try {
const res = await fetch(url, { headers });
const blob = await res.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = item.name;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(a.href);
} catch (err: any) {
setPreviewError(err.message || pickAppText(locale, '下载失败', 'Download failed'));
}
};
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
setUploading(true);
setUploadProgress(0);
try {
for (let i = 0; i < files.length; i++) {
await uploadUserFile(files[i], currentPath, (pct) => {
setUploadProgress(Math.round((i / files.length) * 100 + pct / files.length));
});
}
await load();
} catch (err: any) {
setLoadError(err.message || pickAppText(locale, '上传失败', 'Upload failed'));
} finally {
setUploading(false);
setUploadProgress(0);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
const handleCreateDir = async () => {
const name = newDirName.trim();
if (!name) return;
try {
const dirPath = currentPath ? `${currentPath}/${name}` : name;
await createUserFileDir(dirPath);
setShowMkdir(false);
setNewDirName('');
await load();
} catch (err: any) {
setLoadError(err.message || pickAppText(locale, '创建文件夹失败', 'Failed to create folder'));
}
};
// Build breadcrumbs
const breadcrumbs = currentPath ? currentPath.split('/') : [];
const formatSize = (bytes: number | null) => {
if (bytes === null || bytes === undefined) return '';
if (bytes > 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
if (bytes > 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${bytes} B`;
};
const formatDate = (iso: string | null | undefined) => {
if (!iso) return '';
try {
return new Date(iso).toLocaleString(locale, {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return '';
}
};
return (
<div className="mx-auto w-full max-w-7xl overflow-x-hidden px-4 py-6 sm:px-6">
{/* Header */}
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-bold">{pickAppText(locale, '文件管理', 'Files')}</h1>
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-11"
onClick={() => setShowMkdir(true)}
disabled={loading}
>
<FolderPlus className="w-4 h-4 mr-1" />
{pickAppText(locale, '新建文件夹', 'New folder')}
</Button>
<Button
variant="outline"
size="sm"
className="h-11"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
{uploading ? (
<>
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
{uploadProgress}%
</>
) : (
<>
<Upload className="w-4 h-4 mr-1" />
{pickAppText(locale, '上传', 'Upload')}
</>
)}
</Button>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleUpload}
/>
<Button
variant="outline"
size="icon"
className="h-11 w-11"
onClick={() => load()}
disabled={loading}
aria-label={pickAppText(locale, '刷新', 'Refresh')}
title={pickAppText(locale, '刷新', 'Refresh')}
>
{loading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<RefreshCw className="w-4 h-4" />
)}
</Button>
</div>
</div>
{/* Breadcrumbs */}
<div className="flex items-center gap-1 mb-4 text-sm text-muted-foreground flex-wrap">
<button
onClick={() => navigateTo('')}
className="inline-flex h-11 items-center gap-1 rounded px-2 transition-colors hover:bg-accent hover:text-foreground"
>
<Home className="w-3.5 h-3.5" />
{pickAppText(locale, '文件', 'Files')}
</button>
{breadcrumbs.map((segment, idx) => {
const path = breadcrumbs.slice(0, idx + 1).join('/');
const isLast = idx === breadcrumbs.length - 1;
return (
<React.Fragment key={path}>
<ChevronRight className="w-3 h-3 flex-shrink-0" />
<button
onClick={() => navigateTo(path)}
className={`inline-flex h-11 items-center rounded px-2 text-left transition-colors ${containedLongTextClass} ${
isLast
? 'text-foreground font-medium'
: 'hover:text-foreground hover:bg-accent'
}`}
>
{segment}
</button>
</React.Fragment>
);
})}
</div>
{/* New directory input */}
{showMkdir && (
<div className="mb-4 flex flex-wrap items-center gap-2">
<Folder className="w-4 h-4 text-muted-foreground" />
<label htmlFor="new-folder-name" className="sr-only">
{pickAppText(locale, '文件夹名称', 'Folder name')}
</label>
<input
id="new-folder-name"
ref={mkdirInputRef}
type="text"
value={newDirName}
onChange={(e) => setNewDirName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreateDir();
if (e.key === 'Escape') {
setShowMkdir(false);
setNewDirName('');
}
}}
placeholder={pickAppText(locale, '文件夹名称', 'Folder name')}
className="h-11 min-w-0 flex-1 rounded-md border border-border bg-background px-3 text-sm focus:outline-none focus:ring-1 focus:ring-ring"
autoFocus
/>
<Button size="sm" className="h-11" onClick={handleCreateDir}>
{pickAppText(locale, '创建', 'Create')}
</Button>
<Button
size="sm"
variant="ghost"
className="h-11"
onClick={() => {
setShowMkdir(false);
setNewDirName('');
}}
>
{pickAppText(locale, '取消', 'Cancel')}
</Button>
</div>
)}
<div className="grid min-w-0 grid-cols-[minmax(0,1fr)] gap-4 lg:grid-cols-[minmax(360px,440px)_minmax(0,1fr)]">
{/* File list */}
<div className="min-w-0 rounded-lg border border-border bg-card lg:min-h-[520px]">
{loading && items.length === 0 ? (
<div className="flex items-center justify-center py-20 text-muted-foreground">
<Loader2 className="w-6 h-6 animate-spin" />
</div>
) : loadError ? (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<FolderOpen className="w-12 h-12 mb-4 opacity-50" />
<p className="text-lg font-medium">{pickAppText(locale, '加载失败', 'Failed to load')}</p>
<p className="max-w-sm text-center text-sm">{loadError}</p>
<Button className="mt-4 h-11" variant="outline" size="sm" onClick={() => load()}>
<RefreshCw className="mr-1 h-4 w-4" />
{pickAppText(locale, '重试', 'Retry')}
</Button>
</div>
) : items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<FolderOpen className="w-12 h-12 mb-4 opacity-50" />
<p className="text-lg font-medium">{pickAppText(locale, '空文件夹', 'Empty folder')}</p>
<p className="px-4 text-center text-sm">{pickAppText(locale, '点击上方"上传"或"新建文件夹"按钮开始使用', 'Use "Upload" or "New folder" above to get started')}</p>
</div>
) : (
<ScrollArea className="max-h-[calc(100vh-15rem)] min-h-[360px] lg:min-h-[520px]">
<div className="space-y-1 p-2">
{items.map((item) => (
<div
key={item.path}
className={`group flex min-w-0 flex-col gap-2 rounded-lg border p-2 text-left transition-colors hover:bg-accent/30 sm:flex-row sm:items-center ${
selectedFile?.path === item.path ? 'border-primary bg-accent/40' : 'border-border bg-card'
}`}
>
<button
type="button"
className="flex min-h-[3.5rem] min-w-0 flex-1 items-center gap-3 rounded-md px-1 py-2 text-left"
onClick={() => {
if (item.type === 'directory') {
navigateTo(item.path);
} else {
void openFile(item);
}
}}
>
<div className="flex-shrink-0">
{item.type === 'directory' ? (
<Folder className="w-5 h-5 text-blue-500" />
) : (
<FileIcon name={item.name} contentType={item.content_type || undefined} />
)}
</div>
<div className="min-w-0 flex-1">
<div className={`text-sm font-medium ${containedLongTextClass}`}>{item.name}</div>
<p className={`text-xs text-muted-foreground ${containedLongTextClass}`}>
{item.type === 'file' && formatSize(item.size)}
{item.modified && (
<>
{item.type === 'file' && ' · '}
{formatDate(item.modified)}
</>
)}
</p>
</div>
</button>
<div className="flex shrink-0 items-center justify-end gap-1 opacity-100 md:opacity-0 md:transition-opacity md:group-hover:opacity-100">
{item.type === 'file' && (
<button
type="button"
className="inline-flex h-11 w-11 items-center justify-center rounded-md hover:bg-accent"
onClick={(event) => {
event.stopPropagation();
void handleDownload(item);
}}
aria-label={`${pickAppText(locale, '下载', 'Download')} ${item.name}`}
title={pickAppText(locale, '下载', 'Download')}
>
<Download className="w-4 h-4" />
</button>
)}
<button
type="button"
className="inline-flex h-11 w-11 items-center justify-center rounded-md text-destructive hover:bg-accent hover:text-destructive"
onClick={(event) => {
event.stopPropagation();
void handleDelete(item);
}}
aria-label={`${pickAppText(locale, '删除', 'Delete')} ${item.name}`}
title={pickAppText(locale, '删除', 'Delete')}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
</ScrollArea>
)}
</div>
<FilePreviewPanel
file={selectedFile}
loading={previewLoading}
error={previewError}
formatSize={formatSize}
formatDate={formatDate}
downloadUrl={selectedFile ? getUserFileDownloadUrl(selectedFile.path) : null}
locale={locale}
/>
</div>
</div>
);
}
function FilePreviewPanel({
file,
loading,
error,
formatSize,
formatDate,
downloadUrl,
locale,
}: {
file: UserFileContent | null;
loading: boolean;
error: string | null;
formatSize: (bytes: number | null) => string;
formatDate: (iso: string | null | undefined) => string;
downloadUrl: string | null;
locale: AppLocale;
}) {
return (
<div className="min-w-0 rounded-lg border border-border bg-card p-4 lg:min-h-[520px]">
{loading ? (
<div className="flex h-[420px] items-center justify-center text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : error ? (
<div className="flex h-[420px] items-center justify-center text-center text-sm text-destructive">{error}</div>
) : !file ? (
<div className="flex h-[420px] flex-col items-center justify-center text-center text-muted-foreground">
<FileText className="mb-3 h-10 w-10 opacity-50" />
<p className="text-sm font-medium">{pickAppText(locale, '点击左侧文件查看内容', 'Click a file to preview its contents')}</p>
</div>
) : (
<div className="space-y-3">
<div className="flex min-w-0 flex-wrap items-start justify-between gap-3 border-b border-border pb-3">
<div className="min-w-0">
<h2 className={`text-base font-semibold ${containedLongTextClass}`}>{file.name}</h2>
<p className={`mt-1 text-xs text-muted-foreground ${containedLongTextClass}`}>
{formatSize(file.size)} · {formatDate(file.modified)} · {file.content_type}
{file.is_truncated ? ` · ${pickAppText(locale, '仅预览前 1MB', 'Showing first 1MB')}` : ''}
</p>
</div>
{downloadUrl && (
<Button variant="outline" size="sm" className="h-11" asChild>
<a href={downloadUrl}>
<Download className="mr-2 h-4 w-4" />
{pickAppText(locale, '下载', 'Download')}
</a>
</Button>
)}
</div>
{isImage(file) && downloadUrl ? (
<div className="flex max-h-[620px] items-start justify-center overflow-auto rounded-md border border-border bg-muted/20 p-3">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={downloadUrl} alt={file.name} className="max-h-[580px] max-w-full object-contain" />
</div>
) : file.is_binary ? (
<div className="flex h-[360px] flex-col items-center justify-center text-center text-muted-foreground">
<FileArchive className="mb-3 h-10 w-10 opacity-50" />
<p className="text-sm font-medium">{pickAppText(locale, '该文件不能直接预览', 'This file cannot be previewed')}</p>
</div>
) : isMarkdown(file) ? (
<div className={`prose prose-sm max-h-[620px] max-w-none overflow-auto text-black prose-a:text-black prose-code:text-black prose-headings:text-black prose-li:text-black prose-p:text-black prose-pre:bg-muted prose-pre:text-black prose-strong:text-black [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_*]:min-w-0 ${containedLongTextClass}`}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{file.content || ''}</ReactMarkdown>
</div>
) : (
<pre className={`max-h-[620px] overflow-auto rounded-md border border-border bg-background p-4 text-xs leading-5 text-black ${containedPreservedLongTextClass}`}>
{file.content || ''}
</pre>
)}
</div>
)}
</div>
);
}
function FileIcon({ name, contentType }: { name: string; contentType?: string }) {
const ext = name.split('.').pop()?.toLowerCase() || '';
const ct = contentType || '';
if (ct.startsWith('image/') || ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'].includes(ext)) {
return <FileImage className="w-5 h-5 text-green-500" />;
}
if (
['js', 'ts', 'tsx', 'jsx', 'py', 'rb', 'go', 'rs', 'java', 'c', 'cpp', 'h', 'css', 'html', 'json', 'yaml', 'yml', 'toml', 'md', 'sh'].includes(ext)
) {
return <FileCode className="w-5 h-5 text-orange-500" />;
}
if (['zip', 'tar', 'gz', 'bz2', 'rar', '7z', 'xz'].includes(ext)) {
return <FileArchive className="w-5 h-5 text-yellow-500" />;
}
if (['csv', 'xls', 'xlsx', 'tsv'].includes(ext)) {
return <FileSpreadsheet className="w-5 h-5 text-emerald-500" />;
}
return <FileText className="w-5 h-5 text-muted-foreground" />;
}
function isImage(file: UserFileContent): boolean {
return file.content_type.startsWith('image/');
}
function isMarkdown(file: UserFileContent): boolean {
return file.path.toLowerCase().endsWith('.md') || file.content_type.includes('markdown');
}