diff --git a/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch b/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch new file mode 100644 index 00000000..c918d2f3 --- /dev/null +++ b/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch @@ -0,0 +1,29 @@ +diff --git a/lib/pdf-parse.js b/lib/pdf-parse.js +index 96bfbc705dcb4fb73cb077a75f02c115371b3477..6d02d2bb426063c3a31cb740c3d86841de162a22 100644 +--- a/lib/pdf-parse.js ++++ b/lib/pdf-parse.js +@@ -21,12 +21,12 @@ function render_page(pageData) { + for (let item of textContent.items) { + if (lastY == item.transform[5] || !lastY){ + text += item.str; +- } ++ } + else{ + text += '\n' + item.str; +- } ++ } + lastY = item.transform[5]; +- } ++ } + //let strings = textContent.items.map(item => item.str); + //let text = strings.join("\n"); + //text = text.replace(/[ ]+/ig," "); +@@ -60,7 +60,7 @@ async function PDF(dataBuffer, options) { + if (typeof options.version != 'string') options.version = DEFAULT_OPTIONS.version; + if (options.version == 'default') options.version = DEFAULT_OPTIONS.version; + +- PDFJS = PDFJS ? PDFJS : require(`./pdf.js/${options.version}/build/pdf.js`); ++ PDFJS = PDFJS ? PDFJS : require(`./pdf.js/v1.10.100/build/pdf.js`); + + ret.version = PDFJS.version; + diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 415b6879..1b409788 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -4,13 +4,33 @@ import { resolve } from 'path' export default defineConfig({ main: { - plugins: [externalizeDepsPlugin()], + plugins: [ + externalizeDepsPlugin({ + exclude: [ + '@llm-tools/embedjs', + '@llm-tools/embedjs-lancedb', + '@llm-tools/embedjs-ollama', + '@llm-tools/embedjs-openai', + '@llm-tools/embedjs-loader-web', + '@llm-tools/embedjs-loader-markdown', + '@llm-tools/embedjs-loader-msoffice', + '@llm-tools/embedjs-loader-xml', + '@llm-tools/embedjs-loader-pdf', + '@lancedb/lancedb' + ] + }) + ], resolve: { alias: { '@main': resolve('src/main'), '@types': resolve('src/renderer/src/types'), '@shared': resolve('packages/shared') } + }, + build: { + rollupOptions: { + external: ['@lancedb/lancedb', '@llm-tools/embedjs-loader-sitemap'] + } } }, preload: { diff --git a/package.json b/package.json index e4f61c8b..c87afcaa 100644 --- a/package.json +++ b/package.json @@ -11,9 +11,11 @@ "local", "packages/*" ], - "nohoist": [ - "packages/database" - ] + "installConfig": { + "hoistingLimits": [ + "packages/database" + ] + } }, "scripts": { "format": "prettier --write .", @@ -38,7 +40,19 @@ "dependencies": { "@electron-toolkit/preload": "^3.0.0", "@electron-toolkit/utils": "^3.0.0", + "@llm-tools/embedjs": "^0.1.24", + "@llm-tools/embedjs-lancedb": "^0.1.24", + "@llm-tools/embedjs-loader-csv": "^0.1.24", + "@llm-tools/embedjs-loader-markdown": "^0.1.24", + "@llm-tools/embedjs-loader-msoffice": "^0.1.24", + "@llm-tools/embedjs-loader-pdf": "^0.1.24", + "@llm-tools/embedjs-loader-web": "^0.1.24", + "@llm-tools/embedjs-loader-xml": "^0.1.24", + "@llm-tools/embedjs-ollama": "^0.1.24", + "@llm-tools/embedjs-openai": "^0.1.24", + "@types/react-infinite-scroll-component": "^5.0.0", "adm-zip": "^0.5.16", + "apache-arrow": "^18.0.0", "docx": "^9.0.2", "electron-log": "^5.1.5", "electron-store": "^8.2.0", @@ -124,7 +138,8 @@ "react-dom": "^17.0.0 || ^18.0.0" }, "resolutions": { - "@electron/notarize@npm:2.2.1": "patch:@electron/notarize@npm%3A2.3.2#~/.yarn/patches/@electron-notarize-npm-2.3.2-535908a4bd.patch" + "@electron/notarize@npm:2.2.1": "patch:@electron/notarize@npm%3A2.3.2#~/.yarn/patches/@electron-notarize-npm-2.3.2-535908a4bd.patch", + "pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch" }, "packageManager": "yarn@4.5.0" } diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 90303677..8f5c82bf 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -11,6 +11,7 @@ import BackupManager from './services/BackupManager' import { configManager } from './services/ConfigManager' import { ExportService } from './services/ExportService' import FileStorage from './services/FileStorage' +import KnowledgeService from './services/KnowledgeService' import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService' import { windowService } from './services/WindowService' import { compress, decompress } from './utils/zip' @@ -144,4 +145,12 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { registerShortcuts(mainWindow) } }) + + // knowledge base + ipcMain.handle('knowledge-base:create', KnowledgeService.create) + ipcMain.handle('knowledge-base:reset', KnowledgeService.reset) + ipcMain.handle('knowledge-base:delete', KnowledgeService.delete) + ipcMain.handle('knowledge-base:add', KnowledgeService.add) + ipcMain.handle('knowledge-base:remove', KnowledgeService.remove) + ipcMain.handle('knowledge-base:search', KnowledgeService.search) } diff --git a/src/main/services/KnowledgeService.ts b/src/main/services/KnowledgeService.ts new file mode 100644 index 00000000..92e1017f --- /dev/null +++ b/src/main/services/KnowledgeService.ts @@ -0,0 +1,109 @@ +import * as fs from 'node:fs' +import path from 'node:path' + +import { RAGApplication, RAGApplicationBuilder, TextLoader } from '@llm-tools/embedjs' +import { AddLoaderReturn, ExtractChunkData } from '@llm-tools/embedjs-interfaces' +import { LanceDb } from '@llm-tools/embedjs-lancedb' +import { MarkdownLoader } from '@llm-tools/embedjs-loader-markdown' +import { DocxLoader } from '@llm-tools/embedjs-loader-msoffice' +import { PdfLoader } from '@llm-tools/embedjs-loader-pdf' +import { WebLoader } from '@llm-tools/embedjs-loader-web' +import { OpenAiEmbeddings } from '@llm-tools/embedjs-openai' +import { FileType, RagAppRequestParams } from '@types' +import { app } from 'electron' +import Logger from 'electron-log' + +class KnowledgeService { + private storageDir = path.join(app.getPath('userData'), 'Data', 'KnowledgeBase') + + constructor() { + this.initStorageDir() + } + + private initStorageDir = (): void => { + if (!fs.existsSync(this.storageDir)) { + fs.mkdirSync(this.storageDir, { recursive: true }) + } + } + + private getRagApplication = async ({ id, model, apiKey, baseURL }: RagAppRequestParams): Promise => { + Logger.log('getRagApplication', { id, model, apiKey, baseURL }) + return new RAGApplicationBuilder() + .setModel('NO_MODEL') + .setEmbeddingModel( + new OpenAiEmbeddings({ + model, + apiKey, + configuration: { baseURL }, + dimensions: 1024 + }) + ) + .setVectorDatabase(new LanceDb({ path: path.join(this.storageDir, id) })) + .build() + } + + public create = async ( + _: Electron.IpcMainInvokeEvent, + { id, model, apiKey, baseURL }: RagAppRequestParams + ): Promise => { + this.getRagApplication({ id, model, apiKey, baseURL }) + } + + public reset = async (_: Electron.IpcMainInvokeEvent, { config }: { config: RagAppRequestParams }): Promise => { + const ragApplication = await this.getRagApplication(config) + await ragApplication.reset() + } + + public delete = async (_: Electron.IpcMainInvokeEvent, id: string): Promise => { + const dbPath = path.join(this.storageDir, id) + if (fs.existsSync(dbPath)) { + fs.rmSync(dbPath, { recursive: true }) + } + } + + public add = async ( + _: Electron.IpcMainInvokeEvent, + { data, config }: { data: string | FileType; config: RagAppRequestParams } + ): Promise => { + const ragApplication = await this.getRagApplication(config) + + if (typeof data === 'string') { + if (data.startsWith('http')) { + return await ragApplication.addLoader(new WebLoader({ urlOrContent: data })) + } + return await ragApplication.addLoader(new TextLoader({ text: data })) + } + + if (data.ext === '.pdf') { + return await ragApplication.addLoader(new PdfLoader({ filePathOrUrl: data.path }) as any) + } + + if (data.ext === '.docx') { + return await ragApplication.addLoader(new DocxLoader({ filePathOrUrl: data.path }) as any) + } + + if (data.ext === '.md') { + return await ragApplication.addLoader(new MarkdownLoader({ filePathOrUrl: data.path }) as any) + } + + return { entriesAdded: 0, uniqueId: '', loaderType: '' } + } + + public remove = async ( + _: Electron.IpcMainInvokeEvent, + { uniqueId, config }: { uniqueId: string; config: RagAppRequestParams } + ): Promise => { + const ragApplication = await this.getRagApplication(config) + await ragApplication.deleteLoader(uniqueId) + } + + public search = async ( + _: Electron.IpcMainInvokeEvent, + { search, config }: { search: string; config: RagAppRequestParams } + ): Promise => { + const ragApplication = await this.getRagApplication(config) + return await ragApplication.search(search) + } +} + +export default new KnowledgeService() diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 3846ac84..d0d526ec 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -1,7 +1,8 @@ import { ElectronAPI } from '@electron-toolkit/preload' +import { AddLoaderReturn, ExtractChunkData } from '@llm-tools/embedjs-interfaces' import { FileType } from '@renderer/types' import { WebDavConfig } from '@renderer/types' -import { AppInfo, LanguageVarious } from '@renderer/types' +import { AppInfo, LanguageVarious, RagAppRequestParams } from '@renderer/types' import type { OpenDialogOptions } from 'electron' import type { UpdateInfo } from 'electron-updater' import { Readable } from 'stream' @@ -58,6 +59,14 @@ declare global { shortcuts: { update: (shortcuts: Shortcut[]) => Promise } + knowledgeBase: { + create: ({ id, model, apiKey, baseURL }: RagAppRequestParams) => Promise + reset: ({ config }: { config: RagAppRequestParams }) => Promise + delete: (id: string) => Promise + add: ({ data, config }: { data: string | FileType; config: RagAppRequestParams }) => Promise + remove: ({ uniqueId, config }: { uniqueId: string; config: RagAppRequestParams }) => Promise + search: ({ search, config }: { search: string; config: RagAppRequestParams }) => Promise + } } } } diff --git a/src/preload/index.ts b/src/preload/index.ts index c625a3ac..657dd573 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,5 +1,5 @@ import { electronAPI } from '@electron-toolkit/preload' -import { Shortcut, WebDavConfig } from '@types' +import { FileType, RagAppRequestParams, Shortcut, WebDavConfig } from '@types' import { contextBridge, ipcRenderer, OpenDialogOptions } from 'electron' // Custom APIs for renderer @@ -50,6 +50,18 @@ const api = { openPath: (path: string) => ipcRenderer.invoke('open:path', path), shortcuts: { update: (shortcuts: Shortcut[]) => ipcRenderer.invoke('shortcuts:update', shortcuts) + }, + knowledgeBase: { + create: ({ id, model, apiKey, baseURL }: RagAppRequestParams) => + ipcRenderer.invoke('knowledge-base:create', { id, model, apiKey, baseURL }), + reset: ({ config }: { config: RagAppRequestParams }) => ipcRenderer.invoke('knowledge-base:reset', { config }), + delete: (id: string) => ipcRenderer.invoke('knowledge-base:delete', id), + add: ({ data, config }: { data: string | FileType; config: RagAppRequestParams }) => + ipcRenderer.invoke('knowledge-base:add', { data, config }), + remove: ({ uniqueId, config }: { uniqueId: string; config: RagAppRequestParams }) => + ipcRenderer.invoke('knowledge-base:remove', { uniqueId, config }), + search: ({ search, config }: { search: string; config: RagAppRequestParams }) => + ipcRenderer.invoke('knowledge-base:search', { search, config }) } } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 6ac062ec..11f15171 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -14,6 +14,7 @@ import AgentsPage from './pages/agents/AgentsPage' import AppsPage from './pages/apps/AppsPage' import FilesPage from './pages/files/FilesPage' import HomePage from './pages/home/HomePage' +import KnowledgePage from './pages/knowledge/KnowledgePage' import PaintingsPage from './pages/paintings/PaintingsPage' import SettingsPage from './pages/settings/SettingsPage' import TranslatePage from './pages/translate/TranslatePage' @@ -30,10 +31,11 @@ function App(): JSX.Element { } /> - } /> } /> } /> } /> + } /> + } /> } /> } /> diff --git a/src/renderer/src/components/ListItem/index.tsx b/src/renderer/src/components/ListItem/index.tsx new file mode 100644 index 00000000..e4e31a24 --- /dev/null +++ b/src/renderer/src/components/ListItem/index.tsx @@ -0,0 +1,52 @@ +import { ReactNode } from 'react' +import styled from 'styled-components' + +interface ListItemProps { + active?: boolean + icon?: ReactNode + title: string + onClick?: () => void +} + +const ListItem = ({ active, icon, title, onClick }: ListItemProps) => { + return ( + + + {icon && {icon}} + {title} + + + ) +} + +const ListItemContainer = styled.div` + padding: 7px 12px; + border-radius: 16px; + font-size: 13px; + display: flex; + flex-direction: column; + justify-content: space-between; + position: relative; + font-family: Ubuntu; + cursor: pointer; + border: 1px solid transparent; + + &:hover { + background-color: var(--color-background-soft); + } + + &.active { + background-color: var(--color-background-soft); + border: 1px solid var(--color-border-soft); + } +` + +const ListItemContent = styled.div` + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; + font-size: 13px; +` + +export default ListItem diff --git a/src/renderer/src/components/app/Sidebar.tsx b/src/renderer/src/components/app/Sidebar.tsx index 8b2a7674..709cd812 100644 --- a/src/renderer/src/components/app/Sidebar.tsx +++ b/src/renderer/src/components/app/Sidebar.tsx @@ -1,4 +1,4 @@ -import { FolderOutlined, PictureOutlined, TranslationOutlined } from '@ant-design/icons' +import { BookOutlined, FolderOutlined, PictureOutlined, TranslationOutlined } from '@ant-design/icons' import { isMac } from '@renderer/config/constant' import { isLocalAi, UserAvatar } from '@renderer/config/env' import { useTheme } from '@renderer/context/ThemeProvider' @@ -88,6 +88,13 @@ const Sidebar: FC = () => { )} + + to('/knowledge')}> + + + + + {showFilesIcon && ( to('/files')}> diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index 7adef97d..46882134 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -153,7 +153,7 @@ export const VISION_REGEX = new RegExp( const TEXT_TO_IMAGE_REGEX = /flux|diffusion|stabilityai|sd-|dall|cogview/i const EMBEDDING_REGEX = /(?:^text-|embed|rerank|davinci|babbage|bge-|base|retrieval|uae-)/i -const NOT_SUPPORTED_REGEX = /(?:^text-|embed|tts|rerank|whisper|speech|davinci|babbage|bge-|base|retrieval|uae-)/i +const NOT_SUPPORTED_REGEX = /(?:^tts|rerank|whisper|speech)/i export function getModelLogo(modelId: string) { const isLight = true diff --git a/src/renderer/src/context/AntdProvider.tsx b/src/renderer/src/context/AntdProvider.tsx index e571ba0d..20cc5b24 100644 --- a/src/renderer/src/context/AntdProvider.tsx +++ b/src/renderer/src/context/AntdProvider.tsx @@ -31,6 +31,13 @@ const AntdProvider: FC = ({ children }) => { Menu: { activeBarBorderWidth: 0, darkItemBg: 'transparent' + }, + Button: { + boxShadow: 'none', + boxShadowSecondary: 'none', + defaultShadow: 'none', + dangerShadow: 'none', + primaryShadow: 'none' } }, token: { diff --git a/src/renderer/src/databases/index.ts b/src/renderer/src/databases/index.ts index 1714d19b..cf90000f 100644 --- a/src/renderer/src/databases/index.ts +++ b/src/renderer/src/databases/index.ts @@ -1,4 +1,4 @@ -import { FileType, Topic } from '@renderer/types' +import { FileType, KnowledgeItem, Topic } from '@renderer/types' import { Dexie, type EntityTable } from 'dexie' // Database declaration (move this to its own module also) @@ -6,6 +6,7 @@ export const db = new Dexie('CherryStudio') as Dexie & { files: EntityTable topics: EntityTable, 'id'> settings: EntityTable<{ id: string; value: any }, 'id'> + knowledge_notes: EntityTable } db.version(1).stores({ @@ -18,4 +19,11 @@ db.version(2).stores({ settings: '&id, value' }) +db.version(3).stores({ + files: 'id, name, origin_name, path, size, ext, type, created_at, count', + topics: '&id, messages', + settings: '&id, value', + knowledge_notes: '&id, baseId, type, content, created_at, updated_at' +}) + export default db diff --git a/src/renderer/src/hooks/useAppInit.ts b/src/renderer/src/hooks/useAppInit.ts index 3a803f0a..c074db1b 100644 --- a/src/renderer/src/hooks/useAppInit.ts +++ b/src/renderer/src/hooks/useAppInit.ts @@ -73,4 +73,8 @@ export function useAppInit() { dispatch(setFilesPath(info.filesPath)) }) }, [dispatch]) + + useEffect(() => { + import('@renderer/queue/KnowledgeQueue') + }, []) } diff --git a/src/renderer/src/hooks/useknowledge.ts b/src/renderer/src/hooks/useknowledge.ts new file mode 100644 index 00000000..a5f44703 --- /dev/null +++ b/src/renderer/src/hooks/useknowledge.ts @@ -0,0 +1,247 @@ +/* eslint-disable react-hooks/rules-of-hooks */ +import { db } from '@renderer/databases/index' +import { RootState, useAppSelector } from '@renderer/store' +import { + addItem, + addProcessingItem, + clearAllItems, + clearCompletedItems, + removeItem as removeItemAction, + removeProcessingItem, + renameBase, + selectProcessingItemBySource, + selectProcessingItemsByType, + updateBase, + updateFiles as updateFilesAction, + updateNotes, + updateProcessingStatus +} from '@renderer/store/knowledge' +import { FileType, KnowledgeBase, ProcessingStatus } from '@renderer/types' +import { KnowledgeItem } from '@renderer/types' +import { runAsyncFunction } from '@renderer/utils' +import { useEffect, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { v4 as uuidv4 } from 'uuid' + +export const useKnowledge = (baseId: string) => { + const dispatch = useDispatch() + const base = useSelector((state: RootState) => state.knowledge.bases.find((b) => b.id === baseId)) + const knowledgeState = useAppSelector((state: RootState) => state.knowledge) + + // 重命名知识库 + const renameKnowledgeBase = (name: string) => { + dispatch(renameBase({ baseId, name })) + } + + // 更新知识库 + const updateKnowledgeBase = (base: KnowledgeBase) => { + dispatch(updateBase(base)) + } + + // 添加文件列表 + const addFiles = (files: FileType[]) => { + for (const file of files) { + const newItem = { + id: uuidv4(), + type: 'file' as const, + content: file, + created_at: Date.now(), + updated_at: Date.now() + } + dispatch(addItem({ baseId, item: newItem })) + } + } + + // 添加URL + const addUrl = (url: string) => { + const newUrlItem = { + id: uuidv4(), + type: 'url' as const, + content: url, + created_at: Date.now(), + updated_at: Date.now() + } + dispatch(addItem({ baseId, item: newUrlItem })) + } + + // 添加笔记 + const addNote = async (content: string) => { + const noteId = uuidv4() + const note: KnowledgeItem = { + id: noteId, + type: 'note', + content, + created_at: Date.now(), + updated_at: Date.now() + } + + // 存储完整笔记到数据库 + await db.knowledge_notes.add(note) + + // 在 store 中只存储引用 + const noteRef: KnowledgeItem = { + id: noteId, + baseId, + type: 'note', + content: '', // store中不需要存储实际内容 + created_at: Date.now(), + updated_at: Date.now() + } + + dispatch(updateNotes({ baseId, item: noteRef })) + } + + // 更新文件列表 + const updateFiles = (files: FileType[]) => { + const newItems = files.map((file) => ({ + id: uuidv4(), + type: 'file' as const, + content: file, + created_at: Date.now(), + updated_at: Date.now() + })) + dispatch(updateFilesAction({ baseId, items: newItems })) + } + + // 更新笔记内容 + const updateNoteContent = async (noteId: string, content: string) => { + const note = await db.knowledge_notes.get(noteId) + if (note) { + const updatedNote = { + ...note, + content, + updated_at: Date.now() + } + await db.knowledge_notes.put(updatedNote) + dispatch(updateNotes({ baseId, item: updatedNote })) + } + } + + // 获取笔记内容 + const getNoteContent = async (noteId: string) => { + return await db.knowledge_notes.get(noteId) + } + + // 移除项目 + const removeItem = (item: KnowledgeItem) => { + dispatch(removeItemAction({ baseId, item })) + } + + // 添加文件到处理队列 + const addFileToQueue = (itemId: string) => { + dispatch( + addProcessingItem({ + baseId, + 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) => { + dispatch( + updateProcessingStatus({ + baseId, + itemId, + status, + progress, + error + }) + ) + } + + // 获取特定源的处理状态 + const getProcessingStatus = (sourceId: string) => { + return selectProcessingItemBySource(knowledgeState, baseId, sourceId) + } + + // 获取特定类型的所有处理项 + const getProcessingItemsByType = (type: 'file' | 'url' | 'note') => { + return selectProcessingItemsByType(knowledgeState, baseId, type) + } + + // 从队列中移除项目 + const removeFromQueue = (itemId: string) => { + dispatch( + removeProcessingItem({ + baseId, + itemId + }) + ) + } + + // 清除已完成的项目 + const clearCompleted = () => { + dispatch(clearCompletedItems({ baseId })) + } + + // 清除所有队列项目 + const clearAll = () => { + dispatch(clearAllItems({ baseId })) + } + + const fileItems = base?.items.filter((item) => item.type === 'file') || [] + const urlItems = base?.items.filter((item) => item.type === 'url') || [] + const [noteItems, setNoteItems] = useState([]) + + useEffect(() => { + const notes = base?.items.filter((item) => item.type === 'note') || [] + runAsyncFunction(async () => { + const newNoteItems = await Promise.all( + notes.map(async (item) => { + const note = await db.knowledge_notes.get(item.id) + return { ...item, content: note?.content || '' } + }) + ) + setNoteItems(newNoteItems.filter((note) => note !== undefined) as KnowledgeItem[]) + }) + }, [base?.items]) + + return { + base, + fileItems, + urlItems, + noteItems, + renameKnowledgeBase, + updateKnowledgeBase, + addFiles, + addUrl, + addNote, + updateFiles, + updateNoteContent, + getNoteContent, + addFileToQueue, + addUrlToQueue, + addNoteToQueue, + updateItemStatus, + getProcessingStatus, + getProcessingItemsByType, + removeFromQueue, + clearCompleted, + clearAll, + removeItem + } +} diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 37573b11..a76f7a29 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -81,6 +81,7 @@ "input.translate": "Translate to English", "input.upload": "Upload image or document file", "input.web_search": "Enable web search", + "input.knowledge_base": "Knowledge Base", "message.new.branch": "New Branch", "message.new.branch.created": "New Branch Created", "message.regenerate.model": "Switch Model", @@ -390,7 +391,7 @@ "messages.input.paste_long_text_as_file": "Paste long text as file", "messages.input.send_shortcuts": "Send shortcuts", "messages.input.show_estimated_tokens": "Show estimated tokens", - "messages.metrics": "{{time_first_token_millsec}}ms to first token • {{token_speed}} tok/sec • ", + "messages.metrics": "{{time_first_token_millsec}}ms to first token | {{token_speed}} tok/sec", "messages.input.title": "Input Settings", "messages.markdown_rendering_input_message": "Markdown render input msg", "messages.math_engine": "Math render engine", @@ -522,7 +523,43 @@ }, "words": { "knowledgeGraph": "Knowledge Graph", - "visualization": "Visualization" + "visualization": "Visualization", + "show_window": "Show Window", + "quit": "Quit" + }, + "knowledge_base": { + "title": "Knowledge Base", + "search": "Search knowledge base", + "empty": "No knowledge base found", + "drag_file": "Drag file here", + "file_hint": "Support pdf, docx, txt and md", + "add": { + "title": "Add Knowledge Base" + }, + "notes": "Notes", + "notes_placeholder": "Enter additional information or context for this knowledge base...", + "delete": "Delete", + "rename": "Rename", + "urls": "URLs", + "add_url": "Add URL", + "url_placeholder": "Enter URL", + "invalid_url": "Invalid URL", + "add_file": "Add File", + "status": "Status", + "index_all": "Index All", + "index_started": "Indexing started", + "cancel_index": "Cancel Indexing", + "index_cancelled": "Indexing cancelled", + "status_pending": "Pending", + "status_processing": "Processing", + "status_completed": "Completed", + "status_failed": "Failed", + "url_added": "URL added", + "query": "Query", + "search_placeholder": "Enter text to search", + "add_note": "Add Note", + "no_bases": "No knowledge bases available", + "clear_selection": "Clear selection" } } } diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 781a29ed..dab0141a 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -81,6 +81,7 @@ "input.translate": "Перевести на английский", "input.upload": "Загрузить изображение или документ", "input.web_search": "Включить веб-поиск", + "input.knowledge_base": "База знаний", "message.new.branch": "Новая ветка", "message.new.branch.created": "Новая ветка создана", "message.regenerate.model": "Переключить модель", @@ -390,6 +391,7 @@ "messages.input.paste_long_text_as_file": "Вставлять длинный текст как файл", "messages.input.send_shortcuts": "Горячие клавиши для отправки", "messages.input.show_estimated_tokens": "Показывать затраты токенов", + "messages.metrics": "{{time_first_token_millsec}}ms до первого токена | {{token_speed}} tok/sec", "messages.input.title": "Настройки ввода", "messages.markdown_rendering_input_message": "Отображение ввода в формате Markdown", "messages.math_engine": "Математический движок", @@ -521,7 +523,43 @@ }, "words": { "knowledgeGraph": "Граф знаний", - "visualization": "Визуализация" + "visualization": "Визуализация", + "show_window": "Показать окно", + "quit": "Выйти" + }, + "knowledge_base": { + "title": "База знаний", + "search": "Поиск в базе знаний", + "empty": "База знаний не найдена", + "drag_file": "Перетащите файл сюда", + "file_hint": "Поддерживаются pdf, docx, txt и md", + "add": { + "title": "Добавить базу знаний" + }, + "notes": "Заметки", + "notes_placeholder": "Введите дополнительную информацию или контекст для этой базы знаний...", + "delete": "Удалить", + "rename": "Переименовать", + "urls": "URL-адреса", + "add_url": "Добавить URL", + "url_placeholder": "Введите URL", + "invalid_url": "Неверный URL", + "add_file": "Добавить файл", + "status": "Статус", + "index_all": "Индексировать все", + "index_started": "Индексирование началось", + "cancel_index": "Отменить индексирование", + "index_cancelled": "Индексирование отменено", + "status_pending": "Ожидание", + "status_processing": "Обработка", + "status_completed": "Завершено", + "status_failed": "Ошибка", + "url_added": "URL добавлен", + "query": "Поиск", + "search_placeholder": "Введите текст для поиска", + "add_note": "Добавить запись", + "no_bases": "База знаний не найдена", + "clear_selection": "Очистить выбор" } } } diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index c3c7fbdf..7813cb5d 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -81,6 +81,7 @@ "input.translate": "翻译成英文", "input.upload": "上传图片或文档", "input.web_search": "开启网络搜索", + "input.knowledge_base": "知识库", "message.new.branch": "新分支", "message.new.branch.created": "新分支已创建", "message.regenerate.model": "切换模型", @@ -148,7 +149,8 @@ "warning": "警告", "you": "用户", "clear": "清除", - "add": "添加" + "add": "添加", + "footnotes": "引用内容" }, "error": { "backup.file_format": "备份文件格式错误", @@ -390,7 +392,7 @@ "messages.input.paste_long_text_as_file": "长文本粘贴为文件", "messages.input.send_shortcuts": "发送快捷键", "messages.input.show_estimated_tokens": "显示预估 Token 数", - "messages.metrics": "首字时延 {{time_first_token_millsec}}ms • 每秒 {{token_speed}} token • ", + "messages.metrics": "首字时延 {{time_first_token_millsec}}ms | 每秒 {{token_speed}} tokens", "messages.input.title": "输入设置", "messages.markdown_rendering_input_message": "Markdown 渲染输入消息", "messages.math_engine": "数学公式引擎", @@ -510,7 +512,43 @@ }, "words": { "knowledgeGraph": "知识图谱", - "visualization": "可视化" + "visualization": "可视化", + "show_window": "显示窗口", + "quit": "退出" + }, + "knowledge_base": { + "title": "知识库", + "search": "搜索知识库", + "empty": "暂无知识库", + "drag_file": "拖拽文件到这里", + "file_hint": "支持 pdf, docx, txt, md 格式", + "add": { + "title": "添加知识库" + }, + "notes": "笔记", + "notes_placeholder": "输入此知识库的附加信息或上下文...", + "delete": "删除", + "rename": "重命名", + "urls": "网站", + "add_url": "添加网址", + "url_placeholder": "请输入网址", + "invalid_url": "无效的网址", + "add_file": "添加文件", + "status": "状态", + "index_all": "索引全部", + "index_started": "索引开始", + "cancel_index": "取消索引", + "index_cancelled": "索引已取消", + "status_pending": "等待中", + "status_processing": "处理中", + "status_completed": "已完成", + "status_failed": "失败", + "url_added": "网址已添加", + "query": "查询", + "search_placeholder": "输入查询内容", + "add_note": "添加笔记", + "no_bases": "暂无知识库", + "clear_selection": "清除选择" } } } diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 097b267e..2ead2970 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -81,6 +81,7 @@ "input.translate": "翻譯成英文", "input.upload": "上傳圖片或文檔", "input.web_search": "開啟網路搜索", + "input.knowledge_base": "知識庫", "message.new.branch": "新分支", "message.new.branch.created": "新分支已建立", "message.regenerate.model": "切換模型", @@ -130,7 +131,6 @@ "download": "下載", "duplicate": "複製", "edit": "編輯", - "footnotes": "引用", "language": "語言", "model": "模型", "models": "模型", @@ -148,7 +148,8 @@ "warning": "警告", "you": "您", "clear": "清除", - "add": "添加" + "add": "添加", + "footnotes": "引用" }, "error": { "backup.file_format": "備份文件格式錯誤", @@ -390,6 +391,7 @@ "messages.input.paste_long_text_as_file": "將長文本貼上為檔案", "messages.input.send_shortcuts": "發送快捷鍵", "messages.input.show_estimated_tokens": "顯示預估 Token 數", + "messages.metrics": "首字時延 {{time_first_token_millsec}}ms | 每秒 {{token_speed}} tokens", "messages.input.title": "輸入設定", "messages.math_engine": "Markdown 渲染輸入訊息", "messages.math_render_engine": "數學公式引擎", @@ -509,7 +511,43 @@ }, "words": { "knowledgeGraph": "知識圖譜", - "visualization": "可視化" + "visualization": "可視化", + "show_window": "顯示視窗", + "quit": "退出" + }, + "knowledge_base": { + "title": "知識庫", + "search": "搜尋知識庫", + "empty": "暫無知識庫", + "drag_file": "拖拽文件到這裡", + "file_hint": "支持 pdf, docx, txt, md 格式", + "add": { + "title": "添加知識庫" + }, + "notes": "筆記", + "notes_placeholder": "輸入此知識庫的附加資訊或上下文...", + "delete": "刪除", + "rename": "重命名", + "urls": "網址", + "add_url": "添加網址", + "url_placeholder": "請輸入網址", + "invalid_url": "無效的網址", + "add_file": "添加文件", + "status": "狀態", + "index_all": "索引全部", + "index_started": "索引開始", + "cancel_index": "取消索引", + "index_cancelled": "索引已取消", + "status_pending": "等待中", + "status_processing": "處理中", + "status_completed": "已完成", + "status_failed": "失敗", + "url_added": "網址已添加", + "query": "查詢", + "search_placeholder": "輸入查詢內容", + "add_note": "添加筆記", + "no_bases": "暫無知識庫", + "clear_selection": "清除選擇" } } } diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index 56a85255..c7dee77b 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -25,7 +25,7 @@ import { estimateTextTokens as estimateTxtTokens } from '@renderer/services/Toke import { translateText } from '@renderer/services/TranslateService' import store, { useAppDispatch, useAppSelector } from '@renderer/store' import { setGenerating, setSearching } from '@renderer/store/runtime' -import { Assistant, FileType, Message, Topic } from '@renderer/types' +import { Assistant, FileType, KnowledgeBase, Message, Topic } from '@renderer/types' import { delay, getFileExtension, uuid } from '@renderer/utils' import { documentExts, imageExts, textExts } from '@shared/config/constant' import { Button, Popconfirm, Tooltip } from 'antd' @@ -38,6 +38,7 @@ import styled from 'styled-components' import AttachmentButton from './AttachmentButton' import AttachmentPreview from './AttachmentPreview' +import KnowledgeBaseButton from './KnowledgeBaseButton' import SendMessageButton from './SendMessageButton' import TokenCount from './TokenCount' @@ -48,6 +49,7 @@ interface Props { let _text = '' let _files: FileType[] = [] +let _base: KnowledgeBase const Inputbar: FC = ({ assistant: _assistant, setActiveTopic }) => { const [text, setText] = useState(_text) @@ -78,6 +80,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic }) => { const [spaceClickCount, setSpaceClickCount] = useState(0) const spaceClickTimer = useRef() const [isTranslating, setIsTranslating] = useState(false) + const [selectedKnowledgeBase, setSelectedKnowledgeBase] = useState(_base) const isVision = useMemo(() => isVisionModel(model), [model]) const supportExts = useMemo(() => [...textExts, ...documentExts, ...(isVision ? imageExts : [])], [isVision]) @@ -90,6 +93,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic }) => { _text = text _files = files + _base = selectedKnowledgeBase const sendMessage = useCallback(async () => { if (generating) { @@ -111,6 +115,10 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic }) => { status: 'success' } + if (selectedKnowledgeBase) { + message.knowledgeBaseIds = [selectedKnowledgeBase.id] + } + if (files.length > 0) { message.files = await FileManager.uploadFiles(files) } @@ -123,7 +131,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic }) => { setTimeout(() => resizeTextArea(), 0) setExpend(false) - }, [assistant.id, assistant.topics, generating, files, text]) + }, [assistant.id, assistant.topics, generating, files, text, selectedKnowledgeBase]) const translate = async () => { if (isTranslating) { @@ -374,6 +382,10 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic }) => { const textareaRows = window.innerHeight >= 1000 || isBubbleStyle ? 2 : 1 + const handleKnowledgeBaseSelect = (base?: KnowledgeBase) => { + setSelectedKnowledgeBase(base) + } + return ( @@ -438,6 +450,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic }) => { + diff --git a/src/renderer/src/pages/home/Inputbar/KnowledgeBaseButton.tsx b/src/renderer/src/pages/home/Inputbar/KnowledgeBaseButton.tsx new file mode 100644 index 00000000..76865717 --- /dev/null +++ b/src/renderer/src/pages/home/Inputbar/KnowledgeBaseButton.tsx @@ -0,0 +1,117 @@ +import { BookOutlined } from '@ant-design/icons' +import { useAppSelector } from '@renderer/store' +import { KnowledgeBase } from '@renderer/types' +import { Button, Popover, Tooltip } from 'antd' +import { FC } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +interface Props { + selectedBase?: KnowledgeBase + onSelect: (base?: KnowledgeBase) => void +} + +const KnowledgeBaseSelector: FC = ({ selectedBase, onSelect }) => { + const { t } = useTranslation() + const knowledgeState = useAppSelector((state) => state.knowledge) + + return ( + + {knowledgeState.bases.length === 0 ? ( + {t('knowledge.no_bases')} + ) : ( + <> + {selectedBase && ( + + )} + {knowledgeState.bases.map((base) => ( + + ))} + + )} + + ) +} + +const KnowledgeBaseButton: FC = ({ selectedBase, onSelect }) => { + const { t } = useTranslation() + + if (selectedBase) { + return ( + + onSelect(undefined)}> + + + + ) + } + + return ( + + } + trigger="click"> + selectedBase && onSelect(undefined)}> + + + + + ) +} + +const SelectorContainer = styled.div` + max-height: 300px; + overflow-y: auto; +` + +const EmptyMessage = styled.div` + 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 diff --git a/src/renderer/src/pages/home/Messages/MessageTokens.tsx b/src/renderer/src/pages/home/Messages/MessageTokens.tsx index 7a046b83..030175fe 100644 --- a/src/renderer/src/pages/home/Messages/MessageTokens.tsx +++ b/src/renderer/src/pages/home/Messages/MessageTokens.tsx @@ -29,6 +29,7 @@ const MessgeTokens: React.FC<{ message: Message; isLastMessage: boolean }> = ({ if (message.role === 'assistant') { let metrixs = '' + if (message?.metrics?.completion_tokens && message?.metrics?.time_completion_millsec) { metrixs = t('settings.messages.metrics', { time_first_token_millsec: message?.metrics?.time_first_token_millsec, @@ -37,10 +38,13 @@ const MessgeTokens: React.FC<{ message: Message; isLastMessage: boolean }> = ({ ) }) } + return ( - {metrixs !== '' ? metrixs : ''} - Tokens: {message?.usage?.total_tokens} ↑ {message?.usage?.prompt_tokens} ↓ {message?.usage?.completion_tokens} + {metrixs} + + Tokens: {message?.usage?.total_tokens} ↑{message?.usage?.prompt_tokens} ↓{message?.usage?.completion_tokens} + ) } @@ -54,6 +58,25 @@ const MessageMetadata = styled.div` user-select: text; margin: 2px 0; cursor: pointer; + text-align: right; + + .metrics { + display: none; + } + + .tokens { + display: block; + } + + &:hover { + .metrics { + display: block; + } + + .tokens { + display: none; + } + } ` export default MessgeTokens diff --git a/src/renderer/src/pages/knowledge/AddKnowledgePopup.tsx b/src/renderer/src/pages/knowledge/AddKnowledgePopup.tsx new file mode 100644 index 00000000..142bea6c --- /dev/null +++ b/src/renderer/src/pages/knowledge/AddKnowledgePopup.tsx @@ -0,0 +1,128 @@ +import { TopView } from '@renderer/components/TopView' +import { isEmbeddingModel } from '@renderer/config/models' +import { useProviders } from '@renderer/hooks/useProvider' +import { getRagAppRequestParams } from '@renderer/services/KnowledgeService' +import { getModelUniqId } from '@renderer/services/ModelService' +import { addBase } from '@renderer/store/knowledge' +import { Model } from '@renderer/types' +import { Form, Input, Modal, Select } from 'antd' +import { find, sortBy } from 'lodash' +import { nanoid } from 'nanoid' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' + +interface ShowParams { + title: string +} + +interface FormData { + name: string + model: string +} + +interface Props extends ShowParams { + resolve: (data: any) => void +} + +const PopupContainer: React.FC = ({ title, resolve }) => { + const [open, setOpen] = useState(true) + const [form] = Form.useForm() + const { t } = useTranslation() + const dispatch = useDispatch() + const { providers } = useProviders() + const allModels = providers + .map((p) => p.models) + .flat() + .filter((model) => isEmbeddingModel(model)) + + const selectOptions = providers + .filter((p) => p.models.length > 0) + .map((p) => ({ + label: p.isSystem ? t(`provider.${p.id}`) : p.name, + title: p.name, + options: sortBy(p.models, 'name') + .filter((model) => isEmbeddingModel(model)) + .map((m) => ({ + label: m.name, + value: getModelUniqId(m) + })) + })) + .filter((group) => group.options.length > 0) + + const onOk = async () => { + try { + const values = await form.validateFields() + const selectedModel = find(allModels, JSON.parse(values.model)) as Model + + if (selectedModel) { + const newBase = { + id: nanoid(), + name: values.name, + model: selectedModel, + items: [], + processingQueue: [], + created_at: Date.now(), + updated_at: Date.now() + } + + await window.api.knowledgeBase.create(getRagAppRequestParams(newBase)) + + dispatch(addBase(newBase as any)) + setOpen(false) + resolve(newBase) + } + } catch (error) { + console.error('Validation failed:', error) + } + } + + const onCancel = () => { + setOpen(false) + } + + const onClose = () => { + resolve(null) + } + + return ( + +
+ + + + + +