feat: Support multiple knowledge base binding in pipelines (#1766)

* Initial plan

* Add multi-knowledge base support to pipelines

- Created database migration dbm010 to convert knowledge-base field from string to array
- Updated default pipeline config to use knowledge-bases array
- Updated pipeline metadata to use knowledge-base-multi-selector type
- Modified localagent.py to retrieve from multiple knowledge bases and concatenate results
- Added KNOWLEDGE_BASE_MULTI_SELECTOR type to frontend form entities
- Implemented multi-selector UI component with dialog for selecting multiple knowledge bases

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* Add i18n translations for multi-knowledge base selector

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* Fix prettier formatting errors in DynamicFormItemComponent

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* Add accessibility attributes to knowledge base selector checkbox

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* fix: minor fix

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
This commit is contained in:
Copilot
2025-11-08 13:45:09 +08:00
committed by GitHub
parent dd2254203c
commit 3edae3e678
12 changed files with 271 additions and 20 deletions
@@ -0,0 +1,88 @@
from .. import migration
import sqlalchemy
from ...entity.persistence import pipeline as persistence_pipeline
@migration.migration_class(10)
class DBMigratePipelineMultiKnowledgeBase(migration.DBMigration):
"""Pipeline support multiple knowledge base binding"""
async def upgrade(self):
"""Upgrade"""
# read all pipelines
pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline))
for pipeline in pipelines:
serialized_pipeline = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
config = serialized_pipeline['config']
# Convert knowledge-base from string to array
if 'local-agent' in config['ai']:
current_kb = config['ai']['local-agent'].get('knowledge-base', '')
# If it's already a list, skip
if isinstance(current_kb, list):
continue
# Convert string to list
if current_kb and current_kb != '__none__':
config['ai']['local-agent']['knowledge-bases'] = [current_kb]
else:
config['ai']['local-agent']['knowledge-bases'] = []
# Remove old field
if 'knowledge-base' in config['ai']['local-agent']:
del config['ai']['local-agent']['knowledge-base']
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
.where(persistence_pipeline.LegacyPipeline.uuid == serialized_pipeline['uuid'])
.values(
{
'config': config,
'for_version': self.ap.ver_mgr.get_current_version(),
}
)
)
async def downgrade(self):
"""Downgrade"""
# read all pipelines
pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline))
for pipeline in pipelines:
serialized_pipeline = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
config = serialized_pipeline['config']
# Convert knowledge-bases from array back to string
if 'local-agent' in config['ai']:
current_kbs = config['ai']['local-agent'].get('knowledge-bases', [])
# If it's already a string, skip
if isinstance(current_kbs, str):
continue
# Convert list to string (take first one or empty)
if current_kbs and len(current_kbs) > 0:
config['ai']['local-agent']['knowledge-base'] = current_kbs[0]
else:
config['ai']['local-agent']['knowledge-base'] = ''
# Remove new field
if 'knowledge-bases' in config['ai']['local-agent']:
del config['ai']['local-agent']['knowledge-bases']
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
.where(persistence_pipeline.LegacyPipeline.uuid == serialized_pipeline['uuid'])
.values(
{
'config': config,
'for_version': self.ap.ver_mgr.get_current_version(),
}
)
)
+23 -12
View File
@@ -40,10 +40,14 @@ class LocalAgentRunner(runner.RequestRunner):
"""运行请求""" """运行请求"""
pending_tool_calls = [] pending_tool_calls = []
kb_uuid = query.pipeline_config['ai']['local-agent']['knowledge-base'] # Get knowledge bases list (new field)
kb_uuids = query.pipeline_config['ai']['local-agent'].get('knowledge-bases', [])
if kb_uuid == '__none__':
kb_uuid = None # Fallback to old field for backward compatibility
if not kb_uuids:
old_kb_uuid = query.pipeline_config['ai']['local-agent'].get('knowledge-base', '')
if old_kb_uuid and old_kb_uuid != '__none__':
kb_uuids = [old_kb_uuid]
user_message = copy.deepcopy(query.user_message) user_message = copy.deepcopy(query.user_message)
@@ -57,21 +61,28 @@ class LocalAgentRunner(runner.RequestRunner):
user_message_text += ce.text user_message_text += ce.text
break break
if kb_uuid and user_message_text: if kb_uuids and user_message_text:
# only support text for now # only support text for now
kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid) all_results = []
# Retrieve from each knowledge base
for kb_uuid in kb_uuids:
kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid)
if not kb: if not kb:
self.ap.logger.warning(f'Knowledge base {kb_uuid} not found') self.ap.logger.warning(f'Knowledge base {kb_uuid} not found, skipping')
raise ValueError(f'Knowledge base {kb_uuid} not found') continue
result = await kb.retrieve(user_message_text, kb.knowledge_base_entity.top_k) result = await kb.retrieve(user_message_text, kb.knowledge_base_entity.top_k)
if result:
all_results.extend(result)
final_user_message_text = '' final_user_message_text = ''
if result: if all_results:
rag_context = '\n\n'.join( rag_context = '\n\n'.join(
f'[{i + 1}] {entry.metadata.get("text", "")}' for i, entry in enumerate(result) f'[{i + 1}] {entry.metadata.get("text", "")}' for i, entry in enumerate(all_results)
) )
final_user_message_text = rag_combined_prompt_template.format( final_user_message_text = rag_combined_prompt_template.format(
rag_context=rag_context, user_message=user_message_text rag_context=rag_context, user_message=user_message_text
+1 -1
View File
@@ -1,6 +1,6 @@
semantic_version = 'v4.4.1' semantic_version = 'v4.4.1'
required_database_version = 9 required_database_version = 10
"""Tag the version of the database schema, used to check if the database needs to be migrated""" """Tag the version of the database schema, used to check if the database needs to be migrated"""
debug_mode = False debug_mode = False
+1 -1
View File
@@ -45,7 +45,7 @@
"content": "You are a helpful assistant." "content": "You are a helpful assistant."
} }
], ],
"knowledge-base": "" "knowledge-bases": []
}, },
"dify-service-api": { "dify-service-api": {
"base-url": "https://api.dify.ai/v1", "base-url": "https://api.dify.ai/v1",
+5 -5
View File
@@ -80,16 +80,16 @@ stages:
zh_Hans: 除非您了解消息结构,否则请只使用 system 单提示词 zh_Hans: 除非您了解消息结构,否则请只使用 system 单提示词
type: prompt-editor type: prompt-editor
required: true required: true
- name: knowledge-base - name: knowledge-bases
label: label:
en_US: Knowledge Base en_US: Knowledge Bases
zh_Hans: 知识库 zh_Hans: 知识库
description: description:
en_US: Configure the knowledge base to use for the agent, if not selected, the agent will directly use the LLM to reply en_US: Configure the knowledge bases to use for the agent, if not selected, the agent will directly use the LLM to reply
zh_Hans: 配置用于提升回复质量的知识库,若不选择,则直接使用大模型回复 zh_Hans: 配置用于提升回复质量的知识库,若不选择,则直接使用大模型回复
type: knowledge-base-selector type: knowledge-base-multi-selector
required: false required: false
default: '' default: []
- name: tbox-app-api - name: tbox-app-api
label: label:
en_US: Tbox App API en_US: Tbox App API
@@ -58,6 +58,9 @@ export default function DynamicFormComponent({
case 'knowledge-base-selector': case 'knowledge-base-selector':
fieldSchema = z.string(); fieldSchema = z.string();
break; break;
case 'knowledge-base-multi-selector':
fieldSchema = z.array(z.string());
break;
case 'bot-selector': case 'bot-selector':
fieldSchema = z.string(); fieldSchema = z.string();
break; break;
@@ -29,6 +29,15 @@ import { useTranslation } from 'react-i18next';
import { extractI18nObject } from '@/i18n/I18nProvider'; import { extractI18nObject } from '@/i18n/I18nProvider';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Checkbox } from '@/components/ui/checkbox';
import { Plus, X } from 'lucide-react';
export default function DynamicFormItemComponent({ export default function DynamicFormItemComponent({
config, config,
@@ -44,6 +53,8 @@ export default function DynamicFormItemComponent({
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]); const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
const [bots, setBots] = useState<Bot[]>([]); const [bots, setBots] = useState<Bot[]>([]);
const [uploading, setUploading] = useState<boolean>(false); const [uploading, setUploading] = useState<boolean>(false);
const [kbDialogOpen, setKbDialogOpen] = useState(false);
const [tempSelectedKBIds, setTempSelectedKBIds] = useState<string[]>([]);
const { t } = useTranslation(); const { t } = useTranslation();
const handleFileUpload = async (file: File): Promise<IFileConfig | null> => { const handleFileUpload = async (file: File): Promise<IFileConfig | null> => {
@@ -90,7 +101,10 @@ export default function DynamicFormItemComponent({
}, [config.type]); }, [config.type]);
useEffect(() => { useEffect(() => {
if (config.type === DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR) { if (
config.type === DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR ||
config.type === DynamicFormItemType.KNOWLEDGE_BASE_MULTI_SELECTOR
) {
httpClient httpClient
.getKnowledgeBases() .getKnowledgeBases()
.then((resp) => { .then((resp) => {
@@ -336,6 +350,128 @@ export default function DynamicFormItemComponent({
</Select> </Select>
); );
case DynamicFormItemType.KNOWLEDGE_BASE_MULTI_SELECTOR:
return (
<>
<div className="space-y-2">
{field.value && field.value.length > 0 ? (
<div className="space-y-2">
{field.value.map((kbId: string) => {
const kb = knowledgeBases.find((base) => base.uuid === kbId);
if (!kb) return null;
return (
<div
key={kbId}
className="flex items-center justify-between rounded-lg border p-3 hover:bg-accent"
>
<div className="flex-1">
<div className="font-medium">{kb.name}</div>
{kb.description && (
<div className="text-sm text-muted-foreground">
{kb.description}
</div>
)}
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => {
const newValue = field.value.filter(
(id: string) => id !== kbId,
);
field.onChange(newValue);
}}
>
<X className="h-4 w-4" />
</Button>
</div>
);
})}
</div>
) : (
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border">
<p className="text-sm text-muted-foreground">
{t('knowledge.noKnowledgeBaseSelected')}
</p>
</div>
)}
</div>
<Button
type="button"
onClick={() => {
setTempSelectedKBIds(field.value || []);
setKbDialogOpen(true);
}}
variant="outline"
className="w-full"
>
<Plus className="mr-2 h-4 w-4" />
{t('knowledge.addKnowledgeBase')}
</Button>
{/* Knowledge Base Selection Dialog */}
<Dialog open={kbDialogOpen} onOpenChange={setKbDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>{t('knowledge.selectKnowledgeBases')}</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto space-y-2 pr-2">
{knowledgeBases.map((base) => {
const isSelected = tempSelectedKBIds.includes(
base.uuid ?? '',
);
return (
<div
key={base.uuid}
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer"
onClick={() => {
const kbId = base.uuid ?? '';
setTempSelectedKBIds((prev) =>
prev.includes(kbId)
? prev.filter((id) => id !== kbId)
: [...prev, kbId],
);
}}
>
<Checkbox
checked={isSelected}
aria-label={`Select ${base.name}`}
/>
<div className="flex-1">
<div className="font-medium">{base.name}</div>
{base.description && (
<div className="text-sm text-muted-foreground">
{base.description}
</div>
)}
</div>
</div>
);
})}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setKbDialogOpen(false)}
>
{t('common.cancel')}
</Button>
<Button
onClick={() => {
field.onChange(tempSelectedKBIds);
setKbDialogOpen(false);
}}
>
{t('common.confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
case DynamicFormItemType.BOT_SELECTOR: case DynamicFormItemType.BOT_SELECTOR:
return ( return (
<Select value={field.value} onValueChange={field.onChange}> <Select value={field.value} onValueChange={field.onChange}>
@@ -29,6 +29,7 @@ export enum DynamicFormItemType {
PROMPT_EDITOR = 'prompt-editor', PROMPT_EDITOR = 'prompt-editor',
UNKNOWN = 'unknown', UNKNOWN = 'unknown',
KNOWLEDGE_BASE_SELECTOR = 'knowledge-base-selector', KNOWLEDGE_BASE_SELECTOR = 'knowledge-base-selector',
KNOWLEDGE_BASE_MULTI_SELECTOR = 'knowledge-base-multi-selector',
PLUGIN_SELECTOR = 'plugin-selector', PLUGIN_SELECTOR = 'plugin-selector',
BOT_SELECTOR = 'bot-selector', BOT_SELECTOR = 'bot-selector',
} }
+3
View File
@@ -488,6 +488,9 @@ const enUS = {
createKnowledgeBase: 'Create Knowledge Base', createKnowledgeBase: 'Create Knowledge Base',
editKnowledgeBase: 'Edit Knowledge Base', editKnowledgeBase: 'Edit Knowledge Base',
selectKnowledgeBase: 'Select Knowledge Base', selectKnowledgeBase: 'Select Knowledge Base',
selectKnowledgeBases: 'Select Knowledge Bases',
addKnowledgeBase: 'Add Knowledge Base',
noKnowledgeBaseSelected: 'No knowledge bases selected',
empty: 'Empty', empty: 'Empty',
editDocument: 'Documents', editDocument: 'Documents',
description: 'Configuring knowledge bases for improved LLM responses', description: 'Configuring knowledge bases for improved LLM responses',
+3
View File
@@ -491,6 +491,9 @@ const jaJP = {
createKnowledgeBase: '知識ベースを作成', createKnowledgeBase: '知識ベースを作成',
editKnowledgeBase: '知識ベースを編集', editKnowledgeBase: '知識ベースを編集',
selectKnowledgeBase: '知識ベースを選択', selectKnowledgeBase: '知識ベースを選択',
selectKnowledgeBases: '知識ベースを選択',
addKnowledgeBase: '知識ベースを追加',
noKnowledgeBaseSelected: '知識ベースが選択されていません',
empty: 'なし', empty: 'なし',
editDocument: 'ドキュメント', editDocument: 'ドキュメント',
description: 'LLMの回答品質向上のための知識ベースを設定します', description: 'LLMの回答品質向上のための知識ベースを設定します',
+3
View File
@@ -471,6 +471,9 @@ const zhHans = {
createKnowledgeBase: '创建知识库', createKnowledgeBase: '创建知识库',
editKnowledgeBase: '编辑知识库', editKnowledgeBase: '编辑知识库',
selectKnowledgeBase: '选择知识库', selectKnowledgeBase: '选择知识库',
selectKnowledgeBases: '选择知识库',
addKnowledgeBase: '添加知识库',
noKnowledgeBaseSelected: '未选择知识库',
empty: '无', empty: '无',
editDocument: '文档', editDocument: '文档',
description: '配置可用于提升模型回复质量的知识库', description: '配置可用于提升模型回复质量的知识库',
+3
View File
@@ -468,6 +468,9 @@ const zhHant = {
createKnowledgeBase: '建立知識庫', createKnowledgeBase: '建立知識庫',
editKnowledgeBase: '編輯知識庫', editKnowledgeBase: '編輯知識庫',
selectKnowledgeBase: '選擇知識庫', selectKnowledgeBase: '選擇知識庫',
selectKnowledgeBases: '選擇知識庫',
addKnowledgeBase: '新增知識庫',
noKnowledgeBaseSelected: '未選擇知識庫',
empty: '無', empty: '無',
editDocument: '文檔', editDocument: '文檔',
description: '設定可用於提升模型回覆品質的知識庫', description: '設定可用於提升模型回覆品質的知識庫',