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% { 0% {
box-shadow: 0 0 0 0 rgba(var(--pulse-color), 0.5); box-shadow: 0 0 0 0 rgba(var(--pulse-color), 0.5);
} }
@ -14,5 +14,5 @@
.animation-pulse { .animation-pulse {
--pulse-color: 59, 130, 246; --pulse-color: 59, 130, 246;
--pulse-size: 8px; --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 { import {
DeleteOutlined, DeleteOutlined,
EditOutlined, EditOutlined,
EllipsisOutlined, ExclamationCircleOutlined,
FileImageOutlined, FileImageOutlined,
FilePdfOutlined, FilePdfOutlined,
FileTextOutlined FileTextOutlined,
SortAscendingOutlined,
SortDescendingOutlined
} from '@ant-design/icons' } from '@ant-design/icons'
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar' import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
import TextEditPopup from '@renderer/components/Popups/TextEditPopup' import TextEditPopup from '@renderer/components/Popups/TextEditPopup'
import Scrollbar from '@renderer/components/Scrollbar'
import db from '@renderer/databases' import db from '@renderer/databases'
import { useProviders } from '@renderer/hooks/useProvider' import { useProviders } from '@renderer/hooks/useProvider'
import FileManager from '@renderer/services/FileManager' import FileManager from '@renderer/services/FileManager'
@ -16,18 +17,23 @@ import store from '@renderer/store'
import { FileType, FileTypes } from '@renderer/types' import { FileType, FileTypes } from '@renderer/types'
import { formatFileSize } from '@renderer/utils' import { formatFileSize } from '@renderer/utils'
import type { MenuProps } from 'antd' import type { MenuProps } from 'antd'
import { Button, Dropdown, Menu } from 'antd' import { Button, Empty, Flex, Menu, Popconfirm } from 'antd'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { useLiveQuery } from 'dexie-react-hooks' import { useLiveQuery } from 'dexie-react-hooks'
import { FC, useMemo, useState } from 'react' import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' 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 FilesPage: FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
const [fileType, setFileType] = useState<string>('document') const [fileType, setFileType] = useState<string>('document')
const [sortField, setSortField] = useState<SortField>('created_at')
const [sortOrder, setSortOrder] = useState<SortOrder>('desc')
const { providers } = useProviders() const { providers } = useProviders()
const geminiProviders = providers.filter((provider) => provider.type === 'gemini') 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[]>(() => { const files = useLiveQuery<FileType[]>(() => {
if (fileType === 'all') { if (fileType === 'all') {
return db.files.orderBy('count').toArray().then(tempFilesSort) 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) return db.files.where('type').equals(fileType).sortBy('count').then(tempFilesSort)
}, [fileType]) }, [fileType])
const sortedFiles = files ? sortFiles(files) : []
const handleDelete = async (fileId: string) => { const handleDelete = async (fileId: string) => {
const file = await FileManager.getFile(fileId) const file = await FileManager.getFile(fileId)
@ -89,95 +115,34 @@ const FilesPage: FC = () => {
} }
} }
const getActionMenu = (fileId: string): MenuProps['items'] => [ const dataSource = sortedFiles?.map((file) => {
{
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) => {
return { return {
key: file.id, key: file.id,
file: ( file: <span onClick={() => window.api.file.openPath(file.path)}>{FileManager.formatFileName(file)}</span>,
<FileNameText className="text-nowrap" onClick={() => window.api.file.openPath(file.path)}>
{FileManager.formatFileName(file)}
</FileNameText>
),
size: formatFileSize(file.size), size: formatFileSize(file.size),
size_bytes: file.size, size_bytes: file.size,
count: file.count, count: file.count,
path: file.path,
ext: file.ext,
created_at: dayjs(file.created_at).format('MM-DD HH:mm'), created_at: dayjs(file.created_at).format('MM-DD HH:mm'),
created_at_unix: dayjs(file.created_at).unix(), created_at_unix: dayjs(file.created_at).unix(),
actions: ( actions: (
<Dropdown menu={{ items: getActionMenu(file.id) }} trigger={['click']} placement="bottom" arrow> <Flex align="center" gap={0} style={{ opacity: 0.7 }}>
<Button type="text" size="small" icon={<EllipsisOutlined />} /> <Button type="text" icon={<EditOutlined />} onClick={() => handleRename(file.id)} />
</Dropdown> <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 = [ const menuItems = [
{ key: FileTypes.DOCUMENT, label: t('files.document'), icon: <FilePdfOutlined /> }, { key: FileTypes.DOCUMENT, label: t('files.document'), icon: <FilePdfOutlined /> },
{ key: FileTypes.IMAGE, label: t('files.image'), icon: <FileImageOutlined /> }, { key: FileTypes.IMAGE, label: t('files.image'), icon: <FileImageOutlined /> },
@ -199,9 +164,31 @@ const FilesPage: FC = () => {
<SideNav> <SideNav>
<Menu selectedKeys={[fileType]} items={menuItems} onSelect={({ key }) => setFileType(key as FileTypes)} /> <Menu selectedKeys={[fileType]} items={menuItems} onSelect={({ key }) => setFileType(key as FileTypes)} />
</SideNav> </SideNav>
<TableContainer right> <MainContent>
<ContentView id={fileType} files={files} dataSource={dataSource} columns={columns} /> <SortContainer>
</TableContainer> {['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> </ContentContainer>
</Container> </Container>
) )
@ -214,6 +201,20 @@ const Container = styled.div`
height: calc(100vh - var(--navbar-height)); 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` const ContentContainer = styled.div`
display: flex; display: flex;
flex: 1; flex: 1;
@ -221,19 +222,6 @@ const ContentContainer = styled.div`
min-height: 100%; 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` const SideNav = styled.div`
width: var(--assistants-width); width: var(--assistants-width);
border-right: 0.5px solid var(--color-border); 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 export default FilesPage

View File

@ -2,12 +2,13 @@ import { DeleteOutlined } from '@ant-design/icons'
import type { FileMetadataResponse } from '@google/generative-ai/server' import type { FileMetadataResponse } from '@google/generative-ai/server'
import { useProvider } from '@renderer/hooks/useProvider' import { useProvider } from '@renderer/hooks/useProvider'
import { runAsyncFunction } from '@renderer/utils' import { runAsyncFunction } from '@renderer/utils'
import { Table } from 'antd' import { Spin } from 'antd'
import type { ColumnsType } from 'antd/es/table' import dayjs from 'dayjs'
import { FC, useCallback, useEffect, useState } from 'react' import { FC, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
import FileItem from './FileItem'
interface GeminiFilesProps { interface GeminiFilesProps {
id: string id: string
} }
@ -15,7 +16,6 @@ interface GeminiFilesProps {
const GeminiFiles: FC<GeminiFilesProps> = ({ id }) => { const GeminiFiles: FC<GeminiFilesProps> = ({ id }) => {
const { provider } = useProvider(id) const { provider } = useProvider(id)
const [files, setFiles] = useState<FileMetadataResponse[]>([]) const [files, setFiles] = useState<FileMetadataResponse[]>([])
const { t } = useTranslation()
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const fetchFiles = useCallback(async () => { const fetchFiles = useCallback(async () => {
@ -23,51 +23,6 @@ const GeminiFiles: FC<GeminiFilesProps> = ({ id }) => {
files && setFiles(files.filter((file) => file.state === 'ACTIVE')) files && setFiles(files.filter((file) => file.state === 'ACTIVE'))
}, [provider]) }, [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(() => { useEffect(() => {
runAsyncFunction(async () => { runAsyncFunction(async () => {
try { try {
@ -86,13 +41,61 @@ const GeminiFiles: FC<GeminiFilesProps> = ({ id }) => {
setFiles([]) setFiles([])
}, [id]) }, [id])
if (loading) {
return (
<Container>
<LoadingWrapper>
<Spin />
</LoadingWrapper>
</Container>
)
}
return ( return (
<Container> <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> </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 export default GeminiFiles

View File

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