feat: remove knowledge queue

This commit is contained in:
kangfenmao 2024-12-19 13:45:11 +08:00
parent c2462fd51c
commit ca6027dd83
26 changed files with 388 additions and 375 deletions

View File

@ -55,6 +55,8 @@ class FileStorage {
const storedFilePath = path.join(this.storageDir, file) const storedFilePath = path.join(this.storageDir, file)
const storedStats = fs.statSync(storedFilePath) const storedStats = fs.statSync(storedFilePath)
console.debug('storedFilePath', storedFilePath)
if (storedStats.size === fileSize) { if (storedStats.size === fileSize) {
const [originalHash, storedHash] = await Promise.all([ const [originalHash, storedHash] = await Promise.all([
this.getFileHash(filePath), this.getFileHash(filePath),

View File

@ -11,7 +11,6 @@ import { WebLoader } from '@llm-tools/embedjs-loader-web'
import { OpenAiEmbeddings } from '@llm-tools/embedjs-openai' import { OpenAiEmbeddings } from '@llm-tools/embedjs-openai'
import { FileType, RagAppRequestParams } from '@types' import { FileType, RagAppRequestParams } from '@types'
import { app } from 'electron' import { app } from 'electron'
import Logger from 'electron-log'
class KnowledgeService { class KnowledgeService {
private storageDir = path.join(app.getPath('userData'), 'Data', 'KnowledgeBase') private storageDir = path.join(app.getPath('userData'), 'Data', 'KnowledgeBase')
@ -27,7 +26,6 @@ class KnowledgeService {
} }
private getRagApplication = async ({ id, model, apiKey, baseURL }: RagAppRequestParams): Promise<RAGApplication> => { private getRagApplication = async ({ id, model, apiKey, baseURL }: RagAppRequestParams): Promise<RAGApplication> => {
Logger.log('getRagApplication', { id, model, apiKey, baseURL })
return new RAGApplicationBuilder() return new RAGApplicationBuilder()
.setModel('NO_MODEL') .setModel('NO_MODEL')
.setEmbeddingModel( .setEmbeddingModel(
@ -82,7 +80,7 @@ class KnowledgeService {
return await ragApplication.addLoader(new DocxLoader({ filePathOrUrl: data.path }) as any) return await ragApplication.addLoader(new DocxLoader({ filePathOrUrl: data.path }) as any)
} }
if (data.ext === '.md') { if (data.ext === '.md' || data.ext === '.mdx') {
return await ragApplication.addLoader(new MarkdownLoader({ filePathOrUrl: data.path }) as any) return await ragApplication.addLoader(new MarkdownLoader({ filePathOrUrl: data.path }) as any)
} }

View File

@ -123,6 +123,10 @@ export class WindowService {
private setupWebContentsHandlers(mainWindow: BrowserWindow) { private setupWebContentsHandlers(mainWindow: BrowserWindow) {
mainWindow.webContents.on('will-navigate', (event, url) => { mainWindow.webContents.on('will-navigate', (event, url) => {
if (url.includes('localhost:5173')) {
return
}
event.preventDefault() event.preventDefault()
shell.openExternal(url) shell.openExternal(url)
}) })

View File

@ -1,4 +1,4 @@
import { BookOutlined, FolderOutlined, PictureOutlined, TranslationOutlined } from '@ant-design/icons' import { FileSearchOutlined, FolderOutlined, PictureOutlined, TranslationOutlined } from '@ant-design/icons'
import { isMac } from '@renderer/config/constant' import { isMac } from '@renderer/config/constant'
import { isLocalAi, UserAvatar } from '@renderer/config/env' import { isLocalAi, UserAvatar } from '@renderer/config/env'
import { useTheme } from '@renderer/context/ThemeProvider' import { useTheme } from '@renderer/context/ThemeProvider'
@ -91,7 +91,7 @@ const Sidebar: FC = () => {
<Tooltip title={t('knowledge_base.title')} mouseEnterDelay={0.5} placement="right"> <Tooltip title={t('knowledge_base.title')} mouseEnterDelay={0.5} placement="right">
<StyledLink onClick={() => to('/knowledge')}> <StyledLink onClick={() => to('/knowledge')}>
<Icon className={isRoute('/knowledge')}> <Icon className={isRoute('/knowledge')}>
<BookOutlined /> <FileSearchOutlined />
</Icon> </Icon>
</StyledLink> </StyledLink>
</Tooltip> </Tooltip>

View File

@ -1,20 +1,21 @@
/* eslint-disable react-hooks/rules-of-hooks */ /* eslint-disable react-hooks/rules-of-hooks */
import { db } from '@renderer/databases/index' import { db } from '@renderer/databases/index'
import { RootState, useAppSelector } from '@renderer/store' import KnowledgeQueue from '@renderer/queue/KnowledgeQueue'
import FileManager from '@renderer/services/FileManager'
import { getRagAppRequestParams } from '@renderer/services/KnowledgeService'
import { RootState } from '@renderer/store'
import { import {
addBase,
addItem, addItem,
addProcessingItem, clearAllProcessing,
clearAllItems, clearCompletedProcessing,
clearCompletedItems, deleteBase,
removeItem as removeItemAction, removeItem as removeItemAction,
removeProcessingItem,
renameBase, renameBase,
selectProcessingItemBySource,
selectProcessingItemsByType,
updateBase, updateBase,
updateFiles as updateFilesAction, updateFiles as updateFilesAction,
updateNotes, updateItemProcessingStatus,
updateProcessingStatus updateNotes
} from '@renderer/store/knowledge' } from '@renderer/store/knowledge'
import { FileType, KnowledgeBase, ProcessingStatus } from '@renderer/types' import { FileType, KnowledgeBase, ProcessingStatus } from '@renderer/types'
import { KnowledgeItem } from '@renderer/types' import { KnowledgeItem } from '@renderer/types'
@ -26,7 +27,6 @@ import { v4 as uuidv4 } from 'uuid'
export const useKnowledge = (baseId: string) => { export const useKnowledge = (baseId: string) => {
const dispatch = useDispatch() const dispatch = useDispatch()
const base = useSelector((state: RootState) => state.knowledge.bases.find((b) => b.id === baseId)) const base = useSelector((state: RootState) => state.knowledge.bases.find((b) => b.id === baseId))
const knowledgeState = useAppSelector((state: RootState) => state.knowledge)
// 重命名知识库 // 重命名知识库
const renameKnowledgeBase = (name: string) => { const renameKnowledgeBase = (name: string) => {
@ -41,27 +41,37 @@ export const useKnowledge = (baseId: string) => {
// 添加文件列表 // 添加文件列表
const addFiles = (files: FileType[]) => { const addFiles = (files: FileType[]) => {
for (const file of files) { for (const file of files) {
const newItem = { const newItem: KnowledgeItem = {
id: uuidv4(), id: uuidv4(),
type: 'file' as const, type: 'file' as const,
content: file, content: file,
created_at: Date.now(), created_at: Date.now(),
updated_at: Date.now() updated_at: Date.now(),
processingStatus: 'pending',
processingProgress: 0,
processingError: '',
retryCount: 0
} }
dispatch(addItem({ baseId, item: newItem })) dispatch(addItem({ baseId, item: newItem }))
} }
setTimeout(() => KnowledgeQueue.checkAllBases(), 0)
} }
// 添加URL // 添加URL
const addUrl = (url: string) => { const addUrl = (url: string) => {
const newUrlItem = { const newUrlItem: KnowledgeItem = {
id: uuidv4(), id: uuidv4(),
type: 'url' as const, type: 'url' as const,
content: url, content: url,
created_at: Date.now(), created_at: Date.now(),
updated_at: Date.now() updated_at: Date.now(),
processingStatus: 'pending',
processingProgress: 0,
processingError: '',
retryCount: 0
} }
dispatch(addItem({ baseId, item: newUrlItem })) dispatch(addItem({ baseId, item: newUrlItem }))
setTimeout(() => KnowledgeQueue.checkAllBases(), 0)
} }
// 添加笔记 // 添加笔记
@ -85,10 +95,15 @@ export const useKnowledge = (baseId: string) => {
type: 'note', type: 'note',
content: '', // store中不需要存储实际内容 content: '', // store中不需要存储实际内容
created_at: Date.now(), created_at: Date.now(),
updated_at: Date.now() updated_at: Date.now(),
processingStatus: 'pending',
processingProgress: 0,
processingError: '',
retryCount: 0
} }
dispatch(updateNotes({ baseId, item: noteRef })) dispatch(updateNotes({ baseId, item: noteRef }))
setTimeout(() => KnowledgeQueue.checkAllBases(), 0)
} }
// 更新文件列表 // 更新文件列表
@ -101,6 +116,7 @@ export const useKnowledge = (baseId: string) => {
updated_at: Date.now() updated_at: Date.now()
})) }))
dispatch(updateFilesAction({ baseId, items: newItems })) dispatch(updateFilesAction({ baseId, items: newItems }))
setTimeout(() => KnowledgeQueue.checkAllBases(), 0)
} }
// 更新笔记内容 // 更新笔记内容
@ -115,6 +131,7 @@ export const useKnowledge = (baseId: string) => {
await db.knowledge_notes.put(updatedNote) await db.knowledge_notes.put(updatedNote)
dispatch(updateNotes({ baseId, item: updatedNote })) dispatch(updateNotes({ baseId, item: updatedNote }))
} }
setTimeout(() => KnowledgeQueue.checkAllBases(), 0)
} }
// 获取笔记内容 // 获取笔记内容
@ -123,47 +140,23 @@ export const useKnowledge = (baseId: string) => {
} }
// 移除项目 // 移除项目
const removeItem = (item: KnowledgeItem) => { const removeItem = async (item: KnowledgeItem) => {
dispatch(removeItemAction({ baseId, item })) dispatch(removeItemAction({ baseId, item }))
} if (base) {
const config = getRagAppRequestParams(base)
// 添加文件到处理队列 if (item?.uniqueId) {
const addFileToQueue = (itemId: string) => { await window.api.knowledgeBase.remove({ uniqueId: item.uniqueId, config })
dispatch( }
addProcessingItem({ if (item.type === 'file' && typeof item.content === 'object') {
baseId, await FileManager.deleteFile(item.content.id)
type: 'file', }
sourceId: itemId }
})
)
}
// 添加URL到处理队列
const addUrlToQueue = (itemId: string) => {
dispatch(
addProcessingItem({
baseId,
type: 'url',
sourceId: itemId
})
)
}
// 添加笔记到处理队列
const addNoteToQueue = (itemId: string) => {
dispatch(
addProcessingItem({
baseId,
type: 'note',
sourceId: itemId
})
)
} }
// 更新处理状态 // 更新处理状态
const updateItemStatus = (itemId: string, status: ProcessingStatus, progress?: number, error?: string) => { const updateItemStatus = (itemId: string, status: ProcessingStatus, progress?: number, error?: string) => {
dispatch( dispatch(
updateProcessingStatus({ updateItemProcessingStatus({
baseId, baseId,
itemId, itemId,
status, status,
@ -173,38 +166,46 @@ export const useKnowledge = (baseId: string) => {
) )
} }
// 获取特定的处理状态 // 获取特定项目的处理状态
const getProcessingStatus = (sourceId: string) => { const getProcessingStatus = (itemId: string) => {
return selectProcessingItemBySource(knowledgeState, baseId, sourceId) return base?.items.find((item) => item.id === itemId)?.processingStatus
} }
// 获取特定类型的所有处理项 // 获取特定类型的所有处理项
const getProcessingItemsByType = (type: 'file' | 'url' | 'note') => { const getProcessingItemsByType = (type: 'file' | 'url' | 'note') => {
return selectProcessingItemsByType(knowledgeState, baseId, type) return base?.items.filter((item) => item.type === type && item.processingStatus !== undefined) || []
}
// 从队列中移除项目
const removeFromQueue = (itemId: string) => {
dispatch(
removeProcessingItem({
baseId,
itemId
})
)
} }
// 清除已完成的项目 // 清除已完成的项目
const clearCompleted = () => { const clearCompleted = () => {
dispatch(clearCompletedItems({ baseId })) dispatch(clearCompletedProcessing({ baseId }))
} }
// 清除所有队列项目 // 清除所有处理状态
const clearAll = () => { const clearAll = () => {
dispatch(clearAllItems({ baseId })) dispatch(clearAllProcessing({ baseId }))
}
// 添加 Sitemap
const addSitemap = (url: string) => {
const newSitemapItem: KnowledgeItem = {
id: uuidv4(),
type: 'sitemap' as const,
content: url,
created_at: Date.now(),
updated_at: Date.now(),
processingStatus: 'pending',
processingProgress: 0,
processingError: '',
retryCount: 0
}
dispatch(addItem({ baseId, item: newSitemapItem }))
setTimeout(() => KnowledgeQueue.checkAllBases(), 0)
} }
const fileItems = base?.items.filter((item) => item.type === 'file') || [] const fileItems = base?.items.filter((item) => item.type === 'file') || []
const urlItems = base?.items.filter((item) => item.type === 'url') || [] const urlItems = base?.items.filter((item) => item.type === 'url') || []
const sitemapItems = base?.items.filter((item) => item.type === 'sitemap') || []
const [noteItems, setNoteItems] = useState<KnowledgeItem[]>([]) const [noteItems, setNoteItems] = useState<KnowledgeItem[]>([])
useEffect(() => { useEffect(() => {
@ -224,24 +225,46 @@ export const useKnowledge = (baseId: string) => {
base, base,
fileItems, fileItems,
urlItems, urlItems,
sitemapItems,
noteItems, noteItems,
renameKnowledgeBase, renameKnowledgeBase,
updateKnowledgeBase, updateKnowledgeBase,
addFiles, addFiles,
addUrl, addUrl,
addSitemap,
addNote, addNote,
updateFiles, updateFiles,
updateNoteContent, updateNoteContent,
getNoteContent, getNoteContent,
addFileToQueue,
addUrlToQueue,
addNoteToQueue,
updateItemStatus, updateItemStatus,
getProcessingStatus, getProcessingStatus,
getProcessingItemsByType, getProcessingItemsByType,
removeFromQueue,
clearCompleted, clearCompleted,
clearAll, clearAll,
removeItem removeItem
} }
} }
export const useKnowledgeBases = () => {
const dispatch = useDispatch()
const bases = useSelector((state: RootState) => state.knowledge.bases)
const addKnowledgeBase = (base: KnowledgeBase) => {
dispatch(addBase(base))
}
const renameKnowledgeBase = (baseId: string, name: string) => {
dispatch(renameBase({ baseId, name }))
}
const deleteKnowledgeBase = (baseId: string) => {
dispatch(deleteBase({ baseId }))
}
return {
bases,
addKnowledgeBase,
renameKnowledgeBase,
deleteKnowledgeBase
}
}

View File

@ -555,11 +555,13 @@
"status_completed": "Completed", "status_completed": "Completed",
"status_failed": "Failed", "status_failed": "Failed",
"url_added": "URL added", "url_added": "URL added",
"query": "Query",
"search_placeholder": "Enter text to search", "search_placeholder": "Enter text to search",
"add_note": "Add Note", "add_note": "Add Note",
"no_bases": "No knowledge bases available", "no_bases": "No knowledge bases available",
"clear_selection": "Clear selection" "clear_selection": "Clear selection",
"delete_confirm": "Are you sure you want to delete this knowledge base?",
"sitemaps": "Site Maps",
"add_sitemap": "Add Site Map"
} }
} }
} }

View File

@ -555,11 +555,13 @@
"status_completed": "Завершено", "status_completed": "Завершено",
"status_failed": "Ошибка", "status_failed": "Ошибка",
"url_added": "URL добавлен", "url_added": "URL добавлен",
"query": "Поиск",
"search_placeholder": "Введите текст для поиска", "search_placeholder": "Введите текст для поиска",
"add_note": "Добавить запись", "add_note": "Добавить запись",
"no_bases": "База знаний не найдена", "no_bases": "База знаний не найдена",
"clear_selection": "Очистить выбор" "clear_selection": "Очистить выбор",
"delete_confirm": "Вы уверены, что хотите удалить эту базу знаний?",
"sitemaps": "Карта сайта",
"add_sitemap": "Добавить карту сайта"
} }
} }
} }

View File

@ -529,7 +529,7 @@
"notes_placeholder": "输入此知识库的附加信息或上下文...", "notes_placeholder": "输入此知识库的附加信息或上下文...",
"delete": "删除", "delete": "删除",
"rename": "重命名", "rename": "重命名",
"urls": "网", "urls": "网",
"add_url": "添加网址", "add_url": "添加网址",
"url_placeholder": "请输入网址", "url_placeholder": "请输入网址",
"invalid_url": "无效的网址", "invalid_url": "无效的网址",
@ -544,11 +544,13 @@
"status_completed": "已完成", "status_completed": "已完成",
"status_failed": "失败", "status_failed": "失败",
"url_added": "网址已添加", "url_added": "网址已添加",
"query": "查询",
"search_placeholder": "输入查询内容", "search_placeholder": "输入查询内容",
"add_note": "添加笔记", "add_note": "添加笔记",
"no_bases": "暂无知识库", "no_bases": "暂无知识库",
"clear_selection": "清除选择" "clear_selection": "清除选择",
"delete_confirm": "确定要删除此知识库吗?",
"sitemaps": "站点地图",
"add_sitemap": "添加站点地图"
} }
} }
} }

View File

@ -543,11 +543,13 @@
"status_completed": "已完成", "status_completed": "已完成",
"status_failed": "失敗", "status_failed": "失敗",
"url_added": "網址已添加", "url_added": "網址已添加",
"query": "查詢",
"search_placeholder": "輸入查詢內容", "search_placeholder": "輸入查詢內容",
"add_note": "添加筆記", "add_note": "添加筆記",
"no_bases": "暫無知識庫", "no_bases": "暫無知識庫",
"clear_selection": "清除選擇" "clear_selection": "清除選擇",
"delete_confirm": "確定要刪除此知識庫嗎?",
"sitemaps": "站點地圖",
"add_sitemap": "添加站點地圖"
} }
} }
} }

View File

@ -11,9 +11,10 @@ interface Props {
files: FileType[] files: FileType[]
setFiles: (files: FileType[]) => void setFiles: (files: FileType[]) => void
ToolbarButton: any ToolbarButton: any
disabled?: boolean
} }
const AttachmentButton: FC<Props> = ({ model, files, setFiles, ToolbarButton }) => { const AttachmentButton: FC<Props> = ({ model, files, setFiles, ToolbarButton, disabled }) => {
const { t } = useTranslation() const { t } = useTranslation()
const extensions = isVisionModel(model) const extensions = isVisionModel(model)
? [...imageExts, ...documentExts, ...textExts] ? [...imageExts, ...documentExts, ...textExts]
@ -37,7 +38,7 @@ const AttachmentButton: FC<Props> = ({ model, files, setFiles, ToolbarButton })
return ( return (
<Tooltip placement="top" title={t('chat.input.upload')} arrow> <Tooltip placement="top" title={t('chat.input.upload')} arrow>
<ToolbarButton type="text" className={files.length ? 'active' : ''} onClick={onSelectFile}> <ToolbarButton type="text" className={files.length ? 'active' : ''} onClick={onSelectFile} disabled={disabled}>
<PaperClipOutlined style={{ rotate: '135deg' }} /> <PaperClipOutlined style={{ rotate: '135deg' }} />
</ToolbarButton> </ToolbarButton>
</Tooltip> </Tooltip>

View File

@ -49,7 +49,7 @@ interface Props {
let _text = '' let _text = ''
let _files: FileType[] = [] let _files: FileType[] = []
let _base: KnowledgeBase let _base: KnowledgeBase | undefined
const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => { const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
const [text, setText] = useState(_text) const [text, setText] = useState(_text)
@ -80,7 +80,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
const [spaceClickCount, setSpaceClickCount] = useState(0) const [spaceClickCount, setSpaceClickCount] = useState(0)
const spaceClickTimer = useRef<NodeJS.Timeout>() const spaceClickTimer = useRef<NodeJS.Timeout>()
const [isTranslating, setIsTranslating] = useState(false) const [isTranslating, setIsTranslating] = useState(false)
const [selectedKnowledgeBase, setSelectedKnowledgeBase] = useState<KnowledgeBase>(_base) const [selectedKnowledgeBase, setSelectedKnowledgeBase] = useState<KnowledgeBase | undefined>(_base)
const isVision = useMemo(() => isVisionModel(model), [model]) const isVision = useMemo(() => isVisionModel(model), [model])
const supportExts = useMemo(() => [...textExts, ...documentExts, ...(isVision ? imageExts : [])], [isVision]) const supportExts = useMemo(() => [...textExts, ...documentExts, ...(isVision ? imageExts : [])], [isVision])
@ -450,8 +450,19 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
<ControlOutlined /> <ControlOutlined />
</ToolbarButton> </ToolbarButton>
</Tooltip> </Tooltip>
<KnowledgeBaseButton selectedBase={selectedKnowledgeBase} onSelect={handleKnowledgeBaseSelect} /> <KnowledgeBaseButton
<AttachmentButton model={model} files={files} setFiles={setFiles} ToolbarButton={ToolbarButton} /> selectedBase={selectedKnowledgeBase}
onSelect={handleKnowledgeBaseSelect}
ToolbarButton={ToolbarButton}
disabled={files.length > 0}
/>
<AttachmentButton
model={model}
files={files}
setFiles={setFiles}
ToolbarButton={ToolbarButton}
disabled={!!selectedKnowledgeBase}
/>
<ToolbarButton type="text" onClick={onNewContext}> <ToolbarButton type="text" onClick={onNewContext}>
<Tooltip placement="top" title={t('chat.input.new.context')}> <Tooltip placement="top" title={t('chat.input.new.context')}>
<PicCenterOutlined /> <PicCenterOutlined />

View File

@ -1,4 +1,4 @@
import { BookOutlined } from '@ant-design/icons' import { FileSearchOutlined } from '@ant-design/icons'
import { useAppSelector } from '@renderer/store' import { useAppSelector } from '@renderer/store'
import { KnowledgeBase } from '@renderer/types' import { KnowledgeBase } from '@renderer/types'
import { Button, Popover, Tooltip } from 'antd' import { Button, Popover, Tooltip } from 'antd'
@ -9,6 +9,8 @@ import styled from 'styled-components'
interface Props { interface Props {
selectedBase?: KnowledgeBase selectedBase?: KnowledgeBase
onSelect: (base?: KnowledgeBase) => void onSelect: (base?: KnowledgeBase) => void
disabled?: boolean
ToolbarButton?: any
} }
const KnowledgeBaseSelector: FC<Props> = ({ selectedBase, onSelect }) => { const KnowledgeBaseSelector: FC<Props> = ({ selectedBase, onSelect }) => {
@ -42,14 +44,14 @@ const KnowledgeBaseSelector: FC<Props> = ({ selectedBase, onSelect }) => {
) )
} }
const KnowledgeBaseButton: FC<Props> = ({ selectedBase, onSelect }) => { const KnowledgeBaseButton: FC<Props> = ({ selectedBase, onSelect, disabled, ToolbarButton }) => {
const { t } = useTranslation() const { t } = useTranslation()
if (selectedBase) { if (selectedBase) {
return ( return (
<Tooltip placement="top" title={t('chat.input.knowledge_base')} arrow> <Tooltip placement="top" title={t('chat.input.knowledge_base')} arrow>
<ToolbarButton type="text" onClick={() => onSelect(undefined)}> <ToolbarButton type="text" onClick={() => onSelect(undefined)}>
<BookOutlined style={{ color: selectedBase ? 'var(--color-link)' : 'var(--color-icon)' }} /> <FileSearchOutlined style={{ color: selectedBase ? 'var(--color-link)' : 'var(--color-icon)' }} />
</ToolbarButton> </ToolbarButton>
</Tooltip> </Tooltip>
) )
@ -61,8 +63,8 @@ const KnowledgeBaseButton: FC<Props> = ({ selectedBase, onSelect }) => {
placement="top" placement="top"
content={<KnowledgeBaseSelector selectedBase={selectedBase} onSelect={onSelect} />} content={<KnowledgeBaseSelector selectedBase={selectedBase} onSelect={onSelect} />}
trigger="click"> trigger="click">
<ToolbarButton type="text" onClick={() => selectedBase && onSelect(undefined)}> <ToolbarButton type="text" onClick={() => selectedBase && onSelect(undefined)} disabled={disabled}>
<BookOutlined style={{ color: selectedBase ? 'var(--color-link)' : 'var(--color-icon)' }} /> <FileSearchOutlined style={{ color: selectedBase ? 'var(--color-link)' : 'var(--color-icon)' }} />
</ToolbarButton> </ToolbarButton>
</Popover> </Popover>
</Tooltip> </Tooltip>
@ -78,40 +80,4 @@ const EmptyMessage = styled.div`
padding: 8px; padding: 8px;
` `
const ToolbarButton = styled(Button)`
width: 30px;
height: 30px;
font-size: 17px;
border-radius: 50%;
transition: all 0.3s ease;
color: var(--color-icon);
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
padding: 0;
&.anticon,
&.iconfont {
transition: all 0.3s ease;
color: var(--color-icon);
}
&:hover {
background-color: var(--color-background-soft);
.anticon,
.iconfont {
color: var(--color-text-1);
}
}
&.active {
background-color: var(--color-primary) !important;
.anticon,
.iconfont {
color: var(--color-white-soft);
}
&:hover {
background-color: var(--color-primary);
}
}
`
export default KnowledgeBaseButton export default KnowledgeBaseButton

View File

@ -29,18 +29,20 @@ const MessgeTokens: React.FC<{ message: Message; isLastMessage: boolean }> = ({
if (message.role === 'assistant') { if (message.role === 'assistant') {
let metrixs = '' let metrixs = ''
let hasMetrics = false
if (message?.metrics?.completion_tokens && message?.metrics?.time_completion_millsec) { if (message?.metrics?.completion_tokens && message?.metrics?.time_completion_millsec) {
hasMetrics = true
metrixs = t('settings.messages.metrics', { metrixs = t('settings.messages.metrics', {
time_first_token_millsec: message?.metrics?.time_first_token_millsec, time_first_token_millsec: message?.metrics?.time_first_token_millsec,
token_speed: (message?.metrics?.completion_tokens / (message?.metrics?.time_completion_millsec / 1000)).toFixed( token_speed: (message?.metrics?.completion_tokens / (message?.metrics?.time_completion_millsec / 1000)).toFixed(
2 0
) )
}) })
} }
return ( return (
<MessageMetadata className="message-tokens" onClick={locateMessage}> <MessageMetadata className={`message-tokens ${hasMetrics ? 'has-metrics' : ''}`} onClick={locateMessage}>
<span className="metrics">{metrixs}</span> <span className="metrics">{metrixs}</span>
<span className="tokens"> <span className="tokens">
Tokens: {message?.usage?.total_tokens} {message?.usage?.prompt_tokens} {message?.usage?.completion_tokens} Tokens: {message?.usage?.total_tokens} {message?.usage?.prompt_tokens} {message?.usage?.completion_tokens}
@ -68,7 +70,7 @@ const MessageMetadata = styled.div`
display: block; display: block;
} }
&:hover { &.has-metrics:hover {
.metrics { .metrics {
display: block; display: block;
} }

View File

@ -2,10 +2,10 @@ import {
DeleteOutlined, DeleteOutlined,
EditOutlined, EditOutlined,
FileTextOutlined, FileTextOutlined,
GlobalOutlined,
LinkOutlined, LinkOutlined,
PlusOutlined, PlusOutlined,
SearchOutlined, SearchOutlined
StopOutlined
} from '@ant-design/icons' } from '@ant-design/icons'
import PromptPopup from '@renderer/components/Popups/PromptPopup' import PromptPopup from '@renderer/components/Popups/PromptPopup'
import TextEditPopup from '@renderer/components/Popups/TextEditPopup' import TextEditPopup from '@renderer/components/Popups/TextEditPopup'
@ -18,8 +18,8 @@ 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 KnowledgeSearchPopup from './KnowledgeSearchPopup' import KnowledgeSearchPopup from './components/KnowledgeSearchPopup'
import StatusIcon from './StatusIcon' import StatusIcon from './components/StatusIcon'
const { Dragger } = Upload const { Dragger } = Upload
const { Title } = Typography const { Title } = Typography
@ -35,15 +35,13 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
noteItems, noteItems,
fileItems, fileItems,
urlItems, urlItems,
sitemapItems,
addFiles, addFiles,
updateNoteContent, updateNoteContent,
addUrl, addUrl,
addSitemap,
removeItem, removeItem,
getProcessingStatus, getProcessingStatus,
addFileToQueue,
addNoteToQueue,
addUrlToQueue,
clearAll,
addNote addNote
} = useKnowledge(selectedBase.id || '') } = useKnowledge(selectedBase.id || '')
@ -109,23 +107,36 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
} }
} }
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 handleAddNote = async () => {
const note = await TextEditPopup.show({ text: '', textareaProps: { rows: 20 } }) const note = await TextEditPopup.show({ text: '', textareaProps: { rows: 20 } })
note && addNote(note) note && addNote(note)
} }
const handleIndexAll = async () => {
fileItems.forEach((item) => !item.uniqueId && addFileToQueue(item.id))
urlItems.forEach((item) => !item.uniqueId && addUrlToQueue(item.id))
noteItems.forEach((note) => !note.uniqueId && addNoteToQueue(note.id))
message.success(t('knowledge_base.index_started'))
}
const handleCancelIndex = () => {
clearAll()
message.success(t('knowledge_base.index_cancelled'))
}
const handleEditNote = async (note: any) => { const handleEditNote = async (note: any) => {
const editedText = await TextEditPopup.show({ text: note.content as string, textareaProps: { rows: 20 } }) const editedText = await TextEditPopup.show({ text: note.content as string, textareaProps: { rows: 20 } })
editedText && updateNoteContent(note.id, editedText) editedText && updateNoteContent(note.id, editedText)
@ -198,6 +209,33 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
</div> </div>
</ContentSection> </ContentSection>
<ContentSection>
<TitleWrapper>
<Title level={5}>{t('knowledge_base.sitemaps')}</Title>
<Button icon={<PlusOutlined />} onClick={handleAddSitemap}>
{t('knowledge_base.add_sitemap')}
</Button>
</TitleWrapper>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{sitemapItems.map((item) => (
<ItemCard key={item.id}>
<ItemContent>
<ItemInfo>
<GlobalOutlined style={{ fontSize: '16px' }} />
<a href={item.content as string} target="_blank" rel="noopener noreferrer">
{item.content as string}
</a>
</ItemInfo>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<StatusIcon sourceId={item.id} base={base} getProcessingStatus={getProcessingStatus} />
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
</div>
</ItemContent>
</ItemCard>
))}
</div>
</ContentSection>
<ContentSection> <ContentSection>
<TitleWrapper> <TitleWrapper>
<Title level={5}>{t('knowledge_base.notes')}</Title> <Title level={5}>{t('knowledge_base.notes')}</Title>
@ -222,19 +260,9 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
))} ))}
</div> </div>
</ContentSection> </ContentSection>
<IndexSection> <IndexSection>
<Button type="primary" onClick={handleIndexAll} icon={<FileTextOutlined />}> <Button type="primary" onClick={() => KnowledgeSearchPopup.show({ base })} icon={<SearchOutlined />}>
{t('knowledge_base.index_all')} {t('knowledge_base.search')}
</Button>
<Button onClick={handleCancelIndex} icon={<StopOutlined />} style={{ marginLeft: '10px' }}>
{t('knowledge_base.cancel_index')}
</Button>
<Button
onClick={() => KnowledgeSearchPopup.show({ base })}
icon={<SearchOutlined />}
style={{ marginLeft: '10px' }}>
{t('knowledge_base.query')}
</Button> </Button>
</IndexSection> </IndexSection>
<div style={{ minHeight: '20px' }} /> <div style={{ minHeight: '20px' }} />
@ -321,7 +349,7 @@ const ItemInfo = styled.div`
const IndexSection = styled.div` const IndexSection = styled.div`
margin-top: 20px; margin-top: 20px;
display: flex; display: flex;
justify-content: flex-end; justify-content: center;
` `
export default KnowledgeContent export default KnowledgeContent

View File

@ -3,35 +3,35 @@ import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
import ListItem from '@renderer/components/ListItem' import ListItem from '@renderer/components/ListItem'
import PromptPopup from '@renderer/components/Popups/PromptPopup' import PromptPopup from '@renderer/components/Popups/PromptPopup'
import Scrollbar from '@renderer/components/Scrollbar' import Scrollbar from '@renderer/components/Scrollbar'
import { RootState } from '@renderer/store' import { useKnowledgeBases } from '@renderer/hooks/useknowledge'
import { deleteBase, renameBase } from '@renderer/store/knowledge'
import { KnowledgeBase } from '@renderer/types' import { KnowledgeBase } from '@renderer/types'
import { Dropdown, Empty, MenuProps } from 'antd' import { Dropdown, Empty, MenuProps } from 'antd'
import { FC, useCallback, useEffect, useState } from 'react' import { FC, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useDispatch, useSelector } from 'react-redux'
import styled from 'styled-components' import styled from 'styled-components'
import AddKnowledgePopup from './AddKnowledgePopup' import AddKnowledgePopup from './components/AddKnowledgePopup'
import KnowledgeContent from './KnowledgeContent' import KnowledgeContent from './KnowledgeContent'
const KnowledgePage: FC = () => { const KnowledgePage: FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
const { bases } = useSelector((state: RootState) => state.knowledge) const { bases, renameKnowledgeBase, deleteKnowledgeBase } = useKnowledgeBases()
const [selectedBase, setSelectedBase] = useState<KnowledgeBase>() const [selectedBase, setSelectedBase] = useState<KnowledgeBase>()
const dispatch = useDispatch()
const handleAddKnowledge = async () => { const handleAddKnowledge = async () => {
await AddKnowledgePopup.show({ await AddKnowledgePopup.show({ title: t('knowledge_base.add.title') })
title: t('knowledge_base.add.title')
})
} }
useEffect(() => { useEffect(() => {
if (bases.length > 0) { if (bases.length > 0) {
setSelectedBase(bases[0]) if (!selectedBase) {
return setSelectedBase(bases[0])
}
if (selectedBase && !bases.includes(selectedBase)) {
return setSelectedBase(bases[0])
}
} }
}, [bases]) }, [bases, selectedBase])
const getMenuItems = useCallback( const getMenuItems = useCallback(
(base: KnowledgeBase) => { (base: KnowledgeBase) => {
@ -47,7 +47,7 @@ const KnowledgePage: FC = () => {
defaultValue: base.name || '' defaultValue: base.name || ''
}) })
if (name && base.name !== name) { if (name && base.name !== name) {
dispatch(renameBase({ baseId: base.id, name })) renameKnowledgeBase(base.id, name)
} }
} }
}, },
@ -58,14 +58,20 @@ const KnowledgePage: FC = () => {
key: 'delete', key: 'delete',
icon: <DeleteOutlined />, icon: <DeleteOutlined />,
onClick: () => { onClick: () => {
dispatch(deleteBase({ baseId: base.id })) window.modal.confirm({
title: t('knowledge_base.delete_confirm'),
centered: true,
onOk: () => {
deleteKnowledgeBase(base.id)
}
})
} }
} }
] ]
return menus return menus
}, },
[dispatch, t] [deleteKnowledgeBase, renameKnowledgeBase, t]
) )
return ( return (

View File

@ -1,16 +1,15 @@
import { TopView } from '@renderer/components/TopView' import { TopView } from '@renderer/components/TopView'
import { isEmbeddingModel } from '@renderer/config/models' import { isEmbeddingModel } from '@renderer/config/models'
import { useKnowledgeBases } from '@renderer/hooks/useknowledge'
import { useProviders } from '@renderer/hooks/useProvider' import { useProviders } from '@renderer/hooks/useProvider'
import { getRagAppRequestParams } from '@renderer/services/KnowledgeService' import { getRagAppRequestParams } from '@renderer/services/KnowledgeService'
import { getModelUniqId } from '@renderer/services/ModelService' import { getModelUniqId } from '@renderer/services/ModelService'
import { addBase } from '@renderer/store/knowledge'
import { Model } from '@renderer/types' import { Model } from '@renderer/types'
import { Form, Input, Modal, Select } from 'antd' import { Form, Input, Modal, Select } from 'antd'
import { find, sortBy } from 'lodash' import { find, sortBy } from 'lodash'
import { nanoid } from 'nanoid' import { nanoid } from 'nanoid'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useDispatch } from 'react-redux'
interface ShowParams { interface ShowParams {
title: string title: string
@ -29,8 +28,8 @@ const PopupContainer: React.FC<Props> = ({ title, resolve }) => {
const [open, setOpen] = useState(true) const [open, setOpen] = useState(true)
const [form] = Form.useForm<FormData>() const [form] = Form.useForm<FormData>()
const { t } = useTranslation() const { t } = useTranslation()
const dispatch = useDispatch()
const { providers } = useProviders() const { providers } = useProviders()
const { addKnowledgeBase } = useKnowledgeBases()
const allModels = providers const allModels = providers
.map((p) => p.models) .map((p) => p.models)
.flat() .flat()
@ -61,14 +60,13 @@ const PopupContainer: React.FC<Props> = ({ title, resolve }) => {
name: values.name, name: values.name,
model: selectedModel, model: selectedModel,
items: [], items: [],
processingQueue: [],
created_at: Date.now(), created_at: Date.now(),
updated_at: Date.now() updated_at: Date.now()
} }
await window.api.knowledgeBase.create(getRagAppRequestParams(newBase)) await window.api.knowledgeBase.create(getRagAppRequestParams(newBase))
dispatch(addBase(newBase as any)) addKnowledgeBase(newBase as any)
setOpen(false) setOpen(false)
resolve(newBase) resolve(newBase)
} }

View File

@ -1,6 +1,6 @@
import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons' import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons'
import { Center } from '@renderer/components/Layout' import { Center } from '@renderer/components/Layout'
import { KnowledgeBase, ProcessingItem } from '@renderer/types' import { KnowledgeBase, ProcessingStatus } from '@renderer/types'
import { Tooltip } from 'antd' import { Tooltip } from 'antd'
import { FC } from 'react' import { FC } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -9,13 +9,14 @@ import styled from 'styled-components'
interface StatusIconProps { interface StatusIconProps {
sourceId: string sourceId: string
base: KnowledgeBase base: KnowledgeBase
getProcessingStatus: (sourceId: string) => ProcessingItem | undefined getProcessingStatus: (sourceId: string) => ProcessingStatus | undefined
} }
const StatusIcon: FC<StatusIconProps> = ({ sourceId, base, getProcessingStatus }) => { const StatusIcon: FC<StatusIconProps> = ({ sourceId, base, getProcessingStatus }) => {
const { t } = useTranslation() const { t } = useTranslation()
const status = getProcessingStatus(sourceId) const status = getProcessingStatus(sourceId)
const item = base.items.find((item) => item.id === sourceId) const item = base.items.find((item) => item.id === sourceId)
const errorText = item?.processingError
if (!status) { if (!status) {
if (item?.uniqueId) { if (item?.uniqueId) {
@ -34,7 +35,7 @@ const StatusIcon: FC<StatusIconProps> = ({ sourceId, base, getProcessingStatus }
) )
} }
switch (status.status) { switch (status) {
case 'pending': case 'pending':
return ( return (
<Tooltip title={t('knowledge_base.status_pending')} placement="left"> <Tooltip title={t('knowledge_base.status_pending')} placement="left">
@ -55,7 +56,7 @@ const StatusIcon: FC<StatusIconProps> = ({ sourceId, base, getProcessingStatus }
) )
case 'failed': case 'failed':
return ( return (
<Tooltip title={t('knowledge_base.status_failed')} placement="left"> <Tooltip title={errorText || t('knowledge_base.status_failed')} placement="left">
<CloseCircleOutlined style={{ color: '#ff4d4f' }} /> <CloseCircleOutlined style={{ color: '#ff4d4f' }} />
</Tooltip> </Tooltip>
) )

View File

@ -1,4 +1,4 @@
import { GithubOutlined, TwitterOutlined } from '@ant-design/icons' import { GithubOutlined, XOutlined } from '@ant-design/icons'
import { FileProtectOutlined, GlobalOutlined, MailOutlined, SendOutlined, SoundOutlined } from '@ant-design/icons' import { FileProtectOutlined, GlobalOutlined, MailOutlined, SendOutlined, SoundOutlined } from '@ant-design/icons'
import IndicatorLight from '@renderer/components/IndicatorLight' import IndicatorLight from '@renderer/components/IndicatorLight'
import { HStack } from '@renderer/components/Layout' import { HStack } from '@renderer/components/Layout'
@ -208,7 +208,7 @@ const AboutSettings: FC = () => {
<SettingDivider /> <SettingDivider />
<SettingRow> <SettingRow>
<SettingRowTitle> <SettingRowTitle>
<TwitterOutlined />X <XOutlined />X
</SettingRowTitle> </SettingRowTitle>
<Button onClick={() => onOpenWebsite('https://x.com/kangfenmao')}> <Button onClick={() => onOpenWebsite('https://x.com/kangfenmao')}>
{t('settings.about.website.button')} {t('settings.about.website.button')}

View File

@ -25,7 +25,12 @@ export default class AnthropicProvider extends BaseProvider {
} }
private async getMessageParam(message: Message): Promise<MessageParam> { private async getMessageParam(message: Message): Promise<MessageParam> {
const parts: MessageParam['content'] = [{ type: 'text', text: message.content }] const parts: MessageParam['content'] = [
{
type: 'text',
text: await this.getMessageContent(message)
}
]
for (const file of message.files || []) { for (const file of message.files || []) {
if (file.type === FileTypes.IMAGE) { if (file.type === FileTypes.IMAGE) {
@ -83,11 +88,20 @@ export default class AnthropicProvider extends BaseProvider {
system: assistant.prompt system: assistant.prompt
} }
let time_first_token_millsec = 0
const start_time_millsec = new Date().getTime()
if (!streamOutput) { if (!streamOutput) {
const message = await this.sdk.messages.create({ ...body, stream: false }) const message = await this.sdk.messages.create({ ...body, stream: false })
const time_completion_millsec = new Date().getTime() - start_time_millsec
return onChunk({ return onChunk({
text: message.content[0].type === 'text' ? message.content[0].text : '', text: message.content[0].type === 'text' ? message.content[0].text : '',
usage: message.usage usage: message.usage,
metrics: {
completion_tokens: message.usage.output_tokens,
time_completion_millsec,
time_first_token_millsec: 0
}
}) })
} }
@ -99,7 +113,18 @@ export default class AnthropicProvider extends BaseProvider {
stream.controller.abort() stream.controller.abort()
return resolve() return resolve()
} }
onChunk({ text }) if (time_first_token_millsec == 0) {
time_first_token_millsec = new Date().getTime() - start_time_millsec
}
const time_completion_millsec = new Date().getTime() - start_time_millsec
onChunk({
text,
metrics: {
completion_tokens: undefined,
time_completion_millsec,
time_first_token_millsec
}
})
}) })
.on('finalMessage', (message) => { .on('finalMessage', (message) => {
onChunk({ onChunk({
@ -108,6 +133,11 @@ export default class AnthropicProvider extends BaseProvider {
prompt_tokens: message.usage.input_tokens, prompt_tokens: message.usage.input_tokens,
completion_tokens: message.usage.output_tokens, completion_tokens: message.usage.output_tokens,
total_tokens: sum(Object.values(message.usage)) total_tokens: sum(Object.values(message.usage))
},
metrics: {
completion_tokens: message.usage.output_tokens,
time_completion_millsec: new Date().getTime() - start_time_millsec,
time_first_token_millsec
} }
}) })
resolve() resolve()

View File

@ -19,6 +19,24 @@ export default abstract class BaseProvider {
this.apiKey = this.getApiKey() this.apiKey = this.getApiKey()
} }
abstract completions({ messages, assistant, onChunk, onFilterMessages }: CompletionsParams): Promise<void>
abstract translate(message: Message, assistant: Assistant): Promise<string>
abstract summaries(messages: Message[], assistant: Assistant): Promise<string>
abstract suggestions(messages: Message[], assistant: Assistant): Promise<Suggestion[]>
abstract generateText({ prompt, content }: { prompt: string; content: string }): Promise<string>
abstract check(): Promise<{ valid: boolean; error: Error | null }>
abstract models(): Promise<OpenAI.Models.Model[]>
abstract generateImage(_params: {
prompt: string
negativePrompt: string
imageSize: string
batchSize: number
seed?: string
numInferenceSteps: number
guidanceScale: number
signal?: AbortSignal
}): Promise<string[]>
public getBaseURL(): string { public getBaseURL(): string {
const host = this.provider.apiHost const host = this.provider.apiHost
return host.endsWith('/') ? host : `${host}/v1/` return host.endsWith('/') ? host : `${host}/v1/`
@ -63,7 +81,7 @@ export default abstract class BaseProvider {
} }
} }
public async getMessageContentWithKnowledgeBase(message: Message) { public async getMessageContent(message: Message) {
if (!message.knowledgeBaseIds) { if (!message.knowledgeBaseIds) {
return message.content return message.content
} }
@ -81,8 +99,6 @@ export default abstract class BaseProvider {
config: getRagAppRequestParams(base) config: getRagAppRequestParams(base)
}) })
console.debug('searchResults', searchResults)
const references = take(searchResults, 5) const references = take(searchResults, 5)
.map((item, index) => { .map((item, index) => {
let sourceUrl = '' let sourceUrl = ''
@ -93,16 +109,15 @@ export default abstract class BaseProvider {
if (baseItem) { if (baseItem) {
switch (baseItem.type) { switch (baseItem.type) {
case 'file': case 'file':
sourceUrl = `file://${(baseItem?.content as FileType).path}` // sourceUrl = `file://${encodeURIComponent((baseItem?.content as FileType).path)}`
sourceName = (baseItem?.content as FileType).origin_name sourceName = (baseItem?.content as FileType).origin_name
break break
case 'url': case 'url':
sourceUrl = baseItem.content as string sourceUrl = baseItem.content as string
sourceName = '' sourceName = baseItem.content as string
break break
case 'note': case 'note':
sourceUrl = '' sourceName = baseItem.content as string
sourceName = ''
break break
} }
} }
@ -118,26 +133,9 @@ source_url: ${sourceUrl}
}) })
.join('\n\n') .join('\n\n')
const prompt = `回答问题请参考以下内容,并使用类似 [^1] content [source_name](source_url) 的脚注格式引用数据来源,脚注内容可以点击跳转。当 source_name 为空的时候可以使用 content 作为脚注内容。` const prompt =
'回答问题请参考以下内容,并使用类似 [^1]: source 的脚注格式引用数据来源, source 根据 source_type 决定'
return [message.content, prompt, references].join('\n\n') return [message.content, prompt, references].join('\n\n')
} }
abstract completions({ messages, assistant, onChunk, onFilterMessages }: CompletionsParams): Promise<void>
abstract translate(message: Message, assistant: Assistant): Promise<string>
abstract summaries(messages: Message[], assistant: Assistant): Promise<string>
abstract suggestions(messages: Message[], assistant: Assistant): Promise<Suggestion[]>
abstract generateText({ prompt, content }: { prompt: string; content: string }): Promise<string>
abstract check(): Promise<{ valid: boolean; error: Error | null }>
abstract models(): Promise<OpenAI.Models.Model[]>
abstract generateImage(_params: {
prompt: string
negativePrompt: string
imageSize: string
batchSize: number
seed?: string
numInferenceSteps: number
guidanceScale: number
signal?: AbortSignal
}): Promise<string[]>
} }

View File

@ -17,6 +17,7 @@ import axios from 'axios'
import { first, isEmpty, takeRight } from 'lodash' import { first, isEmpty, takeRight } from 'lodash'
import OpenAI from 'openai' import OpenAI from 'openai'
import { CompletionsParams } from '.'
import BaseProvider from './BaseProvider' import BaseProvider from './BaseProvider'
export default class GeminiProvider extends BaseProvider { export default class GeminiProvider extends BaseProvider {
@ -34,7 +35,7 @@ export default class GeminiProvider extends BaseProvider {
private async getMessageContents(message: Message): Promise<Content> { private async getMessageContents(message: Message): Promise<Content> {
const role = message.role === 'user' ? 'user' : 'model' const role = message.role === 'user' ? 'user' : 'model'
const parts: Part[] = [{ text: message.content }] const parts: Part[] = [{ text: await this.getMessageContent(message) }]
for (const file of message.files || []) { for (const file of message.files || []) {
if (file.type === FileTypes.IMAGE) { if (file.type === FileTypes.IMAGE) {
@ -107,29 +108,47 @@ export default class GeminiProvider extends BaseProvider {
const chat = geminiModel.startChat({ history }) const chat = geminiModel.startChat({ history })
const messageContents = await this.getMessageContents(userLastMessage!) const messageContents = await this.getMessageContents(userLastMessage!)
const start_time_millsec = new Date().getTime()
if (!streamOutput) { if (!streamOutput) {
const { response } = await chat.sendMessage(messageContents.parts) const { response } = await chat.sendMessage(messageContents.parts)
const time_completion_millsec = new Date().getTime() - start_time_millsec
onChunk({ onChunk({
text: response.candidates?.[0].content.parts[0].text, text: response.candidates?.[0].content.parts[0].text,
usage: { usage: {
prompt_tokens: response.usageMetadata?.promptTokenCount || 0, prompt_tokens: response.usageMetadata?.promptTokenCount || 0,
completion_tokens: response.usageMetadata?.candidatesTokenCount || 0, completion_tokens: response.usageMetadata?.candidatesTokenCount || 0,
total_tokens: response.usageMetadata?.totalTokenCount || 0 total_tokens: response.usageMetadata?.totalTokenCount || 0
},
metrics: {
completion_tokens: response.usageMetadata?.candidatesTokenCount,
time_completion_millsec,
time_first_token_millsec: 0
} }
}) })
return return
} }
const userMessagesStream = await chat.sendMessageStream(messageContents.parts) const userMessagesStream = await chat.sendMessageStream(messageContents.parts)
let time_first_token_millsec = 0
for await (const chunk of userMessagesStream.stream) { for await (const chunk of userMessagesStream.stream) {
if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) break if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) break
if (time_first_token_millsec == 0) {
time_first_token_millsec = new Date().getTime() - start_time_millsec
}
const time_completion_millsec = new Date().getTime() - start_time_millsec
onChunk({ onChunk({
text: chunk.text(), text: chunk.text(),
usage: { usage: {
prompt_tokens: chunk.usageMetadata?.promptTokenCount || 0, prompt_tokens: chunk.usageMetadata?.promptTokenCount || 0,
completion_tokens: chunk.usageMetadata?.candidatesTokenCount || 0, completion_tokens: chunk.usageMetadata?.candidatesTokenCount || 0,
total_tokens: chunk.usageMetadata?.totalTokenCount || 0 total_tokens: chunk.usageMetadata?.totalTokenCount || 0
},
metrics: {
completion_tokens: chunk.usageMetadata?.candidatesTokenCount,
time_completion_millsec,
time_first_token_millsec
} }
}) })
} }

View File

@ -50,11 +50,12 @@ export default class OpenAIProvider extends BaseProvider {
model: Model model: Model
): Promise<OpenAI.Chat.Completions.ChatCompletionMessageParam> { ): Promise<OpenAI.Chat.Completions.ChatCompletionMessageParam> {
const isVision = isVisionModel(model) const isVision = isVisionModel(model)
const content = await this.getMessageContent(message)
if (!message.files) { if (!message.files) {
return { return {
role: message.role, role: message.role,
content: await this.getMessageContentWithKnowledgeBase(message) content
} }
} }
@ -74,21 +75,21 @@ export default class OpenAIProvider extends BaseProvider {
return { return {
role: message.role, role: message.role,
content: message.content + divider + text content: content + divider + text
} }
} }
} }
return { return {
role: message.role, role: message.role,
content: message.content content
} }
} }
const parts: ChatCompletionContentPart[] = [ const parts: ChatCompletionContentPart[] = [
{ {
type: 'text', type: 'text',
text: message.content text: content
} }
] ]

View File

@ -2,13 +2,13 @@ import { AddLoaderReturn } from '@llm-tools/embedjs-interfaces'
import db from '@renderer/databases' import db from '@renderer/databases'
import { getRagAppRequestParams } from '@renderer/services/KnowledgeService' import { getRagAppRequestParams } from '@renderer/services/KnowledgeService'
import store from '@renderer/store' import store from '@renderer/store'
import { removeProcessingItem, updateBaseItemUniqueId, updateProcessingStatus } from '@renderer/store/knowledge' import { clearCompletedProcessing, updateBaseItemUniqueId, updateItemProcessingStatus } from '@renderer/store/knowledge'
import { ProcessingItem } from '@renderer/types' import { KnowledgeItem } from '@renderer/types'
class KnowledgeQueue { class KnowledgeQueue {
private processing: Map<string, boolean> = new Map() private processing: Map<string, boolean> = new Map()
private pollingInterval: NodeJS.Timeout | null = null private pollingInterval: NodeJS.Timeout | null = null
private readonly POLLING_INTERVAL = 5000 // private readonly POLLING_INTERVAL = 5000
private readonly MAX_RETRIES = 3 private readonly MAX_RETRIES = 3
constructor() { constructor() {
@ -21,10 +21,10 @@ class KnowledgeQueue {
const state = store.getState() const state = store.getState()
state.knowledge.bases.forEach((base) => { state.knowledge.bases.forEach((base) => {
base.processingQueue.forEach((item) => { base.items.forEach((item) => {
if (item.status === 'processing') { if (item.processingStatus === 'processing') {
store.dispatch( store.dispatch(
updateProcessingStatus({ updateItemProcessingStatus({
baseId: base.id, baseId: base.id,
itemId: item.id, itemId: item.id,
status: 'pending', status: 'pending',
@ -35,9 +35,9 @@ class KnowledgeQueue {
}) })
}) })
this.pollingInterval = setInterval(() => { // this.pollingInterval = setInterval(() => {
this.checkAllBases() // this.checkAllBases()
}, this.POLLING_INTERVAL) // }, this.POLLING_INTERVAL)
} }
private stopPolling(): void { private stopPolling(): void {
@ -47,27 +47,21 @@ class KnowledgeQueue {
} }
} }
private async checkAllBases(): Promise<void> { public async checkAllBases(): Promise<void> {
const state = store.getState() const state = store.getState()
const bases = state.knowledge.bases const bases = state.knowledge.bases
console.log('[KnowledgeQueue] Checking all bases for pending items...')
await Promise.all( await Promise.all(
bases.map(async (base) => { bases.map(async (base) => {
const processableItems = base.processingQueue.filter((item) => { const processableItems = base.items.filter((item) => {
if (item.status === 'failed') { if (item.processingStatus === 'failed') {
return !item.retryCount || item.retryCount < this.MAX_RETRIES return !item.retryCount || item.retryCount < this.MAX_RETRIES
} }
return item.status === 'pending' return item.processingStatus === 'pending'
}) })
const hasProcessableItems = processableItems.length > 0 const hasProcessableItems = processableItems.length > 0
console.log(
`[KnowledgeQueue] Base ${base.id}: ${hasProcessableItems ? 'has processable items' : 'no processable items'}`
)
if (hasProcessableItems && !this.processing.get(base.id)) { if (hasProcessableItems && !this.processing.get(base.id)) {
await this.processQueue(base.id) await this.processQueue(base.id)
} }
@ -91,11 +85,11 @@ class KnowledgeQueue {
throw new Error('Knowledge base not found') throw new Error('Knowledge base not found')
} }
const processableItems = base.processingQueue.filter((item) => { const processableItems = base.items.filter((item) => {
if (item.status === 'failed') { if (item.processingStatus === 'failed') {
return !item.retryCount || item.retryCount < this.MAX_RETRIES return !item.retryCount || item.retryCount < this.MAX_RETRIES
} }
return item.status === 'pending' return item.processingStatus === 'pending'
}) })
for (const item of processableItems) { for (const item of processableItems) {
@ -124,7 +118,7 @@ class KnowledgeQueue {
} }
} }
private async processItem(baseId: string, item: ProcessingItem): Promise<void> { private async processItem(baseId: string, item: KnowledgeItem): Promise<void> {
try { try {
if (item.retryCount && item.retryCount >= this.MAX_RETRIES) { if (item.retryCount && item.retryCount >= this.MAX_RETRIES) {
console.log(`[KnowledgeQueue] Item ${item.id} has reached max retries, skipping`) console.log(`[KnowledgeQueue] Item ${item.id} has reached max retries, skipping`)
@ -132,9 +126,9 @@ class KnowledgeQueue {
} }
console.log(`[KnowledgeQueue] Starting to process item ${item.id} (${item.type})`) console.log(`[KnowledgeQueue] Starting to process item ${item.id} (${item.type})`)
// Update status to processing
store.dispatch( store.dispatch(
updateProcessingStatus({ updateItemProcessingStatus({
baseId, baseId,
itemId: item.id, itemId: item.id,
status: 'processing', status: 'processing',
@ -149,10 +143,10 @@ class KnowledgeQueue {
} }
const requestParams = getRagAppRequestParams(base) const requestParams = getRagAppRequestParams(base)
const sourceItem = base.items.find((i) => i.id === item.sourceId) const sourceItem = base.items.find((i) => i.id === item.id)
if (!sourceItem) { if (!sourceItem) {
throw new Error(`[KnowledgeQueue] Source item ${item.sourceId} not found in base ${baseId}`) throw new Error(`[KnowledgeQueue] Source item ${item.id} not found in base ${baseId}`)
} }
let result: AddLoaderReturn | null = null let result: AddLoaderReturn | null = null
@ -169,10 +163,15 @@ class KnowledgeQueue {
result = await window.api.knowledgeBase.add({ data: sourceItem.content, config: requestParams }) result = await window.api.knowledgeBase.add({ data: sourceItem.content, config: requestParams })
console.log(`[KnowledgeQueue] Result: ${JSON.stringify(result)}`) console.log(`[KnowledgeQueue] Result: ${JSON.stringify(result)}`)
break break
case 'sitemap':
console.log(`[KnowledgeQueue] Processing Sitemap: ${sourceItem.content}`)
result = await window.api.knowledgeBase.add({ data: sourceItem.content, config: requestParams })
console.log(`[KnowledgeQueue] Result: ${JSON.stringify(result)}`)
break
case 'note': case 'note':
console.log(`[KnowledgeQueue] Processing note: ${sourceItem.content}`) console.log(`[KnowledgeQueue] Processing note: ${sourceItem.content}`)
note = await db.knowledge_notes.get(item.sourceId) note = await db.knowledge_notes.get(item.id)
if (!note) throw new Error(`Source note ${item.sourceId} not found`) if (!note) throw new Error(`Source note ${item.id} not found`)
content = note.content as string content = note.content as string
result = await window.api.knowledgeBase.add({ data: content, config: requestParams }) result = await window.api.knowledgeBase.add({ data: content, config: requestParams })
console.log(`[KnowledgeQueue] Result: ${JSON.stringify(result)}`) console.log(`[KnowledgeQueue] Result: ${JSON.stringify(result)}`)
@ -181,36 +180,31 @@ class KnowledgeQueue {
console.log(`[KnowledgeQueue] Successfully completed processing item ${item.id}`) console.log(`[KnowledgeQueue] Successfully completed processing item ${item.id}`)
// Mark as completed
store.dispatch( store.dispatch(
updateProcessingStatus({ updateItemProcessingStatus({
baseId, baseId,
itemId: item.id, itemId: item.id,
status: 'completed' status: 'completed'
}) })
) )
// Update uniqueId
if (result) { if (result) {
store.dispatch( store.dispatch(
updateBaseItemUniqueId({ updateBaseItemUniqueId({
baseId, baseId,
itemId: item.sourceId, itemId: item.id,
uniqueId: result.uniqueId uniqueId: result.uniqueId
}) })
) )
} }
console.debug(`[KnowledgeQueue] Updated uniqueId for item ${item.sourceId} in base ${baseId}`) console.debug(`[KnowledgeQueue] Updated uniqueId for item ${item.id} in base ${baseId}`)
// Remove from queue after successful processing setTimeout(() => store.dispatch(clearCompletedProcessing({ baseId })), 1000)
setTimeout(() => {
store.dispatch(removeProcessingItem({ baseId, itemId: item.id }))
}, 1000)
} catch (error) { } catch (error) {
console.error(`[KnowledgeQueue] Error processing item ${item.id}:`, error) console.error(`[KnowledgeQueue] Error processing item ${item.id}:`, error)
store.dispatch( store.dispatch(
updateProcessingStatus({ updateItemProcessingStatus({
baseId, baseId,
itemId: item.id, itemId: item.id,
status: 'failed', status: 'failed',

View File

@ -1,7 +1,6 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit' import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import FileManager from '@renderer/services/FileManager' import FileManager from '@renderer/services/FileManager'
import { getRagAppRequestParams } from '@renderer/services/KnowledgeService' import { FileType, KnowledgeBase, KnowledgeItem, ProcessingStatus } from '@renderer/types'
import { FileType, KnowledgeBase, KnowledgeItem, ProcessingItem, ProcessingStatus } from '@renderer/types'
export interface KnowledgeState { export interface KnowledgeState {
bases: KnowledgeBase[] bases: KnowledgeBase[]
@ -15,12 +14,10 @@ const knowledgeSlice = createSlice({
name: 'knowledge', name: 'knowledge',
initialState, initialState,
reducers: { reducers: {
// 添加知识库
addBase(state, action: PayloadAction<KnowledgeBase>) { addBase(state, action: PayloadAction<KnowledgeBase>) {
state.bases.push(action.payload) state.bases.push(action.payload)
}, },
// 删除知识库
deleteBase(state, action: PayloadAction<{ baseId: string }>) { deleteBase(state, action: PayloadAction<{ baseId: string }>) {
const base = state.bases.find((b) => b.id === action.payload.baseId) const base = state.bases.find((b) => b.id === action.payload.baseId)
if (base) { if (base) {
@ -31,7 +28,6 @@ const knowledgeSlice = createSlice({
} }
}, },
// 重命名知识库
renameBase(state, action: PayloadAction<{ baseId: string; name: string }>) { renameBase(state, action: PayloadAction<{ baseId: string; name: string }>) {
const base = state.bases.find((b) => b.id === action.payload.baseId) const base = state.bases.find((b) => b.id === action.payload.baseId)
if (base) { if (base) {
@ -40,7 +36,6 @@ const knowledgeSlice = createSlice({
} }
}, },
// 更新知识库
updateBase(state, action: PayloadAction<KnowledgeBase>) { updateBase(state, action: PayloadAction<KnowledgeBase>) {
const index = state.bases.findIndex((b) => b.id === action.payload.id) const index = state.bases.findIndex((b) => b.id === action.payload.id)
if (index !== -1) { if (index !== -1) {
@ -48,7 +43,6 @@ const knowledgeSlice = createSlice({
} }
}, },
// 添加条目
addItem(state, action: PayloadAction<{ baseId: string; item: KnowledgeItem }>) { addItem(state, action: PayloadAction<{ baseId: string; item: KnowledgeItem }>) {
const base = state.bases.find((b) => b.id === action.payload.baseId) const base = state.bases.find((b) => b.id === action.payload.baseId)
if (base) { if (base) {
@ -68,26 +62,15 @@ const knowledgeSlice = createSlice({
} }
}, },
// 删除条目
removeItem(state, action: PayloadAction<{ baseId: string; item: KnowledgeItem }>) { removeItem(state, action: PayloadAction<{ baseId: string; item: KnowledgeItem }>) {
const { baseId, item } = action.payload const { baseId } = action.payload
const base = state.bases.find((b) => b.id === baseId) const base = state.bases.find((b) => b.id === baseId)
if (base) { if (base) {
base.items = base.items.filter((item) => item.id !== action.payload.item.id) base.items = base.items.filter((item) => item.id !== action.payload.item.id)
base.updated_at = Date.now() base.updated_at = Date.now()
if (item?.uniqueId) {
window.api.knowledgeBase.remove({
uniqueId: item.uniqueId,
config: getRagAppRequestParams(base)
})
}
if (item.type === 'file' && typeof item.content === 'object') {
FileManager.deleteFile(item.content.id)
}
} }
}, },
// 更新文件
updateFiles(state, action: PayloadAction<{ baseId: string; items: KnowledgeItem[] }>) { updateFiles(state, action: PayloadAction<{ baseId: string; items: KnowledgeItem[] }>) {
const base = state.bases.find((b) => b.id === action.payload.baseId) const base = state.bases.find((b) => b.id === action.payload.baseId)
if (base) { if (base) {
@ -98,7 +81,6 @@ const knowledgeSlice = createSlice({
} }
}, },
// 更新笔记
updateNotes(state, action: PayloadAction<{ baseId: string; item: KnowledgeItem }>) { updateNotes(state, action: PayloadAction<{ baseId: string; item: KnowledgeItem }>) {
const base = state.bases.find((b) => b.id === action.payload.baseId) const base = state.bases.find((b) => b.id === action.payload.baseId)
if (base) { if (base) {
@ -114,33 +96,7 @@ const knowledgeSlice = createSlice({
} }
}, },
// 添加处理队列项 updateItemProcessingStatus(
addProcessingItem(
state,
action: PayloadAction<{ baseId: string; type: 'file' | 'url' | 'note'; sourceId: string }>
) {
const base = state.bases.find((b) => b.id === action.payload.baseId)
if (base) {
const newItem: ProcessingItem = {
id: `${action.payload.type}-${action.payload.sourceId}`,
type: action.payload.type,
sourceId: action.payload.sourceId,
status: 'pending',
createdAt: Date.now(),
updatedAt: Date.now(),
baseId: action.payload.baseId
}
// 避免重复添加
const exists = base.processingQueue.some((item) => item.sourceId === action.payload.sourceId)
if (!exists) {
base.processingQueue.push(newItem)
}
}
},
// 更新处理状态
updateProcessingStatus(
state, state,
action: PayloadAction<{ action: PayloadAction<{
baseId: string baseId: string
@ -153,44 +109,42 @@ const knowledgeSlice = createSlice({
) { ) {
const base = state.bases.find((b) => b.id === action.payload.baseId) const base = state.bases.find((b) => b.id === action.payload.baseId)
if (base) { if (base) {
const item = base.processingQueue.find((item) => item.id === action.payload.itemId) const item = base.items.find((item) => item.id === action.payload.itemId)
if (item) { if (item) {
item.status = action.payload.status item.processingStatus = action.payload.status
item.progress = action.payload.progress item.processingProgress = action.payload.progress
item.error = action.payload.error item.processingError = action.payload.error
item.retryCount = action.payload.retryCount item.retryCount = action.payload.retryCount
item.updatedAt = Date.now()
} }
} }
}, },
// 移除处理队列项 clearCompletedProcessing(state, action: PayloadAction<{ baseId: string }>) {
removeProcessingItem(state, action: PayloadAction<{ baseId: string; itemId: string }>) {
const base = state.bases.find((b) => b.id === action.payload.baseId) const base = state.bases.find((b) => b.id === action.payload.baseId)
if (base) { if (base) {
base.processingQueue = base.processingQueue.filter((item) => item.id !== action.payload.itemId) base.items.forEach((item) => {
if (item.processingStatus === 'completed' || item.processingStatus === 'failed') {
item.processingStatus = undefined
item.processingProgress = undefined
item.processingError = undefined
item.retryCount = undefined
}
})
} }
}, },
// 清除已完成的项目 clearAllProcessing(state, action: PayloadAction<{ baseId: string }>) {
clearCompletedItems(state, action: PayloadAction<{ baseId: string }>) {
const base = state.bases.find((b) => b.id === action.payload.baseId) const base = state.bases.find((b) => b.id === action.payload.baseId)
if (base) { if (base) {
base.processingQueue = base.processingQueue.filter( base.items.forEach((item) => {
(item) => item.status !== 'completed' && item.status !== 'failed' item.processingStatus = undefined
) item.processingProgress = undefined
item.processingError = undefined
item.retryCount = undefined
})
} }
}, },
// 清除所有队列项目
clearAllItems(state, action: PayloadAction<{ baseId: string }>) {
const base = state.bases.find((b) => b.id === action.payload.baseId)
if (base) {
base.processingQueue = []
}
},
// 更新知识库单个条目下面的 uniqueId
updateBaseItemUniqueId(state, action: PayloadAction<{ baseId: string; itemId: string; uniqueId: string }>) { updateBaseItemUniqueId(state, action: PayloadAction<{ baseId: string; itemId: string; uniqueId: string }>) {
const base = state.bases.find((b) => b.id === action.payload.baseId) const base = state.bases.find((b) => b.id === action.payload.baseId)
if (base) { if (base) {
@ -203,25 +157,6 @@ const knowledgeSlice = createSlice({
} }
}) })
// Selectors
export const selectProcessingItemBySource = (
state: KnowledgeState,
baseId: string,
sourceId: string
): ProcessingItem | undefined => {
const base = state.bases.find((b) => b.id === baseId)
return base?.processingQueue.find((item) => item.sourceId === sourceId)
}
export const selectProcessingItemsByType = (
state: KnowledgeState,
baseId: string,
type: 'file' | 'url' | 'note'
): ProcessingItem[] => {
const base = state.bases.find((b) => b.id === baseId)
return base?.processingQueue.filter((item) => item.type === type) || []
}
export const { export const {
addBase, addBase,
deleteBase, deleteBase,
@ -231,11 +166,9 @@ export const {
updateFiles, updateFiles,
updateNotes, updateNotes,
removeItem, removeItem,
addProcessingItem, updateItemProcessingStatus,
updateProcessingStatus, clearCompletedProcessing,
removeProcessingItem, clearAllProcessing,
clearCompletedItems,
clearAllItems,
updateBaseItemUniqueId updateBaseItemUniqueId
} = knowledgeSlice.actions } = knowledgeSlice.actions

View File

@ -183,27 +183,18 @@ export interface Shortcut {
export type ProcessingStatus = 'pending' | 'processing' | 'completed' | 'failed' export type ProcessingStatus = 'pending' | 'processing' | 'completed' | 'failed'
export type ProcessingItem = {
id: string
type: 'file' | 'url' | 'note'
status: ProcessingStatus
progress?: number
error?: string
createdAt: number
updatedAt: number
sourceId: string // file id, url, or note id
baseId: string
retryCount?: number
}
export type KnowledgeItem = { export type KnowledgeItem = {
id: string id: string
baseId?: string baseId?: string
uniqueId?: string uniqueId?: string
type: 'file' | 'url' | 'note' type: 'file' | 'url' | 'note' | 'sitemap'
content: string | FileType // for files: FileType, for urls: string, for notes: string content: string | FileType
created_at: number created_at: number
updated_at: number updated_at: number
processingStatus?: ProcessingStatus
processingProgress?: number
processingError?: string
retryCount?: number
} }
export interface KnowledgeBase { export interface KnowledgeBase {
@ -214,7 +205,6 @@ export interface KnowledgeBase {
items: KnowledgeItem[] items: KnowledgeItem[]
created_at: number created_at: number
updated_at: number updated_at: number
processingQueue: ProcessingItem[]
} }
export type RagAppRequestParams = { export type RagAppRequestParams = {