feat: imrpove resource manager (#11277)

* fix: Resource explorer overflow

* style: Loading style of resource

* style: New code viewer style

* style: New code viewer style

* feat: Add code agent

* feat: Add code agent

* feat: Add code agent

* feat: Upload folder

* feat: adjust header size

* fix: loading indicator

* style: Fix content overflow

* fix: Cannot batch select

* fix: Cannot batch select

* fix: Cannot batch select

* feat: support mode extension

* fix: markdown highlight

* style: Animate the upload dock

* feat: Cancel file upload

* fix: Lint error
This commit is contained in:
René Wang
2026-01-07 20:46:08 +08:00
committed by GitHub
parent f81e615451
commit 70b34d5f3c
34 changed files with 1383 additions and 457 deletions

View File

@@ -37,6 +37,7 @@
"header.actions.notionGuide.title": "导入 Notion 内容",
"header.actions.uploadFile": "上传文件",
"header.actions.uploadFolder": "上传文件夹",
"header.actions.uploadFolder.creatingFolders": "正在创建文件夹结构...",
"header.newPageButton": "新建文稿",
"header.uploadButton": "上传",
"home.getStarted": "开始使用",
@@ -119,6 +120,8 @@
"title": "资源",
"toggleLeftPanel": "显示/隐藏左侧面板",
"uploadDock.body.collapse": "收起",
"uploadDock.body.item.cancel": "取消",
"uploadDock.body.item.cancelled": "已取消",
"uploadDock.body.item.done": "已上传",
"uploadDock.body.item.error": "上传遇到了问题,请重试",
"uploadDock.body.item.pending": "准备上传…",
@@ -126,6 +129,7 @@
"uploadDock.body.item.restTime": "剩余 {{time}}",
"uploadDock.fileQueueInfo": "正在上传前 {{count}} 个文件,剩余 {{remaining}} 个文件将排队上传",
"uploadDock.totalCount": "共 {{count}} 项",
"uploadDock.uploadStatus.cancelled": "上传已取消",
"uploadDock.uploadStatus.error": "上传出错",
"uploadDock.uploadStatus.pending": "等待上传",
"uploadDock.uploadStatus.processing": "正在上传",

View File

@@ -206,7 +206,7 @@
"@lobehub/icons": "^4.0.2",
"@lobehub/market-sdk": "^0.27.1",
"@lobehub/tts": "^4.0.2",
"@lobehub/ui": "^4.11.5",
"@lobehub/ui": "^4.11.6",
"@modelcontextprotocol/sdk": "^1.25.1",
"@neondatabase/serverless": "^1.0.2",
"@next/third-parties": "^16.1.1",
@@ -454,4 +454,4 @@
"access": "public",
"registry": "https://registry.npmjs.org"
}
}
}

View File

@@ -14,7 +14,13 @@ export interface FileUploadState {
speed: number;
}
export type FileUploadStatus = 'pending' | 'uploading' | 'processing' | 'success' | 'error';
export type FileUploadStatus =
| 'pending'
| 'uploading'
| 'processing'
| 'success'
| 'error'
| 'cancelled';
export type FileProcessStatus = 'pending' | 'chunking' | 'embedding' | 'success' | 'error';
@@ -22,6 +28,10 @@ export const UPLOAD_STATUS_SET = new Set(['uploading', 'pending', 'processing'])
// the file that is upload at chat page
export interface UploadFileItem {
/**
* AbortController to cancel the upload
*/
abortController?: AbortController;
/**
* base64 data, it will use in other data
*/

View File

@@ -2,7 +2,6 @@
import { ActionIcon, Flexbox, Icon, Tag } from '@lobehub/ui';
import { Descriptions, Divider } from 'antd';
import { cssVar } from 'antd-style';
import dayjs from 'dayjs';
import { BoltIcon, DownloadIcon } from 'lucide-react';
import { memo } from 'react';
@@ -12,10 +11,23 @@ import { type FileListItem } from '@/types/files';
import { downloadFile } from '@/utils/client/downloadFile';
import { formatSize } from '@/utils/format';
export const DETAIL_PANEL_WIDTH = 300;
interface FileDetailProps extends FileListItem {
showDownloadButton?: boolean;
showTitle?: boolean;
}
const FileDetail = memo<FileListItem>((props) => {
const { name, embeddingStatus, size, createdAt, updatedAt, chunkCount, url } = props || {};
const FileDetail = memo<FileDetailProps>((props) => {
const {
name,
embeddingStatus,
size,
createdAt,
updatedAt,
chunkCount,
url,
showDownloadButton = true,
showTitle = true,
} = props || {};
const { t } = useTranslation('file');
if (!props) return null;
@@ -64,16 +76,12 @@ const FileDetail = memo<FileListItem>((props) => {
];
return (
<Flexbox
padding={16}
style={{ borderInlineStart: `1px solid ${cssVar.colorSplit}` }}
width={DETAIL_PANEL_WIDTH}
>
<Flexbox>
<Descriptions
colon={false}
column={1}
extra={
url && (
showDownloadButton && url ? (
<ActionIcon
icon={DownloadIcon}
onClick={() => {
@@ -81,12 +89,12 @@ const FileDetail = memo<FileListItem>((props) => {
}}
title={t('download', { ns: 'common' })}
/>
)
) : undefined
}
items={items}
labelStyle={{ width: 120 }}
size={'small'}
title={t('detail.basic.title')}
title={showTitle ? t('detail.basic.title') : undefined}
/>
<Divider />
<Descriptions

View File

@@ -5,8 +5,6 @@ import { ConfigProvider } from 'antd';
import { createStaticStyles, cx } from 'antd-style';
import { type ReactNode, useCallback, useState } from 'react';
import { DETAIL_PANEL_WIDTH } from '../FileDetail';
const styles = createStaticStyles(({ css, cssVar }) => ({
body: css`
height: 100%;
@@ -23,7 +21,7 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
inset-block: 0 0;
inset-inline-end: 0;
width: ${DETAIL_PANEL_WIDTH}px;
width: 0;
border-inline-start: 1px solid ${cssVar.colorSplit};
background: ${cssVar.colorBgLayout};
@@ -46,7 +44,7 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
}
`,
modal_withDetail: css`
width: calc(100vw - ${DETAIL_PANEL_WIDTH}px) !important;
width: calc(100vw) !important;
`,
}));

View File

@@ -0,0 +1,224 @@
'use client';
import { Center, Flexbox, Highlighter } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { memo } from 'react';
import NeuralNetworkLoading from '@/components/NeuralNetworkLoading';
import { useTextFileLoader } from '../../hooks/useTextFileLoader';
const styles = createStaticStyles(({ css }) => ({
page: css`
width: 100%;
height: 100%;
padding-inline: 24px 4px;
`,
}));
const getLanguage = (fileName?: string): string => {
if (!fileName) return 'txt';
const ext = fileName.toLowerCase().split('.').pop();
switch (ext) {
// JavaScript/TypeScript
case 'js':
case 'mjs':
case 'cjs': {
return 'javascript';
}
case 'ts': {
return 'typescript';
}
case 'tsx': {
return 'tsx';
}
case 'jsx': {
return 'jsx';
}
// Python
case 'py':
case 'pyw': {
return 'python';
}
// Java/JVM Languages
case 'java': {
return 'java';
}
case 'kt':
case 'kts': {
return 'kotlin';
}
case 'scala': {
return 'scala';
}
case 'groovy': {
return 'groovy';
}
// C/C++
case 'c':
case 'h': {
return 'c';
}
case 'cpp':
case 'cxx':
case 'cc':
case 'hpp':
case 'hxx': {
return 'cpp';
}
// C#
case 'cs': {
return 'csharp';
}
// Go
case 'go': {
return 'go';
}
// Rust
case 'rs': {
return 'rust';
}
// Ruby
case 'rb': {
return 'ruby';
}
// PHP
case 'php': {
return 'php';
}
// Swift
case 'swift': {
return 'swift';
}
// Shell
case 'sh':
case 'bash':
case 'zsh': {
return 'bash';
}
// Web
case 'html':
case 'htm': {
return 'html';
}
case 'css': {
return 'css';
}
case 'scss': {
return 'scss';
}
case 'sass': {
return 'sass';
}
case 'less': {
return 'less';
}
// Data formats
case 'json': {
return 'json';
}
case 'xml': {
return 'xml';
}
case 'yaml':
case 'yml': {
return 'yaml';
}
case 'toml': {
return 'toml';
}
// Markdown
case 'md':
case 'mdx': {
return 'markdown';
}
// SQL
case 'sql': {
return 'sql';
}
// Other popular languages
case 'lua': {
return 'lua';
}
case 'r': {
return 'r';
}
case 'dart': {
return 'dart';
}
case 'elixir':
case 'ex':
case 'exs': {
return 'elixir';
}
case 'erl':
case 'hrl': {
return 'erlang';
}
case 'clj':
case 'cljs':
case 'cljc': {
return 'clojure';
}
case 'vim': {
return 'vim';
}
case 'dockerfile': {
return 'dockerfile';
}
case 'graphql':
case 'gql': {
return 'graphql';
}
default: {
return 'txt';
}
}
};
interface CodeViewerProps {
fileId: string;
fileName?: string;
url: string | null;
}
/**
* Render any code file.
*/
const CodeViewer = memo<CodeViewerProps>(({ url, fileName }) => {
const { fileData, loading } = useTextFileLoader(url);
const language = getLanguage(fileName);
return (
<Flexbox className={styles.page}>
{!loading && fileData ? (
<Highlighter language={language} showLanguage={false} variant={'borderless'}>
{fileData}
</Highlighter>
) : (
<Center height={'100%'}>
<NeuralNetworkLoading size={36} />
</Center>
)}
</Flexbox>
);
});
export default CodeViewer;

View File

@@ -1,7 +1,9 @@
'use client';
import { Center } from '@lobehub/ui';
import { memo } from 'react';
import { memo, useState } from 'react';
import NeuralNetworkLoading from '@/components/NeuralNetworkLoading';
interface ImageViewerProps {
fileId: string;
@@ -9,15 +11,20 @@ interface ImageViewerProps {
}
const ImageViewer = memo<ImageViewerProps>(({ url }) => {
const [isLoaded, setIsLoaded] = useState(false);
if (!url) return null;
return (
<Center height={'100%'} width={'100%'}>
{!isLoaded && <NeuralNetworkLoading size={36} />}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
alt="Image preview"
onLoad={() => setIsLoaded(true)}
src={url}
style={{
display: isLoaded ? 'block' : 'none',
height: '100%',
objectFit: 'contain',
overflow: 'hidden',

View File

@@ -1,66 +0,0 @@
'use client';
import { Center, Flexbox, Highlighter } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { memo } from 'react';
import CircleLoading from '@/components/Loading/CircleLoading';
import { useTextFileLoader } from '../../hooks/useTextFileLoader';
const styles = createStaticStyles(({ css, cssVar }) => ({
page: css`
width: 100%;
padding: 24px;
border-radius: 4px;
background: ${cssVar.colorBgContainer};
box-shadow: ${cssVar.boxShadowTertiary};
`,
}));
const getLanguage = (fileName?: string): string => {
if (!fileName) return 'javascript';
const ext = fileName.toLowerCase().split('.').pop();
switch (ext) {
case 'ts': {
return 'typescript';
}
case 'tsx': {
return 'tsx';
}
case 'jsx': {
return 'jsx';
}
default: {
return 'javascript';
}
}
};
interface JavaScriptViewerProps {
fileId: string;
fileName?: string;
url: string | null;
}
const JavaScriptViewer = memo<JavaScriptViewerProps>(({ url, fileName }) => {
const { fileData, loading } = useTextFileLoader(url);
const language = getLanguage(fileName);
return (
<Flexbox className={styles.page} id="javascript-renderer">
{!loading && fileData ? (
<Highlighter language={language} showLanguage={false} variant={'borderless'}>
{fileData}
</Highlighter>
) : (
<Center height={'100%'}>
<CircleLoading />
</Center>
)}
</Flexbox>
);
});
export default JavaScriptViewer;

View File

@@ -1,12 +1,13 @@
'use client';
import { Flexbox } from '@lobehub/ui';
import { Center, Flexbox } from '@lobehub/ui';
import type { PDFDocumentProxy } from 'pdfjs-dist';
import { Fragment, memo, useCallback, useState } from 'react';
import { Document, Page, pdfjs } from 'react-pdf';
import 'react-pdf/dist/esm/Page/AnnotationLayer.css';
import 'react-pdf/dist/esm/Page/TextLayer.css';
import NeuralNetworkLoading from '@/components/NeuralNetworkLoading';
import { lambdaQuery } from '@/libs/trpc/client';
import HighlightLayer from './HighlightLayer';
@@ -62,13 +63,14 @@ const PDFViewer = memo<PDFViewerProps>(({ url, fileId }) => {
<Flexbox
align={'center'}
className={styles.documentContainer}
justify={isLoaded ? undefined : 'center'}
padding={24}
ref={setContainerRef}
style={{ height: isLoaded ? undefined : '100%' }}
>
<Document
className={styles.document}
file={url}
loading={<NeuralNetworkLoading size={36} />}
onLoadSuccess={onDocumentLoadSuccess}
options={options}
>

View File

@@ -2,12 +2,13 @@ import { createStaticStyles } from 'antd-style';
export const styles = createStaticStyles(({ css, cssVar }) => ({
container: css`
min-height: 100%;
height: 100%;
`,
document: css`
position: relative;
`,
documentContainer: css`
flex: 1;
padding-block: 10px;
background-color: ${cssVar.colorBgLayout};
`,

View File

@@ -1,50 +0,0 @@
'use client';
import { Center, Flexbox, Highlighter } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { memo } from 'react';
import CircleLoading from '@/components/Loading/CircleLoading';
import { useTextFileLoader } from '../../hooks/useTextFileLoader';
const styles = createStaticStyles(({ css, cssVar }) => ({
page: css`
width: 100%;
padding: 24px;
border-radius: 4px;
background: ${cssVar.colorBgContainer};
box-shadow: ${cssVar.boxShadowTertiary};
`,
}));
interface TXTViewerProps {
fileId: string;
url: string | null;
}
const TXTViewer = memo<TXTViewerProps>(({ url }) => {
const { fileData, loading } = useTextFileLoader(url);
return (
<Flexbox className={styles.page} id="txt-renderer">
{!loading && fileData ? (
<Highlighter
language={'txt'}
showLanguage={false}
style={{ height: '100%' }}
variant={'borderless'}
>
{fileData}
</Highlighter>
) : (
<Center height={'100%'}>
<CircleLoading />
</Center>
)}
</Flexbox>
);
});
export default TXTViewer;

View File

@@ -5,12 +5,10 @@ import { type CSSProperties, memo } from 'react';
import { type FileListItem } from '@/types/files';
import NotSupport from './NotSupport';
import CodeViewer from './Renderer/Code';
import ImageViewer from './Renderer/Image';
import JavaScriptViewer from './Renderer/JavaScript';
import MSDocViewer from './Renderer/MSDoc';
import MarkdownViewer from './Renderer/Markdown';
import PDFViewer from './Renderer/PDF';
import TXTViewer from './Renderer/TXT';
import VideoViewer from './Renderer/Video';
// File type definitions
@@ -27,8 +25,79 @@ const IMAGE_MIME_TYPES = new Set([
const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.ogg'];
const VIDEO_MIME_TYPES = new Set(['video/mp4', 'video/webm', 'video/ogg', 'mp4', 'webm', 'ogg']);
const JS_EXTENSIONS = ['.js', '.jsx', '.ts', '.tsx'];
const JS_MIME_TYPES = new Set([
const CODE_EXTENSIONS = [
// JavaScript/TypeScript
'.js',
'.jsx',
'.ts',
'.tsx',
'.mjs',
'.cjs',
// Python
'.py',
'.pyw',
// Java/JVM
'.java',
'.kt',
'.kts',
'.scala',
'.groovy',
// C/C++
'.c',
'.h',
'.cpp',
'.cxx',
'.cc',
'.hpp',
'.hxx',
// Other compiled languages
'.cs',
'.go',
'.rs',
'.rb',
'.php',
'.swift',
'.lua',
'.r',
'.dart',
// Shell
'.sh',
'.bash',
'.zsh',
// Web
'.html',
'.htm',
'.css',
'.scss',
'.sass',
'.less',
// Data formats
'.json',
'.xml',
'.yaml',
'.yml',
'.toml',
'.sql',
// Functional languages
'.ex',
'.exs',
'.erl',
'.hrl',
'.clj',
'.cljs',
'.cljc',
// Markdown
'.md',
'.mdx',
// Other
'.vim',
'.graphql',
'.gql',
'.txt',
];
const CODE_MIME_TYPES = new Set([
// JavaScript/TypeScript
'js',
'jsx',
'ts',
@@ -38,14 +107,66 @@ const JS_MIME_TYPES = new Set([
'text/javascript',
'application/typescript',
'text/typescript',
// Python
'python',
'text/x-python',
'application/x-python-code',
// Java/JVM
'java',
'text/x-java-source',
'kotlin',
'scala',
// C/C++
'c',
'text/x-c',
'cpp',
'text/x-c++',
// Other languages
'csharp',
'go',
'rust',
'ruby',
'php',
'text/x-php',
'swift',
'lua',
'r',
'dart',
// Shell
'bash',
'shell',
'text/x-shellscript',
// Web
'html',
'text/html',
'css',
'text/css',
'scss',
'sass',
'less',
// Data
'json',
'application/json',
'xml',
'text/xml',
'application/xml',
'yaml',
'text/yaml',
'application/x-yaml',
'toml',
'sql',
'text/x-sql',
// Markdown
'md',
'mdx',
'text/markdown',
'text/x-markdown',
// Other
'graphql',
'txt',
'text/plain',
]);
const MARKDOWN_EXTENSIONS = ['.md', '.mdx'];
const MARKDOWN_MIME_TYPES = new Set(['md', 'mdx', 'text/markdown', 'text/x-markdown']);
const TXT_EXTENSIONS = ['.txt'];
const TXT_MIME_TYPES = new Set(['txt', 'text/plain']);
const MSDOC_EXTENSIONS = ['.doc', '.docx', '.odt', '.ppt', '.pptx', '.xls', '.xlsx'];
const MSDOC_MIME_TYPES = new Set([
'doc',
@@ -117,19 +238,9 @@ const FileViewer = memo<FileViewerProps>(({ id, style, fileType, url, name }) =>
return <VideoViewer fileId={id} url={url} />;
}
// JavaScript/TypeScript files
if (matchesFileType(fileType, name, JS_EXTENSIONS, JS_MIME_TYPES)) {
return <JavaScriptViewer fileId={id} fileName={name} url={url} />;
}
// Markdown files
if (matchesFileType(fileType, name, MARKDOWN_EXTENSIONS, MARKDOWN_MIME_TYPES)) {
return <MarkdownViewer fileId={id} url={url} />;
}
// Text files
if (matchesFileType(fileType, name, TXT_EXTENSIONS, TXT_MIME_TYPES)) {
return <TXTViewer fileId={id} url={url} />;
// Code files (JavaScript, TypeScript, Python, Java, C++, Go, Rust, Markdown, etc.)
if (matchesFileType(fileType, name, CODE_EXTENSIONS, CODE_MIME_TYPES)) {
return <CodeViewer fileId={id} fileName={name} url={url} />;
}
// Microsoft Office documents

View File

@@ -6,8 +6,6 @@ import { memo } from 'react';
import FileViewer from '@/features/FileViewer';
import { fileManagerSelectors, useFileStore } from '@/store/file';
import FileDetail from './FileDetail';
interface FilePreviewerProps {
fileId?: string;
}
@@ -22,11 +20,10 @@ const FilePreviewer = memo<FilePreviewerProps>(({ fileId }) => {
if (!fileId || !displayFile) return null;
return (
<Flexbox height={'100%'} horizontal width={'100%'}>
<Flexbox height={'100%'} width={'100%'}>
<Flexbox flex={1} height={'100%'} style={{ overflow: 'auto' }}>
<FileViewer {...displayFile} />
</Flexbox>
<FileDetail id={fileId} />
</Flexbox>
);
});

View File

@@ -0,0 +1,64 @@
'use client';
import { Flexbox } from '@lobehub/ui';
import { memo, useEffect } from 'react';
import DragUploadZone, { useUploadFiles } from '@/components/DragUploadZone';
import type { ActionKeys } from '@/features/ChatInput';
import { ChatInput, ChatList } from '@/features/Conversation';
import RightPanel from '@/features/RightPanel';
import { useAgentStore } from '@/store/agent';
import { agentByIdSelectors, builtinAgentSelectors } from '@/store/agent/selectors';
import { useChatStore } from '@/store/chat';
const actions: ActionKeys[] = ['model', 'search'];
/**
* Help analyze and work with files
*/
const FileCopilot = memo(() => {
const pageAgentId = useAgentStore(builtinAgentSelectors.pageAgentId);
const [activeAgentId, setActiveAgentId, useFetchAgentConfig] = useAgentStore((s) => [
s.activeAgentId,
s.setActiveAgentId,
s.useFetchAgentConfig,
]);
useEffect(() => {
setActiveAgentId(pageAgentId);
// Also set the chat store's activeAgentId so topic selectors can work correctly
useChatStore.setState({ activeAgentId: pageAgentId });
}, [pageAgentId, setActiveAgentId]);
const currentAgentId = activeAgentId || pageAgentId;
// Fetch agent config when activeAgentId changes to ensure it's loaded in the store
useFetchAgentConfig(true, currentAgentId);
// Get agent's model info for vision support check
const model = useAgentStore((s) => agentByIdSelectors.getAgentModelById(currentAgentId)(s));
const provider = useAgentStore((s) =>
agentByIdSelectors.getAgentModelProviderById(currentAgentId)(s),
);
const { handleUploadFiles } = useUploadFiles({ model, provider });
return (
<RightPanel>
<DragUploadZone
onUploadFiles={handleUploadFiles}
style={{ flex: 1, height: '100%', minWidth: 300 }}
>
<Flexbox flex={1} height={'100%'}>
<Flexbox flex={1} style={{ overflow: 'hidden' }}>
<ChatList />
</Flexbox>
<ChatInput leftActions={actions} />
</Flexbox>
</DragUploadZone>
</RightPanel>
);
});
FileCopilot.displayName = 'FileCopilot';
export default FileCopilot;

View File

@@ -1,22 +1,107 @@
'use client';
import { BUILTIN_AGENT_SLUGS } from '@lobechat/builtin-agents';
import { ActionIcon, Flexbox } from '@lobehub/ui';
import { cssVar } from 'antd-style';
import { ArrowLeftIcon } from 'lucide-react';
import { memo } from 'react';
import { Modal } from 'antd';
import { cssVar, useTheme } from 'antd-style';
import { ArrowLeftIcon, BotMessageSquareIcon, DownloadIcon, InfoIcon } from 'lucide-react';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import FileDetailComponent from '@/app/[variants]/(main)/resource/features/FileDetail';
import { useResourceManagerStore } from '@/app/[variants]/(main)/resource/features/store';
import Loading from '@/components/Loading/BrandTextLoading';
import NavHeader from '@/features/NavHeader';
import PageAgentProvider from '@/features/PageEditor/PageAgentProvider';
import ToggleRightPanelButton from '@/features/RightPanel/ToggleRightPanelButton';
import { useAgentStore } from '@/store/agent';
import { builtinAgentSelectors } from '@/store/agent/selectors';
import { fileManagerSelectors, useFileStore } from '@/store/file';
import { downloadFile } from '@/utils/client/downloadFile';
import Breadcrumb from '../Explorer/Header/Breadcrumb';
import FileContent from './FileContent';
import FileCopilot from './FileCopilot';
interface FileEditorProps {
onBack?: () => void;
}
const FileEditorCanvas = memo<FileEditorProps>(({ onBack }) => {
const { t } = useTranslation(['common', 'file']);
const theme = useTheme();
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
const currentViewItemId = useResourceManagerStore((s) => s.currentViewItemId);
const fileDetail = useFileStore(fileManagerSelectors.getFileById(currentViewItemId));
return (
<>
<Flexbox height={'100%'} horizontal width={'100%'}>
<Flexbox flex={1} height={'100%'}>
<NavHeader
left={
<Flexbox align={'center'} gap={12} horizontal style={{ minHeight: 32 }}>
<ActionIcon icon={ArrowLeftIcon} onClick={onBack} title={t('back')} />
<span
style={{
color: theme.colorText,
fontSize: 14,
fontWeight: 500,
}}
>
{fileDetail?.name}
</span>
</Flexbox>
}
right={
<Flexbox gap={8} horizontal>
<ToggleRightPanelButton icon={BotMessageSquareIcon} showActive={true} size={20} />
{fileDetail?.url && (
<ActionIcon
icon={DownloadIcon}
onClick={() => {
if (fileDetail?.url && fileDetail?.name) {
downloadFile(fileDetail.url, fileDetail.name);
}
}}
title={t('download', { ns: 'common' })}
/>
)}
<ActionIcon icon={InfoIcon} onClick={() => setIsDetailModalOpen(true)} />
</Flexbox>
}
style={{
borderBottom: `1px solid ${cssVar.colorBorderSecondary}`,
}}
styles={{
left: { padding: 0 },
}}
/>
<Flexbox flex={1} style={{ overflow: 'hidden' }}>
<FileContent fileId={currentViewItemId} />
</Flexbox>
</Flexbox>
<FileCopilot />
</Flexbox>
<Modal
footer={null}
onCancel={() => setIsDetailModalOpen(false)}
open={isDetailModalOpen}
title={t('detail.basic.title', { ns: 'file' })}
width={400}
>
{fileDetail && (
<FileDetailComponent {...fileDetail} showDownloadButton={false} showTitle={false} />
)}
</Modal>
</>
);
});
FileEditorCanvas.displayName = 'FileEditorCanvas';
/**
* View or Edit a file
*
@@ -24,38 +109,20 @@ interface FileEditorProps {
* So we depend on context, not props.
*/
const FileEditor = memo<FileEditorProps>(({ onBack }) => {
const { t } = useTranslation('common');
const useInitBuiltinAgent = useAgentStore((s) => s.useInitBuiltinAgent);
const pageAgentId = useAgentStore(builtinAgentSelectors.pageAgentId);
const [currentViewItemId, category] = useResourceManagerStore((s) => [
s.currentViewItemId,
s.category,
]);
useInitBuiltinAgent(BUILTIN_AGENT_SLUGS.pageAgent);
const fileDetail = useFileStore(fileManagerSelectors.getFileById(currentViewItemId));
if (!pageAgentId) return <Loading debugId="FileEditor > PageAgent Init" />;
return (
<Flexbox height={'100%'}>
<NavHeader
left={
<Flexbox align={'center'} gap={4} horizontal style={{ minHeight: 32 }}>
<ActionIcon icon={ArrowLeftIcon} onClick={onBack} title={t('back')} />
<Flexbox align={'center'} style={{ marginLeft: 8 }}>
<Breadcrumb category={category} fileName={fileDetail?.name} />
</Flexbox>
</Flexbox>
}
style={{
borderBottom: `1px solid ${cssVar.colorBorderSecondary}`,
}}
styles={{
left: { padding: 0 },
}}
/>
<Flexbox flex={1} style={{ overflow: 'hidden' }}>
<FileContent fileId={currentViewItemId} />
</Flexbox>
</Flexbox>
<PageAgentProvider pageAgentId={pageAgentId}>
<FileEditorCanvas onBack={onBack} />
</PageAgentProvider>
);
});
FileEditor.displayName = 'FileEditor';
export default FileEditor;

View File

@@ -56,8 +56,9 @@ export const useFileItemDropdown = ({
s.useFetchKnowledgeBaseList,
]);
// Only fetch knowledge bases when dropdown is enabled (open)
// This prevents the expensive SWR call for all 20-25 visible items
// Fetch knowledge bases - SWR caches this across all dropdown instances
// Only the first call fetches from server, subsequent calls use cache
// The expensive menu computation is deferred until dropdown opens (menuItems is a function)
const { data: knowledgeBases } = useFetchKnowledgeBaseList();
const inKnowledgeBase = !!knowledgeBaseId;

View File

@@ -0,0 +1,119 @@
'use client';
import { createStaticStyles, cssVar } from 'antd-style';
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
const styles = createStaticStyles(({ css }) => ({
handle: css`
cursor: col-resize;
user-select: none;
position: absolute;
z-index: 1;
inset-block: 0 0;
inset-inline-end: 0;
transform: translateX(-4px);
display: flex;
align-items: center;
justify-content: center;
width: 16px;
&::after {
content: '';
width: 1.5px;
height: calc(100% - 16px);
border-radius: 1px;
background-color: ${cssVar.colorBorder};
transition: all 0.2s;
}
&:hover::after {
width: 3px;
background-color: ${cssVar.colorPrimary};
}
`,
handleDragging: css`
&::after {
width: 3px !important;
background-color: ${cssVar.colorPrimary} !important;
}
`,
}));
interface ColumnResizeHandleProps {
column: 'name' | 'date' | 'size';
currentWidth: number;
maxWidth: number;
minWidth: number;
onResize: (width: number) => void;
}
const ColumnResizeHandle = memo<ColumnResizeHandleProps>(
({ currentWidth, minWidth, maxWidth, onResize }) => {
const [isDragging, setIsDragging] = useState(false);
const startXRef = useRef(0);
const startWidthRef = useRef(0);
const handleMouseMove = useCallback(
(e: MouseEvent) => {
const delta = e.clientX - startXRef.current;
const newWidth = Math.max(minWidth, Math.min(maxWidth, startWidthRef.current + delta));
// Update width in real-time during drag
onResize(newWidth);
},
[minWidth, maxWidth, onResize],
);
const handleMouseUp = useCallback(() => {
setIsDragging(false);
}, []);
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
startXRef.current = e.clientX;
startWidthRef.current = currentWidth;
},
[currentWidth],
);
// Attach document-level event listeners when dragging
useEffect(() => {
if (isDragging) {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
// Disable text selection and lock cursor during drag
document.body.style.userSelect = 'none';
document.body.style.cursor = 'col-resize';
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.body.style.userSelect = '';
document.body.style.cursor = '';
};
}
}, [isDragging, handleMouseMove, handleMouseUp]);
return (
<div
className={`${styles.handle} ${isDragging ? styles.handleDragging : ''}`}
onMouseDown={handleMouseDown}
/>
);
},
);
ColumnResizeHandle.displayName = 'ColumnResizeHandle';
export default ColumnResizeHandle;

View File

@@ -26,6 +26,7 @@ import DropdownMenu from '../../ItemDropdown/DropdownMenu';
import { useFileItemDropdown } from '../../ItemDropdown/useFileItemDropdown';
import ChunksBadge from './ChunkTag';
// Initialize dayjs plugin once at module level
dayjs.extend(relativeTime);
export const FILE_DATE_WIDTH = 160;
@@ -35,6 +36,7 @@ const styles = createStaticStyles(({ css }) => {
return {
container: css`
cursor: pointer;
min-width: 800px;
&:hover {
background: ${cssVar.colorFillTertiary};
@@ -83,7 +85,6 @@ const styles = createStaticStyles(({ css }) => {
overflow: hidden;
flex: 1;
min-width: 0;
max-width: 600px;
`,
selected: css`
background: ${cssVar.colorFillTertiary};
@@ -96,6 +97,11 @@ const styles = createStaticStyles(({ css }) => {
});
interface FileListItemProps extends FileListItemType {
columnWidths: {
date: number;
name: number;
size: number;
};
index: number;
onSelectedChange: (id: string, selected: boolean, shiftKey: boolean, index: number) => void;
pendingRenameItemId?: string | null;
@@ -107,6 +113,7 @@ const FileListItem = memo<FileListItemProps>(
({
size,
chunkingError,
columnWidths,
embeddingError,
embeddingStatus,
finishEmbedding,
@@ -242,7 +249,7 @@ const FileListItem = memo<FileListItemProps>(
[createdAt],
);
const handleRenameStart = () => {
const handleRenameStart = useCallback(() => {
setIsRenaming(true);
setRenamingValue(name);
// Focus input after render
@@ -250,9 +257,9 @@ const FileListItem = memo<FileListItemProps>(
inputRef.current?.focus();
inputRef.current?.select();
}, 0);
};
}, [name]);
const handleRenameConfirm = async () => {
const handleRenameConfirm = useCallback(async () => {
if (!renamingValue.trim()) {
message.error(t('FileManager.actions.renameError'));
return;
@@ -271,12 +278,12 @@ const FileListItem = memo<FileListItemProps>(
console.error('Rename error:', error);
message.error(t('FileManager.actions.renameError'));
}
};
}, [renamingValue, name, fileStoreState.renameFolder, id, message, t]);
const handleRenameCancel = () => {
const handleRenameCancel = useCallback(() => {
setIsRenaming(false);
setRenamingValue(name);
};
}, [name]);
// Memoize click handler to prevent recreation on every render
const handleItemClick = useCallback(() => {
@@ -357,29 +364,42 @@ const FileListItem = memo<FileListItemProps>(
paddingInline={8}
style={{
borderBlockEnd: `1px solid ${cssVar.colorBorderSecondary}`,
userSelect: 'none',
}}
>
<Center
height={40}
onClick={(e) => {
e.stopPropagation();
onSelectedChange(id, !selected, e.shiftKey, index);
}}
onPointerDown={(e) => {
e.stopPropagation();
// Prevent text selection when shift-clicking for batch selection
if (e.shiftKey) {
e.preventDefault();
}
}}
style={{ paddingInline: 4 }}
>
<Checkbox checked={selected} />
</Center>
<Flexbox
align={'center'}
className={styles.item}
distribution={'space-between'}
flex={1}
horizontal
onClick={handleItemClick}
style={{
flexShrink: 0,
maxWidth: columnWidths.name,
minWidth: columnWidths.name,
paddingInline: 8,
width: columnWidths.name,
}}
>
<Flexbox align={'center'} className={styles.nameContainer} horizontal>
<Center
height={48}
onClick={(e) => {
e.stopPropagation();
onSelectedChange(id, !selected, e.shiftKey, index);
}}
onPointerDown={(e) => e.stopPropagation()}
style={{ paddingInline: 4 }}
>
<Checkbox checked={selected} />
</Center>
<Flexbox
align={'center'}
justify={'center'}
@@ -479,10 +499,10 @@ const FileListItem = memo<FileListItemProps>(
</Flexbox>
{!isDragging && (
<>
<Flexbox className={styles.item} width={FILE_DATE_WIDTH}>
<Flexbox className={styles.item} style={{ flexShrink: 0 }} width={columnWidths.date}>
{displayTime}
</Flexbox>
<Flexbox className={styles.item} width={FILE_SIZE_WIDTH}>
<Flexbox className={styles.item} style={{ flexShrink: 0 }} width={columnWidths.size}>
{isFolder || isPage ? '-' : formatSize(size)}
</Flexbox>
</>
@@ -491,6 +511,31 @@ const FileListItem = memo<FileListItemProps>(
</ContextMenuTrigger>
);
},
// Custom comparison function to prevent unnecessary re-renders
(prevProps, nextProps) => {
// Only re-render if these critical props change
return (
prevProps.id === nextProps.id &&
prevProps.name === nextProps.name &&
prevProps.selected === nextProps.selected &&
prevProps.chunkingStatus === nextProps.chunkingStatus &&
prevProps.embeddingStatus === nextProps.embeddingStatus &&
prevProps.chunkCount === nextProps.chunkCount &&
prevProps.chunkingError === nextProps.chunkingError &&
prevProps.embeddingError === nextProps.embeddingError &&
prevProps.finishEmbedding === nextProps.finishEmbedding &&
prevProps.pendingRenameItemId === nextProps.pendingRenameItemId &&
prevProps.size === nextProps.size &&
prevProps.createdAt === nextProps.createdAt &&
prevProps.fileType === nextProps.fileType &&
prevProps.sourceType === nextProps.sourceType &&
prevProps.slug === nextProps.slug &&
prevProps.url === nextProps.url &&
prevProps.columnWidths.name === nextProps.columnWidths.name &&
prevProps.columnWidths.date === nextProps.columnWidths.date &&
prevProps.columnWidths.size === nextProps.columnWidths.size
);
},
);
FileListItem.displayName = 'FileListItem';

View File

@@ -1,20 +1,55 @@
import { Flexbox, Skeleton } from '@lobehub/ui';
import { Center, Checkbox, Flexbox, Skeleton } from '@lobehub/ui';
import { cssVar } from 'antd-style';
import { FILE_DATE_WIDTH, FILE_SIZE_WIDTH } from './ListItem';
const ListViewSkeleton = () => (
<Flexbox style={{ marginInline: 16 }}>
{Array.from({ length: 4 }).map((_, index) => (
<Flexbox align={'center'} distribution={'space-between'} height={48} horizontal key={index}>
<Flexbox align={'center'} flex={1} gap={8} horizontal paddingInline={8}>
interface ListViewSkeletonProps {
columnWidths?: {
date: number;
name: number;
size: number;
};
count?: number;
}
const ListViewSkeleton = ({
columnWidths = { date: FILE_DATE_WIDTH, name: 400, size: FILE_SIZE_WIDTH },
count = 3,
}: ListViewSkeletonProps) => (
<Flexbox>
{Array.from({ length: count }).map((_, index) => (
<Flexbox
align={'center'}
height={48}
horizontal
key={index}
paddingInline={8}
style={{
borderBlockEnd: `1px solid ${cssVar.colorBorderSecondary}`,
}}
>
<Center height={40} style={{ paddingInline: 4 }}>
<Checkbox disabled />
</Center>
<Flexbox
align={'center'}
horizontal
paddingInline={8}
style={{
flexShrink: 0,
maxWidth: columnWidths.name,
minWidth: columnWidths.name,
width: columnWidths.name,
}}
>
<Skeleton.Avatar active shape={'square'} size={24} style={{ marginInline: 8 }} />
<Skeleton.Button active style={{ height: 16, width: 300 }} />
<Skeleton.Button active style={{ height: 16, width: '60%' }} />
</Flexbox>
<Flexbox paddingInline={24} width={FILE_DATE_WIDTH}>
<Skeleton.Button active style={{ height: 16 }} />
<Flexbox paddingInline={24} style={{ flexShrink: 0 }} width={columnWidths.date}>
<Skeleton.Button active style={{ height: 16, width: '80%' }} />
</Flexbox>
<Flexbox paddingInline={24} width={FILE_SIZE_WIDTH}>
<Skeleton.Button active style={{ height: 16 }} />
<Flexbox paddingInline={24} style={{ flexShrink: 0 }} width={columnWidths.size}>
<Skeleton.Button active style={{ height: 16, width: '60%' }} />
</Flexbox>
</Flexbox>
))}

View File

@@ -15,8 +15,12 @@ import {
useResourceManagerStore,
} from '@/app/[variants]/(main)/resource/features/store';
import { sortFileList } from '@/app/[variants]/(main)/resource/features/store/selectors';
import { useGlobalStore } from '@/store/global';
import { INITIAL_STATUS } from '@/store/global/initialState';
import FileListItem, { FILE_DATE_WIDTH, FILE_SIZE_WIDTH } from './ListItem';
import ColumnResizeHandle from './ColumnResizeHandle';
import FileListItem from './ListItem';
import ListViewSkeleton from './Skeleton';
const log = debug('resource-manager:list-view');
@@ -31,18 +35,19 @@ const styles = createStaticStyles(({ css }) => ({
outline-offset: -4px;
`,
header: css`
min-width: 800px;
height: 40px;
min-height: 40px;
color: ${cssVar.colorTextDescription};
`,
headerItem: css`
padding-block: 0;
padding-block: 6px;
padding-inline: 0 24px;
height: 100%;
`,
loadingIndicator: css`
padding: 16px;
font-size: 14px;
color: ${cssVar.colorTextDescription};
scrollContainer: css`
overflow: auto hidden;
flex: 1;
`,
}));
@@ -72,6 +77,12 @@ const ListView = memo(() => {
s.sortType,
]);
// Access column widths from Global store
const columnWidths = useGlobalStore(
(s) => s.status.resourceManagerColumnWidths || INITIAL_STATUS.resourceManagerColumnWidths,
);
const updateColumnWidth = useGlobalStore((s) => s.updateResourceManagerColumnWidth);
const { t } = useTranslation(['components', 'file']);
const virtuosoRef = useRef<VirtuosoHandle>(null);
const [isLoadingMore, setIsLoadingMore] = useState(false);
@@ -88,7 +99,6 @@ const ListView = memo(() => {
// Get current folder ID - either from breadcrumb or null for root
const currentFolderId = folderBreadcrumb?.at(-1)?.id || null;
// Fetch data with SWR
const { data: rawData } = useResourceManagerFetchKnowledgeItems({
category,
knowledgeBaseId: libraryId,
@@ -103,7 +113,11 @@ const ListView = memo(() => {
// Handle selection change with shift-click support for range selection
const handleSelectionChange = useCallback(
(id: string, checked: boolean, shiftKey: boolean, clickedIndex: number) => {
if (shiftKey && lastSelectedIndex !== null && selectFileIds.length > 0 && data) {
// Always get the latest state from the store to avoid stale closure issues
const currentSelected = useResourceManagerStore.getState().selectedFileIds;
if (shiftKey && lastSelectedIndex !== null && data) {
// Shift-click: select range from lastSelectedIndex to current index
const start = Math.min(lastSelectedIndex, clickedIndex);
const end = Math.max(lastSelectedIndex, clickedIndex);
const rangeIds = data
@@ -111,19 +125,21 @@ const ListView = memo(() => {
.filter(Boolean)
.map((item) => item.id);
const prevSet = new Set(selectFileIds);
// Merge with existing selection
const prevSet = new Set(currentSelected);
rangeIds.forEach((rangeId) => prevSet.add(rangeId));
setSelectedFileIds(Array.from(prevSet));
} else {
// Regular click: toggle single item
if (checked) {
setSelectedFileIds([...selectFileIds, id]);
setSelectedFileIds([...currentSelected, id]);
} else {
setSelectedFileIds(selectFileIds.filter((item) => item !== id));
setSelectedFileIds(currentSelected.filter((item) => item !== id));
}
}
setLastSelectedIndex(clickedIndex);
},
[lastSelectedIndex, selectFileIds, data, setSelectedFileIds],
[lastSelectedIndex, data, setSelectedFileIds],
);
// Clean up invalid selections when data changes
@@ -254,81 +270,124 @@ const ListView = memo(() => {
};
}, [clearScrollTimers]);
// Memoize footer component to show skeleton loaders when loading more
const Footer = useCallback(() => {
if (!isLoadingMore || !fileListHasMore) return null;
return <ListViewSkeleton columnWidths={columnWidths} />;
}, [isLoadingMore, fileListHasMore, columnWidths]);
return (
<Flexbox height={'100%'}>
<Flexbox
align={'center'}
className={styles.header}
horizontal
paddingInline={8}
style={{
borderBlockEnd: `1px solid ${cssVar.colorBorderSecondary}`,
fontSize: 12,
}}
>
<Center height={40} style={{ paddingInline: 4 }}>
<Checkbox
checked={allSelected}
indeterminate={indeterminate}
onChange={handleSelectAll}
/>
</Center>
<Flexbox className={styles.headerItem} flex={1} style={{ paddingInline: 8 }}>
{t('FileManager.title.title')}
</Flexbox>
<Flexbox className={styles.headerItem} width={FILE_DATE_WIDTH}>
{t('FileManager.title.createdAt')}
</Flexbox>
<Flexbox className={styles.headerItem} width={FILE_SIZE_WIDTH}>
{t('FileManager.title.size')}
</Flexbox>
</Flexbox>
<div
className={cx(styles.dropZone, isDropZoneActive && styles.dropZoneActive)}
data-drop-target-id={currentFolderId || undefined}
data-is-folder="true"
onDragLeave={handleDropZoneDragLeave}
onDragOver={(e) => {
handleDropZoneDragOver(e);
handleDragMove(e);
}}
onDrop={handleDropZoneDrop}
ref={containerRef}
style={{ flex: 1, overflow: 'hidden', position: 'relative' }}
>
<Virtuoso
data={data || []}
defaultItemHeight={48}
endReached={handleEndReached}
increaseViewportBy={{ bottom: 800, top: 1200 }}
initialItemCount={30}
itemContent={(index, item) => {
if (!item) return null;
return (
<FileListItem
index={index}
key={item.id}
onSelectedChange={handleSelectionChange}
pendingRenameItemId={pendingRenameItemId}
selected={selectFileIds.includes(item.id)}
{...item}
/>
);
<div className={styles.scrollContainer}>
<Flexbox
align={'center'}
className={styles.header}
horizontal
paddingInline={8}
style={{
borderBlockEnd: `1px solid ${cssVar.colorBorderSecondary}`,
fontSize: 12,
}}
overscan={48 * 5}
ref={virtuosoRef}
style={{ height: '100%' }}
/>
{isLoadingMore && (
<Center
className={styles.loadingIndicator}
>
<Center height={40} style={{ paddingInline: 4 }}>
<Checkbox
checked={allSelected}
indeterminate={indeterminate}
onChange={handleSelectAll}
/>
</Center>
<Flexbox
className={styles.headerItem}
justify={'center'}
style={{
borderBlockStart: `1px solid ${cssVar.colorBorderSecondary}`,
flexShrink: 0,
maxWidth: columnWidths.name,
minWidth: columnWidths.name,
paddingInline: 8,
paddingInlineEnd: 16,
position: 'relative',
width: columnWidths.name,
}}
>
{t('loading', { defaultValue: 'Loading...', ns: 'file' })}
</Center>
)}
{t('FileManager.title.title')}
<ColumnResizeHandle
column="name"
currentWidth={columnWidths.name}
maxWidth={1200}
minWidth={200}
onResize={(width) => updateColumnWidth('name', width)}
/>
</Flexbox>
<Flexbox
className={styles.headerItem}
justify={'center'}
style={{ flexShrink: 0, paddingInlineEnd: 16, position: 'relative' }}
width={columnWidths.date}
>
{t('FileManager.title.createdAt')}
<ColumnResizeHandle
column="date"
currentWidth={columnWidths.date}
maxWidth={300}
minWidth={120}
onResize={(width) => updateColumnWidth('date', width)}
/>
</Flexbox>
<Flexbox
className={styles.headerItem}
justify={'center'}
style={{ flexShrink: 0, paddingInlineEnd: 16, position: 'relative' }}
width={columnWidths.size}
>
{t('FileManager.title.size')}
<ColumnResizeHandle
column="size"
currentWidth={columnWidths.size}
maxWidth={200}
minWidth={80}
onResize={(width) => updateColumnWidth('size', width)}
/>
</Flexbox>
</Flexbox>
<div
className={cx(styles.dropZone, isDropZoneActive && styles.dropZoneActive)}
data-drop-target-id={currentFolderId || undefined}
data-is-folder="true"
onDragLeave={handleDropZoneDragLeave}
onDragOver={(e) => {
handleDropZoneDragOver(e);
handleDragMove(e);
}}
onDrop={handleDropZoneDrop}
ref={containerRef}
style={{ overflow: 'hidden', position: 'relative' }}
>
<Virtuoso
components={{ Footer }}
data={data || []}
defaultItemHeight={48}
endReached={handleEndReached}
increaseViewportBy={{ bottom: 800, top: 1200 }}
initialItemCount={30}
itemContent={(index, item) => {
if (!item) return null;
return (
<FileListItem
columnWidths={columnWidths}
index={index}
key={item.id}
onSelectedChange={handleSelectionChange}
pendingRenameItemId={pendingRenameItemId}
selected={selectFileIds.includes(item.id)}
{...item}
/>
);
}}
overscan={48 * 5}
ref={virtuosoRef}
style={{ height: 'calc(100vh - 100px)' }}
/>
</div>
</div>
</Flexbox>
);

View File

@@ -1,8 +1,9 @@
import { type DropdownItem, DropdownMenu } from '@lobehub/ui';
import { Dropdown } from '@lobehub/ui';
import { ArrowDownAZ } from 'lucide-react';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { type MenuProps } from '@/components/Menu';
import { useResourceManagerStore } from '@/app/[variants]/(main)/resource/features/store';
import ActionIconWithChevron from './ActionIconWithChevron';
@@ -21,23 +22,30 @@ const SortDropdown = memo(() => {
[t],
);
const menuItems: DropdownItem[] = sortOptions.map((option) => ({
key: option.key,
label: option.label,
onClick: () => setSorter(option.key as 'name' | 'createdAt' | 'size'),
style:
option.key === (sorter || 'createdAt')
? { backgroundColor: 'var(--ant-control-item-bg-active)' }
: {},
}));
const menuItems: MenuProps['items'] = useMemo(
() =>
sortOptions.map((option) => ({
key: option.key,
label: option.label,
onClick: () => setSorter(option.key as 'name' | 'createdAt' | 'size'),
})),
[setSorter, sortOptions],
);
const currentSortLabel =
sortOptions.find((option) => option.key === sorter)?.label || t('FileManager.sort.dateAdded');
return (
<DropdownMenu items={menuItems}>
<Dropdown
arrow={false}
menu={{
items: menuItems,
selectable: true,
selectedKeys: [sorter || 'createdAt'],
}}
>
<ActionIconWithChevron icon={ArrowDownAZ} title={currentSortLabel} />
</DropdownMenu>
</Dropdown>
);
});

View File

@@ -1,8 +1,10 @@
import { type DropdownItem, DropdownMenu, Icon } from '@lobehub/ui';
import { Dropdown, type DropdownProps, Icon } from '@lobehub/ui';
import { Grid3x3Icon, ListIcon } from 'lucide-react';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { type MenuProps } from '@/components/Menu';
import { useViewMode } from '../hooks/useViewMode';
import ActionIconWithChevron from './ActionIconWithChevron';
@@ -18,30 +20,36 @@ const ViewSwitcher = memo(() => {
const currentViewLabel =
viewMode === 'list' ? t('FileManager.view.list') : t('FileManager.view.masonry');
const menuItems = useMemo<DropdownItem[]>(() => {
return [
const menuItems: MenuProps['items'] = useMemo(
() => [
{
icon: <Icon icon={ListIcon} />,
key: 'list',
label: t('FileManager.view.list'),
onClick: () => setViewMode('list'),
style: viewMode === 'list' ? { backgroundColor: 'var(--ant-control-item-bg-active)' } : {},
},
{
icon: <Icon icon={Grid3x3Icon} />,
key: 'masonry',
label: t('FileManager.view.masonry'),
onClick: () => setViewMode('masonry'),
style:
viewMode === 'masonry' ? { backgroundColor: 'var(--ant-control-item-bg-active)' } : {},
},
];
}, [setViewMode, t, viewMode]);
],
[setViewMode, t],
);
return (
<DropdownMenu items={menuItems} placement="bottomRight">
<Dropdown
arrow={false}
menu={{
items: menuItems,
selectable: true,
selectedKeys: [viewMode],
}}
placement="bottomRight"
>
<ActionIconWithChevron icon={currentViewIcon} title={currentViewLabel} />
</DropdownMenu>
</Dropdown>
);
});

View File

@@ -1,14 +1,25 @@
import { Flexbox, Text } from '@lobehub/ui';
import { ActionIcon, Flexbox, Text } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { XIcon } from 'lucide-react';
import { type ReactNode, memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import FileIcon from '@/components/FileIcon';
import { useFileStore } from '@/store/file';
import { type UploadFileItem } from '@/types/files/upload';
import { formatSize, formatSpeed, formatTime } from '@/utils/format';
const styles = createStaticStyles(({ css, cssVar }) => {
return {
cancelButton: css`
opacity: 0;
transition: opacity 0.2s ease;
`,
container: css`
&:hover .cancel-button {
opacity: 1;
}
`,
progress: css`
position: absolute;
inset-block: 0 0;
@@ -33,9 +44,10 @@ const styles = createStaticStyles(({ css, cssVar }) => {
type UploadItemProps = UploadFileItem;
const UploadItem = memo<UploadItemProps>(({ file, status, uploadState }) => {
const UploadItem = memo<UploadItemProps>(({ id, file, status, uploadState }) => {
const { t } = useTranslation('file');
const { type, name, size } = file;
const cancelUpload = useFileStore((s) => s.cancelUpload);
const desc: ReactNode = useMemo(() => {
switch (status) {
@@ -87,28 +99,48 @@ const UploadItem = memo<UploadItemProps>(({ file, status, uploadState }) => {
</Text>
);
}
case 'cancelled': {
return (
<Text style={{ fontSize: 12 }} type={'warning'}>
{formatSize(size)} · {t('uploadDock.body.item.cancelled')}
</Text>
);
}
default: {
return '';
}
}
}, [status, uploadState]);
}, [status, uploadState, size, t]);
return (
<Flexbox
align={'center'}
gap={4}
className={styles.container}
gap={12}
horizontal
key={name}
paddingBlock={8}
paddingInline={12}
style={{ position: 'relative' }}
>
<FileIcon fileName={name} fileType={type} />
<Flexbox style={{ overflow: 'hidden' }}>
<FileIcon fileName={name} fileType={type} size={36} />
<Flexbox flex={1} style={{ overflow: 'hidden' }}>
<div className={styles.title}>{name}</div>
{desc}
</Flexbox>
{(status === 'uploading' || status === 'pending') && (
<ActionIcon
className={`${styles.cancelButton} cancel-button`}
icon={XIcon}
onClick={() => {
cancelUpload(id);
}}
size="small"
title={t('uploadDock.body.item.cancel')}
/>
)}
{status === 'uploading' && !!uploadState && (
<div
className={styles.progress}

View File

@@ -3,6 +3,7 @@ import { ActionIcon, Center, Flexbox, Icon, Text } from '@lobehub/ui';
import { createStaticStyles, cssVar } from 'antd-style';
import isEqual from 'fast-deep-equal';
import { UploadIcon, XIcon } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import { memo, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -132,49 +133,69 @@ const UploadDock = memo(() => {
)}
</Flexbox>
{expand ? (
<Flexbox
justify={'space-between'}
style={{
background: `color-mix(in srgb, ${cssVar.colorBgLayout} 95%, white)`,
borderBottomLeftRadius: 8,
borderBottomRightRadius: 8,
height: 400,
}}
>
<Flexbox gap={8} paddingBlock={16} style={{ overflowY: 'scroll' }}>
{fileList.map((item) => (
<Item key={item.id} {...item} />
))}
</Flexbox>
<Center style={{ height: 40, minHeight: 40 }}>
<Text
onClick={() => {
setExpand(false);
<AnimatePresence mode="wait">
{expand ? (
<motion.div
animate={{ height: 400, opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
initial={{ height: 0, opacity: 0 }}
key="expanded"
style={{ overflow: 'hidden' }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
>
<Flexbox
justify={'space-between'}
style={{
background: cssVar.colorBgContainer,
borderBottomLeftRadius: 8,
borderBottomRightRadius: 8,
height: 400,
}}
style={{ cursor: 'pointer' }}
type={'secondary'}
>
{t('uploadDock.body.collapse')}
</Text>
</Center>
</Flexbox>
) : (
overviewUploadingStatus !== 'pending' && (
<div
className={styles.progress}
style={{
borderColor:
overviewUploadingStatus === 'success'
? cssVar.colorSuccess
: overviewUploadingStatus === 'error'
? cssVar.colorError
: undefined,
insetInlineEnd: `${100 - totalUploadingProgress}%`,
}}
/>
)
)}
<Flexbox gap={8} paddingBlock={8} style={{ overflowY: 'scroll' }}>
{fileList.map((item) => (
<Item key={item.id} {...item} />
))}
</Flexbox>
<Center style={{ height: 40, minHeight: 40 }}>
<Text
onClick={() => {
setExpand(false);
}}
style={{ cursor: 'pointer' }}
type={'secondary'}
>
{t('uploadDock.body.collapse')}
</Text>
</Center>
</Flexbox>
</motion.div>
) : (
overviewUploadingStatus !== 'pending' && (
<motion.div
animate={{ opacity: 1, scaleY: 1 }}
exit={{ opacity: 0, scaleY: 0 }}
initial={{ opacity: 0, scaleY: 0 }}
key="collapsed"
style={{ originY: 0 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
>
<div
className={styles.progress}
style={{
borderColor:
overviewUploadingStatus === 'success'
? cssVar.colorSuccess
: overviewUploadingStatus === 'error'
? cssVar.colorError
: undefined,
insetInlineEnd: `${100 - totalUploadingProgress}%`,
}}
/>
</motion.div>
)
)}
</AnimatePresence>
</Flexbox>
);
});

View File

@@ -22,6 +22,7 @@ const useStyles = createStyles(({ css, token }) => {
return {
container: css`
position: relative;
overflow: hidden;
`,
editorOverlay: css`
position: absolute;

View File

@@ -39,6 +39,7 @@ export default {
'header.actions.notionGuide.title': 'Import from Notion',
'header.actions.uploadFile': 'Upload File',
'header.actions.uploadFolder': 'Upload Folder',
'header.actions.uploadFolder.creatingFolders': 'Creating folder structure...',
'header.newPageButton': 'New Page',
'header.uploadButton': 'Upload',
'home.getStarted': 'Get Started',
@@ -129,6 +130,8 @@ export default {
'title': 'Resources',
'toggleLeftPanel': 'Show/Hide Left Panel',
'uploadDock.body.collapse': 'Collapse',
'uploadDock.body.item.cancel': 'Cancel',
'uploadDock.body.item.cancelled': 'Cancelled',
'uploadDock.body.item.done': 'Uploaded',
'uploadDock.body.item.error': 'Upload failed, please try again',
'uploadDock.body.item.pending': 'Preparing to upload...',
@@ -137,6 +140,7 @@ export default {
'uploadDock.fileQueueInfo':
'Uploading the first {{count}} files, {{remaining}} remaining in queue',
'uploadDock.totalCount': 'Total {{count}} items',
'uploadDock.uploadStatus.cancelled': 'Upload cancelled',
'uploadDock.uploadStatus.error': 'Upload error',
'uploadDock.uploadStatus.pending': 'Waiting to upload',
'uploadDock.uploadStatus.processing': 'Uploading',

View File

@@ -55,6 +55,50 @@ export const documentRouter = router({
});
}),
createDocuments: documentProcedure
.input(
z.object({
documents: z.array(
z.object({
content: z.string().optional(),
editorData: z.string(),
fileType: z.string().optional(),
knowledgeBaseId: z.string().optional(),
metadata: z.record(z.any()).optional(),
parentId: z.string().optional(),
slug: z.string().optional(),
title: z.string(),
}),
),
}),
)
.mutation(async ({ ctx, input }) => {
// Process each document: resolve parentId and parse editorData
const processedDocuments = await Promise.all(
input.documents.map(async (doc) => {
// Resolve parentId if it's a slug
let resolvedParentId = doc.parentId;
if (doc.parentId) {
const docBySlug = await ctx.documentModel.findBySlug(doc.parentId);
if (docBySlug) {
resolvedParentId = docBySlug.id;
}
}
// Parse editorData from JSON string to object
const editorData = JSON.parse(doc.editorData);
return {
...doc,
editorData,
parentId: resolvedParentId,
};
}),
);
return ctx.documentService.createDocuments(processedDocuments);
}),
deleteDocument: documentProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {

View File

@@ -100,6 +100,28 @@ export class DocumentService {
return document;
}
/**
* Create multiple documents in batch (optimized for folder creation)
* Returns array of created documents with same order as input
*/
async createDocuments(
documents: Array<{
content?: string;
editorData: Record<string, any>;
fileType?: string;
knowledgeBaseId?: string;
metadata?: Record<string, any>;
parentId?: string;
slug?: string;
title: string;
}>,
): Promise<DocumentItem[]> {
// Create all documents in parallel for better performance
const results = await Promise.all(documents.map((params) => this.createDocument(params)));
return results;
}
/**
* Query documents with pagination
*/

View File

@@ -28,6 +28,10 @@ export class DocumentService {
return lambdaClient.document.createDocument.mutate(params);
}
async createDocuments(documents: CreateDocumentParams[]): Promise<DocumentItem[]> {
return lambdaClient.document.createDocuments.mutate({ documents });
}
async queryDocuments(params?: {
current?: number;
fileTypes?: string[];

View File

@@ -44,6 +44,7 @@ const generateFilePathMetadata = (
};
interface UploadFileToS3Options {
abortController?: AbortController;
directory?: string;
filename?: string;
onNotSupported?: () => void;
@@ -58,13 +59,18 @@ class UploadService {
*/
uploadFileToS3 = async (
file: File,
{ onProgress, directory, pathname }: UploadFileToS3Options,
{ onProgress, directory, pathname, abortController }: UploadFileToS3Options,
): Promise<{ data: FileMetadata; success: boolean }> => {
// Server-side upload logic
// if is server mode, upload to server s3,
const data = await this.uploadToServerS3(file, { directory, onProgress, pathname });
const data = await this.uploadToServerS3(file, {
abortController,
directory,
onProgress,
pathname,
});
return { data, success: true };
};
@@ -129,7 +135,9 @@ class UploadService {
onProgress,
directory,
pathname,
abortController,
}: {
abortController?: AbortController;
directory?: string;
onProgress?: (status: FileUploadStatus, state: FileUploadState) => void;
pathname?: string;
@@ -139,6 +147,14 @@ class UploadService {
const { preSignUrl, ...result } = await this.getSignedUploadUrl(file, { directory, pathname });
let startTime = Date.now();
// Setup abort listener
if (abortController) {
abortController.signal.addEventListener('abort', () => {
xhr.abort();
});
}
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
const progress = Number(((event.loaded / event.total) * 100).toFixed(1));
@@ -177,6 +193,10 @@ class UploadService {
if (xhr.status === 0) reject(UPLOAD_NETWORK_ERROR);
else reject(xhr.statusText);
});
xhr.addEventListener('abort', () => {
onProgress?.('cancelled', { progress: 0, restTime: 0, speed: 0 });
reject(new Error('Upload cancelled by user'));
});
xhr.send(data);
});

View File

@@ -1,10 +1,18 @@
import { buildFolderTree, sanitizeFolderName, topologicalSortFolders } from '@lobechat/utils';
import {
buildFolderTree,
createNanoId,
sanitizeFolderName,
topologicalSortFolders,
} from '@lobechat/utils';
import { t } from 'i18next';
import pMap from 'p-map';
import type { SWRResponse } from 'swr';
import { type StateCreator } from 'zustand/vanilla';
import { message } from '@/components/AntdStaticMethods';
import { FILE_UPLOAD_BLACKLIST, MAX_UPLOAD_FILE_COUNT } from '@/const/file';
import { mutate, useClientDataSWR } from '@/libs/swr';
import { documentService } from '@/services/document';
import { FileService, fileService } from '@/services/file';
import { ragService } from '@/services/rag';
import {
@@ -27,6 +35,7 @@ export interface FolderCrumb {
}
export interface FileManageAction {
cancelUpload: (id: string) => void;
dispatchDockFileList: (payload: UploadFileListDispatch) => void;
embeddingChunks: (fileIds: string[]) => Promise<void>;
loadMoreKnowledgeItems: () => Promise<void>;
@@ -67,6 +76,21 @@ export const createFileManageSlice: StateCreator<
[],
FileManageAction
> = (set, get) => ({
cancelUpload: (id) => {
const { dockUploadFileList, dispatchDockFileList } = get();
const uploadItem = dockUploadFileList.find((item) => item.id === id);
if (uploadItem?.abortController) {
uploadItem.abortController.abort();
}
// Update status to cancelled
dispatchDockFileList({
id,
status: 'cancelled',
type: 'updateFileStatus',
});
},
dispatchDockFileList: (payload: UploadFileListDispatch) => {
const nextValue = uploadFileListReducer(get().dockUploadFileList, payload);
if (nextValue === get().dockUploadFileList) return;
@@ -186,19 +210,31 @@ export const createFileManageSlice: StateCreator<
// 1. skip file in blacklist
const files = filesToUpload.filter((file) => !FILE_UPLOAD_BLACKLIST.includes(file.name));
// 2. Add all files to dock
// 2. Create upload items with abort controllers
const uploadFiles = files.map((file) => {
const abortController = new AbortController();
return {
abortController,
file,
id: file.name,
status: 'pending' as const,
};
});
// 3. Add all files to dock
dispatchDockFileList({
atStart: true,
files: files.map((file) => ({ file, id: file.name, status: 'pending' })),
files: uploadFiles,
type: 'addFiles',
});
// 3. Upload files with concurrency limit using p-map
// 4. Upload files with concurrency limit using p-map
const uploadResults = await pMap(
files,
async (file) => {
uploadFiles,
async (uploadFileItem) => {
const result = await get().uploadWithProgress({
file,
abortController: uploadFileItem.abortController,
file: uploadFileItem.file,
knowledgeBaseId,
onStatusUpdate: dispatchDockFileList,
parentId,
@@ -207,7 +243,11 @@ export const createFileManageSlice: StateCreator<
// Note: Don't refresh after each file to avoid flickering
// We'll refresh once at the end
return { file, fileId: result?.id, fileType: file.type };
return {
file: uploadFileItem.file,
fileId: result?.id,
fileType: uploadFileItem.file.type,
};
},
{ concurrency: MAX_UPLOAD_FILE_COUNT },
);
@@ -215,7 +255,7 @@ export const createFileManageSlice: StateCreator<
// Refresh the file list once after all uploads are complete
await get().refreshFileList();
// 4. auto-embed files that support chunking
// 5. auto-embed files that support chunking
const fileIdsToEmbed = uploadResults
.filter(({ fileType, fileId }) => fileId && !isChunkingUnsupported(fileType))
.map(({ fileId }) => fileId!);
@@ -353,82 +393,137 @@ export const createFileManageSlice: StateCreator<
// 2. Sort folders by depth to ensure parents are created before children
const sortedFolderPaths = topologicalSortFolders(folders);
// Map to store created folder IDs: relative path -> folder ID
const folderIdMap = new Map<string, string>();
// 3. Create all folders sequentially (maintaining hierarchy)
for (const folderPath of sortedFolderPaths) {
const folder = folders[folderPath];
// Determine parent ID: either from previously created folder or current folder
const parentId = folder.parent ? folderIdMap.get(folder.parent) : currentFolderId;
// Sanitize folder name to remove invalid characters
const sanitizedName = sanitizeFolderName(folder.name);
// Create folder
const folderId = await get().createFolder(sanitizedName, parentId, knowledgeBaseId);
// Store mapping for child folders
folderIdMap.set(folderPath, folderId);
// Show toast notification if there are folders to create
const messageKey = 'uploadFolder.creatingFolders';
if (sortedFolderPaths.length > 0) {
message.loading({
content: t('header.actions.uploadFolder.creatingFolders', { ns: 'file' }),
duration: 0, // Don't auto-dismiss
key: messageKey,
});
}
// 4. Prepare all file uploads with their target folder IDs
const allUploads: Array<{ file: File; parentId: string | undefined }> = [];
try {
// Map to store created folder IDs: relative path -> folder ID
const folderIdMap = new Map<string, string>();
for (const [folderPath, folderFiles] of Object.entries(filesByFolder)) {
// Root-level files (no folder path) go to currentFolderId
const targetFolderId = folderPath ? folderIdMap.get(folderPath) : currentFolderId;
// 3. Group folders by depth level for batch creation
const foldersByLevel = new Map<number, string[]>();
for (const folderPath of sortedFolderPaths) {
const depth = (folderPath.match(/\//g) || []).length;
if (!foldersByLevel.has(depth)) {
foldersByLevel.set(depth, []);
}
foldersByLevel.get(depth)!.push(folderPath);
}
allUploads.push(
...folderFiles.map((file) => ({
file,
parentId: targetFolderId,
})),
);
}
// 4. Create folders level by level using batch API
const generateSlug = createNanoId(8);
const levels = Array.from(foldersByLevel.keys()).sort((a, b) => a - b);
for (const level of levels) {
const foldersAtThisLevel = foldersByLevel.get(level)!;
// 5. Filter out blacklisted files
const validUploads = allUploads.filter(
({ file }) => !FILE_UPLOAD_BLACKLIST.includes(file.name),
);
// Prepare batch creation data for this level
const batchCreateData = foldersAtThisLevel.map((folderPath) => {
const folder = folders[folderPath];
const parentId = folder.parent ? folderIdMap.get(folder.parent) : currentFolderId;
const sanitizedName = sanitizeFolderName(folder.name);
// 6. Add all files to dock
dispatchDockFileList({
atStart: true,
files: validUploads.map(({ file }) => ({ file, id: file.name, status: 'pending' })),
type: 'addFiles',
});
// Generate unique slug for the folder
const slug = generateSlug();
// 7. Upload files with concurrency limit
const uploadResults = await pMap(
validUploads,
async ({ file, parentId }) => {
const result = await get().uploadWithProgress({
file,
knowledgeBaseId,
onStatusUpdate: dispatchDockFileList,
parentId,
return {
content: '',
editorData: '{}',
fileType: 'custom/folder',
knowledgeBaseId,
metadata: { createdAt: Date.now() },
parentId,
slug,
title: sanitizedName,
};
});
// Note: Don't refresh after each file to avoid flickering
// We'll refresh once at the end
// Create all folders at this level in a single batch request
const createdFolders = await documentService.createDocuments(batchCreateData);
return { file, fileId: result?.id, fileType: file.type };
},
{ concurrency: MAX_UPLOAD_FILE_COUNT },
);
// Store folder ID mappings for the next level
for (const [i, element] of foldersAtThisLevel.entries()) {
folderIdMap.set(element, createdFolders[i].id);
}
}
// Refresh the file list once after all uploads are complete
await get().refreshFileList();
// Dismiss the toast after folders are created
if (sortedFolderPaths.length > 0) {
message.destroy(messageKey);
}
// 8. Auto-embed files that support chunking
const fileIdsToEmbed = uploadResults
.filter(({ fileType, fileId }) => fileId && !isChunkingUnsupported(fileType))
.map(({ fileId }) => fileId!);
// Refresh file list to show the new folders
await get().refreshFileList();
if (fileIdsToEmbed.length > 0) {
await get().parseFilesToChunks(fileIdsToEmbed, { skipExist: false });
// 5. Prepare all file uploads with their target folder IDs
const allUploads: Array<{ file: File; parentId: string | undefined }> = [];
for (const [folderPath, folderFiles] of Object.entries(filesByFolder)) {
// Root-level files (no folder path) go to currentFolderId
const targetFolderId = folderPath ? folderIdMap.get(folderPath) : currentFolderId;
allUploads.push(
...folderFiles.map((file) => ({
file,
parentId: targetFolderId,
})),
);
}
// 6. Filter out blacklisted files
const validUploads = allUploads.filter(
({ file }) => !FILE_UPLOAD_BLACKLIST.includes(file.name),
);
// 7. Add all files to dock
dispatchDockFileList({
atStart: true,
files: validUploads.map(({ file }) => ({ file, id: file.name, status: 'pending' })),
type: 'addFiles',
});
// 8. Upload files with concurrency limit
const uploadResults = await pMap(
validUploads,
async ({ file, parentId }) => {
const result = await get().uploadWithProgress({
file,
knowledgeBaseId,
onStatusUpdate: dispatchDockFileList,
parentId,
});
// Note: Don't refresh after each file to avoid flickering
// We'll refresh once at the end
return { file, fileId: result?.id, fileType: file.type };
},
{ concurrency: MAX_UPLOAD_FILE_COUNT },
);
// Refresh the file list once after all uploads are complete
await get().refreshFileList();
// 9. Auto-embed files that support chunking
const fileIdsToEmbed = uploadResults
.filter(({ fileType, fileId }) => fileId && !isChunkingUnsupported(fileType))
.map(({ fileId }) => fileId!);
if (fileIdsToEmbed.length > 0) {
await get().parseFilesToChunks(fileIdsToEmbed, { skipExist: false });
}
} catch (error) {
// Dismiss toast on error
if (sortedFolderPaths.length > 0) {
message.destroy(messageKey);
}
throw error;
}
},

View File

@@ -25,6 +25,7 @@ type OnStatusUpdate = (
) => void;
interface UploadWithProgressParams {
abortController?: AbortController;
file: File;
knowledgeBaseId?: string;
onStatusUpdate?: OnStatusUpdate;
@@ -93,6 +94,7 @@ export const createFileUploadSlice: StateCreator<
skipCheckFileType,
parentId,
source,
abortController,
}) => {
const fileArrayBuffer = await file.arrayBuffer();
@@ -117,6 +119,7 @@ export const createFileUploadSlice: StateCreator<
// 3. if file don't exist, need upload files
else {
const { data, success } = await uploadService.uploadFileToS3(file, {
abortController,
onNotSupported: () => {
onStatusUpdate?.({ id: file.name, type: 'removeFile' });
message.info({

View File

@@ -20,6 +20,7 @@ export interface GlobalGeneralAction {
openAgentInNewWindow: (agentId: string) => Promise<void>;
openTopicInNewWindow: (agentId: string, topicId: string) => Promise<void>;
switchLocale: (locale: LocaleMode, params?: { skipBroadcast?: boolean }) => void;
updateResourceManagerColumnWidth: (column: 'name' | 'date' | 'size', width: number) => void;
updateSystemStatus: (status: Partial<SystemStatus>, action?: any) => void;
useCheckLatestVersion: (enabledCheck?: boolean) => SWRResponse<string>;
useInitSystemStatus: () => SWRResponse;
@@ -110,6 +111,20 @@ export const generalActionSlice: StateCreator<
})();
}
},
updateResourceManagerColumnWidth: (column, width) => {
const currentWidths = get().status.resourceManagerColumnWidths || {
date: 160,
name: 574,
size: 140,
};
get().updateSystemStatus({
resourceManagerColumnWidths: {
...currentWidths,
[column]: width,
},
});
},
updateSystemStatus: (status, action) => {
if (!get().isStatusInit) return;

View File

@@ -105,6 +105,14 @@ export interface SystemStatus {
* 记住用户最后选择的图像生成模型
*/
lastSelectedImageModel?: string;
/**
* Resource Manager column widths
*/
resourceManagerColumnWidths?: {
date: number;
name: number;
size: number;
};
/**
* 记住用户最后选择的图像生成提供商
*/
@@ -187,6 +195,11 @@ export const INITIAL_STATUS = {
knowledgeBaseModalViewMode: 'list' as const,
leftPanelWidth: 320,
mobileShowTopic: false,
resourceManagerColumnWidths: {
date: 160,
name: 574,
size: 140,
},
modelSwitchPanelGroupMode: 'byProvider',
modelSwitchPanelWidth: 430,
noWideScreen: true,