fix: knowledge bugs

This commit is contained in:
kangfenmao 2024-12-23 10:48:40 +08:00
parent 8f11d2b1c9
commit 35fd5aef22
40 changed files with 1265 additions and 596 deletions

View File

@ -41,6 +41,20 @@ jobs:
node-version: 20 node-version: 20
arch: ${{ matrix.arch }} arch: ${{ matrix.arch }}
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT
- name: Cache yarn dependencies
uses: actions/cache@v3
with:
path: |
${{ steps.yarn-cache-dir-path.outputs.dir }}
node_modules
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install corepack - name: Install corepack
run: corepack enable && corepack prepare yarn@4.3.1 --activate run: corepack enable && corepack prepare yarn@4.3.1 --activate

1
.gitignore vendored
View File

@ -36,6 +36,7 @@ node_modules
dist dist
out out
build/icons build/icons
stats.html
# ENV # ENV
.env .env

View File

@ -11,6 +11,16 @@ files:
- '!src' - '!src'
- '!scripts' - '!scripts'
- '!local' - '!local'
- '!docs'
- '!packages'
- '!stats.html'
- '!*.md'
- '!node_modules/rollup-plugin-visualizer'
- '!node_modules/js-tiktoken'
- '!node_modules/node_modules/pdf-parse/lib/pdf.js/v1.9.426'
- '!node_modules/node_modules/pdf-parse/lib/pdf.js/v1.10.88'
- '!node_modules/node_modules/pdf-parse/lib/pdf.js/v2.0.550'
asarUnpack: asarUnpack:
- resources/** - resources/**
- '**/*.{node,dll,metal,exp,lib}' - '**/*.{node,dll,metal,exp,lib}'

View File

@ -1,6 +1,11 @@
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite' import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import { resolve } from 'path' import { resolve } from 'path'
import { visualizer } from 'rollup-plugin-visualizer'
const visualizerPlugin = (type: 'renderer' | 'main') => {
return process.env[`VISUALIZER_${type.toUpperCase()}`] ? [visualizer({ open: true })] : []
}
export default defineConfig({ export default defineConfig({
main: { main: {
@ -8,8 +13,6 @@ export default defineConfig({
externalizeDepsPlugin({ externalizeDepsPlugin({
exclude: [ exclude: [
'@llm-tools/embedjs', '@llm-tools/embedjs',
'@llm-tools/embedjs-lancedb',
'@llm-tools/embedjs-ollama',
'@llm-tools/embedjs-openai', '@llm-tools/embedjs-openai',
'@llm-tools/embedjs-loader-web', '@llm-tools/embedjs-loader-web',
'@llm-tools/embedjs-loader-markdown', '@llm-tools/embedjs-loader-markdown',
@ -17,9 +20,10 @@ export default defineConfig({
'@llm-tools/embedjs-loader-xml', '@llm-tools/embedjs-loader-xml',
'@llm-tools/embedjs-loader-pdf', '@llm-tools/embedjs-loader-pdf',
'@llm-tools/embedjs-loader-sitemap', '@llm-tools/embedjs-loader-sitemap',
'@lancedb/lancedb' '@llm-tools/embedjs-libsql'
] ]
}) }),
...visualizerPlugin('main')
], ],
resolve: { resolve: {
alias: { alias: {
@ -30,7 +34,7 @@ export default defineConfig({
}, },
build: { build: {
rollupOptions: { rollupOptions: {
external: ['@lancedb/lancedb'] external: ['@libsql/client']
} }
} }
}, },
@ -38,7 +42,7 @@ export default defineConfig({
plugins: [externalizeDepsPlugin()] plugins: [externalizeDepsPlugin()]
}, },
renderer: { renderer: {
plugins: [react()], plugins: [react(), ...visualizerPlugin('renderer')],
resolve: { resolve: {
alias: { alias: {
'@renderer': resolve('src/renderer/src'), '@renderer': resolve('src/renderer/src'),

View File

@ -25,6 +25,8 @@
"typecheck": "npm run typecheck:node && npm run typecheck:web", "typecheck": "npm run typecheck:node && npm run typecheck:web",
"start": "electron-vite preview", "start": "electron-vite preview",
"dev": "electron-vite dev", "dev": "electron-vite dev",
"analyze:renderer": "VISUALIZER_RENDERER=true yarn build",
"analyze:main": "VISUALIZER_MAIN=true yarn build",
"build": "npm run typecheck && electron-vite build", "build": "npm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps", "postinstall": "electron-builder install-app-deps",
"build:unpack": "dotenv npm run build && electron-builder --dir", "build:unpack": "dotenv npm run build && electron-builder --dir",
@ -46,17 +48,16 @@
"@electron-toolkit/preload": "^3.0.0", "@electron-toolkit/preload": "^3.0.0",
"@electron-toolkit/utils": "^3.0.0", "@electron-toolkit/utils": "^3.0.0",
"@electron/notarize": "^2.5.0", "@electron/notarize": "^2.5.0",
"@llm-tools/embedjs": "^0.1.24", "@llm-tools/embedjs": "^0.1.25",
"@llm-tools/embedjs-lancedb": "^0.1.24", "@llm-tools/embedjs-libsql": "^0.1.25",
"@llm-tools/embedjs-loader-csv": "^0.1.24", "@llm-tools/embedjs-loader-csv": "^0.1.25",
"@llm-tools/embedjs-loader-markdown": "^0.1.24", "@llm-tools/embedjs-loader-markdown": "^0.1.25",
"@llm-tools/embedjs-loader-msoffice": "^0.1.24", "@llm-tools/embedjs-loader-msoffice": "^0.1.25",
"@llm-tools/embedjs-loader-pdf": "^0.1.24", "@llm-tools/embedjs-loader-pdf": "^0.1.25",
"@llm-tools/embedjs-loader-sitemap": "^0.1.24", "@llm-tools/embedjs-loader-sitemap": "^0.1.25",
"@llm-tools/embedjs-loader-web": "^0.1.24", "@llm-tools/embedjs-loader-web": "^0.1.25",
"@llm-tools/embedjs-loader-xml": "^0.1.24", "@llm-tools/embedjs-loader-xml": "^0.1.25",
"@llm-tools/embedjs-ollama": "^0.1.24", "@llm-tools/embedjs-openai": "^0.1.25",
"@llm-tools/embedjs-openai": "^0.1.24",
"@types/react-infinite-scroll-component": "^5.0.0", "@types/react-infinite-scroll-component": "^5.0.0",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"apache-arrow": "^18.0.0", "apache-arrow": "^18.0.0",
@ -69,6 +70,7 @@
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"officeparser": "^4.1.1", "officeparser": "^4.1.1",
"tokenx": "^0.4.1",
"webdav": "4.11.4" "webdav": "4.11.4"
}, },
"devDependencies": { "devDependencies": {
@ -109,7 +111,6 @@
"eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-unused-imports": "^4.0.0", "eslint-plugin-unused-imports": "^4.0.0",
"gpt-tokens": "^1.3.10",
"i18next": "^23.11.5", "i18next": "^23.11.5",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"mime": "^4.0.4", "mime": "^4.0.4",
@ -132,6 +133,7 @@
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.0", "remark-gfm": "^4.0.0",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"rollup-plugin-visualizer": "^5.12.0",
"sass": "^1.77.2", "sass": "^1.77.2",
"shiki": "^1.22.2", "shiki": "^1.22.2",
"styled-components": "^6.1.11", "styled-components": "^6.1.11",

View File

@ -101,6 +101,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
// file // file
ipcMain.handle('file:open', fileManager.open) ipcMain.handle('file:open', fileManager.open)
ipcMain.handle('file:openPath', fileManager.openPath)
ipcMain.handle('file:save', fileManager.save) ipcMain.handle('file:save', fileManager.save)
ipcMain.handle('file:select', fileManager.selectFile) ipcMain.handle('file:select', fileManager.selectFile)
ipcMain.handle('file:upload', fileManager.uploadFile) ipcMain.handle('file:upload', fileManager.uploadFile)

View File

@ -8,7 +8,8 @@ import {
OpenDialogOptions, OpenDialogOptions,
OpenDialogReturnValue, OpenDialogReturnValue,
SaveDialogOptions, SaveDialogOptions,
SaveDialogReturnValue SaveDialogReturnValue,
shell
} from 'electron' } from 'electron'
import logger from 'electron-log' import logger from 'electron-log'
import * as fs from 'fs' import * as fs from 'fs'
@ -300,6 +301,10 @@ class FileStorage {
} }
} }
public openPath = async (_: Electron.IpcMainInvokeEvent, path: string): Promise<void> => {
shell.openPath(path).catch((err) => logger.error('[IPC - Error] Failed to open file:', err))
}
public save = async ( public save = async (
_: Electron.IpcMainInvokeEvent, _: Electron.IpcMainInvokeEvent,
fileName: string, fileName: string,

View File

@ -1,11 +1,11 @@
import * as fs from 'node:fs' import * as fs from 'node:fs'
import path from 'node:path' import path from 'node:path'
import { RAGApplication, RAGApplicationBuilder, TextLoader } from '@llm-tools/embedjs' import { LocalPathLoader, RAGApplication, RAGApplicationBuilder, TextLoader } from '@llm-tools/embedjs'
import { AddLoaderReturn, ExtractChunkData } from '@llm-tools/embedjs-interfaces' import { AddLoaderReturn, ExtractChunkData } from '@llm-tools/embedjs-interfaces'
import { LanceDb } from '@llm-tools/embedjs-lancedb' import { LibSqlDb } from '@llm-tools/embedjs-libsql'
import { MarkdownLoader } from '@llm-tools/embedjs-loader-markdown' import { MarkdownLoader } from '@llm-tools/embedjs-loader-markdown'
import { DocxLoader } from '@llm-tools/embedjs-loader-msoffice' import { DocxLoader, ExcelLoader, PptLoader } from '@llm-tools/embedjs-loader-msoffice'
import { PdfLoader } from '@llm-tools/embedjs-loader-pdf' import { PdfLoader } from '@llm-tools/embedjs-loader-pdf'
import { SitemapLoader } from '@llm-tools/embedjs-loader-sitemap' import { SitemapLoader } from '@llm-tools/embedjs-loader-sitemap'
import { WebLoader } from '@llm-tools/embedjs-loader-web' import { WebLoader } from '@llm-tools/embedjs-loader-web'
@ -34,10 +34,11 @@ class KnowledgeService {
model, model,
apiKey, apiKey,
configuration: { baseURL }, configuration: { baseURL },
dimensions: 1024 dimensions: 1024,
batchSize: 10
}) })
) )
.setVectorDatabase(new LanceDb({ path: path.join(this.storageDir, id) })) .setVectorDatabase(new LibSqlDb({ path: path.join(this.storageDir, id) }))
.build() .build()
} }
@ -62,41 +63,58 @@ class KnowledgeService {
public add = async ( public add = async (
_: Electron.IpcMainInvokeEvent, _: Electron.IpcMainInvokeEvent,
{ base, item }: { base: KnowledgeBaseParams; item: KnowledgeItem } { base, item, forceReload = false }: { base: KnowledgeBaseParams; item: KnowledgeItem; forceReload: boolean }
): Promise<AddLoaderReturn> => { ): Promise<AddLoaderReturn> => {
const ragApplication = await this.getRagApplication(base) const ragApplication = await this.getRagApplication(base)
if (item.type === 'directory') {
const directory = item.content as string
return await ragApplication.addLoader(new LocalPathLoader({ path: directory }), forceReload)
}
if (item.type === 'url') { if (item.type === 'url') {
const content = item.content as string const content = item.content as string
if (content.startsWith('http')) { if (content.startsWith('http')) {
return await ragApplication.addLoader(new WebLoader({ urlOrContent: content })) return await ragApplication.addLoader(new WebLoader({ urlOrContent: content }), forceReload)
} }
} }
if (item.type === 'sitemap') { if (item.type === 'sitemap') {
const content = item.content as string const content = item.content as string
return await ragApplication.addLoader(new SitemapLoader({ url: content })) return await ragApplication.addLoader(new SitemapLoader({ url: content }), forceReload)
} }
if (item.type === 'note') { if (item.type === 'note') {
const content = item.content as string const content = item.content as string
return await ragApplication.addLoader(new TextLoader({ text: content })) return await ragApplication.addLoader(new TextLoader({ text: content }), forceReload)
} }
if (item.type === 'file') { if (item.type === 'file') {
const file = item.content as FileType const file = item.content as FileType
if (file.ext === '.pdf') { if (file.ext === '.pdf') {
return await ragApplication.addLoader(new PdfLoader({ filePathOrUrl: file.path }) as any) return await ragApplication.addLoader(new PdfLoader({ filePathOrUrl: file.path }) as any, forceReload)
} }
if (file.ext === '.docx') { if (file.ext === '.docx') {
return await ragApplication.addLoader(new DocxLoader({ filePathOrUrl: file.path }) as any) return await ragApplication.addLoader(new DocxLoader({ filePathOrUrl: file.path }) as any, forceReload)
} }
if (file.ext.startsWith('.md')) { if (file.ext === '.pptx') {
return await ragApplication.addLoader(new MarkdownLoader({ filePathOrUrl: file.path }) as any) return await ragApplication.addLoader(new PptLoader({ filePathOrUrl: file.path }) as any, forceReload)
} }
if (file.ext === '.xlsx') {
return await ragApplication.addLoader(new ExcelLoader({ filePathOrUrl: file.path }) as any, forceReload)
}
if (['.md', '.mdx'].includes(file.ext)) {
return await ragApplication.addLoader(new MarkdownLoader({ filePathOrUrl: file.path }) as any, forceReload)
}
const fileContent = fs.readFileSync(file.path, 'utf-8')
return await ragApplication.addLoader(new TextLoader({ text: fileContent }), forceReload)
} }
return { entriesAdded: 0, uniqueId: '', loaderType: '' } return { entriesAdded: 0, uniqueId: '', loaderType: '' }

View File

@ -1,6 +1,7 @@
import { is } from '@electron-toolkit/utils' import { is } from '@electron-toolkit/utils'
import { isLinux, isWin } from '@main/constant' import { isLinux, isWin } from '@main/constant'
import { app, BrowserWindow, Menu, MenuItem, shell } from 'electron' import { app, BrowserWindow, Menu, MenuItem, shell } from 'electron'
import Logger from 'electron-log'
import windowStateKeeper from 'electron-window-state' import windowStateKeeper from 'electron-window-state'
import { join } from 'path' import { join } from 'path'
@ -132,7 +133,16 @@ export class WindowService {
}) })
mainWindow.webContents.setWindowOpenHandler((details) => { mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url) const { url } = details
if (url.includes('http://file/')) {
const fileUrl = url.replace('http://file/', '')
const filePath = decodeURIComponent(fileUrl)
shell.openPath(filePath).catch((err) => Logger.error('Failed to open file:', err))
} else {
shell.openExternal(details.url)
}
return { action: 'deny' } return { action: 'deny' }
}) })

View File

@ -42,6 +42,7 @@ declare global {
create: (fileName: string) => Promise<string> create: (fileName: string) => Promise<string>
write: (filePath: string, data: Uint8Array | string) => Promise<void> write: (filePath: string, data: Uint8Array | string) => Promise<void>
open: (options?: OpenDialogOptions) => Promise<{ fileName: string; filePath: string; content: Buffer } | null> open: (options?: OpenDialogOptions) => Promise<{ fileName: string; filePath: string; content: Buffer } | null>
openPath: (path: string) => Promise<void>
save: ( save: (
path: string, path: string,
content: string | NodeJS.ArrayBufferView, content: string | NodeJS.ArrayBufferView,
@ -63,7 +64,15 @@ declare global {
create: ({ id, model, apiKey, baseURL }: KnowledgeBaseParams) => Promise<void> create: ({ id, model, apiKey, baseURL }: KnowledgeBaseParams) => Promise<void>
reset: ({ base }: { base: KnowledgeBaseParams }) => Promise<void> reset: ({ base }: { base: KnowledgeBaseParams }) => Promise<void>
delete: (id: string) => Promise<void> delete: (id: string) => Promise<void>
add: ({ base, item }: { base: KnowledgeBaseParams; item: KnowledgeItem }) => Promise<AddLoaderReturn> add: ({
base,
item,
forceReload = false
}: {
base: KnowledgeBaseParams
item: KnowledgeItem
forceReload?: boolean
}) => Promise<AddLoaderReturn>
remove: ({ uniqueId, base }: { uniqueId: string; base: KnowledgeBaseParams }) => Promise<void> remove: ({ uniqueId, base }: { uniqueId: string; base: KnowledgeBaseParams }) => Promise<void>
search: ({ search, base }: { search: string; base: KnowledgeBaseParams }) => Promise<ExtractChunkData[]> search: ({ search, base }: { search: string; base: KnowledgeBaseParams }) => Promise<ExtractChunkData[]>
} }

View File

@ -36,6 +36,7 @@ const api = {
create: (fileName: string) => ipcRenderer.invoke('file:create', fileName), create: (fileName: string) => ipcRenderer.invoke('file:create', fileName),
write: (filePath: string, data: Uint8Array | string) => ipcRenderer.invoke('file:write', filePath, data), write: (filePath: string, data: Uint8Array | string) => ipcRenderer.invoke('file:write', filePath, data),
open: (options?: { decompress: boolean }) => ipcRenderer.invoke('file:open', options), open: (options?: { decompress: boolean }) => ipcRenderer.invoke('file:open', options),
openPath: (path: string) => ipcRenderer.invoke('file:openPath', path),
save: (path: string, content: string, options?: { compress: boolean }) => save: (path: string, content: string, options?: { compress: boolean }) =>
ipcRenderer.invoke('file:save', path, content, options), ipcRenderer.invoke('file:save', path, content, options),
selectFolder: () => ipcRenderer.invoke('file:selectFolder'), selectFolder: () => ipcRenderer.invoke('file:selectFolder'),
@ -56,8 +57,15 @@ const api = {
ipcRenderer.invoke('knowledge-base:create', { id, model, apiKey, baseURL }), ipcRenderer.invoke('knowledge-base:create', { id, model, apiKey, baseURL }),
reset: ({ base }: { base: KnowledgeBaseParams }) => ipcRenderer.invoke('knowledge-base:reset', { base }), reset: ({ base }: { base: KnowledgeBaseParams }) => ipcRenderer.invoke('knowledge-base:reset', { base }),
delete: (id: string) => ipcRenderer.invoke('knowledge-base:delete', id), delete: (id: string) => ipcRenderer.invoke('knowledge-base:delete', id),
add: ({ base, item }: { base: KnowledgeBaseParams; item: KnowledgeItem }) => add: ({
ipcRenderer.invoke('knowledge-base:add', { base, item }), base,
item,
forceReload = false
}: {
base: KnowledgeBaseParams
item: KnowledgeItem
forceReload?: boolean
}) => ipcRenderer.invoke('knowledge-base:add', { base, item, forceReload }),
remove: ({ uniqueId, base }: { uniqueId: string; base: KnowledgeBaseParams }) => remove: ({ uniqueId, base }: { uniqueId: string; base: KnowledgeBaseParams }) =>
ipcRenderer.invoke('knowledge-base:remove', { uniqueId, base }), ipcRenderer.invoke('knowledge-base:remove', { uniqueId, base }),
search: ({ search, base }: { search: string; base: KnowledgeBaseParams }) => search: ({ search, base }: { search: string; base: KnowledgeBaseParams }) =>

View File

@ -42,6 +42,7 @@
--color-active: rgba(55, 55, 55, 1); --color-active: rgba(55, 55, 55, 1);
--color-frame-border: #333; --color-frame-border: #333;
--color-group-background: var(--color-background-soft); --color-group-background: var(--color-background-soft);
--color-reference-background: #0b0e12;
--navbar-background-mac: rgba(30, 30, 30, 0.6); --navbar-background-mac: rgba(30, 30, 30, 0.6);
--navbar-background: rgba(30, 30, 30); --navbar-background: rgba(30, 30, 30);
@ -99,6 +100,7 @@ body[theme-mode='light'] {
--color-active: var(--color-white-soft); --color-active: var(--color-white-soft);
--color-frame-border: #ddd; --color-frame-border: #ddd;
--color-group-background: var(--color-white); --color-group-background: var(--color-white);
--color-reference-background: #f1f7ff;
--navbar-background-mac: rgba(255, 255, 255, 0.6); --navbar-background-mac: rgba(255, 255, 255, 0.6);
--navbar-background: rgba(255, 255, 255); --navbar-background: rgba(255, 255, 255);

View File

@ -229,11 +229,24 @@
.footnotes { .footnotes {
margin-top: 1em; margin-top: 1em;
margin-bottom: 1em;
padding-top: 1em; padding-top: 1em;
border-top: 1px solid var(--color-border);
background-color: var(--color-reference-background);
border-radius: 8px;
padding: 8px 12px;
h4 {
margin-bottom: 5px;
font-size: 12px;
}
ol { ol {
padding-left: 1em; padding-left: 1em;
margin: 0;
li:last-child {
margin-bottom: 0;
}
} }
li { li {

View File

@ -241,7 +241,7 @@ const AssistantModelSettings: FC<Props> = ({ assistant, updateAssistant, updateA
)} )}
<Divider style={{ margin: '10px 0' }} /> <Divider style={{ margin: '10px 0' }} />
<SettingRow style={{ minHeight: 30 }}> <SettingRow style={{ minHeight: 30 }}>
<Label>{t('model.stream_output')}</Label> <Label>{t('models.stream_output')}</Label>
<Switch <Switch
checked={streamOutput} checked={streamOutput}
onChange={(checked) => { onChange={(checked) => {

View File

@ -1,7 +1,7 @@
import { PushpinOutlined, SearchOutlined } from '@ant-design/icons' import { PushpinOutlined, SearchOutlined } from '@ant-design/icons'
import VisionIcon from '@renderer/components/Icons/VisionIcon' import VisionIcon from '@renderer/components/Icons/VisionIcon'
import { TopView } from '@renderer/components/TopView' import { TopView } from '@renderer/components/TopView'
import { getModelLogo, isVisionModel, isWebSearchModel } from '@renderer/config/models' import { getModelLogo, isEmbeddingModel, isVisionModel, isWebSearchModel } from '@renderer/config/models'
import db from '@renderer/databases' import db from '@renderer/databases'
import { useProviders } from '@renderer/hooks/useProvider' import { useProviders } from '@renderer/hooks/useProvider'
import { getModelUniqId } from '@renderer/services/ModelService' import { getModelUniqId } from '@renderer/services/ModelService'
@ -66,6 +66,7 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
.filter((p) => p.models && p.models.length > 0) .filter((p) => p.models && p.models.length > 0)
.map((p) => { .map((p) => {
const filteredModels = sortBy(p.models, ['group', 'name']) const filteredModels = sortBy(p.models, ['group', 'name'])
.filter((m) => !isEmbeddingModel(m))
.filter((m) => .filter((m) =>
[m.name + m.provider + t('provider.' + p.id)].join('').toLowerCase().includes(searchText.toLowerCase()) [m.name + m.provider + t('provider.' + p.id)].join('').toLowerCase().includes(searchText.toLowerCase())
) )
@ -142,7 +143,7 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
if (pinnedItems.length > 0) { if (pinnedItems.length > 0) {
filteredItems.unshift({ filteredItems.unshift({
key: 'pinned', key: 'pinned',
label: t('model.pinned'), label: t('models.pinned'),
type: 'group', type: 'group',
children: pinnedItems children: pinnedItems
} as MenuItem) } as MenuItem)
@ -188,7 +189,7 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
</SearchIcon> </SearchIcon>
} }
ref={inputRef} ref={inputRef}
placeholder={t('model.search')} placeholder={t('models.search')}
value={searchText} value={searchText}
onChange={(e) => setSearchText(e.target.value)} onChange={(e) => setSearchText(e.target.value)}
allowClear allowClear

View File

@ -221,7 +221,8 @@ const _apps: MinAppType[] = [
id: 'thinkany', id: 'thinkany',
name: 'ThinkAny', name: 'ThinkAny',
logo: ThinkAnyLogo, logo: ThinkAnyLogo,
url: 'https://thinkany.ai/' url: 'https://thinkany.ai/',
bodered: true
} }
] ]

View File

@ -151,9 +151,9 @@ export const VISION_REGEX = new RegExp(
'i' 'i'
) )
const TEXT_TO_IMAGE_REGEX = /flux|diffusion|stabilityai|sd-|dall|cogview/i export 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 export const EMBEDDING_REGEX = /(?:^text-|embed|rerank|davinci|babbage|bge-|base|retrieval|uae-|gte-)/i
const NOT_SUPPORTED_REGEX = /(?:^tts|rerank|whisper|speech)/i export const NOT_SUPPORTED_REGEX = /(?:^tts|rerank|whisper|speech)/i
export function getModelLogo(modelId: string) { export function getModelLogo(modelId: string) {
const isLight = true const isLight = true
@ -1047,18 +1047,38 @@ export function isTextToImageModel(model: Model): boolean {
} }
export function isEmbeddingModel(model: Model): boolean { export function isEmbeddingModel(model: Model): boolean {
return EMBEDDING_REGEX.test(model.id) if (!model) {
return false
}
if (['anthropic'].includes(model?.provider)) {
return false
}
return EMBEDDING_REGEX.test(model.id) || model.type?.includes('embedding') || false
} }
export function isVisionModel(model: Model): boolean { export function isVisionModel(model: Model): boolean {
if (!model) {
return false
}
return VISION_REGEX.test(model.id) || model.type?.includes('vision') || false return VISION_REGEX.test(model.id) || model.type?.includes('vision') || false
} }
export function isSupportedModel(model: OpenAI.Models.Model): boolean { export function isSupportedModel(model: OpenAI.Models.Model): boolean {
if (!model) {
return false
}
return !NOT_SUPPORTED_REGEX.test(model.id) return !NOT_SUPPORTED_REGEX.test(model.id)
} }
export function isWebSearchModel(model: Model): boolean { export function isWebSearchModel(model: Model): boolean {
if (!model) {
return false
}
const provider = getProviderByModel(model) const provider = getProviderByModel(model)
if (!provider) { if (!provider) {

View File

@ -49,3 +49,30 @@ export const SUMMARIZE_PROMPT =
export const TRANSLATE_PROMPT = export const TRANSLATE_PROMPT =
'You are a translation expert. Translate from input language to {{target_language}}, provide the translation result directly without any explanation and keep original format. Do not translate if the target language is the same as the source language.' 'You are a translation expert. Translate from input language to {{target_language}}, provide the translation result directly without any explanation and keep original format. Do not translate if the target language is the same as the source language.'
export const REFERENCE_PROMPT = `请根据参考资料回答问题,并使用脚注格式引用数据来源。参考资料可能和问题无关,请忽略无关的参考资料。
##
1. ****使 [^] [^1]
2. ****使 [^]:
##
1. type file [^1]: [__name__](http://file/__url__)
2. type directory [^1]: [__name__](http://file/__url__)
3. type url,sitemap [^1]: [__name__](__url__)
4. type note [^1]: __note__
__url__ url
__name__ url
__note__ content
##
{question}
##
{references}
`

View File

@ -355,11 +355,11 @@ export const PROVIDER_CONFIG = {
}, },
aihubmix: { aihubmix: {
api: { api: {
url: 'https://aihubmix.com' url: 'https://aihubmix.com?aff=SJyh'
}, },
websites: { websites: {
official: 'https://aihubmix.com/', official: 'https://aihubmix.com/',
apiKey: 'https://aihubmix.com/token', apiKey: 'https://aihubmix.com?aff=SJyh',
docs: 'https://doc.aihubmix.com/', docs: 'https://doc.aihubmix.com/',
models: 'https://aihubmix.com/models' models: 'https://aihubmix.com/models'
} }

View File

@ -0,0 +1,279 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { db } from '@renderer/databases/index'
import KnowledgeQueue from '@renderer/queue/KnowledgeQueue'
import FileManager from '@renderer/services/FileManager'
import { getKnowledgeBaseParams } from '@renderer/services/KnowledgeService'
import { RootState } from '@renderer/store'
import {
addBase,
addFiles as addFilesAction,
addItem,
clearAllProcessing,
clearCompletedProcessing,
deleteBase,
removeItem as removeItemAction,
renameBase,
updateBase,
updateBases,
updateItemProcessingStatus,
updateNotes
} 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 renameKnowledgeBase = (name: string) => {
dispatch(renameBase({ baseId, name }))
}
// 更新知识库
const updateKnowledgeBase = (base: KnowledgeBase) => {
dispatch(updateBase(base))
}
// 批量添加文件
const addFiles = (files: FileType[]) => {
const filesItems: KnowledgeItem[] = files.map((file) => ({
id: uuidv4(),
type: 'file' as const,
content: file,
created_at: Date.now(),
updated_at: Date.now(),
processingStatus: 'pending',
processingProgress: 0,
processingError: '',
retryCount: 0
}))
dispatch(addFilesAction({ baseId, items: filesItems }))
setTimeout(() => KnowledgeQueue.checkAllBases(), 0)
}
// 添加URL
const addUrl = (url: string) => {
const newUrlItem: KnowledgeItem = {
id: uuidv4(),
type: 'url' as const,
content: url,
created_at: Date.now(),
updated_at: Date.now(),
processingStatus: 'pending',
processingProgress: 0,
processingError: '',
retryCount: 0
}
dispatch(addItem({ baseId, item: newUrlItem }))
setTimeout(() => KnowledgeQueue.checkAllBases(), 0)
}
// 添加笔记
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(),
processingStatus: 'pending',
processingProgress: 0,
processingError: '',
retryCount: 0
}
dispatch(updateNotes({ baseId, item: noteRef }))
setTimeout(() => KnowledgeQueue.checkAllBases(), 0)
}
// 更新笔记内容
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 }))
}
setTimeout(() => KnowledgeQueue.checkAllBases(), 0)
}
// 获取笔记内容
const getNoteContent = async (noteId: string) => {
return await db.knowledge_notes.get(noteId)
}
// 移除项目
const removeItem = async (item: KnowledgeItem) => {
dispatch(removeItemAction({ baseId, item }))
if (base) {
if (item?.uniqueId) {
await window.api.knowledgeBase.remove({ uniqueId: item.uniqueId, base: getKnowledgeBaseParams(base) })
}
if (item.type === 'file' && typeof item.content === 'object') {
await FileManager.deleteFile(item.content.id)
}
}
}
// 更新处理状态
const updateItemStatus = (itemId: string, status: ProcessingStatus, progress?: number, error?: string) => {
dispatch(
updateItemProcessingStatus({
baseId,
itemId,
status,
progress,
error
})
)
}
// 获取特定项目的处理状态
const getProcessingStatus = (itemId: string) => {
return base?.items.find((item) => item.id === itemId)?.processingStatus
}
// 获取特定类型的所有处理项
const getProcessingItemsByType = (type: 'file' | 'url' | 'note') => {
return base?.items.filter((item) => item.type === type && item.processingStatus !== undefined) || []
}
// 清除已完成的项目
const clearCompleted = () => {
dispatch(clearCompletedProcessing({ baseId }))
}
// 清除所有处理状态
const clearAll = () => {
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)
}
// Add directory support
const addDirectory = (path: string) => {
const newDirectoryItem: KnowledgeItem = {
id: uuidv4(),
type: 'directory',
content: path,
created_at: Date.now(),
updated_at: Date.now(),
processingStatus: 'pending',
processingProgress: 0,
processingError: '',
retryCount: 0
}
dispatch(addItem({ baseId, item: newDirectoryItem }))
setTimeout(() => KnowledgeQueue.checkAllBases(), 0)
}
const fileItems = base?.items.filter((item) => item.type === 'file') || []
const directoryItems = base?.items.filter((item) => item.type === 'directory') || []
const urlItems = base?.items.filter((item) => item.type === 'url') || []
const sitemapItems = base?.items.filter((item) => item.type === 'sitemap') || []
const [noteItems, setNoteItems] = useState<KnowledgeItem[]>([])
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,
sitemapItems,
noteItems,
renameKnowledgeBase,
updateKnowledgeBase,
addFiles,
addUrl,
addSitemap,
addNote,
updateNoteContent,
getNoteContent,
updateItemStatus,
getProcessingStatus,
getProcessingItemsByType,
clearCompleted,
clearAll,
removeItem,
directoryItems,
addDirectory
}
}
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 }))
}
const updateKnowledgeBases = (bases: KnowledgeBase[]) => {
dispatch(updateBases(bases))
}
return {
bases,
addKnowledgeBase,
renameKnowledgeBase,
deleteKnowledgeBase,
updateKnowledgeBases
}
}

View File

@ -37,8 +37,10 @@ export const useShortcut = (
const shortcutConfig = shortcuts.find((s) => s.key === shortcutKey) const shortcutConfig = shortcuts.find((s) => s.key === shortcutKey)
console.log(shortcutConfig)
useHotkeys( useHotkeys(
shortcutConfig?.enabled ? formatShortcut(shortcutConfig.shortcut) : '', shortcutConfig?.enabled ? formatShortcut(shortcutConfig.shortcut) : 'none',
(e) => { (e) => {
if (options.preventDefault) { if (options.preventDefault) {
e.preventDefault() e.preventDefault()
@ -49,7 +51,8 @@ export const useShortcut = (
}, },
{ {
enableOnFormTags: options.enableOnFormTags, enableOnFormTags: options.enableOnFormTags,
description: options.description || shortcutConfig?.key description: options.description || shortcutConfig?.key,
enabled: !!shortcutConfig?.enabled
} }
) )
} }

View File

@ -6,6 +6,7 @@ import { getKnowledgeBaseParams } from '@renderer/services/KnowledgeService'
import { RootState } from '@renderer/store' import { RootState } from '@renderer/store'
import { import {
addBase, addBase,
addFiles as addFilesAction,
addItem, addItem,
clearAllProcessing, clearAllProcessing,
clearCompletedProcessing, clearCompletedProcessing,
@ -13,7 +14,7 @@ import {
removeItem as removeItemAction, removeItem as removeItemAction,
renameBase, renameBase,
updateBase, updateBase,
updateFiles as updateFilesAction, updateBases,
updateItemProcessingStatus, updateItemProcessingStatus,
updateNotes updateNotes
} from '@renderer/store/knowledge' } from '@renderer/store/knowledge'
@ -38,22 +39,20 @@ export const useKnowledge = (baseId: string) => {
dispatch(updateBase(base)) dispatch(updateBase(base))
} }
// 添加文件列表 // 批量添加文件
const addFiles = (files: FileType[]) => { const addFiles = (files: FileType[]) => {
for (const file of files) { const filesItems: KnowledgeItem[] = files.map((file) => ({
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',
processingStatus: 'pending', processingProgress: 0,
processingProgress: 0, processingError: '',
processingError: '', retryCount: 0
retryCount: 0 }))
} dispatch(addFilesAction({ baseId, items: filesItems }))
dispatch(addItem({ baseId, item: newItem }))
}
setTimeout(() => KnowledgeQueue.checkAllBases(), 0) setTimeout(() => KnowledgeQueue.checkAllBases(), 0)
} }
@ -106,19 +105,6 @@ export const useKnowledge = (baseId: string) => {
setTimeout(() => KnowledgeQueue.checkAllBases(), 0) setTimeout(() => KnowledgeQueue.checkAllBases(), 0)
} }
// 更新文件列表
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 }))
setTimeout(() => KnowledgeQueue.checkAllBases(), 0)
}
// 更新笔记内容 // 更新笔记内容
const updateNoteContent = async (noteId: string, content: string) => { const updateNoteContent = async (noteId: string, content: string) => {
const note = await db.knowledge_notes.get(noteId) const note = await db.knowledge_notes.get(noteId)
@ -202,7 +188,25 @@ export const useKnowledge = (baseId: string) => {
setTimeout(() => KnowledgeQueue.checkAllBases(), 0) setTimeout(() => KnowledgeQueue.checkAllBases(), 0)
} }
// Add directory support
const addDirectory = (path: string) => {
const newDirectoryItem: KnowledgeItem = {
id: uuidv4(),
type: 'directory',
content: path,
created_at: Date.now(),
updated_at: Date.now(),
processingStatus: 'pending',
processingProgress: 0,
processingError: '',
retryCount: 0
}
dispatch(addItem({ baseId, item: newDirectoryItem }))
setTimeout(() => KnowledgeQueue.checkAllBases(), 0)
}
const fileItems = base?.items.filter((item) => item.type === 'file') || [] const fileItems = base?.items.filter((item) => item.type === 'file') || []
const directoryItems = base?.items.filter((item) => item.type === 'directory') || []
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 sitemapItems = base?.items.filter((item) => item.type === 'sitemap') || []
const [noteItems, setNoteItems] = useState<KnowledgeItem[]>([]) const [noteItems, setNoteItems] = useState<KnowledgeItem[]>([])
@ -232,7 +236,6 @@ export const useKnowledge = (baseId: string) => {
addUrl, addUrl,
addSitemap, addSitemap,
addNote, addNote,
updateFiles,
updateNoteContent, updateNoteContent,
getNoteContent, getNoteContent,
updateItemStatus, updateItemStatus,
@ -240,7 +243,9 @@ export const useKnowledge = (baseId: string) => {
getProcessingItemsByType, getProcessingItemsByType,
clearCompleted, clearCompleted,
clearAll, clearAll,
removeItem removeItem,
directoryItems,
addDirectory
} }
} }
@ -260,10 +265,15 @@ export const useKnowledgeBases = () => {
dispatch(deleteBase({ baseId })) dispatch(deleteBase({ baseId }))
} }
const updateKnowledgeBases = (bases: KnowledgeBase[]) => {
dispatch(updateBases(bases))
}
return { return {
bases, bases,
addKnowledgeBase, addKnowledgeBase,
renameKnowledgeBase, renameKnowledgeBase,
deleteKnowledgeBase deleteKnowledgeBase,
updateKnowledgeBases
} }
} }

View File

@ -252,16 +252,6 @@
"minapp": { "minapp": {
"title": "MinApp" "title": "MinApp"
}, },
"model": {
"pinned": "Pinned",
"search": "Search models...",
"stream_output": "Stream output",
"type": {
"select": "Select Model Types",
"text": "Text",
"vision": "Vision"
}
},
"ollama": { "ollama": {
"keep_alive_time.description": "The time in minutes to keep the connection alive, default is 5 minutes.", "keep_alive_time.description": "The time in minutes to keep the connection alive, default is 5 minutes.",
"keep_alive_time.placeholder": "Minutes", "keep_alive_time.placeholder": "Minutes",
@ -532,7 +522,7 @@
"search": "Search knowledge base", "search": "Search knowledge base",
"empty": "No knowledge base found", "empty": "No knowledge base found",
"drag_file": "Drag file here", "drag_file": "Drag file here",
"file_hint": "Support pdf, docx, txt and md", "file_hint": "Support {{file_types}}",
"add": { "add": {
"title": "Add Knowledge Base" "title": "Add Knowledge Base"
}, },
@ -562,7 +552,26 @@
"delete_confirm": "Are you sure you want to delete this knowledge base?", "delete_confirm": "Are you sure you want to delete this knowledge base?",
"sitemaps": "Websites", "sitemaps": "Websites",
"add_sitemap": "Website Map", "add_sitemap": "Website Map",
"sitemap_placeholder": "Enter Website Map URL" "sitemap_placeholder": "Enter Website Map URL",
"directories": "Directories",
"add_directory": "Add Directory",
"directory_placeholder": "Enter Directory Path"
},
"models": {
"pinned": "Pinned",
"search": "Search models...",
"stream_output": "Stream output",
"type": {
"select": "Select Model Types",
"text": "Text",
"vision": "Vision",
"embedding": "Embedding"
},
"all": "All",
"vision": "Vision",
"websearch": "WebSearch",
"free": "Free",
"embedding": "Embedding"
} }
} }
} }

View File

@ -252,16 +252,6 @@
"minapp": { "minapp": {
"title": "Встроенные приложения" "title": "Встроенные приложения"
}, },
"model": {
"pinned": "Закреплено",
"search": "Поиск моделей...",
"stream_output": "Потоковый вывод",
"type": {
"select": "Выберите тип модели",
"text": "Текст",
"vision": "Изображение"
}
},
"ollama": { "ollama": {
"keep_alive_time.description": "Время в минутах, в течение которого модель остается активной, по умолчанию 5 минут.", "keep_alive_time.description": "Время в минутах, в течение которого модель остается активной, по умолчанию 5 минут.",
"keep_alive_time.placeholder": "Минуты", "keep_alive_time.placeholder": "Минуты",
@ -532,7 +522,7 @@
"search": "Поиск в базе знаний", "search": "Поиск в базе знаний",
"empty": "База знаний не найдена", "empty": "База знаний не найдена",
"drag_file": "Перетащите файл сюда", "drag_file": "Перетащите файл сюда",
"file_hint": "Поддерживаются pdf, docx, txt и md", "file_hint": "Поддерживаются {{file_types}}",
"add": { "add": {
"title": "Добавить базу знаний" "title": "Добавить базу знаний"
}, },
@ -562,7 +552,26 @@
"delete_confirm": "Вы уверены, что хотите удалить эту базу знаний?", "delete_confirm": "Вы уверены, что хотите удалить эту базу знаний?",
"sitemaps": "Сайты", "sitemaps": "Сайты",
"add_sitemap": "Карта сайта", "add_sitemap": "Карта сайта",
"sitemap_placeholder": "Введите URL карты сайта" "sitemap_placeholder": "Введите URL карты сайта",
"directories": "Директории",
"add_directory": "Добавить директорию",
"directory_placeholder": "Введите путь к директории"
},
"models": {
"pinned": "Закреплено",
"search": "Поиск моделей...",
"stream_output": "Потоковый вывод",
"type": {
"select": "Выберите тип модели",
"text": "Текст",
"vision": "Изображение",
"embedding": "Встраиваемые"
},
"all": "Все",
"vision": "Визуальные модели",
"websearch": "Веб-поисковые модели",
"free": "Бесплатные модели",
"embedding": "Встраиваемые модели"
} }
} }
} }

View File

@ -253,16 +253,6 @@
"minapp": { "minapp": {
"title": "小程序" "title": "小程序"
}, },
"model": {
"pinned": "已固定",
"search": "搜索模型...",
"stream_output": "流式输出",
"type": {
"select": "选择模型类型",
"text": "文本",
"vision": "图像"
}
},
"ollama": { "ollama": {
"keep_alive_time.description": "对话后模型在内存中保持的时间默认5分钟", "keep_alive_time.description": "对话后模型在内存中保持的时间默认5分钟",
"keep_alive_time.placeholder": "分钟", "keep_alive_time.placeholder": "分钟",
@ -521,7 +511,7 @@
"search": "搜索知识库", "search": "搜索知识库",
"empty": "暂无知识库", "empty": "暂无知识库",
"drag_file": "拖拽文件到这里", "drag_file": "拖拽文件到这里",
"file_hint": "支持 pdf, docx, txt, md 格式", "file_hint": "支持 {{file_types}} 格式",
"add": { "add": {
"title": "添加知识库" "title": "添加知识库"
}, },
@ -551,7 +541,26 @@
"delete_confirm": "确定要删除此知识库吗?", "delete_confirm": "确定要删除此知识库吗?",
"sitemaps": "网站", "sitemaps": "网站",
"add_sitemap": "站点地图", "add_sitemap": "站点地图",
"sitemap_placeholder": "请输入站点地图 URL" "sitemap_placeholder": "请输入站点地图 URL",
"directories": "目录",
"add_directory": "添加目录",
"directory_placeholder": "请输入目录路径"
},
"models": {
"pinned": "已固定",
"search": "搜索模型...",
"stream_output": "流式输出",
"type": {
"select": "选择模型类型",
"text": "文本",
"vision": "图像",
"embedding": "嵌入"
},
"all": "全部",
"vision": "视觉模型",
"websearch": "网络搜索模型",
"free": "免费模型",
"embedding": "嵌入模型"
} }
} }
} }

View File

@ -252,16 +252,6 @@
"minapp": { "minapp": {
"title": "小程序" "title": "小程序"
}, },
"model": {
"pinned": "已固定",
"search": "搜尋模型...",
"stream_output": "串流輸出",
"type": {
"select": "選擇模型類型",
"text": "文字",
"vision": "圖像"
}
},
"ollama": { "ollama": {
"keep_alive_time.description": "對話後模型在記憶體中保持的時間(預設為 5 分鐘)。", "keep_alive_time.description": "對話後模型在記憶體中保持的時間(預設為 5 分鐘)。",
"keep_alive_time.placeholder": "分鐘", "keep_alive_time.placeholder": "分鐘",
@ -520,7 +510,7 @@
"search": "搜尋知識庫", "search": "搜尋知識庫",
"empty": "暫無知識庫", "empty": "暫無知識庫",
"drag_file": "拖拽文件到這裡", "drag_file": "拖拽文件到這裡",
"file_hint": "支持 pdf, docx, txt, md 格式", "file_hint": "支持 {{file_types}} 格式",
"add": { "add": {
"title": "添加知識庫" "title": "添加知識庫"
}, },
@ -550,7 +540,26 @@
"delete_confirm": "確定要刪除此知識庫嗎?", "delete_confirm": "確定要刪除此知識庫嗎?",
"sitemaps": "網站", "sitemaps": "網站",
"add_sitemap": "網站地圖", "add_sitemap": "網站地圖",
"sitemap_placeholder": "請輸入網站地圖 URL" "sitemap_placeholder": "請輸入網站地圖 URL",
"directories": "目錄",
"add_directory": "添加目錄",
"directory_placeholder": "請輸入目錄路徑"
},
"models": {
"pinned": "已固定",
"search": "搜尋模型...",
"stream_output": "串流輸出",
"type": {
"select": "選擇模型類型",
"text": "文字",
"vision": "圖像",
"embedding": "嵌入"
},
"all": "全部",
"vision": "視覺模型",
"websearch": "網路搜索模型",
"free": "免費模型",
"embedding": "嵌入模型"
} }
} }
} }

View File

@ -1,4 +1,4 @@
import { SearchOutlined } from '@ant-design/icons' import { FormOutlined, SearchOutlined } from '@ant-design/icons'
import { Navbar, NavbarLeft, NavbarRight } from '@renderer/components/app/Navbar' import { Navbar, NavbarLeft, NavbarRight } from '@renderer/components/app/Navbar'
import AssistantSettingsPopup from '@renderer/components/AssistantSettings' import AssistantSettingsPopup from '@renderer/components/AssistantSettings'
import { HStack } from '@renderer/components/Layout' import { HStack } from '@renderer/components/Layout'
@ -47,8 +47,8 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant }) => {
<NavbarIcon onClick={toggleShowAssistants} style={{ marginLeft: isMac ? 8 : 0 }}> <NavbarIcon onClick={toggleShowAssistants} style={{ marginLeft: isMac ? 8 : 0 }}>
<i className="iconfont icon-hide-sidebar" /> <i className="iconfont icon-hide-sidebar" />
</NavbarIcon> </NavbarIcon>
<NavbarIcon onClick={() => SearchPopup.show()}> <NavbarIcon onClick={() => EventEmitter.emit(EVENT_NAMES.ADD_NEW_TOPIC)}>
<SearchOutlined /> <FormOutlined />
</NavbarIcon> </NavbarIcon>
</NavbarLeft> </NavbarLeft>
)} )}
@ -70,6 +70,9 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant }) => {
<SelectModelButton assistant={assistant} /> <SelectModelButton assistant={assistant} />
</HStack> </HStack>
<HStack alignItems="center"> <HStack alignItems="center">
<NavbarIcon onClick={() => SearchPopup.show()}>
<SearchOutlined />
</NavbarIcon>
<AppStorePopover> <AppStorePopover>
<NavbarIcon> <NavbarIcon>
<i className="iconfont icon-appstore" /> <i className="iconfont icon-appstore" />

View File

@ -162,7 +162,7 @@ const SettingsTab: FC<Props> = (props) => {
</Col> </Col>
</Row> </Row>
<SettingRow> <SettingRow>
<SettingRowTitleSmall>{t('model.stream_output')}</SettingRowTitleSmall> <SettingRowTitleSmall>{t('models.stream_output')}</SettingRowTitleSmall>
<Switch <Switch
size="small" size="small"
checked={streamOutput} checked={streamOutput}

View File

@ -2,6 +2,7 @@ import {
DeleteOutlined, DeleteOutlined,
EditOutlined, EditOutlined,
FileTextOutlined, FileTextOutlined,
FolderOutlined,
GlobalOutlined, GlobalOutlined,
LinkOutlined, LinkOutlined,
PlusOutlined, PlusOutlined,
@ -10,7 +11,7 @@ import {
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'
import Scrollbar from '@renderer/components/Scrollbar' import Scrollbar from '@renderer/components/Scrollbar'
import { useKnowledge } from '@renderer/hooks/useknowledge' import { useKnowledge } from '@renderer/hooks/useKnowledge'
import FileManager from '@renderer/services/FileManager' import FileManager from '@renderer/services/FileManager'
import { FileType, FileTypes, KnowledgeBase } from '@renderer/types' import { FileType, FileTypes, KnowledgeBase } from '@renderer/types'
import { Button, Card, message, Typography, Upload } from 'antd' import { Button, Card, message, Typography, Upload } from 'antd'
@ -28,6 +29,32 @@ interface KnowledgeContentProps {
selectedBase: KnowledgeBase selectedBase: KnowledgeBase
} }
const fileTypes = ['.pdf', '.docx', '.pptx', '.xlsx', '.txt', '.md', '.mdx']
const FlexColumn = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
`
const FlexAlignCenter = styled.div`
display: flex;
align-items: center;
gap: 16px;
`
const ClickableSpan = styled.span`
cursor: pointer;
`
const FileIcon = styled(FileTextOutlined)`
font-size: 16px;
`
const BottomSpacer = styled.div`
min-height: 20px;
`
const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => { const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { const {
@ -36,13 +63,15 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
fileItems, fileItems,
urlItems, urlItems,
sitemapItems, sitemapItems,
directoryItems,
addFiles, addFiles,
updateNoteContent, updateNoteContent,
addUrl, addUrl,
addSitemap, addSitemap,
removeItem, removeItem,
getProcessingStatus, getProcessingStatus,
addNote addNote,
addDirectory
} = useKnowledge(selectedBase.id || '') } = useKnowledge(selectedBase.id || '')
if (!base) { if (!base) {
@ -53,12 +82,10 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
const input = document.createElement('input') const input = document.createElement('input')
input.type = 'file' input.type = 'file'
input.multiple = true input.multiple = true
input.accept = '.pdf,.txt,.docx,.md' input.accept = fileTypes.join(',')
input.onchange = (e) => { input.onchange = (e) => {
const files = (e.target as HTMLInputElement).files const files = (e.target as HTMLInputElement).files
if (files) { files && handleDrop(Array.from(files))
handleDrop(Array.from(files))
}
} }
input.click() input.click()
} }
@ -142,6 +169,12 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
editedText && updateNoteContent(note.id, editedText) editedText && updateNoteContent(note.id, editedText)
} }
const handleAddDirectory = async () => {
const path = await window.api.file.selectFolder()
console.log('[KnowledgeContent] Selected directory:', path)
path && addDirectory(path)
}
return ( return (
<MainContent> <MainContent>
<FileSection> <FileSection>
@ -155,10 +188,12 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
showUploadList={false} showUploadList={false}
customRequest={({ file }) => handleDrop([file as File])} customRequest={({ file }) => handleDrop([file as File])}
multiple={true} multiple={true}
accept=".pdf,.txt,.docx,.md" accept={fileTypes.join(',')}
style={{ marginTop: 10, background: 'transparent' }}> style={{ marginTop: 10, background: 'transparent' }}>
<p className="ant-upload-text">{t('knowledge_base.drag_file')}</p> <p className="ant-upload-text">{t('knowledge_base.drag_file')}</p>
<p className="ant-upload-hint">{t('knowledge_base.file_hint')}</p> <p className="ant-upload-hint">
{t('knowledge_base.file_hint', { file_types: fileTypes.join(', ').replaceAll('.', '') })}
</p>
</Dragger> </Dragger>
</FileSection> </FileSection>
@ -169,19 +204,46 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
<ItemCard key={item.id}> <ItemCard key={item.id}>
<ItemContent> <ItemContent>
<ItemInfo> <ItemInfo>
<FileTextOutlined style={{ fontSize: '16px' }} /> <FileIcon />
<span>{file.origin_name}</span> <ClickableSpan onClick={() => window.api.file.openPath(file.path)}>{file.origin_name}</ClickableSpan>
</ItemInfo> </ItemInfo>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}> <FlexAlignCenter>
<StatusIcon sourceId={item.id} base={base} getProcessingStatus={getProcessingStatus} /> <StatusIcon sourceId={item.id} base={base} getProcessingStatus={getProcessingStatus} />
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} /> <Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
</div> </FlexAlignCenter>
</ItemContent> </ItemContent>
</ItemCard> </ItemCard>
) )
})} })}
</FileListSection> </FileListSection>
<ContentSection>
<TitleWrapper>
<Title level={5}>{t('knowledge_base.directories')}</Title>
<Button icon={<PlusOutlined />} onClick={handleAddDirectory}>
{t('knowledge_base.add_directory')}
</Button>
</TitleWrapper>
<FlexColumn>
{directoryItems.map((item) => (
<ItemCard key={item.id}>
<ItemContent>
<ItemInfo>
<FolderOutlined />
<ClickableSpan onClick={() => window.api.file.openPath(item.content as string)}>
{item.content as string}
</ClickableSpan>
</ItemInfo>
<FlexAlignCenter>
<StatusIcon sourceId={item.id} base={base} getProcessingStatus={getProcessingStatus} />
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
</FlexAlignCenter>
</ItemContent>
</ItemCard>
))}
</FlexColumn>
</ContentSection>
<ContentSection> <ContentSection>
<TitleWrapper> <TitleWrapper>
<Title level={5}>{t('knowledge_base.urls')}</Title> <Title level={5}>{t('knowledge_base.urls')}</Title>
@ -189,24 +251,24 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
{t('knowledge_base.add_url')} {t('knowledge_base.add_url')}
</Button> </Button>
</TitleWrapper> </TitleWrapper>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}> <FlexColumn>
{urlItems.map((item) => ( {urlItems.map((item) => (
<ItemCard key={item.id}> <ItemCard key={item.id}>
<ItemContent> <ItemContent>
<ItemInfo> <ItemInfo>
<LinkOutlined style={{ fontSize: '16px' }} /> <LinkOutlined />
<a href={item.content as string} target="_blank" rel="noopener noreferrer"> <a href={item.content as string} target="_blank" rel="noopener noreferrer">
{item.content as string} {item.content as string}
</a> </a>
</ItemInfo> </ItemInfo>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}> <FlexAlignCenter>
<StatusIcon sourceId={item.id} base={base} getProcessingStatus={getProcessingStatus} /> <StatusIcon sourceId={item.id} base={base} getProcessingStatus={getProcessingStatus} />
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} /> <Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
</div> </FlexAlignCenter>
</ItemContent> </ItemContent>
</ItemCard> </ItemCard>
))} ))}
</div> </FlexColumn>
</ContentSection> </ContentSection>
<ContentSection> <ContentSection>
@ -216,24 +278,24 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
{t('knowledge_base.add_sitemap')} {t('knowledge_base.add_sitemap')}
</Button> </Button>
</TitleWrapper> </TitleWrapper>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}> <FlexColumn>
{sitemapItems.map((item) => ( {sitemapItems.map((item) => (
<ItemCard key={item.id}> <ItemCard key={item.id}>
<ItemContent> <ItemContent>
<ItemInfo> <ItemInfo>
<GlobalOutlined style={{ fontSize: '16px' }} /> <GlobalOutlined />
<a href={item.content as string} target="_blank" rel="noopener noreferrer"> <a href={item.content as string} target="_blank" rel="noopener noreferrer">
{item.content as string} {item.content as string}
</a> </a>
</ItemInfo> </ItemInfo>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}> <FlexAlignCenter>
<StatusIcon sourceId={item.id} base={base} getProcessingStatus={getProcessingStatus} /> <StatusIcon sourceId={item.id} base={base} getProcessingStatus={getProcessingStatus} />
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} /> <Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
</div> </FlexAlignCenter>
</ItemContent> </ItemContent>
</ItemCard> </ItemCard>
))} ))}
</div> </FlexColumn>
</ContentSection> </ContentSection>
<ContentSection> <ContentSection>
@ -243,29 +305,31 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
{t('knowledge_base.add_note')} {t('knowledge_base.add_note')}
</Button> </Button>
</TitleWrapper> </TitleWrapper>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}> <FlexColumn>
{noteItems.map((note) => ( {noteItems.map((note) => (
<ItemCard key={note.id}> <ItemCard key={note.id}>
<ItemContent> <ItemContent>
<ItemInfo onClick={() => handleEditNote(note)} style={{ cursor: 'pointer' }}> <ItemInfo onClick={() => handleEditNote(note)} style={{ cursor: 'pointer' }}>
<span>{(note.content as string).slice(0, 50)}...</span> <span>{(note.content as string).slice(0, 50)}...</span>
</ItemInfo> </ItemInfo>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}> <FlexAlignCenter>
<Button type="text" onClick={() => handleEditNote(note)} icon={<EditOutlined />} /> <Button type="text" onClick={() => handleEditNote(note)} icon={<EditOutlined />} />
<StatusIcon sourceId={note.id} base={base} getProcessingStatus={getProcessingStatus} /> <StatusIcon sourceId={note.id} base={base} getProcessingStatus={getProcessingStatus} />
<Button type="text" danger onClick={() => removeItem(note)} icon={<DeleteOutlined />} /> <Button type="text" danger onClick={() => removeItem(note)} icon={<DeleteOutlined />} />
</div> </FlexAlignCenter>
</ItemContent> </ItemContent>
</ItemCard> </ItemCard>
))} ))}
</div> </FlexColumn>
</ContentSection> </ContentSection>
<IndexSection> <IndexSection>
<Button type="primary" onClick={() => KnowledgeSearchPopup.show({ base })} icon={<SearchOutlined />}> <Button type="primary" onClick={() => KnowledgeSearchPopup.show({ base })} icon={<SearchOutlined />}>
{t('knowledge_base.search')} {t('knowledge_base.search')}
</Button> </Button>
</IndexSection> </IndexSection>
<div style={{ minHeight: '20px' }} />
<BottomSpacer />
</MainContent> </MainContent>
) )
} }

View File

@ -1,9 +1,10 @@
import { DeleteOutlined, EditOutlined, FileTextOutlined, PlusOutlined } from '@ant-design/icons' import { DeleteOutlined, EditOutlined, FileTextOutlined, PlusOutlined } from '@ant-design/icons'
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar' import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
import DragableList from '@renderer/components/DragableList'
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 { useKnowledgeBases } from '@renderer/hooks/useknowledge' import { useKnowledgeBases } from '@renderer/hooks/useKnowledge'
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'
@ -15,8 +16,9 @@ import KnowledgeContent from './KnowledgeContent'
const KnowledgePage: FC = () => { const KnowledgePage: FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
const { bases, renameKnowledgeBase, deleteKnowledgeBase } = useKnowledgeBases() const { bases, renameKnowledgeBase, deleteKnowledgeBase, updateKnowledgeBases } = useKnowledgeBases()
const [selectedBase, setSelectedBase] = useState<KnowledgeBase>() const [selectedBase, setSelectedBase] = useState<KnowledgeBase>()
const [isDragging, setIsDragging] = useState(false)
const handleAddKnowledge = async () => { const handleAddKnowledge = async () => {
await AddKnowledgePopup.show({ title: t('knowledge_base.add.title') }) await AddKnowledgePopup.show({ title: t('knowledge_base.add.title') })
@ -82,24 +84,33 @@ const KnowledgePage: FC = () => {
<ContentContainer id="content-container"> <ContentContainer id="content-container">
<SideNav> <SideNav>
<ScrollContainer> <ScrollContainer>
{bases.map((base) => ( <DragableList
<Dropdown menu={{ items: getMenuItems(base) }} trigger={['contextMenu']} key={base.id}> list={bases}
<div> onUpdate={updateKnowledgeBases}
<ListItem style={{ marginBottom: 0, paddingBottom: isDragging ? 50 : 0 }}
active={selectedBase?.id === base.id} onDragStart={() => setIsDragging(true)}
icon={<FileTextOutlined />} onDragEnd={() => setIsDragging(false)}>
title={base.name} {(base) => (
onClick={() => setSelectedBase(base)} <Dropdown menu={{ items: getMenuItems(base) }} trigger={['contextMenu']} key={base.id}>
/> <div>
</div> <ListItem
</Dropdown> active={selectedBase?.id === base.id}
))} icon={<FileTextOutlined />}
<AddKnowledgeItem onClick={handleAddKnowledge}> title={base.name}
<AddKnowledgeName> onClick={() => setSelectedBase(base)}
<PlusOutlined style={{ color: 'var(--color-text-2)', marginRight: 4 }} /> />
{t('button.add')} </div>
</AddKnowledgeName> </Dropdown>
</AddKnowledgeItem> )}
</DragableList>
{!isDragging && (
<AddKnowledgeItem onClick={handleAddKnowledge}>
<AddKnowledgeName>
<PlusOutlined style={{ color: 'var(--color-text-2)', marginRight: 4 }} />
{t('button.add')}
</AddKnowledgeName>
</AddKnowledgeItem>
)}
<div style={{ minHeight: '10px' }}></div> <div style={{ minHeight: '10px' }}></div>
</ScrollContainer> </ScrollContainer>
</SideNav> </SideNav>

View File

@ -1,6 +1,6 @@
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 { useKnowledgeBases } from '@renderer/hooks/useKnowledge'
import { useProviders } from '@renderer/hooks/useProvider' import { useProviders } from '@renderer/hooks/useProvider'
import { getKnowledgeBaseParams } from '@renderer/services/KnowledgeService' import { getKnowledgeBaseParams } from '@renderer/services/KnowledgeService'
import { getModelUniqId } from '@renderer/services/ModelService' import { getModelUniqId } from '@renderer/services/ModelService'

View File

@ -1,4 +1,4 @@
import { ExtractChunkData } from '@llm-tools/embedjs-interfaces' import type { ExtractChunkData } from '@llm-tools/embedjs-interfaces'
import { TopView } from '@renderer/components/TopView' import { TopView } from '@renderer/components/TopView'
import { getKnowledgeBaseParams } from '@renderer/services/KnowledgeService' import { getKnowledgeBaseParams } from '@renderer/services/KnowledgeService'
import { KnowledgeBase } from '@renderer/types' import { KnowledgeBase } from '@renderer/types'

View File

@ -1,12 +1,13 @@
import { LoadingOutlined, MinusOutlined, PlusOutlined, QuestionCircleOutlined } from '@ant-design/icons' import { LoadingOutlined, MinusOutlined, PlusOutlined, QuestionCircleOutlined } from '@ant-design/icons'
import VisionIcon from '@renderer/components/Icons/VisionIcon' import VisionIcon from '@renderer/components/Icons/VisionIcon'
import WebSearchIcon from '@renderer/components/Icons/WebSearchIcon' import WebSearchIcon from '@renderer/components/Icons/WebSearchIcon'
import { Center } from '@renderer/components/Layout'
import { getModelLogo, isEmbeddingModel, isVisionModel, isWebSearchModel, SYSTEM_MODELS } from '@renderer/config/models' import { getModelLogo, isEmbeddingModel, isVisionModel, isWebSearchModel, SYSTEM_MODELS } from '@renderer/config/models'
import { useProvider } from '@renderer/hooks/useProvider' import { useProvider } from '@renderer/hooks/useProvider'
import { fetchModels } from '@renderer/services/ApiService' import { fetchModels } from '@renderer/services/ApiService'
import { Model, Provider } from '@renderer/types' import { Model, Provider } from '@renderer/types'
import { getDefaultGroupName, isFreeModel, runAsyncFunction } from '@renderer/utils' import { getDefaultGroupName, isFreeModel, runAsyncFunction } from '@renderer/utils'
import { Avatar, Button, Empty, Flex, Modal, Popover, Tag } from 'antd' import { Avatar, Button, Empty, Flex, Modal, Popover, Radio, Tag } from 'antd'
import Search from 'antd/es/input/Search' import Search from 'antd/es/input/Search'
import { groupBy, isEmpty, uniqBy } from 'lodash' import { groupBy, isEmpty, uniqBy } from 'lodash'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
@ -29,14 +30,29 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
const [listModels, setListModels] = useState<Model[]>([]) const [listModels, setListModels] = useState<Model[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [searchText, setSearchText] = useState('') const [searchText, setSearchText] = useState('')
const { t } = useTranslation() const [filterType, setFilterType] = useState<string>('all')
const { t, i18n } = useTranslation()
const systemModels = SYSTEM_MODELS[_provider.id] || [] const systemModels = SYSTEM_MODELS[_provider.id] || []
const allModels = uniqBy([...systemModels, ...listModels, ...models], 'id') const allModels = uniqBy([...systemModels, ...listModels, ...models], 'id')
const list = searchText const list = allModels.filter((model) => {
? allModels.filter((model) => model.id.toLocaleLowerCase().includes(searchText.toLocaleLowerCase())) if (searchText && !model.id.toLocaleLowerCase().includes(searchText.toLocaleLowerCase())) {
: allModels return false
}
switch (filterType) {
case 'vision':
return isVisionModel(model)
case 'websearch':
return isWebSearchModel(model)
case 'free':
return isFreeModel(model)
case 'embedding':
return isEmbeddingModel(model)
default:
return true
}
})
const modelGroups = groupBy(list, 'group') const modelGroups = groupBy(list, 'group')
@ -89,7 +105,9 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
return ( return (
<Flex> <Flex>
<ModelHeaderTitle> <ModelHeaderTitle>
{provider.isSystem ? t(`provider.${provider.id}`) : provider.name} {t('common.models')} {provider.isSystem ? t(`provider.${provider.id}`) : provider.name}
{i18n.language.startsWith('zh') ? '' : ' '}
{t('common.models')}
</ModelHeaderTitle> </ModelHeaderTitle>
{loading && <LoadingOutlined size={20} />} {loading && <LoadingOutlined size={20} />}
</Flex> </Flex>
@ -111,6 +129,15 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
}} }}
centered> centered>
<SearchContainer> <SearchContainer>
<Center>
<Radio.Group value={filterType} onChange={(e) => setFilterType(e.target.value)} buttonStyle="solid">
<Radio.Button value="all">{t('models.all')}</Radio.Button>
<Radio.Button value="vision">{t('models.vision')}</Radio.Button>
<Radio.Button value="websearch">{t('models.websearch')}</Radio.Button>
<Radio.Button value="free">{t('models.free')}</Radio.Button>
<Radio.Button value="embedding">{t('models.embedding')}</Radio.Button>
</Radio.Group>
</Center>
<Search placeholder={t('settings.provider.search_placeholder')} allowClear onSearch={setSearchText} /> <Search placeholder={t('settings.provider.search_placeholder')} allowClear onSearch={setSearchText} />
</SearchContainer> </SearchContainer>
<ListContainer> <ListContainer>
@ -131,12 +158,12 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
{isWebSearchModel(model) && <WebSearchIcon />} {isWebSearchModel(model) && <WebSearchIcon />}
{isFreeModel(model) && ( {isFreeModel(model) && (
<Tag style={{ marginLeft: 10 }} color="green"> <Tag style={{ marginLeft: 10 }} color="green">
Free {t('models.free')}
</Tag> </Tag>
)} )}
{isEmbeddingModel(model) && ( {isEmbeddingModel(model) && (
<Tag style={{ marginLeft: 10 }} color="orange"> <Tag style={{ marginLeft: 10 }} color="orange">
Embed {t('models.embedding')}
</Tag> </Tag>
)} )}
{!isEmpty(model.description) && ( {!isEmpty(model.description) && (
@ -168,11 +195,16 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
const SearchContainer = styled.div` const SearchContainer = styled.div`
display: flex; display: flex;
flex-direction: row; flex-direction: column;
align-items: center; gap: 12px;
justify-content: space-between;
padding: 0 22px; padding: 0 22px;
padding-bottom: 20px; padding-bottom: 20px;
margin-top: -10px;
.ant-radio-group {
display: flex;
flex-wrap: wrap;
}
` `
const ListContainer = styled.div` const ListContainer = styled.div`

View File

@ -9,7 +9,7 @@ import {
} from '@ant-design/icons' } from '@ant-design/icons'
import VisionIcon from '@renderer/components/Icons/VisionIcon' import VisionIcon from '@renderer/components/Icons/VisionIcon'
import WebSearchIcon from '@renderer/components/Icons/WebSearchIcon' import WebSearchIcon from '@renderer/components/Icons/WebSearchIcon'
import { getModelLogo, isVisionModel, isWebSearchModel, VISION_REGEX } from '@renderer/config/models' import { EMBEDDING_REGEX, getModelLogo, isVisionModel, isWebSearchModel, VISION_REGEX } from '@renderer/config/models'
import { PROVIDER_CONFIG } from '@renderer/config/providers' import { PROVIDER_CONFIG } from '@renderer/config/providers'
import { useTheme } from '@renderer/context/ThemeProvider' import { useTheme } from '@renderer/context/ThemeProvider'
import { useAssistants, useDefaultModel } from '@renderer/hooks/useAssistant' import { useAssistants, useDefaultModel } from '@renderer/hooks/useAssistant'
@ -165,7 +165,10 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
<Checkbox.Group <Checkbox.Group
value={model.type} value={model.type}
onChange={(types) => onUpdateModelTypes(model, types as ModelType[])} onChange={(types) => onUpdateModelTypes(model, types as ModelType[])}
options={[{ label: t('model.type.vision'), value: 'vision', disabled: VISION_REGEX.test(model.id) }]} options={[
{ label: t('models.type.vision'), value: 'vision', disabled: VISION_REGEX.test(model.id) },
{ label: t('models.type.embedding'), value: 'embedding', disabled: EMBEDDING_REGEX.test(model.id) }
]}
/> />
</div> </div>
) )
@ -270,7 +273,7 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
</Avatar> </Avatar>
{model.name} {isVisionModel(model) && <VisionIcon />} {model.name} {isVisionModel(model) && <VisionIcon />}
{isWebSearchModel(model) && <WebSearchIcon />} {isWebSearchModel(model) && <WebSearchIcon />}
<Popover content={modelTypeContent(model)} title={t('model.type.select')} trigger="click"> <Popover content={modelTypeContent(model)} title={t('models.type.select')} trigger="click">
<SettingIcon /> <SettingIcon />
</Popover> </Popover>
</ModelListHeader> </ModelListHeader>

View File

@ -1,7 +1,8 @@
import { REFERENCE_PROMPT } from '@renderer/config/prompts'
import { getOllamaKeepAliveTime } from '@renderer/hooks/useOllama' import { getOllamaKeepAliveTime } from '@renderer/hooks/useOllama'
import { getKnowledgeBaseParams } from '@renderer/services/KnowledgeService' import { getKnowledgeBaseParams } from '@renderer/services/KnowledgeService'
import store from '@renderer/store' import store from '@renderer/store'
import { Assistant, FileType, Message, Provider, Suggestion } from '@renderer/types' import { Assistant, Message, Provider, Suggestion } from '@renderer/types'
import { delay } from '@renderer/utils' import { delay } from '@renderer/utils'
import { take } from 'lodash' import { take } from 'lodash'
import OpenAI from 'openai' import OpenAI from 'openai'
@ -88,7 +89,6 @@ export default abstract class BaseProvider {
const knowledgeId = message.knowledgeBaseIds[0] const knowledgeId = message.knowledgeBaseIds[0]
const base = store.getState().knowledge.bases.find((kb) => kb.id === knowledgeId) const base = store.getState().knowledge.bases.find((kb) => kb.id === knowledgeId)
console.debug('knowledge', base)
if (!base) { if (!base) {
return message.content return message.content
@ -99,43 +99,20 @@ export default abstract class BaseProvider {
base: getKnowledgeBaseParams(base) base: getKnowledgeBaseParams(base)
}) })
const references = take(searchResults, 5) const references = take(searchResults, 6).map((item, index) => {
.map((item, index) => { const sourceUrl = item.metadata.source
let sourceUrl = '' const baseItem = base.items.find((i) => i.uniqueId === item.metadata.uniqueLoaderId)
let sourceName = ''
const baseItem = base.items.find((i) => i.uniqueId === item.metadata.uniqueLoaderId) return {
id: index,
content: item.pageContent,
url: sourceUrl,
type: baseItem?.type
}
})
if (baseItem) { const referencesContent = JSON.stringify(references, null, 2)
switch (baseItem.type) {
case 'file':
// sourceUrl = `file://${encodeURIComponent((baseItem?.content as FileType).path)}`
sourceName = (baseItem?.content as FileType).origin_name
break
case 'url':
sourceUrl = baseItem.content as string
sourceName = baseItem.content as string
break
case 'note':
sourceName = baseItem.content as string
break
}
}
return ` return REFERENCE_PROMPT.replace('{question}', message.content).replace('{references}', referencesContent)
---
id: ${index}
content: ${item.pageContent}
source_type: ${baseItem?.type}
source_name: ${sourceName}
source_url: ${sourceUrl}
`
})
.join('\n\n')
const prompt =
'回答问题请参考以下内容,并使用类似 [^1]: source 的脚注格式引用数据来源, source 根据 source_type 决定'
return [message.content, prompt, references].join('\n\n')
} }
} }

View File

@ -1,4 +1,4 @@
import { AddLoaderReturn } from '@llm-tools/embedjs-interfaces' import type { AddLoaderReturn } from '@llm-tools/embedjs-interfaces'
import db from '@renderer/databases' import db from '@renderer/databases'
import { getKnowledgeBaseParams } from '@renderer/services/KnowledgeService' import { getKnowledgeBaseParams } from '@renderer/services/KnowledgeService'
import store from '@renderer/store' import store from '@renderer/store'
@ -98,8 +98,7 @@ class KnowledgeQueue {
break break
} }
console.log(`[KnowledgeQueue] Processing item ${item.id} (${item.type}) in base ${baseId}`) this.processItem(baseId, item)
await this.processItem(baseId, item)
} }
} finally { } finally {
console.log(`[KnowledgeQueue] Finished processing queue for base ${baseId}`) console.log(`[KnowledgeQueue] Finished processing queue for base ${baseId}`)
@ -152,25 +151,18 @@ class KnowledgeQueue {
let result: AddLoaderReturn | null = null let result: AddLoaderReturn | null = null
let note, content let note, content
console.log(`[KnowledgeQueue] Processing item: ${sourceItem.content}`)
switch (item.type) { switch (item.type) {
case 'file':
console.log(`[KnowledgeQueue] Processing file: ${sourceItem.content}`)
result = await window.api.knowledgeBase.add({ base: baseParams, item: sourceItem })
break
case 'url':
console.log(`[KnowledgeQueue] Processing URL: ${sourceItem.content}`)
result = await window.api.knowledgeBase.add({ base: baseParams, item: sourceItem })
break
case 'sitemap':
console.log(`[KnowledgeQueue] Processing Sitemap: ${sourceItem.content}`)
result = await window.api.knowledgeBase.add({ base: baseParams, item: sourceItem })
break
case 'note': case 'note':
console.log(`[KnowledgeQueue] Processing note: ${sourceItem.content}`)
note = await db.knowledge_notes.get(item.id) note = await db.knowledge_notes.get(item.id)
if (!note) throw new Error(`Source note ${item.id} not found`) if (note) {
content = note.content as string content = note.content as string
result = await window.api.knowledgeBase.add({ base: baseParams, item: { ...sourceItem, content } }) result = await window.api.knowledgeBase.add({ base: baseParams, item: { ...sourceItem, content } })
}
break
default:
result = await window.api.knowledgeBase.add({ base: baseParams, item: sourceItem })
break break
} }

View File

@ -1,7 +1,7 @@
import { Assistant, FileType, FileTypes, Message } from '@renderer/types' import { Assistant, FileType, FileTypes, Message } from '@renderer/types'
import { GPTTokens } from 'gpt-tokens'
import { flatten, takeRight } from 'lodash' import { flatten, takeRight } from 'lodash'
import { CompletionUsage } from 'openai/resources' import { CompletionUsage } from 'openai/resources'
import { approximateTokenSize } from 'tokenx'
import { getAssistantSettings } from './AssistantService' import { getAssistantSettings } from './AssistantService'
import { filterContextMessages, filterMessages } from './MessagesService' import { filterContextMessages, filterMessages } from './MessagesService'
@ -45,12 +45,7 @@ async function getMessageParam(message: Message): Promise<MessageItem[]> {
} }
export function estimateTextTokens(text: string) { export function estimateTextTokens(text: string) {
const { usedTokens } = new GPTTokens({ return approximateTokenSize(text)
model: 'gpt-4o',
messages: [{ role: 'user', content: text }]
})
return usedTokens - 7
} }
export function estimateImageTokens(file: FileType) { export function estimateImageTokens(file: FileType) {
@ -58,11 +53,6 @@ export function estimateImageTokens(file: FileType) {
} }
export async function estimateMessageUsage(message: Message): Promise<CompletionUsage> { export async function estimateMessageUsage(message: Message): Promise<CompletionUsage> {
const { usedTokens, promptUsedTokens, completionUsedTokens } = new GPTTokens({
model: 'gpt-4o',
messages: await getMessageParam(message)
})
let imageTokens = 0 let imageTokens = 0
if (message.files) { if (message.files) {
@ -74,10 +64,12 @@ export async function estimateMessageUsage(message: Message): Promise<Completion
} }
} }
const tokens = estimateTextTokens(message.content)
return { return {
prompt_tokens: promptUsedTokens, prompt_tokens: tokens,
completion_tokens: completionUsedTokens, completion_tokens: tokens,
total_tokens: usedTokens + (imageTokens ? imageTokens - 7 : 0) total_tokens: tokens + (imageTokens ? imageTokens - 7 : 0)
} }
} }
@ -121,16 +113,10 @@ export async function estimateHistoryTokens(assistant: Assistant, msgs: Message[
allMessages = allMessages.concat(items) allMessages = allMessages.concat(items)
} }
const { usedTokens } = new GPTTokens({ const prompt = assistant.prompt
model: 'gpt-4o', const input = flatten(allMessages)
messages: [ .map((m) => m.content)
{ .join('\n')
role: 'system',
content: assistant.prompt
},
...flatten(allMessages)
]
})
return usedTokens - 7 + uasageTokens return estimateTextTokens(prompt + input) + uasageTokens
} }

View File

@ -43,12 +43,24 @@ const knowledgeSlice = createSlice({
} }
}, },
updateBases(state, action: PayloadAction<KnowledgeBase[]>) {
state.bases = action.payload
},
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) {
if (action.payload.item.type === 'note') { if (action.payload.item.type === 'file') {
action.payload.item.created_at = new Date(action.payload.item.created_at).getTime()
action.payload.item.updated_at = new Date(action.payload.item.updated_at).getTime()
base.items.push(action.payload.item) base.items.push(action.payload.item)
} }
if (action.payload.item.type === 'directory') {
const directoryExists = base.items.some((item) => item.content === action.payload.item.content)
if (!directoryExists) {
base.items.push(action.payload.item)
}
}
if (action.payload.item.type === 'url') { if (action.payload.item.type === 'url') {
const urlExists = base.items.some((item) => item.content === action.payload.item.content) const urlExists = base.items.some((item) => item.content === action.payload.item.content)
if (!urlExists) { if (!urlExists) {
@ -61,9 +73,7 @@ const knowledgeSlice = createSlice({
base.items.push(action.payload.item) base.items.push(action.payload.item)
} }
} }
if (action.payload.item.type === 'file') { if (action.payload.item.type === 'note') {
action.payload.item.created_at = new Date(action.payload.item.created_at).getTime()
action.payload.item.updated_at = new Date(action.payload.item.updated_at).getTime()
base.items.push(action.payload.item) base.items.push(action.payload.item)
} }
base.updated_at = Date.now() base.updated_at = Date.now()
@ -79,12 +89,10 @@ const knowledgeSlice = createSlice({
} }
}, },
updateFiles(state, action: PayloadAction<{ baseId: string; items: KnowledgeItem[] }>) { addFiles(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) {
// 保留非文件类型的项目 base.items = [...base.items, ...action.payload.items]
const nonFileItems = base.items.filter((item) => item.type !== 'file')
base.items = [...nonFileItems, ...action.payload.items]
base.updated_at = Date.now() base.updated_at = Date.now()
} }
}, },
@ -170,8 +178,9 @@ export const {
deleteBase, deleteBase,
renameBase, renameBase,
updateBase, updateBase,
updateBases,
addItem, addItem,
updateFiles, addFiles,
updateNotes, updateNotes,
removeItem, removeItem,
updateItemProcessingStatus, updateItemProcessingStatus,

View File

@ -89,7 +89,7 @@ export type Provider = {
export type ProviderType = 'openai' | 'anthropic' | 'gemini' export type ProviderType = 'openai' | 'anthropic' | 'gemini'
export type ModelType = 'text' | 'vision' export type ModelType = 'text' | 'vision' | 'embedding'
export type Model = { export type Model = {
id: string id: string
@ -183,11 +183,13 @@ export interface Shortcut {
export type ProcessingStatus = 'pending' | 'processing' | 'completed' | 'failed' export type ProcessingStatus = 'pending' | 'processing' | 'completed' | 'failed'
export type KnowledgeItemType = 'file' | 'url' | 'note' | 'sitemap' | 'directory'
export type KnowledgeItem = { export type KnowledgeItem = {
id: string id: string
baseId?: string baseId?: string
uniqueId?: string uniqueId?: string
type: 'file' | 'url' | 'note' | 'sitemap' type: KnowledgeItemType
content: string | FileType content: string | FileType
created_at: number created_at: number
updated_at: number updated_at: number
@ -197,8 +199,6 @@ export type KnowledgeItem = {
retryCount?: number retryCount?: number
} }
export type KnowledgeItemType = 'file' | 'url' | 'note' | 'sitemap'
export interface KnowledgeBase { export interface KnowledgeBase {
id: string id: string
name: string name: string

726
yarn.lock

File diff suppressed because it is too large Load Diff