mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-27 13:29:15 +07:00
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:
@@ -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": "正在上传",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
`,
|
||||
}));
|
||||
|
||||
|
||||
224
src/features/FileViewer/Renderer/Code/index.tsx
Normal file
224
src/features/FileViewer/Renderer/Code/index.tsx
Normal 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;
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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};
|
||||
`,
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ const useStyles = createStyles(({ css, token }) => {
|
||||
return {
|
||||
container: css`
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
`,
|
||||
editorOverlay: css`
|
||||
position: absolute;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user