feat: integrate MinIO-backed user filesystem

This commit is contained in:
Codex
2026-06-03 12:06:34 +08:00
parent a27560102b
commit ffa1249403
56 changed files with 4810 additions and 116 deletions

View File

@ -21,49 +21,71 @@ import {
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import {
browseWorkspace,
getWorkspaceFile,
getWorkspaceDownloadUrl,
uploadToWorkspace,
deleteWorkspacePath,
createWorkspaceDir,
browseUserFiles,
getUserFile,
getUserFileDownloadUrl,
uploadUserFile,
deleteUserFile,
createUserFileDir,
getAccessToken,
} from '@/lib/api';
import type { WorkspaceFileContent, WorkspaceItem } 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';
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<WorkspaceItem[]>([]);
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<WorkspaceFileContent | null>(null);
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);
const data = await browseWorkspace(path);
setItems(data.items);
setCurrentPath(data.path);
setSelectedFile(null);
setPreviewError(null);
} catch {
// ignore
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]);
}, [currentPath, locale]);
useEffect(() => {
load('');
@ -73,12 +95,12 @@ export default function FilesPage() {
load(path);
};
const openFile = async (item: WorkspaceItem) => {
const openFile = async (item: UserFileItem) => {
if (item.type !== 'file') return;
setPreviewLoading(true);
setPreviewError(null);
try {
setSelectedFile(await getWorkspaceFile(item.path));
setSelectedFile(await getUserFile(item.path));
} catch (err: any) {
setPreviewError(err.message || pickAppText(locale, '加载文件失败', 'Failed to load file'));
setSelectedFile(null);
@ -87,7 +109,7 @@ export default function FilesPage() {
}
};
const handleDelete = async (item: WorkspaceItem) => {
const handleDelete = async (item: UserFileItem) => {
const label = item.type === 'directory'
? pickAppText(locale, '文件夹', 'folder')
: pickAppText(locale, '文件', 'file');
@ -99,7 +121,7 @@ export default function FilesPage() {
return;
}
try {
await deleteWorkspacePath(item.path);
await deleteUserFile(item.path);
setItems((prev) => prev.filter((i) => i.path !== item.path));
if (selectedFile?.path === item.path) {
setSelectedFile(null);
@ -109,8 +131,8 @@ export default function FilesPage() {
}
};
const handleDownload = async (item: WorkspaceItem) => {
const url = getWorkspaceDownloadUrl(item.path);
const handleDownload = async (item: UserFileItem) => {
const url = getUserFileDownloadUrl(item.path);
const token = getAccessToken();
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
@ -138,7 +160,7 @@ export default function FilesPage() {
setUploadProgress(0);
try {
for (let i = 0; i < files.length; i++) {
await uploadToWorkspace(files[i], currentPath, (pct) => {
await uploadUserFile(files[i], currentPath || 'uploads', (pct) => {
setUploadProgress(Math.round((i / files.length) * 100 + pct / files.length));
});
}
@ -157,7 +179,7 @@ export default function FilesPage() {
if (!name) return;
try {
const dirPath = currentPath ? `${currentPath}/${name}` : name;
await createWorkspaceDir(dirPath);
await createUserFileDir(dirPath);
setShowMkdir(false);
setNewDirName('');
await load();
@ -176,7 +198,8 @@ export default function FilesPage() {
return `${bytes} B`;
};
const formatDate = (iso: string) => {
const formatDate = (iso: string | null | undefined) => {
if (!iso) return '';
try {
return new Date(iso).toLocaleString(locale, {
month: '2-digit',
@ -199,7 +222,7 @@ export default function FilesPage() {
variant="outline"
size="sm"
onClick={() => setShowMkdir(true)}
disabled={loading}
disabled={loading || !currentPath}
>
<FolderPlus className="w-4 h-4 mr-1" />
{pickAppText(locale, '新建文件夹', 'New folder')}
@ -208,7 +231,7 @@ export default function FilesPage() {
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
disabled={uploading || !currentPath}
>
{uploading ? (
<>
@ -246,7 +269,7 @@ export default function FilesPage() {
className="flex items-center gap-1 hover:text-foreground transition-colors px-1.5 py-0.5 rounded hover:bg-accent"
>
<Home className="w-3.5 h-3.5" />
{pickAppText(locale, '工作区', 'Workspace')}
{pickAppText(locale, '文件', 'Files')}
</button>
{breadcrumbs.map((segment, idx) => {
const path = breadcrumbs.slice(0, idx + 1).join('/');
@ -312,6 +335,16 @@ export default function FilesPage() {
<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" 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" />
@ -340,7 +373,7 @@ export default function FilesPage() {
{item.type === 'directory' ? (
<Folder className="w-5 h-5 text-blue-500" />
) : (
<FileIcon name={item.name} contentType={item.content_type} />
<FileIcon name={item.name} contentType={item.content_type || undefined} />
)}
</div>
@ -412,7 +445,7 @@ export default function FilesPage() {
error={previewError}
formatSize={formatSize}
formatDate={formatDate}
downloadUrl={selectedFile ? getWorkspaceDownloadUrl(selectedFile.path) : null}
downloadUrl={selectedFile ? getUserFileDownloadUrl(selectedFile.path) : null}
locale={locale}
/>
</div>
@ -429,11 +462,11 @@ function FilePreviewPanel({
downloadUrl,
locale,
}: {
file: WorkspaceFileContent | null;
file: UserFileContent | null;
loading: boolean;
error: string | null;
formatSize: (bytes: number | null) => string;
formatDate: (iso: string) => string;
formatDate: (iso: string | null | undefined) => string;
downloadUrl: string | null;
locale: AppLocale;
}) {
@ -516,10 +549,10 @@ function FileIcon({ name, contentType }: { name: string; contentType?: string })
return <FileText className="w-5 h-5 text-muted-foreground" />;
}
function isImage(file: WorkspaceFileContent): boolean {
function isImage(file: UserFileContent): boolean {
return file.content_type.startsWith('image/');
}
function isMarkdown(file: WorkspaceFileContent): boolean {
function isMarkdown(file: UserFileContent): boolean {
return file.path.toLowerCase().endsWith('.md') || file.content_type.includes('markdown');
}

View File

@ -97,6 +97,16 @@ function transportLabel(transport: string | undefined, locale: AppLocale) {
return transport || '-';
}
function discoveredToolCount(
serverId: string,
tools: Array<{ server_id: string; tools: Array<Record<string, unknown>> }>,
fallback?: number,
) {
const group = tools.find((item) => item.server_id === serverId);
if (group) return group.tools.length;
return fallback || 0;
}
export default function MCPPage() {
const { locale } = useAppI18n();
const t = (zh: string, en: string) => pickAppText(locale, zh, en);
@ -543,7 +553,7 @@ export default function MCPPage() {
<div><span className="font-medium">Scopes</span> <span className="text-muted-foreground">{t('由 AuthZ 动态决定', 'Derived from AuthZ')}</span></div>
)}
<div className="flex items-center gap-2 flex-wrap text-xs text-muted-foreground">
<span>{t(`${server.tool_count || 0} 个工具`, `${server.tool_count || 0} tools`)}</span>
<span>{t(`${discoveredToolCount(server.id, tools, server.tool_count)} 个工具`, `${discoveredToolCount(server.id, tools, server.tool_count)} tools`)}</span>
<span>{selectedServerId === server.id ? t('已选中', 'Selected') : t('点击查看工具', 'Click to view tools')}</span>
{server.last_error && <span className="text-rose-300">{server.last_error}</span>}
</div>

View File

@ -1,6 +1,7 @@
'use client';
import React from 'react';
import { usePathname } from 'next/navigation';
import { getStatus, listSessions, wsManager } from '@/lib/api';
import { useChatStore } from '@/lib/store';
@ -37,6 +38,7 @@ function isSessionUpdatedEvent(data: WsEvent | Record<string, unknown>): data is
}
export function AppRuntimeBridge() {
const pathname = usePathname();
const sessionId = useChatStore((state) => state.sessionId);
const setSessions = useChatStore((state) => state.setSessions);
const setWsStatus = useChatStore((state) => state.setWsStatus);
@ -45,6 +47,7 @@ export function AppRuntimeBridge() {
const ingestProcessEvent = useChatStore((state) => state.ingestProcessEvent);
const statusCheckCleanupRef = React.useRef<(() => void) | null>(null);
const statusCheckInFlightRef = React.useRef(false);
const chatRuntimeEnabled = pathname === '/' || pathname.startsWith('/tasks') || pathname.startsWith('/notifications');
const loadSessions = React.useCallback(async () => {
try {
@ -73,15 +76,27 @@ export function AppRuntimeBridge() {
}, [setBeaverReady]);
React.useEffect(() => {
if (!chatRuntimeEnabled) {
return;
}
void loadSessions();
}, [loadSessions]);
}, [chatRuntimeEnabled, loadSessions]);
React.useEffect(() => {
if (!chatRuntimeEnabled) {
wsManager.disconnect();
setWsStatus('disconnected');
setBeaverReady(null);
return;
}
resetProcessState();
wsManager.connect(sessionId);
}, [resetProcessState, sessionId]);
}, [chatRuntimeEnabled, resetProcessState, sessionId, setBeaverReady, setWsStatus]);
React.useEffect(() => {
if (!chatRuntimeEnabled) {
return;
}
const unsubStatus = wsManager.onStatusChange((status) => {
setWsStatus(status);
if (status === 'connected') {
@ -98,9 +113,12 @@ export function AppRuntimeBridge() {
statusCheckCleanupRef.current = null;
unsubStatus();
};
}, [scheduleStatusCheck, setBeaverReady, setWsStatus]);
}, [chatRuntimeEnabled, scheduleStatusCheck, setBeaverReady, setWsStatus]);
React.useEffect(() => {
if (!chatRuntimeEnabled) {
return;
}
const unsubMessage = wsManager.onMessage((data) => {
if (isSessionUpdatedEvent(data)) {
void loadSessions();
@ -115,7 +133,7 @@ export function AppRuntimeBridge() {
return () => {
unsubMessage();
};
}, [ingestProcessEvent, loadSessions]);
}, [chatRuntimeEnabled, ingestProcessEvent, loadSessions]);
return null;
}

View File

@ -1363,3 +1363,112 @@ export async function createWorkspaceDir(path: string): Promise<WorkspaceItem> {
method: 'POST',
});
}
// ---------------------------------------------------------------------------
// User File System
// ---------------------------------------------------------------------------
export interface UserFileItem {
name: string;
path: string;
type: 'file' | 'directory';
size: number | null;
content_type?: string | null;
modified?: string | null;
}
export interface UserFileBrowseResult {
path: string;
items: UserFileItem[];
}
export interface UserFileContent {
name: string;
path: string;
size: number;
content_type: string;
modified: string | null;
is_binary: boolean;
is_truncated: boolean;
content: string | null;
}
export interface UserFilesStatus {
configured: boolean;
storage_mode: string;
roots: string[];
workspace_visible: boolean;
}
export async function getUserFilesStatus(): Promise<UserFilesStatus> {
return fetchJSON('/api/user-files/status');
}
export async function browseUserFiles(path: string = ''): Promise<UserFileBrowseResult> {
const params = path ? `?path=${encodeURIComponent(path)}` : '';
return fetchJSON(`/api/user-files/browse${params}`);
}
export async function getUserFile(path: string): Promise<UserFileContent> {
return fetchJSON(`/api/user-files/preview?path=${encodeURIComponent(path)}`);
}
export function getUserFileDownloadUrl(path: string): string {
return buildApiUrl(`/api/user-files/download?path=${encodeURIComponent(path)}`);
}
export async function uploadUserFile(
file: File,
dirPath: string = 'uploads',
onProgress?: (percent: number) => void
): Promise<UserFileItem> {
const locale = getCurrentAppLocale();
const formData = new FormData();
formData.append('file', file);
formData.append('path', dirPath);
return new Promise<UserFileItem>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', buildApiUrl('/api/user-files/upload'));
const token = getAccessToken();
if (token) {
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
}
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && onProgress) {
onProgress(Math.round((e.loaded / e.total) * 100));
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText));
} else {
let detail = '';
try {
const data = JSON.parse(xhr.responseText);
detail = typeof data?.detail === 'string' ? data.detail : '';
} catch {
detail = '';
}
reject(new Error(detail || `${pickAppText(locale, '上传失败', 'Upload failed')}: ${xhr.status}`));
}
};
xhr.onerror = () => reject(new Error(pickAppText(locale, '上传失败', 'Upload failed')));
xhr.send(formData);
});
}
export async function deleteUserFile(path: string): Promise<void> {
await fetchJSON(`/api/user-files/delete?path=${encodeURIComponent(path)}`, {
method: 'DELETE',
});
}
export async function createUserFileDir(path: string): Promise<UserFileItem> {
return fetchJSON(`/api/user-files/mkdir?path=${encodeURIComponent(path)}`, {
method: 'POST',
});
}

View File

@ -0,0 +1,32 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
const root = resolve(__dirname, '..');
describe('user file system frontend wiring', () => {
it('routes API client helpers to user file endpoints', () => {
const apiSource = readFileSync(resolve(root, 'lib/api.ts'), 'utf8');
expect(apiSource).toContain('/api/user-files/browse');
expect(apiSource).toContain('/api/user-files/upload');
expect(apiSource).toContain('/api/user-files/download');
expect(apiSource).toContain('/api/user-files/preview');
expect(apiSource).toContain('/api/user-files/delete');
expect(apiSource).toContain('/api/user-files/mkdir');
});
it('does not wire the Files page to workspace or MinIO management APIs', () => {
const pageSource = readFileSync(resolve(root, 'app/(app)/files/page.tsx'), 'utf8');
expect(pageSource).toContain('browseUserFiles');
expect(pageSource).toContain('uploadUserFile');
expect(pageSource).not.toContain('browseWorkspace');
expect(pageSource).not.toContain('uploadToWorkspace');
expect(pageSource).not.toContain('MinIO');
expect(pageSource).not.toContain('bucket');
expect(pageSource).not.toContain('accessKey');
expect(pageSource).not.toContain('secretKey');
});
});