Files
beaver_project/app-instance/frontend/app/(app)/files/page.tsx
steven_li 8aeb97a5fc feat(app): 移除内置agents并添加CORS支持和技能上传优化
移除了agents/registry.json中的所有内置agents配置,将agents数组清空。
为web应用添加了CORS中间件支持,允许指定的前端地址跨域访问。
重构了技能上传功能,增加了LLM重写机制,自动规范化上传的技能格式。
新增了工具名称提取逻辑,从技能正文中自动识别Required Tools段落。
更新了技能学习候选者和草稿的载荷结构,添加评估报告统计信息。
修改了意图路由技能的说明,改进任务状态管理逻辑。
2026-06-12 13:25:20 +08:00

611 lines
23 KiB
TypeScript
Raw 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,
isApiError,
} from '@/lib/api';
import type { UserFileContent, UserFileItem } from '@/lib/api';
import { canMutateUserFilesPath } from '@/lib/user-file-paths';
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);
});
}
function isAuthError(error: unknown): boolean {
return isApiError(error, 401);
}
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;
if (isAuthError(err)) {
break;
}
}
}
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;
if (!canMutateUserFilesPath(currentPath)) {
setLoadError(pickAppText(
locale,
'请先进入 uploads、outputs、shared 或 tasks 目录后再上传。',
'Open uploads, outputs, shared, or tasks before uploading.'
));
if (fileInputRef.current) fileInputRef.current.value = '';
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;
if (!canMutateUserFilesPath(currentPath)) {
setLoadError(pickAppText(
locale,
'请先进入 uploads、outputs、shared 或 tasks 目录后再新建文件夹。',
'Open uploads, outputs, shared, or tasks before creating a folder.'
));
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 canMutateCurrentPath = canMutateUserFilesPath(currentPath);
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 || !canMutateCurrentPath}
title={
canMutateCurrentPath
? undefined
: pickAppText(locale, '先进入 uploads、outputs、shared 或 tasks', 'Open uploads, outputs, shared, or tasks first')
}
>
<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 || !canMutateCurrentPath}
title={
canMutateCurrentPath
? undefined
: pickAppText(locale, '先进入 uploads、outputs、shared 或 tasks', 'Open uploads, outputs, shared, or tasks first')
}
>
{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>
{!canMutateCurrentPath && !loading && (
<p className="mb-4 rounded-md border border-[#E6E1DE] bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
{pickAppText(
locale,
'请选择 uploads、outputs、shared 或 tasks 后再上传或新建文件夹。',
'Select uploads, outputs, shared, or tasks before uploading or creating folders.'
)}
</p>
)}
{/* 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');
}