feat: allow knowledge base multiple search #1346 (#1773)

* feat: agent can select multiple knowledge bases

* feat: basic search multiple knowledge base

* fix bug: knowledge base is delete, assistants and agents sync delete

* fix bug: assistant and knowledge base button sync

* feat: allow to search multiple knowledge base

* chore: finish rebase to upstream/main
This commit is contained in:
Chen Tao 2025-02-17 16:36:25 +08:00 committed by GitHub
parent 194a2e1d3a
commit 2e8af9ef26
8 changed files with 83 additions and 78 deletions

View File

@ -307,16 +307,22 @@ export const useKnowledgeBases = () => {
// remove assistant knowledge_base // remove assistant knowledge_base
const _assistants = assistants.map((assistant) => { const _assistants = assistants.map((assistant) => {
if (assistant.knowledge_base?.id === baseId) { if (assistant.knowledge_bases?.find((kb) => kb.id === baseId)) {
return { ...assistant, knowledge_base: undefined } return {
...assistant,
knowledge_bases: assistant.knowledge_bases.filter((kb) => kb.id !== baseId)
}
} }
return assistant return assistant
}) })
// remove agent knowledge_base // remove agent knowledge_base
const _agents = agents.map((agent) => { const _agents = agents.map((agent) => {
if (agent.knowledge_base?.id === baseId) { if (agent.knowledge_bases?.find((kb) => kb.id === baseId)) {
return { ...agent, knowledge_base: undefined } return {
...agent,
knowledge_bases: agent.knowledge_bases.filter((kb) => kb.id !== baseId)
}
} }
return agent return agent
}) })

View File

@ -9,7 +9,7 @@ import { useSidebarIconShow } from '@renderer/hooks/useSidebarIcon'
import { fetchGenerate } from '@renderer/services/ApiService' import { fetchGenerate } from '@renderer/services/ApiService'
import { getDefaultModel } from '@renderer/services/AssistantService' import { getDefaultModel } from '@renderer/services/AssistantService'
import { useAppSelector } from '@renderer/store' import { useAppSelector } from '@renderer/store'
import { Agent } from '@renderer/types' import { Agent, KnowledgeBase } from '@renderer/types'
import { getLeadingEmoji, uuid } from '@renderer/utils' import { getLeadingEmoji, uuid } from '@renderer/utils'
import { Button, Form, FormInstance, Input, Modal, Popover, Select, SelectProps } from 'antd' import { Button, Form, FormInstance, Input, Modal, Popover, Select, SelectProps } from 'antd'
import TextArea from 'antd/es/input/TextArea' import TextArea from 'antd/es/input/TextArea'
@ -25,7 +25,7 @@ type FieldType = {
id: string id: string
name: string name: string
prompt: string prompt: string
knowledge_base_id: string knowledge_base_id: string[]
} }
const PopupContainer: React.FC<Props> = ({ resolve }) => { const PopupContainer: React.FC<Props> = ({ resolve }) => {
@ -57,7 +57,9 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
const _agent: Agent = { const _agent: Agent = {
id: uuid(), id: uuid(),
name: values.name, name: values.name,
knowledge_base: knowledgeState.bases.find((t) => t.id === values.knowledge_base_id), knowledge_bases: values.knowledge_base_id
.map((id) => knowledgeState.bases.find((t) => t.id === id))
.filter((base): base is KnowledgeBase => base !== undefined),
emoji: _emoji, emoji: _emoji,
prompt: values.prompt, prompt: values.prompt,
defaultModel: getDefaultModel(), defaultModel: getDefaultModel(),
@ -156,6 +158,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
{showKnowledgeIcon && ( {showKnowledgeIcon && (
<Form.Item name="knowledge_base_id" label={t('agents.add.knowledge_base')} rules={[{ required: false }]}> <Form.Item name="knowledge_base_id" label={t('agents.add.knowledge_base')} rules={[{ required: false }]}>
<Select <Select
mode="multiple"
allowClear allowClear
placeholder={t('agents.add.knowledge_base.placeholder')} placeholder={t('agents.add.knowledge_base.placeholder')}
menuItemSelectedIcon={<CheckOutlined />} menuItemSelectedIcon={<CheckOutlined />}

View File

@ -52,7 +52,6 @@ interface Props {
let _text = '' let _text = ''
let _files: FileType[] = [] let _files: FileType[] = []
let _base: KnowledgeBase | undefined
const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => { const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
const [text, setText] = useState(_text) const [text, setText] = useState(_text)
@ -83,7 +82,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
const [spaceClickCount, setSpaceClickCount] = useState(0) const [spaceClickCount, setSpaceClickCount] = useState(0)
const spaceClickTimer = useRef<NodeJS.Timeout>() const spaceClickTimer = useRef<NodeJS.Timeout>()
const [isTranslating, setIsTranslating] = useState(false) const [isTranslating, setIsTranslating] = useState(false)
const [selectedKnowledgeBase, setSelectedKnowledgeBase] = useState<KnowledgeBase | undefined>(_base) const [selectedKnowledgeBases, setSelectedKnowledgeBases] = useState<KnowledgeBase[]>([])
const [mentionModels, setMentionModels] = useState<Model[]>([]) const [mentionModels, setMentionModels] = useState<Model[]>([])
const [isMentionPopupOpen, setIsMentionPopupOpen] = useState(false) const [isMentionPopupOpen, setIsMentionPopupOpen] = useState(false)
@ -104,7 +103,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
_text = text _text = text
_files = files _files = files
_base = selectedKnowledgeBase
const sendMessage = useCallback(async () => { const sendMessage = useCallback(async () => {
await modelGenerating() await modelGenerating()
@ -124,8 +122,8 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
status: 'success' status: 'success'
} }
if (selectedKnowledgeBase) { if (selectedKnowledgeBases) {
message.knowledgeBaseIds = [selectedKnowledgeBase.id] message.knowledgeBaseIds = selectedKnowledgeBases.map((base) => base.id)
} }
if (files.length > 0) { if (files.length > 0) {
@ -144,7 +142,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
setTimeout(() => resizeTextArea(), 0) setTimeout(() => resizeTextArea(), 0)
setExpend(false) setExpend(false)
}, [inputEmpty, text, assistant.id, assistant.topics, selectedKnowledgeBase, files, mentionModels]) }, [inputEmpty, text, assistant.id, assistant.topics, selectedKnowledgeBases, files, mentionModels])
const translate = async () => { const translate = async () => {
if (isTranslating) { if (isTranslating) {
@ -458,14 +456,15 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
}, []) }, [])
useEffect(() => { useEffect(() => {
setSelectedKnowledgeBase(showKnowledgeIcon ? assistant.knowledge_base : undefined) // if assistant knowledge bases are undefined return []
}, [assistant.id, assistant.knowledge_base, showKnowledgeIcon]) setSelectedKnowledgeBases(showKnowledgeIcon ? (assistant.knowledge_bases ?? []) : [])
}, [assistant.id, assistant.knowledge_bases, showKnowledgeIcon])
const textareaRows = window.innerHeight >= 1000 || isBubbleStyle ? 2 : 1 const textareaRows = window.innerHeight >= 1000 || isBubbleStyle ? 2 : 1
const handleKnowledgeBaseSelect = (base?: KnowledgeBase) => { const handleKnowledgeBaseSelect = (bases?: KnowledgeBase[]) => {
updateAssistant({ ...assistant, knowledge_base: base }) updateAssistant({ ...assistant, knowledge_bases: bases })
setSelectedKnowledgeBase(base) setSelectedKnowledgeBases(bases ?? [])
} }
const onMentionModel = (model: Model) => { const onMentionModel = (model: Model) => {
@ -573,7 +572,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
</Tooltip> </Tooltip>
{showKnowledgeIcon && ( {showKnowledgeIcon && (
<KnowledgeBaseButton <KnowledgeBaseButton
selectedBase={selectedKnowledgeBase} selectedBases={selectedKnowledgeBases}
onSelect={handleKnowledgeBaseSelect} onSelect={handleKnowledgeBaseSelect}
ToolbarButton={ToolbarButton} ToolbarButton={ToolbarButton}
disabled={files.length > 0} disabled={files.length > 0}

View File

@ -1,71 +1,63 @@
import { FileSearchOutlined } from '@ant-design/icons' import { CheckOutlined, FileSearchOutlined } from '@ant-design/icons'
import { useAppSelector } from '@renderer/store' import { useAppSelector } from '@renderer/store'
import { KnowledgeBase } from '@renderer/types' import { KnowledgeBase } from '@renderer/types'
import { Button, Popover, Tooltip } from 'antd' import { Popover, Select, SelectProps, Tooltip } from 'antd'
import { FC } from 'react' import { FC } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
interface Props { interface Props {
selectedBase?: KnowledgeBase selectedBases?: KnowledgeBase[]
onSelect: (base?: KnowledgeBase) => void onSelect: (bases: KnowledgeBase[]) => void
disabled?: boolean disabled?: boolean
ToolbarButton?: any ToolbarButton?: any
} }
const KnowledgeBaseSelector: FC<Props> = ({ selectedBase, onSelect }) => { const KnowledgeBaseSelector: FC<Props> = ({ selectedBases, onSelect }) => {
const { t } = useTranslation() const { t } = useTranslation()
const knowledgeState = useAppSelector((state) => state.knowledge) const knowledgeState = useAppSelector((state) => state.knowledge)
const knowledgeOptions: SelectProps['options'] = knowledgeState.bases.map((base) => ({
label: base.name,
value: base.id
}))
return ( return (
<SelectorContainer> <SelectorContainer>
{knowledgeState.bases.length === 0 ? ( {knowledgeState.bases.length === 0 ? (
<EmptyMessage>{t('knowledge.no_bases')}</EmptyMessage> <EmptyMessage>{t('knowledge.no_bases')}</EmptyMessage>
) : ( ) : (
<> <Select
{selectedBase && ( mode="multiple"
<Button type="link" block onClick={() => onSelect(undefined)} style={{ textAlign: 'left' }}> value={selectedBases?.map((base) => base.id)}
{t('knowledge.clear_selection')} allowClear
</Button> placeholder={t('agents.add.knowledge_base.placeholder')}
)} menuItemSelectedIcon={<CheckOutlined />}
{knowledgeState.bases.map((base) => ( options={knowledgeOptions}
<Button onChange={(ids) => {
key={base.id} const newSelected = knowledgeState.bases.filter((base) => ids.includes(base.id))
type={selectedBase?.id === base.id ? 'primary' : 'text'} onSelect(newSelected)
block }}
onClick={() => onSelect(base)} style={{ width: '200px' }}
style={{ textAlign: 'left' }}> />
{base.name}
</Button>
))}
</>
)} )}
</SelectorContainer> </SelectorContainer>
) )
} }
const KnowledgeBaseButton: FC<Props> = ({ selectedBase, onSelect, disabled, ToolbarButton }) => { const KnowledgeBaseButton: FC<Props> = ({ selectedBases, onSelect, disabled, ToolbarButton }) => {
const { t } = useTranslation() const { t } = useTranslation()
if (selectedBase) {
return (
<Tooltip placement="top" title={selectedBase.name} arrow>
<ToolbarButton type="text" onClick={() => onSelect(undefined)}>
<FileSearchOutlined style={{ color: selectedBase ? 'var(--color-link)' : 'var(--color-icon)' }} />
</ToolbarButton>
</Tooltip>
)
}
return ( return (
<Tooltip placement="top" title={t('chat.input.knowledge_base')} arrow> <Tooltip placement="top" title={t('chat.input.knowledge_base')} arrow>
<Popover <Popover
placement="top" placement="top"
content={<KnowledgeBaseSelector selectedBase={selectedBase} onSelect={onSelect} />} content={<KnowledgeBaseSelector selectedBases={selectedBases} onSelect={onSelect} />}
overlayStyle={{ maxWidth: 400 }} overlayStyle={{ maxWidth: 400 }}
trigger="click"> trigger="click">
<ToolbarButton type="text" onClick={() => selectedBase && onSelect(undefined)} disabled={disabled}> <ToolbarButton type="text" disabled={disabled}>
<FileSearchOutlined style={{ color: selectedBase ? 'var(--color-link)' : 'var(--color-icon)' }} /> <FileSearchOutlined
style={{ color: selectedBases && selectedBases?.length > 0 ? 'var(--color-link)' : 'var(--color-icon)' }}
/>
</ToolbarButton> </ToolbarButton>
</Popover> </Popover>
</Tooltip> </Tooltip>

View File

@ -26,8 +26,8 @@ const AssistantKnowledgeBaseSettings: React.FC<Props> = ({ assistant, updateAssi
}) })
const onUpdate = (value) => { const onUpdate = (value) => {
const knowledge_base = knowledgeState.bases.find((t) => t.id === value) const knowledge_bases = value.map((id) => knowledgeState.bases.find((b) => b.id === id))
const _assistant = { ...assistant, knowledge_base } const _assistant = { ...assistant, knowledge_bases }
updateAssistant(_assistant) updateAssistant(_assistant)
} }
@ -37,8 +37,9 @@ const AssistantKnowledgeBaseSettings: React.FC<Props> = ({ assistant, updateAssi
{t('common.knowledge_base')} {t('common.knowledge_base')}
</Box> </Box>
<Select <Select
mode="multiple"
allowClear allowClear
defaultValue={assistant.knowledge_base?.id} value={assistant.knowledge_bases?.map((b) => b.id)}
placeholder={t('agents.add.knowledge_base.placeholder')} placeholder={t('agents.add.knowledge_base.placeholder')}
menuItemSelectedIcon={<CheckOutlined />} menuItemSelectedIcon={<CheckOutlined />}
options={knowledgeOptions} options={knowledgeOptions}

View File

@ -5,6 +5,7 @@ import { getKnowledgeReferences } from '@renderer/services/KnowledgeService'
import store from '@renderer/store' import store from '@renderer/store'
import { Assistant, GenerateImageParams, Message, Model, Provider, Suggestion } from '@renderer/types' import { Assistant, GenerateImageParams, Message, Model, Provider, Suggestion } from '@renderer/types'
import { delay, isJSON, parseJSON } from '@renderer/utils' import { delay, isJSON, parseJSON } from '@renderer/utils'
import { t } from 'i18next'
import OpenAI from 'openai' import OpenAI from 'openai'
import { CompletionsParams } from '.' import { CompletionsParams } from '.'
@ -83,21 +84,35 @@ export default abstract class BaseProvider {
return message.content return message.content
} }
const knowledgeId = message.knowledgeBaseIds[0] const bases = store.getState().knowledge.bases.filter((kb) => message.knowledgeBaseIds?.includes(kb.id))
const base = store.getState().knowledge.bases.find((kb) => kb.id === knowledgeId)
if (!base) { if (!bases || bases.length === 0) {
return message.content return message.content
} }
const { referencesContent, referencesCount } = await getKnowledgeReferences(base, message) const allReferencesPromises = bases.map(async (base) => {
const references = await getKnowledgeReferences(base, message)
// 如果知识库中未检索到内容则使用通用逻辑 return {
if (referencesCount === 0) { knowledgeBaseId: base.id,
references
}
})
const allReferences = (await Promise.all(allReferencesPromises))
.filter((result) => result.references && result.references.length > 0)
.flat()
if (allReferences.length === 0) {
window.message.info({
content: t('knowledge.no_match'),
duration: 4,
key: 'knowledge-base-no-match-info'
})
return message.content return message.content
} }
const allReferencesContent = `\`\`\`json\n${JSON.stringify(allReferences, null, 2)}\n\`\`\``
return REFERENCE_PROMPT.replace('{question}', message.content).replace('{references}', referencesContent) return REFERENCE_PROMPT.replace('{question}', message.content).replace('{references}', allReferencesContent)
} }
protected getCustomParameters(assistant: Assistant) { protected getCustomParameters(assistant: Assistant) {

View File

@ -3,7 +3,6 @@ import { DEFAULT_KNOWLEDGE_DOCUMENT_COUNT, DEFAULT_KNOWLEDGE_THRESHOLD } from '@
import { getEmbeddingMaxContext } from '@renderer/config/embedings' import { getEmbeddingMaxContext } from '@renderer/config/embedings'
import AiProvider from '@renderer/providers/AiProvider' import AiProvider from '@renderer/providers/AiProvider'
import { FileType, KnowledgeBase, KnowledgeBaseParams, Message } from '@renderer/types' import { FileType, KnowledgeBase, KnowledgeBaseParams, Message } from '@renderer/types'
import { t } from 'i18next'
import { take } from 'lodash' import { take } from 'lodash'
import { getProviderByModel } from './AssistantService' import { getProviderByModel } from './AssistantService'
@ -91,14 +90,6 @@ export const getKnowledgeReferences = async (base: KnowledgeBase, message: Messa
return item.score >= threshold return item.score >= threshold
}) })
) )
if (searchResults.length === 0) {
window.message.info({
content: t('knowledge.no_match'),
duration: 4,
key: 'knowledge-base-no-match-info'
})
return { referencesContent: '', referencesCount: 0 }
}
const _searchResults = await Promise.all( const _searchResults = await Promise.all(
searchResults.map(async (item) => { searchResults.map(async (item) => {
@ -121,7 +112,5 @@ export const getKnowledgeReferences = async (base: KnowledgeBase, message: Messa
}) })
) )
const referencesContent = `\`\`\`json\n${JSON.stringify(references, null, 2)}\n\`\`\`` return references
return { referencesContent, referencesCount: references.length }
} }

View File

@ -5,7 +5,7 @@ export type Assistant = {
id: string id: string
name: string name: string
prompt: string prompt: string
knowledge_base?: KnowledgeBase knowledge_bases?: KnowledgeBase[]
topics: Topic[] topics: Topic[]
type: string type: string
emoji?: string emoji?: string