refactor(files): Reconstruct file system UI (#4100)

* refactor(files): Reconstruct file system UI

* refactor(knowledge): replace Card components with CustomCollapse for better UI structure

* refactor(files): update folder icon from FolderOpenOutlined to FolderOpenFilled

* feat(components): add CustomCollapse component for enhanced collapsible UI

* refactor(files): implement virtual scrolling in FileList and KnowledgeContent components
This commit is contained in:
Teo 2025-03-30 13:56:34 +08:00 committed by GitHub
parent 22b0bd54b4
commit 00de616958
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 708 additions and 345 deletions

View File

@ -1,4 +1,4 @@
@keyframes pulse {
@keyframes animation-pulse {
0% {
box-shadow: 0 0 0 0 rgba(var(--pulse-color), 0.5);
}
@ -14,5 +14,5 @@
.animation-pulse {
--pulse-color: 59, 130, 246;
--pulse-size: 8px;
animation: pulse 1.5s infinite;
animation: animation-pulse 1.5s infinite;
}

View File

@ -0,0 +1,44 @@
import { Collapse } from 'antd'
import { FC, memo } from 'react'
interface CustomCollapseProps {
label: string
extra: React.ReactNode
children: React.ReactNode
}
const CustomCollapse: FC<CustomCollapseProps> = ({ label, extra, children }) => {
const CollapseStyle = {
background: 'transparent',
border: '0.5px solid var(--color-border)'
}
const CollapseItemStyles = {
header: {
padding: '8px 16px',
alignItems: 'center',
justifyContent: 'space-between'
},
body: {
borderTop: '0.5px solid var(--color-border)'
}
}
return (
<Collapse
collapsible="header"
bordered={false}
style={CollapseStyle}
defaultActiveKey={['1']}
items={[
{
styles: CollapseItemStyles,
key: '1',
label,
extra,
children
}
]}
/>
)
}
export default memo(CustomCollapse)

View File

@ -0,0 +1,145 @@
import {
FileExcelFilled,
FileImageFilled,
FileMarkdownFilled,
FilePdfFilled,
FilePptFilled,
FileTextFilled,
FileUnknownFilled,
FileWordFilled,
FileZipFilled,
FolderOpenFilled,
GlobalOutlined,
LinkOutlined
} from '@ant-design/icons'
import { Flex } from 'antd'
import React, { memo } from 'react'
import styled from 'styled-components'
interface FileItemProps {
fileInfo: {
name: React.ReactNode | string
ext: string
extra?: React.ReactNode | string
actions: React.ReactNode
}
}
const getFileIcon = (type?: string) => {
if (!type) return <FileUnknownFilled />
const ext = type.toLowerCase()
if (['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'].includes(ext)) {
return <FileImageFilled />
}
if (['.doc', '.docx'].includes(ext)) {
return <FileWordFilled />
}
if (['.xls', '.xlsx'].includes(ext)) {
return <FileExcelFilled />
}
if (['.ppt', '.pptx'].includes(ext)) {
return <FilePptFilled />
}
if (ext === '.pdf') {
return <FilePdfFilled />
}
if (['.md', '.markdown'].includes(ext)) {
return <FileMarkdownFilled />
}
if (['.zip', '.rar', '.7z', '.tar', '.gz'].includes(ext)) {
return <FileZipFilled />
}
if (['.txt', '.json', '.log', '.yml', '.yaml', '.xml', '.csv'].includes(ext)) {
return <FileTextFilled />
}
if (['.url'].includes(ext)) {
return <LinkOutlined />
}
if (['.sitemap'].includes(ext)) {
return <GlobalOutlined />
}
if (['.folder'].includes(ext)) {
return <FolderOpenFilled />
}
return <FileUnknownFilled />
}
const FileItem: React.FC<FileItemProps> = ({ fileInfo }) => {
const { name, ext, extra, actions } = fileInfo
return (
<FileItemCard>
<CardContent>
<FileIcon>{getFileIcon(ext)}</FileIcon>
<Flex vertical gap={0} flex={1} style={{ width: '0px' }}>
<FileName>{name}</FileName>
{extra && <FileInfo>{extra}</FileInfo>}
</Flex>
{actions}
</CardContent>
</FileItemCard>
)
}
const FileItemCard = styled.div`
background: rgba(255, 255, 255, 0.04);
border-radius: 8px;
overflow: hidden;
border: 0.5px solid var(--color-border);
flex-shrink: 0;
transition: box-shadow 0.2s ease;
--shadow-color: rgba(0, 0, 0, 0.05);
&:hover {
box-shadow:
0 10px 15px -3px var(--shadow-color),
0 4px 6px -4px var(--shadow-color);
}
body[theme-mode='dark'] & {
--shadow-color: rgba(255, 255, 255, 0.02);
}
`
const CardContent = styled.div`
padding: 8px 16px;
display: flex;
align-items: center;
gap: 16px;
`
const FileIcon = styled.div`
color: var(--color-text-3);
font-size: 32px;
`
const FileName = styled.div`
font-size: 15px;
font-weight: bold;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
transition: color 0.2s ease;
span {
font-size: 15px;
font-weight: bold;
}
&:hover {
color: var(--color-primary);
}
`
const FileInfo = styled.div`
font-size: 13px;
color: var(--color-text-2);
`
export default memo(FileItem)

View File

@ -0,0 +1,167 @@
import FileManager from '@renderer/services/FileManager'
import { FileType, FileTypes } from '@renderer/types'
import { formatFileSize } from '@renderer/utils'
import { Col, Image, Row, Spin } from 'antd'
import { t } from 'i18next'
import VirtualList from 'rc-virtual-list'
import React, { memo } from 'react'
import styled from 'styled-components'
import FileItem from './FileItem'
import GeminiFiles from './GeminiFiles'
interface FileItemProps {
id: FileTypes | 'all' | string
list: {
key: FileTypes | 'all' | string
file: React.ReactNode
files?: FileType[]
count?: number
size: string
ext: string
created_at: string
actions: React.ReactNode
}[]
files?: FileType[]
}
const FileList: React.FC<FileItemProps> = ({ id, list, files }) => {
if (id === FileTypes.IMAGE && files?.length && files?.length > 0) {
return (
<div style={{ padding: 16 }}>
<Image.PreviewGroup>
<Row gutter={[16, 16]}>
{files?.map((file) => (
<Col key={file.id} xs={24} sm={12} md={8} lg={4} xl={3}>
<ImageWrapper>
<LoadingWrapper>
<Spin />
</LoadingWrapper>
<Image
src={FileManager.getFileUrl(file)}
style={{ height: '100%', objectFit: 'cover', cursor: 'pointer' }}
preview={{ mask: false }}
onLoad={(e) => {
const img = e.target as HTMLImageElement
img.parentElement?.classList.add('loaded')
}}
/>
<ImageInfo>
<div>{formatFileSize(file.size)}</div>
</ImageInfo>
</ImageWrapper>
</Col>
))}
</Row>
</Image.PreviewGroup>
</div>
)
}
if (id.startsWith('gemini_')) {
return <GeminiFiles id={id.replace('gemini_', '') as string} />
}
return (
<VirtualList
data={list}
height={window.innerHeight - 100}
itemHeight={80}
itemKey="key"
style={{ padding: '0 16px 16px 16px' }}
styles={{
verticalScrollBar: {
width: 6
},
verticalScrollBarThumb: {
background: 'var(--color-scrollbar-thumb)'
}
}}>
{(item) => (
<div
style={{
height: '80px',
paddingTop: '12px'
}}>
<FileItem
key={item.key}
fileInfo={{
name: item.file,
ext: item.ext,
extra: `${item.created_at} · ${t('files.count')} ${item.count} · ${item.size}`,
actions: item.actions
}}
/>
</div>
)}
</VirtualList>
)
}
const ImageWrapper = styled.div`
position: relative;
aspect-ratio: 1;
overflow: hidden;
border-radius: 8px;
background-color: var(--color-background-soft);
display: flex;
align-items: center;
justify-content: center;
border: 0.5px solid var(--color-border);
.ant-image {
height: 100%;
width: 100%;
opacity: 0;
transition:
opacity 0.3s ease,
transform 0.3s ease;
&.loaded {
opacity: 1;
}
}
&:hover {
.ant-image.loaded {
transform: scale(1.05);
}
div:last-child {
opacity: 1;
}
}
`
const LoadingWrapper = styled.div`
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--color-background-soft);
`
const ImageInfo = styled.div`
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.6);
color: white;
padding: 5px 8px;
opacity: 0;
transition: opacity 0.3s ease;
font-size: 12px;
> div:first-child {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
`
export default memo(FileList)

View File

@ -1,14 +1,15 @@
import {
DeleteOutlined,
EditOutlined,
EllipsisOutlined,
ExclamationCircleOutlined,
FileImageOutlined,
FilePdfOutlined,
FileTextOutlined
FileTextOutlined,
SortAscendingOutlined,
SortDescendingOutlined
} from '@ant-design/icons'
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
import TextEditPopup from '@renderer/components/Popups/TextEditPopup'
import Scrollbar from '@renderer/components/Scrollbar'
import db from '@renderer/databases'
import { useProviders } from '@renderer/hooks/useProvider'
import FileManager from '@renderer/services/FileManager'
@ -16,18 +17,23 @@ import store from '@renderer/store'
import { FileType, FileTypes } from '@renderer/types'
import { formatFileSize } from '@renderer/utils'
import type { MenuProps } from 'antd'
import { Button, Dropdown, Menu } from 'antd'
import { Button, Empty, Flex, Menu, Popconfirm } from 'antd'
import dayjs from 'dayjs'
import { useLiveQuery } from 'dexie-react-hooks'
import { FC, useMemo, useState } from 'react'
import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import ContentView from './ContentView'
import FileList from './FileList'
type SortField = 'created_at' | 'size' | 'name'
type SortOrder = 'asc' | 'desc'
const FilesPage: FC = () => {
const { t } = useTranslation()
const [fileType, setFileType] = useState<string>('document')
const [sortField, setSortField] = useState<SortField>('created_at')
const [sortOrder, setSortOrder] = useState<SortOrder>('desc')
const { providers } = useProviders()
const geminiProviders = providers.filter((provider) => provider.type === 'gemini')
@ -42,6 +48,24 @@ const FilesPage: FC = () => {
})
}
const sortFiles = (files: FileType[]) => {
return [...files].sort((a, b) => {
let comparison = 0
switch (sortField) {
case 'created_at':
comparison = dayjs(a.created_at).unix() - dayjs(b.created_at).unix()
break
case 'size':
comparison = a.size - b.size
break
case 'name':
comparison = a.origin_name.localeCompare(b.origin_name)
break
}
return sortOrder === 'asc' ? comparison : -comparison
})
}
const files = useLiveQuery<FileType[]>(() => {
if (fileType === 'all') {
return db.files.orderBy('count').toArray().then(tempFilesSort)
@ -49,6 +73,8 @@ const FilesPage: FC = () => {
return db.files.where('type').equals(fileType).sortBy('count').then(tempFilesSort)
}, [fileType])
const sortedFiles = files ? sortFiles(files) : []
const handleDelete = async (fileId: string) => {
const file = await FileManager.getFile(fileId)
@ -89,95 +115,34 @@ const FilesPage: FC = () => {
}
}
const getActionMenu = (fileId: string): MenuProps['items'] => [
{
key: 'rename',
icon: <EditOutlined />,
label: t('files.edit'),
onClick: () => handleRename(fileId)
},
{
key: 'delete',
icon: <DeleteOutlined />,
label: t('files.delete'),
danger: true,
onClick: () => {
window.modal.confirm({
title: t('files.delete.title'),
content: t('files.delete.content'),
centered: true,
okButtonProps: { danger: true },
onOk: () => handleDelete(fileId)
})
}
}
]
const dataSource = files?.map((file) => {
const dataSource = sortedFiles?.map((file) => {
return {
key: file.id,
file: (
<FileNameText className="text-nowrap" onClick={() => window.api.file.openPath(file.path)}>
{FileManager.formatFileName(file)}
</FileNameText>
),
file: <span onClick={() => window.api.file.openPath(file.path)}>{FileManager.formatFileName(file)}</span>,
size: formatFileSize(file.size),
size_bytes: file.size,
count: file.count,
path: file.path,
ext: file.ext,
created_at: dayjs(file.created_at).format('MM-DD HH:mm'),
created_at_unix: dayjs(file.created_at).unix(),
actions: (
<Dropdown menu={{ items: getActionMenu(file.id) }} trigger={['click']} placement="bottom" arrow>
<Button type="text" size="small" icon={<EllipsisOutlined />} />
</Dropdown>
<Flex align="center" gap={0} style={{ opacity: 0.7 }}>
<Button type="text" icon={<EditOutlined />} onClick={() => handleRename(file.id)} />
<Popconfirm
title={t('files.delete.title')}
description={t('files.delete.content')}
okText={t('common.confirm')}
cancelText={t('common.cancel')}
onConfirm={() => handleDelete(file.id)}
icon={<ExclamationCircleOutlined style={{ color: 'red' }} />}>
<Button type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Flex>
)
}
})
const columns = useMemo(
() => [
{
title: t('files.name'),
dataIndex: 'file',
key: 'file',
width: '300px'
},
{
title: t('files.size'),
dataIndex: 'size',
key: 'size',
width: '80px',
sorter: (a: { size_bytes: number }, b: { size_bytes: number }) => b.size_bytes - a.size_bytes,
align: 'center'
},
{
title: t('files.count'),
dataIndex: 'count',
key: 'count',
width: '60px',
sorter: (a: { count: number }, b: { count: number }) => b.count - a.count,
align: 'center'
},
{
title: t('files.created_at'),
dataIndex: 'created_at',
key: 'created_at',
width: '120px',
align: 'center',
sorter: (a: { created_at_unix: number }, b: { created_at_unix: number }) =>
b.created_at_unix - a.created_at_unix
},
{
title: t('files.actions'),
dataIndex: 'actions',
key: 'actions',
width: '80px',
align: 'center'
}
],
[t]
)
const menuItems = [
{ key: FileTypes.DOCUMENT, label: t('files.document'), icon: <FilePdfOutlined /> },
{ key: FileTypes.IMAGE, label: t('files.image'), icon: <FileImageOutlined /> },
@ -199,9 +164,31 @@ const FilesPage: FC = () => {
<SideNav>
<Menu selectedKeys={[fileType]} items={menuItems} onSelect={({ key }) => setFileType(key as FileTypes)} />
</SideNav>
<TableContainer right>
<ContentView id={fileType} files={files} dataSource={dataSource} columns={columns} />
</TableContainer>
<MainContent>
<SortContainer>
{['created_at', 'size', 'name'].map((field) => (
<SortButton
key={field}
active={sortField === field}
onClick={() => {
if (sortField === field) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')
} else {
setSortField(field as 'created_at' | 'size' | 'name')
setSortOrder('desc')
}
}}>
{t(`files.${field}`)}
{sortField === field && (sortOrder === 'desc' ? <SortDescendingOutlined /> : <SortAscendingOutlined />)}
</SortButton>
))}
</SortContainer>
{dataSource && dataSource?.length > 0 ? (
<FileList id={fileType} list={dataSource} files={sortedFiles} />
) : (
<Empty />
)}
</MainContent>
</ContentContainer>
</Container>
)
@ -214,6 +201,20 @@ const Container = styled.div`
height: calc(100vh - var(--navbar-height));
`
const MainContent = styled.div`
display: flex;
flex: 1;
flex-direction: column;
`
const SortContainer = styled.div`
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-bottom: 0.5px solid var(--color-border);
`
const ContentContainer = styled.div`
display: flex;
flex: 1;
@ -221,19 +222,6 @@ const ContentContainer = styled.div`
min-height: 100%;
`
const TableContainer = styled(Scrollbar)`
padding: 15px;
display: flex;
width: 100%;
flex-direction: column;
`
const FileNameText = styled.div`
font-size: 14px;
color: var(--color-text);
cursor: pointer;
`
const SideNav = styled.div`
width: var(--assistants-width);
border-right: 0.5px solid var(--color-border);
@ -266,4 +254,25 @@ const SideNav = styled.div`
}
`
const SortButton = styled(Button)<{ active?: boolean }>`
display: flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
height: 30px;
border-radius: var(--list-item-border-radius);
border: 0.5px solid ${(props) => (props.active ? 'var(--color-border)' : 'transparent')};
background-color: ${(props) => (props.active ? 'var(--color-background-soft)' : 'transparent')};
color: ${(props) => (props.active ? 'var(--color-text)' : 'var(--color-text-secondary)')};
&:hover {
background-color: var(--color-background-soft);
color: var(--color-text);
}
.anticon {
font-size: 12px;
}
`
export default FilesPage

View File

@ -2,12 +2,13 @@ import { DeleteOutlined } from '@ant-design/icons'
import type { FileMetadataResponse } from '@google/generative-ai/server'
import { useProvider } from '@renderer/hooks/useProvider'
import { runAsyncFunction } from '@renderer/utils'
import { Table } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { Spin } from 'antd'
import dayjs from 'dayjs'
import { FC, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import FileItem from './FileItem'
interface GeminiFilesProps {
id: string
}
@ -15,7 +16,6 @@ interface GeminiFilesProps {
const GeminiFiles: FC<GeminiFilesProps> = ({ id }) => {
const { provider } = useProvider(id)
const [files, setFiles] = useState<FileMetadataResponse[]>([])
const { t } = useTranslation()
const [loading, setLoading] = useState(false)
const fetchFiles = useCallback(async () => {
@ -23,51 +23,6 @@ const GeminiFiles: FC<GeminiFilesProps> = ({ id }) => {
files && setFiles(files.filter((file) => file.state === 'ACTIVE'))
}, [provider])
const columns: ColumnsType<FileMetadataResponse> = [
{
title: t('files.name'),
dataIndex: 'displayName',
key: 'displayName'
},
{
title: t('files.type'),
dataIndex: 'mimeType',
key: 'mimeType'
},
{
title: t('files.size'),
dataIndex: 'sizeBytes',
key: 'sizeBytes',
render: (size: string) => `${(parseInt(size) / 1024 / 1024).toFixed(2)} MB`
},
{
title: t('files.created_at'),
dataIndex: 'createTime',
key: 'createTime',
render: (time: string) => new Date(time).toLocaleString()
},
{
title: t('files.actions'),
dataIndex: 'actions',
key: 'actions',
align: 'center',
render: (_, record) => {
return (
<DeleteOutlined
style={{ cursor: 'pointer', color: 'var(--color-error)' }}
onClick={() => {
setFiles(files.filter((file) => file.name !== record.name))
window.api.gemini.deleteFile(provider.apiKey, record.name).catch((error) => {
console.error('Failed to delete file:', error)
setFiles((prev) => [...prev, record])
})
}}
/>
)
}
}
]
useEffect(() => {
runAsyncFunction(async () => {
try {
@ -86,13 +41,61 @@ const GeminiFiles: FC<GeminiFilesProps> = ({ id }) => {
setFiles([])
}, [id])
if (loading) {
return (
<Container>
<LoadingWrapper>
<Spin />
</LoadingWrapper>
</Container>
)
}
return (
<Container>
<Table columns={columns} dataSource={files} rowKey="name" loading={loading} />
<FileListContainer>
{files.map((file) => (
<FileItem
key={file.name}
fileInfo={{
name: file.displayName,
ext: `.${file.name.split('.').pop()}`,
extra: `${dayjs(file.createTime).format('MM-DD HH:mm')} · ${(parseInt(file.sizeBytes) / 1024 / 1024).toFixed(2)} MB`,
actions: (
<DeleteOutlined
style={{ cursor: 'pointer', color: 'var(--color-error)' }}
onClick={() => {
setFiles(files.filter((f) => f.name !== file.name))
window.api.gemini.deleteFile(provider.apiKey, file.name).catch((error) => {
console.error('Failed to delete file:', error)
setFiles((prev) => [...prev, file])
})
}}
/>
)
}}
/>
))}
</FileListContainer>
</Container>
)
}
const Container = styled.div``
const Container = styled.div`
width: 100%;
`
const FileListContainer = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
`
const LoadingWrapper = styled.div`
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
`
export default GeminiFiles

View File

@ -2,10 +2,6 @@ import {
CopyOutlined,
DeleteOutlined,
EditOutlined,
FileTextOutlined,
FolderOutlined,
GlobalOutlined,
LinkOutlined,
PlusOutlined,
RedoOutlined,
SearchOutlined,
@ -19,24 +15,29 @@ import { useKnowledge } from '@renderer/hooks/useKnowledge'
import FileManager from '@renderer/services/FileManager'
import { getProviderName } from '@renderer/services/ProviderService'
import { FileType, FileTypes, KnowledgeBase, KnowledgeItem } from '@renderer/types'
import { formatFileSize } from '@renderer/utils'
import { bookExts, documentExts, textExts, thirdPartyApplicationExts } from '@shared/config/constant'
import { Alert, Button, Card, Divider, Dropdown, message, Tag, Tooltip, Typography, Upload } from 'antd'
import { Alert, Button, Divider, Dropdown, Empty, message, Tag, Tooltip, Upload } from 'antd'
import dayjs from 'dayjs'
import VirtualList from 'rc-virtual-list'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import CustomCollapse from '../../components/CustomCollapse'
import FileItem from '../files/FileItem'
import KnowledgeSearchPopup from './components/KnowledgeSearchPopup'
import KnowledgeSettingsPopup from './components/KnowledgeSettingsPopup'
import StatusIcon from './components/StatusIcon'
const { Dragger } = Upload
const { Title } = Typography
interface KnowledgeContentProps {
selectedBase: KnowledgeBase
}
const fileTypes = [...bookExts, ...thirdPartyApplicationExts, ...documentExts, ...textExts]
const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
const { t } = useTranslation()
@ -234,13 +235,14 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
{!providerName && (
<Alert message={t('knowledge.no_provider')} type="error" style={{ marginBottom: 20 }} showIcon />
)}
<FileSection>
<TitleWrapper>
<Title level={5}>{t('files.title')}</Title>
<Button icon={<PlusOutlined />} onClick={handleAddFile} disabled={disabled}>
<CustomCollapse
label={t('files.title')}
extra={
<Button type="text" icon={<PlusOutlined />} onClick={handleAddFile} disabled={disabled}>
{t('knowledge.add_file')}
</Button>
</TitleWrapper>
}>
<Dragger
showUploadList={false}
customRequest={({ file }) => handleDrop([file as File])}
@ -252,86 +254,123 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
{t('knowledge.file_hint', { file_types: 'TXT, MD, HTML, PDF, DOCX, PPTX, XLSX, EPUB...' })}
</p>
</Dragger>
</FileSection>
<FileListSection>
{fileItems.reverse().map((item) => {
const file = item.content as FileType
return (
<ItemCard key={item.id}>
<ItemContent>
<ItemInfo>
<FileIcon />
<ClickableSpan onClick={() => window.api.file.openPath(file.path)}>
<Ellipsis>
<Tooltip title={file.origin_name}>{file.origin_name}</Tooltip>
</Ellipsis>
</ClickableSpan>
</ItemInfo>
<FlexAlignCenter>
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
<StatusIconWrapper>
<StatusIcon sourceId={item.id} base={base} getProcessingStatus={getProcessingStatus} type="file" />
</StatusIconWrapper>
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
</FlexAlignCenter>
</ItemContent>
</ItemCard>
)
})}
</FileListSection>
<FlexColumn>
{fileItems.length === 0 ? (
<Empty />
) : (
<VirtualList
data={fileItems.reverse()}
height={window.innerHeight - 310}
itemHeight={80}
itemKey="id"
styles={{
verticalScrollBar: {
width: 6
},
verticalScrollBarThumb: {
background: 'var(--color-scrollbar-thumb)'
}
}}>
{(item) => {
const file = item.content as FileType
return (
<div style={{ height: '80px', paddingTop: '12px' }}>
<FileItem
key={item.id}
fileInfo={{
name: (
<ClickableSpan onClick={() => window.api.file.openPath(file.path)}>
<Ellipsis>
<Tooltip title={file.origin_name}>{file.origin_name}</Tooltip>
</Ellipsis>
</ClickableSpan>
),
ext: file.ext,
extra: `${dayjs(file.created_at).format('MM-DD HH:mm')} · ${formatFileSize(file.size)}`,
actions: (
<FlexAlignCenter>
{item.uniqueId && (
<Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />
)}
<StatusIconWrapper>
<StatusIcon
sourceId={item.id}
base={base}
getProcessingStatus={getProcessingStatus}
type="file"
/>
</StatusIconWrapper>
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
</FlexAlignCenter>
)
}}
/>
</div>
)
}}
</VirtualList>
)}
</FlexColumn>
</CustomCollapse>
<ContentSection>
<TitleWrapper>
<Title level={5}>{t('knowledge.directories')}</Title>
<Button icon={<PlusOutlined />} onClick={handleAddDirectory} disabled={disabled}>
<CustomCollapse
label={t('knowledge.directories')}
extra={
<Button type="text" icon={<PlusOutlined />} onClick={handleAddDirectory} disabled={disabled}>
{t('knowledge.add_directory')}
</Button>
</TitleWrapper>
}>
<FlexColumn>
{directoryItems.length === 0 && <Empty />}
{directoryItems.reverse().map((item) => (
<ItemCard key={item.id}>
<ItemContent>
<ItemInfo>
<FolderOutlined />
<FileItem
key={item.id}
fileInfo={{
name: (
<ClickableSpan onClick={() => window.api.file.openPath(item.content as string)}>
<Ellipsis>
<Tooltip title={item.content as string}>{item.content as string}</Tooltip>
</Ellipsis>
</ClickableSpan>
</ItemInfo>
<FlexAlignCenter>
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
<StatusIconWrapper>
<StatusIcon
sourceId={item.id}
base={base}
getProcessingStatus={getProcessingStatus}
getProcessingPercent={getProgressingPercentForItem}
type="directory"
/>
</StatusIconWrapper>
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
</FlexAlignCenter>
</ItemContent>
</ItemCard>
),
ext: '.folder',
extra: `${dayjs(item.created_at).format('MM-DD HH:mm')}`,
actions: (
<FlexAlignCenter>
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
<StatusIconWrapper>
<StatusIcon
sourceId={item.id}
base={base}
getProcessingStatus={getProcessingStatus}
getProcessingPercent={getProgressingPercentForItem}
type="directory"
/>
</StatusIconWrapper>
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
</FlexAlignCenter>
)
}}
/>
))}
</FlexColumn>
</ContentSection>
</CustomCollapse>
<ContentSection>
<TitleWrapper>
<Title level={5}>{t('knowledge.urls')}</Title>
<Button icon={<PlusOutlined />} onClick={handleAddUrl} disabled={disabled}>
<CustomCollapse
label={t('knowledge.urls')}
extra={
<Button type="text" icon={<PlusOutlined />} onClick={handleAddUrl} disabled={disabled}>
{t('knowledge.add_url')}
</Button>
</TitleWrapper>
}>
<FlexColumn>
{urlItems.length === 0 && <Empty />}
{urlItems.reverse().map((item) => (
<ItemCard key={item.id}>
<ItemContent>
<ItemInfo>
<LinkOutlined />
<FileItem
key={item.id}
fileInfo={{
name: (
<Dropdown
menu={{
items: [
@ -363,33 +402,38 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
</Tooltip>
</ClickableSpan>
</Dropdown>
</ItemInfo>
<FlexAlignCenter>
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
<StatusIconWrapper>
<StatusIcon sourceId={item.id} base={base} getProcessingStatus={getProcessingStatus} type="url" />
</StatusIconWrapper>
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
</FlexAlignCenter>
</ItemContent>
</ItemCard>
),
ext: '.url',
extra: `${dayjs(item.created_at).format('MM-DD HH:mm')}`,
actions: (
<FlexAlignCenter>
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
<StatusIconWrapper>
<StatusIcon sourceId={item.id} base={base} getProcessingStatus={getProcessingStatus} type="url" />
</StatusIconWrapper>
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
</FlexAlignCenter>
)
}}
/>
))}
</FlexColumn>
</ContentSection>
</CustomCollapse>
<ContentSection>
<TitleWrapper>
<Title level={5}>{t('knowledge.sitemaps')}</Title>
<Button icon={<PlusOutlined />} onClick={handleAddSitemap} disabled={disabled}>
<CustomCollapse
label={t('knowledge.sitemaps')}
extra={
<Button type="text" icon={<PlusOutlined />} onClick={handleAddSitemap} disabled={disabled}>
{t('knowledge.add_sitemap')}
</Button>
</TitleWrapper>
}>
<FlexColumn>
{sitemapItems.length === 0 && <Empty />}
{sitemapItems.reverse().map((item) => (
<ItemCard key={item.id}>
<ItemContent>
<ItemInfo>
<GlobalOutlined />
<FileItem
key={item.id}
fileInfo={{
name: (
<ClickableSpan>
<Tooltip title={item.content as string}>
<Ellipsis>
@ -399,51 +443,64 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
</Ellipsis>
</Tooltip>
</ClickableSpan>
</ItemInfo>
<FlexAlignCenter>
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
<StatusIconWrapper>
<StatusIcon
sourceId={item.id}
base={base}
getProcessingStatus={getProcessingStatus}
type="sitemap"
/>
</StatusIconWrapper>
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
</FlexAlignCenter>
</ItemContent>
</ItemCard>
),
ext: '.sitemap',
extra: `${dayjs(item.created_at).format('MM-DD HH:mm')}`,
actions: (
<FlexAlignCenter>
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
<StatusIconWrapper>
<StatusIcon
sourceId={item.id}
base={base}
getProcessingStatus={getProcessingStatus}
type="sitemap"
/>
</StatusIconWrapper>
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
</FlexAlignCenter>
)
}}
/>
))}
</FlexColumn>
</ContentSection>
</CustomCollapse>
<ContentSection>
<TitleWrapper>
<Title level={5}>{t('knowledge.notes')}</Title>
<Button icon={<PlusOutlined />} onClick={handleAddNote} disabled={disabled}>
<CustomCollapse
label={t('knowledge.notes')}
extra={
<Button type="text" icon={<PlusOutlined />} onClick={handleAddNote} disabled={disabled}>
{t('knowledge.add_note')}
</Button>
</TitleWrapper>
}>
<FlexColumn>
{noteItems.length === 0 && <Empty />}
{noteItems.reverse().map((note) => (
<ItemCard key={note.id}>
<ItemContent>
<ItemInfo onClick={() => handleEditNote(note)} style={{ cursor: 'pointer' }}>
<span>{(note.content as string).slice(0, 50)}...</span>
</ItemInfo>
<FlexAlignCenter>
<Button type="text" onClick={() => handleEditNote(note)} icon={<EditOutlined />} />
<StatusIconWrapper>
<StatusIcon sourceId={note.id} base={base} getProcessingStatus={getProcessingStatus} type="note" />
</StatusIconWrapper>
<Button type="text" danger onClick={() => removeItem(note)} icon={<DeleteOutlined />} />
</FlexAlignCenter>
</ItemContent>
</ItemCard>
<FileItem
key={note.id}
fileInfo={{
name: <span onClick={() => handleEditNote(note)}>{(note.content as string).slice(0, 50)}...</span>,
ext: '.txt',
extra: `${dayjs(note.created_at).format('MM-DD HH:mm')}`,
actions: (
<FlexAlignCenter>
<Button type="text" onClick={() => handleEditNote(note)} icon={<EditOutlined />} />
<StatusIconWrapper>
<StatusIcon
sourceId={note.id}
base={base}
getProcessingStatus={getProcessingStatus}
type="note"
/>
</StatusIconWrapper>
<Button type="text" danger onClick={() => removeItem(note)} icon={<DeleteOutlined />} />
</FlexAlignCenter>
)
}}
/>
))}
</FlexColumn>
</ContentSection>
</CustomCollapse>
<Divider style={{ margin: '10px 0' }} />
<ModelInfo>
@ -498,69 +555,9 @@ const MainContent = styled(Scrollbar)`
padding-bottom: 50px;
padding: 15px;
position: relative;
`
const FileSection = styled.div`
display: flex;
flex-direction: column;
`
const ContentSection = styled.div`
margin-top: 20px;
display: flex;
flex-direction: column;
gap: 10px;
.ant-input-textarea {
background: var(--color-background-soft);
border-radius: 8px;
}
`
const TitleWrapper = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
background-color: var(--color-background-soft);
padding: 5px 20px;
min-height: 45px;
border-radius: 6px;
.ant-typography {
margin-bottom: 0;
}
`
const FileListSection = styled.div`
margin-top: 20px;
width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
`
const ItemCard = styled(Card)`
background-color: transparent;
border: none;
.ant-card-body {
padding: 0 20px;
}
`
const ItemContent = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
`
const ItemInfo = styled.div`
display: flex;
align-items: center;
gap: 8px;
flex: 1;
`
const IndexSection = styled.div`
margin-top: 20px;
display: flex;
@ -602,10 +599,12 @@ const ModelInfo = styled.div`
color: var(--color-text-2);
}
`
const FlexColumn = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 16px;
`
const FlexAlignCenter = styled.div`
@ -620,10 +619,6 @@ const ClickableSpan = styled.span`
width: 0;
`
const FileIcon = styled(FileTextOutlined)`
font-size: 16px;
`
const BottomSpacer = styled.div`
min-height: 20px;
`