移除了agents/registry.json中的所有内置agents配置,将agents数组清空。 为web应用添加了CORS中间件支持,允许指定的前端地址跨域访问。 重构了技能上传功能,增加了LLM重写机制,自动规范化上传的技能格式。 新增了工具名称提取逻辑,从技能正文中自动识别Required Tools段落。 更新了技能学习候选者和草稿的载荷结构,添加评估报告统计信息。 修改了意图路由技能的说明,改进任务状态管理逻辑。
611 lines
23 KiB
TypeScript
611 lines
23 KiB
TypeScript
'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');
|
||
}
|