feat: 支持工作流知识库多库检索

This commit is contained in:
2026-09-04 17:40:55 +08:00
parent 431fe8e707
commit e938ecda80
56 changed files with 5539 additions and 154 deletions

View File

@@ -0,0 +1,63 @@
import { flushSync, mount, unmount } from 'svelte';
import { afterEach, describe, expect, it, vi } from 'vitest';
import Select from './select.svelte';
describe('Select multiple interaction', () => {
afterEach(() => {
document.body.innerHTML = '';
});
it('keeps the list open across selections and restores focus on Escape', async () => {
const onSelect = vi.fn();
const host = document.createElement('div');
document.body.appendChild(host);
const app = mount(Select, {
target: host,
props: {
items: [
{ label: '知识库一', value: 1 },
{ label: '知识库二', value: 2 },
{
disabledReason: '向量配置不可用',
label: '知识库三',
selectable: false,
value: 3,
},
],
multiple: true,
onSelect,
value: [2, 1],
},
});
flushSync();
const trigger = host.querySelector<HTMLButtonElement>('.tf-select-input')!;
expect(trigger.textContent).toContain('知识库二');
expect(trigger.textContent).toContain('+1');
trigger.click();
flushSync();
const listbox = host.querySelector<HTMLElement>('[role="listbox"]')!;
const floating = listbox.parentElement!;
const options = listbox.querySelectorAll<HTMLButtonElement>('[role="option"]');
expect(floating.style.display).toBe('block');
options[0].click();
options[1].click();
expect(onSelect).toHaveBeenCalledTimes(2);
expect(floating.style.display).toBe('block');
expect(options[2].getAttribute('aria-label')).toContain('向量配置不可用');
options[2].click();
expect(onSelect).toHaveBeenCalledTimes(2);
expect(floating.style.display).toBe('block');
listbox.dispatchEvent(
new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' }),
);
flushSync();
expect(floating.style.display).toBe('none');
expect(document.activeElement).toBe(trigger);
await unmount(app);
});
});

View File

@@ -12,6 +12,7 @@
isArrayDataType,
resolveLoopOutputDataType,
} from '../../utils/loopScope';
import {resolveReferencedParameterDataType} from '../utils/referenceDataType';
onMount(() => {
if (!param.refType) {
@@ -122,7 +123,13 @@
const updateRef = (item: any) => {
const newValue = item.value;
if (!loopOutputAggregation) {
updateParam('ref', newValue);
updateParams({
ref: newValue,
dataType: resolveReferencedParameterDataType(
param,
item.dataType
)
});
return;
}
const dataType = item.dataType || 'String';
@@ -182,6 +189,22 @@
() => acceptedContentTypes,
() => param.ref || ''
);
$effect(() => {
const referencedDataType = selectItems.selected?.dataType;
const nextDataType = resolveReferencedParameterDataType(
param,
referencedDataType
);
if (
!loopOutputAggregation &&
param.refType === 'ref' &&
param.ref &&
referencedDataType &&
param.dataType !== nextDataType
) {
updateParam('dataType', nextDataType);
}
});
let sourceDataType = $derived.by(() => {
return selectItems.selected?.dataType || param.dataType || 'String';
});

View File

@@ -5,7 +5,6 @@
import {type NodeProps, useNodesData, useStore, useSvelteFlow} from '@xyflow/svelte';
import {Heading, Select} from '../base';
import {getCurrentNodeId} from '#components/utils/NodeUtils';
import {useAddParameter} from '../utils/useAddParameter.svelte';
import {getOptions} from '../utils/NodeUtils';
import {onMount} from 'svelte';
import OutputDefList from '../core/OutputDefList.svelte';
@@ -17,6 +16,12 @@
syncManagedParametersForFields,
updateFieldBindingMeta,
} from '../../utils/workflowNodeFields';
import {
buildKnowledgeSelectItems,
ensureKnowledgeOutputDefs,
normalizeKnowledgeIds,
toggleKnowledgeId,
} from '../utils/knowledgeNode';
const { data, ...rest }: {
data: TinyflowNodeData,
@@ -25,7 +30,6 @@
const currentNodeId = getCurrentNodeId();
let currentNode = useNodesData(currentNodeId);
const { addParameter } = useAddParameter();
const { nodes, edges } = $derived(useStore());
const editorParameters = $derived.by(() => {
return buildEditorReferenceParameters(
@@ -37,6 +41,7 @@
});
const options = getOptions();
const reloadKnowledgeValue = '__reload_knowledge_options__';
const retrievalModeOptions: SelectItem[] = [
{ value: 'HYBRID', label: '混合检索' },
{ value: 'VECTOR', label: '向量检索' },
@@ -44,10 +49,33 @@
];
let knowledgeArray = $state<SelectItem[]>([]);
onMount(async () => {
const newLLMs = await options.provider?.knowledge?.();
knowledgeArray.push(...(newLLMs || []));
});
let knowledgeLoading = $state(false);
let knowledgeLoadError = $state('');
const selectedKnowledgeIds = $derived(normalizeKnowledgeIds(data));
const isMultiKnowledge = $derived(selectedKnowledgeIds.length > 1);
const selectableKnowledgeItems = $derived(
buildKnowledgeSelectItems(
knowledgeArray,
selectedKnowledgeIds,
knowledgeLoadError
? { value: reloadKnowledgeValue, label: '重新加载知识库' }
: undefined
)
);
async function loadKnowledges() {
knowledgeLoading = true;
knowledgeLoadError = '';
try {
knowledgeArray = [...((await options.provider?.knowledge?.()) || [])];
} catch (error) {
knowledgeLoadError = error instanceof Error ? error.message : '知识库加载失败';
} finally {
knowledgeLoading = false;
}
}
onMount(loadKnowledges);
const { updateNodeData } = useSvelteFlow();
const syncFieldValue = (fieldName: 'keyword' | 'limit', nextValue: string) => {
@@ -67,59 +95,40 @@
});
};
const toggleKnowledge = (item: SelectItem) => {
if (item.value === reloadKnowledgeValue) {
loadKnowledges();
return;
}
const nextIds = toggleKnowledgeId(selectedKnowledgeIds, item.value);
updateNodeData(currentNodeId, {
knowledgeIds: nextIds,
knowledgeId: undefined,
retrievalMode: nextIds.length > 1
? 'VECTOR'
: (data.retrievalMode || 'HYBRID'),
});
};
$effect(() => {
if (!data.outputDefs || data.outputDefs.length === 0) {
addParameter(currentNodeId, 'outputDefs',
{
name: 'documents',
dataType: 'Array',
nameDisabled: true,
dataTypeDisabled: true,
addChildDisabled: true,
deleteDisabled: true,
children: [
{
name: 'title',
dataType: 'String',
nameDisabled: true,
dataTypeDisabled: true,
deleteDisabled: true
},
{
name: 'content',
dataType: 'String',
nameDisabled: true,
dataTypeDisabled: true,
deleteDisabled: true
},
{
name: 'documentId',
dataType: 'Number',
nameDisabled: true,
dataTypeDisabled: true,
deleteDisabled: true
},
{
name: 'knowledgeId',
dataType: 'Number',
nameDisabled: true,
dataTypeDisabled: true,
deleteDisabled: true
}
]
}
);
const nextOutputDefs = ensureKnowledgeOutputDefs(data.outputDefs);
if (JSON.stringify(nextOutputDefs) !== JSON.stringify(data.outputDefs || [])) {
updateNodeData(currentNodeId, { outputDefs: nextOutputDefs });
}
});
$effect(() => {
if (!data.retrievalMode) {
updateNodeData(currentNodeId, () => {
return {
retrievalMode: 'HYBRID'
};
if (!Array.isArray(data.knowledgeIds) && data.knowledgeId !== undefined) {
updateNodeData(currentNodeId, {
knowledgeIds: normalizeKnowledgeIds(data),
knowledgeId: undefined,
});
}
if (isMultiKnowledge && data.retrievalMode !== 'VECTOR') {
updateNodeData(currentNodeId, { retrievalMode: 'VECTOR' });
} else if (!data.retrievalMode) {
updateNodeData(currentNodeId, { retrievalMode: 'HYBRID' });
}
});
</script>
@@ -136,14 +145,17 @@
<Heading level={3} mt="10px">知识库设置</Heading>
<div class="setting-title">知识库</div>
<div class="setting-item">
<Select items={knowledgeArray} style="width: 100%" placeholder="请选择知识库" onSelect={(item)=>{
const newValue = item.value;
updateNodeData(currentNodeId, ()=>{
return {
knowledgeId: newValue
}
})
}} value={data.knowledgeId ? [data.knowledgeId] : []} />
<Select
items={selectableKnowledgeItems}
style="width: 100%"
placeholder={knowledgeLoading
? '正在加载知识库…'
: (knowledgeLoadError ? '知识库加载失败' : '请选择知识库')}
onSelect={toggleKnowledge}
value={selectedKnowledgeIds}
multiple
disabled={knowledgeLoading}
/>
</div>
<div class="setting-title">关键字</div>
@@ -169,7 +181,8 @@
retrievalMode: newValue
}
})
}} value={data.retrievalMode ? [data.retrievalMode] : ['HYBRID']} />
}} value={data.retrievalMode ? [data.retrievalMode] : ['HYBRID']}
disabled={isMultiKnowledge} />
</div>

View File

@@ -0,0 +1,116 @@
import { describe, expect, it } from 'vitest';
import {
applyEmbeddingCompatibility,
buildKnowledgeSelectItems,
ensureKnowledgeOutputDefs,
normalizeKnowledgeIds,
toggleKnowledgeId,
} from './knowledgeNode';
describe('knowledge node helpers', () => {
it('prefers canonical knowledgeIds over legacy knowledgeId', () => {
expect(normalizeKnowledgeIds({ knowledgeId: '1', knowledgeIds: ['2', '3', '2'] }))
.toEqual(['2', '3']);
expect(normalizeKnowledgeIds({ knowledgeId: '1' })).toEqual(['1']);
expect(normalizeKnowledgeIds({ knowledgeIds: [1, '1', ' 2 '] })).toEqual(['1', '2']);
});
it('toggles one selected knowledge without changing order', () => {
expect(toggleKnowledgeId(['1'], '2')).toEqual(['1', '2']);
expect(toggleKnowledgeId(['1', '2'], '1')).toEqual(['2']);
expect(toggleKnowledgeId([1], '1')).toEqual([]);
});
it('disables incompatible unselected knowledge bases', () => {
const items = applyEmbeddingCompatibility([
{ value: '1', label: '一', embeddingModelId: 'm1', embeddingDimension: 3, vectorStoreEnabled: true },
{ value: '2', label: '二', embeddingModelId: 'm1', embeddingDimension: 3, vectorStoreEnabled: true },
{ value: '3', label: '三', embeddingModelId: 'm2', embeddingDimension: 3, vectorStoreEnabled: true },
], ['1']);
expect(items[1]?.selectable).toBe(true);
expect(items[2]?.selectable).toBe(false);
expect(items[2]?.disabledReason).toContain('Embedding');
});
it('keeps knowledge bases selectable when an older options API omits vector metadata', () => {
const items = applyEmbeddingCompatibility([
{ value: '1', label: '一' },
{ value: '2', label: '二' },
{ value: '3', label: '三' },
], ['1']);
expect(items.every((item) => item.selectable !== false)).toBe(true);
expect(items.every((item) => item.disabledReason === undefined)).toBe(true);
});
it('does not let unknown metadata override an explicit unavailable state', () => {
const unknownFirst = applyEmbeddingCompatibility([
{ value: '1', label: '旧接口知识库' },
{ value: '2', label: '明确不可用', vectorStoreEnabled: false },
], ['1']);
expect(unknownFirst[1]?.selectable).toBe(false);
const unavailableFirst = applyEmbeddingCompatibility([
{ value: '1', label: '明确不可用', vectorStoreEnabled: false },
{ value: '2', label: '旧接口知识库' },
], ['1']);
expect(unavailableFirst[1]?.selectable).toBe(false);
});
it('requires a missing selected knowledge base to be removed before adding another', () => {
const items = applyEmbeddingCompatibility([
{ value: '2', label: '二' },
], ['missing']);
expect(items.find((item) => item.value === '2')?.selectable).toBe(false);
expect(items.find((item) => item.value === 'missing')?.selectable).toBe(true);
});
it('keeps the reload action selectable when loading fails with an existing selection', () => {
const items = buildKnowledgeSelectItems([], ['1'], {
value: '__reload__',
label: '重新加载知识库',
});
expect(items.find((item) => item.value === '1')?.selectable).toBe(true);
expect(items.find((item) => item.value === '__reload__')?.selectable).toBe(true);
expect(items.find((item) => item.value === '__reload__')?.disabledReason).toBeUndefined();
});
it('keeps invalid selected knowledge bases visible and removable', () => {
const items = applyEmbeddingCompatibility([
{ value: 1, label: '一', embeddingModelId: 'm1', embeddingDimension: 3, vectorStoreEnabled: true },
{ value: 2, label: '二', embeddingModelId: 'm2', embeddingDimension: 3, vectorStoreEnabled: true },
], ['1', '2', '9']);
expect(items.map((item) => item.value)).toEqual(['1', '2', '9']);
expect(items[1]?.selectable).toBe(true);
expect(items[1]?.disabledReason).toContain('仅可移除');
expect(items[2]?.label).toContain('已失效');
expect(items[2]?.disabledReason).toContain('仅可移除');
});
it('restores legacy output definitions without exposing retrieval diagnostics', () => {
const outputDefs = ensureKnowledgeOutputDefs([
{ id: 'documents-id', name: 'documents', dataType: 'Array', children: [
{ name: 'title', dataType: 'String' },
] },
{ name: 'retrievalSummary', dataType: 'Object' },
]);
expect(outputDefs.map((item) => item.name)).toEqual(['documents']);
expect(outputDefs[0]?.id).toBe('documents-id');
expect(outputDefs[0]?.children?.find((item) => item.name === 'title')?.id)
.toBe('knowledge_documents_title');
expect(outputDefs[0]?.children?.map((item) => item.name)).toEqual([
'title',
'content',
'documentId',
'knowledgeId',
]);
const ids = outputDefs.flatMap((item) => [item.id, ...(item.children || []).map((child) => child.id)]);
expect(new Set(ids).size).toBe(ids.length);
});
});

View File

@@ -0,0 +1,186 @@
import type { Parameter, SelectItem } from '#types';
export function normalizeKnowledgeIds(data: Record<string, any>): string[] {
if (Array.isArray(data.knowledgeIds)) {
const seen = new Set<string>();
return data.knowledgeIds.flatMap((id: unknown) => {
if (id === null || id === undefined) return [];
const value = String(id).trim();
if (!value || seen.has(value)) return [];
seen.add(value);
return [value];
});
}
return data.knowledgeId === null || data.knowledgeId === undefined || String(data.knowledgeId).trim() === ''
? []
: [String(data.knowledgeId).trim()];
}
export function toggleKnowledgeId(
selectedIds: Array<number | string>,
value: number | string,
): string[] {
const key = String(value);
return selectedIds.some((id) => String(id) === key)
? selectedIds.filter((id) => String(id) !== key).map(String)
: [...selectedIds.map(String), key];
}
export function applyEmbeddingCompatibility(
items: SelectItem[],
selectedIds: Array<number | string>,
): SelectItem[] {
const selectedKeys = new Set(selectedIds.map(String));
const normalizedItems = items.map((item) => ({ ...item, value: String(item.value) }));
for (const selectedId of selectedIds.map(String)) {
if (!normalizedItems.some((item) => String(item.value) === selectedId)) {
normalizedItems.push({
value: selectedId,
label: `已失效知识库(${selectedId}`,
selectable: true,
disabledReason: '知识库不存在或已无权限,仅可移除',
});
}
}
const firstSelected = normalizedItems.find(
(item) => String(item.value) === String(selectedIds[0]),
);
if (!firstSelected) return normalizedItems;
const vectorState = (item: SelectItem): 'available' | 'unavailable' | 'unknown' => {
if (item.vectorStoreEnabled === false) return 'unavailable';
if (
item.vectorStoreEnabled === true
&& item.embeddingModelId !== null
&& item.embeddingModelId !== undefined
&& item.embeddingDimension !== null
&& item.embeddingDimension !== undefined
) {
return 'available';
}
return 'unknown';
};
const firstState = vectorState(firstSelected);
const isCompatible = (item: SelectItem) => firstState === 'available'
&& vectorState(item) === 'available'
&& firstSelected.embeddingModelId === item.embeddingModelId
&& firstSelected.embeddingDimension === item.embeddingDimension;
return normalizedItems.map((item) => {
if (selectedKeys.has(String(item.value))) {
if (item.disabledReason) return item;
const itemState = vectorState(item);
if (itemState === 'unavailable') {
return {
...item,
selectable: true,
disabledReason: '未启用向量检索,仅可移除',
};
}
if (
firstState === 'available'
&& itemState === 'available'
&& !isCompatible(item)
) {
return {
...item,
selectable: true,
disabledReason: '与首个知识库的 Embedding 配置不一致,仅可移除',
};
}
return { ...item, selectable: true, disabledReason: undefined };
}
const itemState = vectorState(item);
if (firstSelected.disabledReason || firstState === 'unavailable') {
return {
...item,
selectable: false,
disabledReason: '请先移除未启用向量检索的知识库',
};
}
if (itemState === 'unavailable') {
return {
...item,
selectable: false,
disabledReason: '未启用向量检索',
};
}
if (firstState === 'unknown' || itemState === 'unknown') {
return { ...item, selectable: true, disabledReason: undefined };
}
return isCompatible(item)
? { ...item, selectable: true, disabledReason: undefined }
: {
...item,
selectable: false,
disabledReason: '与首个知识库的 Embedding 模型或向量维度不一致',
};
});
}
export function buildKnowledgeSelectItems(
items: SelectItem[],
selectedIds: Array<number | string>,
reloadItem?: SelectItem,
): SelectItem[] {
const compatibleItems = applyEmbeddingCompatibility(items, selectedIds);
return reloadItem
? [
...compatibleItems,
{ ...reloadItem, selectable: true, disabledReason: undefined },
]
: compatibleItems;
}
const locked = (parameter: Parameter): Parameter => ({
...parameter,
nameDisabled: true,
dataTypeDisabled: true,
deleteDisabled: true,
});
const expectedOutputDefs: Parameter[] = [
locked({
id: 'knowledge_documents',
name: 'documents',
dataType: 'Array',
addChildDisabled: true,
children: [
locked({ id: 'knowledge_documents_title', name: 'title', dataType: 'String' }),
locked({ id: 'knowledge_documents_content', name: 'content', dataType: 'String' }),
locked({ id: 'knowledge_documents_document_id', name: 'documentId', dataType: 'Number' }),
locked({ id: 'knowledge_documents_knowledge_id', name: 'knowledgeId', dataType: 'Number' }),
],
}),
];
function mergeParameter(current: Parameter | undefined, expected: Parameter): Parameter {
if (!current) {
return {
...expected,
children: expected.children?.map((child) => ({ ...child })),
};
}
const result = { ...current, ...expected, id: current.id || expected.id };
if (expected.children) {
const currentChildren = current.children || [];
result.children = expected.children.map((child) =>
mergeParameter(
currentChildren.find((item) => item.name === child.name),
child,
),
);
}
return result;
}
export function ensureKnowledgeOutputDefs(outputDefs?: Parameter[]): Parameter[] {
const current = outputDefs || [];
return expectedOutputDefs.map((expected) =>
mergeParameter(
current.find((item) => item.name === expected.name),
expected,
),
);
}

View File

@@ -0,0 +1,19 @@
import {describe, expect, it} from 'vitest';
import {resolveReferencedParameterDataType} from './referenceDataType';
describe('resolveReferencedParameterDataType', () => {
it('普通引用跟随来源输出类型变化', () => {
expect(resolveReferencedParameterDataType(
{dataType: 'String'},
'Array<String>',
)).toBe('Array<String>');
});
it('锁定参数保持业务契约类型', () => {
expect(resolveReferencedParameterDataType(
{dataType: 'Array', dataTypeDisabled: true},
'Array<String>',
)).toBe('Array');
});
});

View File

@@ -0,0 +1,13 @@
import type {Parameter} from '#types';
type ReferenceParameter = Pick<Parameter, 'dataType' | 'dataTypeDisabled'>;
export function resolveReferencedParameterDataType(
parameter: ReferenceParameter,
referencedDataType?: string,
) {
if (parameter.dataTypeDisabled === true) {
return parameter.dataType;
}
return referencedDataType || parameter.dataType || 'String';
}

View File

@@ -26,6 +26,9 @@ export type SelectItem = {
isCollection?: boolean;
tags?: string[];
disabledReason?: string;
embeddingModelId?: string;
embeddingDimension?: number;
vectorStoreEnabled?: boolean;
children?: SelectItem[];
};

View File

@@ -7,6 +7,7 @@ export default defineConfig({
conditions: ['browser']
},
test: {
environment: 'happy-dom'
environment: 'happy-dom',
include: ['src/**/*.test.ts'],
}
});