mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-24 10:40:07 +08:00
444 lines
12 KiB
TypeScript
444 lines
12 KiB
TypeScript
import {
|
|
DeleteOutlined,
|
|
EditOutlined,
|
|
FileTextOutlined,
|
|
FolderOutlined,
|
|
GlobalOutlined,
|
|
LinkOutlined,
|
|
PlusOutlined,
|
|
SearchOutlined
|
|
} from '@ant-design/icons'
|
|
import PromptPopup from '@renderer/components/Popups/PromptPopup'
|
|
import TextEditPopup from '@renderer/components/Popups/TextEditPopup'
|
|
import Scrollbar from '@renderer/components/Scrollbar'
|
|
import { useKnowledge } from '@renderer/hooks/useKnowledge'
|
|
import FileManager from '@renderer/services/FileManager'
|
|
import { FileType, FileTypes, KnowledgeBase } from '@renderer/types'
|
|
import { Alert, Button, Card, Divider, message, Tag, Typography, Upload } from 'antd'
|
|
import { FC } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import styled from 'styled-components'
|
|
|
|
import KnowledgeSearchPopup from './components/KnowledgeSearchPopup'
|
|
import StatusIcon from './components/StatusIcon'
|
|
|
|
const { Dragger } = Upload
|
|
const { Title } = Typography
|
|
|
|
interface KnowledgeContentProps {
|
|
selectedBase: KnowledgeBase
|
|
}
|
|
|
|
const fileTypes = ['.pdf', '.docx', '.pptx', '.xlsx', '.txt', '.md', '.mdx']
|
|
|
|
const FlexColumn = styled.div`
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
`
|
|
|
|
const FlexAlignCenter = styled.div`
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16px;
|
|
`
|
|
|
|
const ClickableSpan = styled.span`
|
|
cursor: pointer;
|
|
`
|
|
|
|
const FileIcon = styled(FileTextOutlined)`
|
|
font-size: 16px;
|
|
`
|
|
|
|
const BottomSpacer = styled.div`
|
|
min-height: 20px;
|
|
`
|
|
|
|
const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
|
const { t } = useTranslation()
|
|
const {
|
|
base,
|
|
noteItems,
|
|
fileItems,
|
|
urlItems,
|
|
sitemapItems,
|
|
directoryItems,
|
|
addFiles,
|
|
updateNoteContent,
|
|
addUrl,
|
|
addSitemap,
|
|
removeItem,
|
|
getProcessingStatus,
|
|
addNote,
|
|
addDirectory
|
|
} = useKnowledge(selectedBase.id || '')
|
|
|
|
if (!base) {
|
|
return null
|
|
}
|
|
|
|
const handleAddFile = () => {
|
|
const input = document.createElement('input')
|
|
input.type = 'file'
|
|
input.multiple = true
|
|
input.accept = fileTypes.join(',')
|
|
input.onchange = (e) => {
|
|
const files = (e.target as HTMLInputElement).files
|
|
files && handleDrop(Array.from(files))
|
|
}
|
|
input.click()
|
|
}
|
|
|
|
const handleDrop = async (files: File[]) => {
|
|
if (files) {
|
|
const _files: FileType[] = files.map((file) => ({
|
|
id: file.name,
|
|
name: file.name,
|
|
path: file.path,
|
|
size: file.size,
|
|
ext: `.${file.name.split('.').pop()}`,
|
|
count: 1,
|
|
origin_name: file.name,
|
|
type: file.type as FileTypes,
|
|
created_at: new Date()
|
|
}))
|
|
console.debug('[KnowledgeContent] Uploading files:', _files, files)
|
|
const uploadedFiles = await FileManager.uploadFiles(_files)
|
|
addFiles(uploadedFiles)
|
|
}
|
|
}
|
|
|
|
const handleAddUrl = async () => {
|
|
const url = await PromptPopup.show({
|
|
title: t('knowledge_base.add_url'),
|
|
message: '',
|
|
inputPlaceholder: t('knowledge_base.url_placeholder'),
|
|
inputProps: {
|
|
maxLength: 1000,
|
|
rows: 1
|
|
}
|
|
})
|
|
|
|
if (url) {
|
|
try {
|
|
new URL(url)
|
|
if (urlItems.find((item) => item.content === url)) {
|
|
message.success(t('knowledge_base.url_added'))
|
|
return
|
|
}
|
|
addUrl(url)
|
|
} catch (e) {
|
|
console.error('Invalid URL:', url)
|
|
}
|
|
}
|
|
}
|
|
|
|
const handleAddSitemap = async () => {
|
|
const url = await PromptPopup.show({
|
|
title: t('knowledge_base.add_sitemap'),
|
|
message: '',
|
|
inputPlaceholder: t('knowledge_base.sitemap_placeholder'),
|
|
inputProps: {
|
|
maxLength: 1000,
|
|
rows: 1
|
|
}
|
|
})
|
|
|
|
if (url) {
|
|
try {
|
|
new URL(url)
|
|
if (sitemapItems.find((item) => item.content === url)) {
|
|
message.success(t('knowledge_base.sitemap_added'))
|
|
return
|
|
}
|
|
addSitemap(url)
|
|
} catch (e) {
|
|
console.error('Invalid Sitemap URL:', url)
|
|
}
|
|
}
|
|
}
|
|
|
|
const handleAddNote = async () => {
|
|
const note = await TextEditPopup.show({ text: '', textareaProps: { rows: 20 } })
|
|
note && addNote(note)
|
|
}
|
|
|
|
const handleEditNote = async (note: any) => {
|
|
const editedText = await TextEditPopup.show({ text: note.content as string, textareaProps: { rows: 20 } })
|
|
editedText && updateNoteContent(note.id, editedText)
|
|
}
|
|
|
|
const handleAddDirectory = async () => {
|
|
const path = await window.api.file.selectFolder()
|
|
console.log('[KnowledgeContent] Selected directory:', path)
|
|
path && addDirectory(path)
|
|
}
|
|
|
|
return (
|
|
<MainContent>
|
|
{!base.dimensions && (
|
|
<Alert message={t('knowledge_base.not_support')} type="error" style={{ marginBottom: 20 }} showIcon />
|
|
)}
|
|
<FileSection>
|
|
<TitleWrapper>
|
|
<Title level={5}>{t('files.title')}</Title>
|
|
<Button icon={<PlusOutlined />} onClick={handleAddFile}>
|
|
{t('knowledge_base.add_file')}
|
|
</Button>
|
|
</TitleWrapper>
|
|
<Dragger
|
|
showUploadList={false}
|
|
customRequest={({ file }) => handleDrop([file as File])}
|
|
multiple={true}
|
|
accept={fileTypes.join(',')}
|
|
style={{ marginTop: 10, background: 'transparent' }}>
|
|
<p className="ant-upload-text">{t('knowledge_base.drag_file')}</p>
|
|
<p className="ant-upload-hint">
|
|
{t('knowledge_base.file_hint', { file_types: fileTypes.join(', ').replaceAll('.', '') })}
|
|
</p>
|
|
</Dragger>
|
|
</FileSection>
|
|
|
|
<FileListSection>
|
|
{fileItems.map((item) => {
|
|
const file = item.content as FileType
|
|
return (
|
|
<ItemCard key={item.id}>
|
|
<ItemContent>
|
|
<ItemInfo>
|
|
<FileIcon />
|
|
<ClickableSpan onClick={() => window.api.file.openPath(file.path)}>{file.origin_name}</ClickableSpan>
|
|
</ItemInfo>
|
|
<FlexAlignCenter>
|
|
<StatusIcon sourceId={item.id} base={base} getProcessingStatus={getProcessingStatus} />
|
|
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
|
|
</FlexAlignCenter>
|
|
</ItemContent>
|
|
</ItemCard>
|
|
)
|
|
})}
|
|
</FileListSection>
|
|
|
|
<ContentSection>
|
|
<TitleWrapper>
|
|
<Title level={5}>{t('knowledge_base.directories')}</Title>
|
|
<Button icon={<PlusOutlined />} onClick={handleAddDirectory}>
|
|
{t('knowledge_base.add_directory')}
|
|
</Button>
|
|
</TitleWrapper>
|
|
<FlexColumn>
|
|
{directoryItems.map((item) => (
|
|
<ItemCard key={item.id}>
|
|
<ItemContent>
|
|
<ItemInfo>
|
|
<FolderOutlined />
|
|
<ClickableSpan onClick={() => window.api.file.openPath(item.content as string)}>
|
|
{item.content as string}
|
|
</ClickableSpan>
|
|
</ItemInfo>
|
|
<FlexAlignCenter>
|
|
<StatusIcon sourceId={item.id} base={base} getProcessingStatus={getProcessingStatus} />
|
|
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
|
|
</FlexAlignCenter>
|
|
</ItemContent>
|
|
</ItemCard>
|
|
))}
|
|
</FlexColumn>
|
|
</ContentSection>
|
|
|
|
<ContentSection>
|
|
<TitleWrapper>
|
|
<Title level={5}>{t('knowledge_base.urls')}</Title>
|
|
<Button icon={<PlusOutlined />} onClick={handleAddUrl}>
|
|
{t('knowledge_base.add_url')}
|
|
</Button>
|
|
</TitleWrapper>
|
|
<FlexColumn>
|
|
{urlItems.map((item) => (
|
|
<ItemCard key={item.id}>
|
|
<ItemContent>
|
|
<ItemInfo>
|
|
<LinkOutlined />
|
|
<a href={item.content as string} target="_blank" rel="noopener noreferrer">
|
|
{item.content as string}
|
|
</a>
|
|
</ItemInfo>
|
|
<FlexAlignCenter>
|
|
<StatusIcon sourceId={item.id} base={base} getProcessingStatus={getProcessingStatus} />
|
|
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
|
|
</FlexAlignCenter>
|
|
</ItemContent>
|
|
</ItemCard>
|
|
))}
|
|
</FlexColumn>
|
|
</ContentSection>
|
|
|
|
<ContentSection>
|
|
<TitleWrapper>
|
|
<Title level={5}>{t('knowledge_base.sitemaps')}</Title>
|
|
<Button icon={<PlusOutlined />} onClick={handleAddSitemap}>
|
|
{t('knowledge_base.add_sitemap')}
|
|
</Button>
|
|
</TitleWrapper>
|
|
<FlexColumn>
|
|
{sitemapItems.map((item) => (
|
|
<ItemCard key={item.id}>
|
|
<ItemContent>
|
|
<ItemInfo>
|
|
<GlobalOutlined />
|
|
<a href={item.content as string} target="_blank" rel="noopener noreferrer">
|
|
{item.content as string}
|
|
</a>
|
|
</ItemInfo>
|
|
<FlexAlignCenter>
|
|
<StatusIcon sourceId={item.id} base={base} getProcessingStatus={getProcessingStatus} />
|
|
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
|
|
</FlexAlignCenter>
|
|
</ItemContent>
|
|
</ItemCard>
|
|
))}
|
|
</FlexColumn>
|
|
</ContentSection>
|
|
|
|
<ContentSection>
|
|
<TitleWrapper>
|
|
<Title level={5}>{t('knowledge_base.notes')}</Title>
|
|
<Button icon={<PlusOutlined />} onClick={handleAddNote}>
|
|
{t('knowledge_base.add_note')}
|
|
</Button>
|
|
</TitleWrapper>
|
|
<FlexColumn>
|
|
{noteItems.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 />} />
|
|
<StatusIcon sourceId={note.id} base={base} getProcessingStatus={getProcessingStatus} />
|
|
<Button type="text" danger onClick={() => removeItem(note)} icon={<DeleteOutlined />} />
|
|
</FlexAlignCenter>
|
|
</ItemContent>
|
|
</ItemCard>
|
|
))}
|
|
</FlexColumn>
|
|
</ContentSection>
|
|
|
|
<Divider style={{ margin: '10px 0' }} />
|
|
|
|
<ModelInfo>
|
|
<label htmlFor="model-info">{t('knowledge_base.model_info')}</label>
|
|
<Tag color="blue">{base.model.name}</Tag>
|
|
<Tag color="cyan">{t('models.dimensions', { dimensions: base.dimensions || 0 })}</Tag>
|
|
<Tag color="purple">{base.model.provider}</Tag>
|
|
</ModelInfo>
|
|
|
|
<IndexSection>
|
|
<Button type="primary" onClick={() => KnowledgeSearchPopup.show({ base })} icon={<SearchOutlined />}>
|
|
{t('knowledge_base.search')}
|
|
</Button>
|
|
</IndexSection>
|
|
|
|
<BottomSpacer />
|
|
</MainContent>
|
|
)
|
|
}
|
|
|
|
const MainContent = styled(Scrollbar)`
|
|
display: flex;
|
|
width: 100%;
|
|
flex-direction: column;
|
|
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;
|
|
|
|
a {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
max-width: 600px;
|
|
}
|
|
`
|
|
|
|
const IndexSection = styled.div`
|
|
margin-top: 20px;
|
|
display: flex;
|
|
justify-content: center;
|
|
`
|
|
|
|
const ModelInfo = styled.div`
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 5px;
|
|
color: var(--color-text-3);
|
|
label {
|
|
margin-right: 8px;
|
|
color: var(--color-text-2);
|
|
}
|
|
`
|
|
|
|
export default KnowledgeContent
|