feat: external knowledge bases (#1783)

* Initial plan

* Add backend support for external knowledge bases

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

* Add frontend support for external knowledge bases with tabs UI

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

* Add i18n translations for all languages (Traditional Chinese and Japanese)

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

* Update knowledge base tab list styling to match plugins page

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

* perf: margin-top for kb page

* refactor: switch RetrievalResultEntry to langbot_plugin pkg ones

* feat: knowledge retriever listing and creating

* stash

* refactor: unify sync mechanism for polymorphic components

* feat: use unified retireval result struct in retrieval test page

* chore: remove unused methods

* feat: retriever icon displaying

* feat: localagent retrieval with external kbs

* chore: bump version of langbot-plugin to 0.2.0b1

* fix: i18n

---------

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-27 23:19:43 +08:00
committed by GitHub
parent 3c04eeaff9
commit a8481e43f0
33 changed files with 1924 additions and 161 deletions
@@ -0,0 +1,558 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { UUID } from 'uuidjs';
import {
DynamicFormItemConfig,
getDefaultValues,
parseDynamicFormItemType,
} from '@/app/home/components/dynamic-form/DynamicFormItemConfig';
import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
import { httpClient } from '@/app/infra/http/HttpClient';
import { ExternalKnowledgeBase } from '@/app/infra/entities/api';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from '@/components/ui/hover-card';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { I18nObject } from '@/app/infra/entities/common';
// Form schema
const getFormSchema = (t: (key: string) => string) =>
z.object({
name: z.string().min(1, { message: t('knowledge.nameRequired') }),
description: z.string().optional(),
plugin_author: z.string().min(1, { message: 'Please select a retriever' }),
plugin_name: z.string().min(1, { message: 'Please select a retriever' }),
retriever_name: z.string().min(1, { message: 'Please select a retriever' }),
retriever_config: z.record(z.string(), z.any()),
});
// Retriever information interface
interface RetrieverInfo {
plugin_author: string;
plugin_name: string;
retriever_name: string;
retriever_description: I18nObject;
manifest: {
manifest?: {
metadata?: {
label?: I18nObject;
description?: I18nObject;
};
spec?: {
config?: IDynamicFormItemSchema[];
};
};
};
}
interface ExternalKBFormProps {
initKBId?: string;
onFormSubmit: (value: z.infer<ReturnType<typeof getFormSchema>>) => void;
onKBDeleted: () => void;
onNewKBCreated: (kbId: string) => void;
}
export default function ExternalKBForm({
initKBId,
onFormSubmit,
onKBDeleted,
onNewKBCreated,
}: ExternalKBFormProps) {
const { t } = useTranslation();
const formSchema = getFormSchema(t);
// Form setup
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: '',
description: '',
plugin_author: '',
plugin_name: '',
retriever_name: '',
retriever_config: {},
},
});
// State management
const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false);
const [availableRetrievers, setAvailableRetrievers] = useState<
RetrieverInfo[]
>([]);
const [retrieverNameToConfigMap, setRetrieverNameToConfigMap] = useState(
new Map<string, IDynamicFormItemSchema[]>(),
);
const [showDynamicForm, setShowDynamicForm] = useState<boolean>(false);
const [dynamicFormConfigList, setDynamicFormConfigList] = useState<
IDynamicFormItemSchema[]
>([]);
// Initialize form when initKBId changes
useEffect(() => {
loadFormData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initKBId]);
/**
* Load form data: initialize retrievers list and load KB config if editing
*/
async function loadFormData() {
const configMap = await loadAvailableRetrievers();
if (initKBId) {
// Edit mode: load existing KB configuration
try {
const kbConfig = await loadKBConfig(initKBId);
// Set form values
form.setValue('name', kbConfig.name);
form.setValue('description', kbConfig.description || '');
form.setValue('plugin_author', kbConfig.plugin_author);
form.setValue('plugin_name', kbConfig.plugin_name);
form.setValue('retriever_name', kbConfig.retriever_name);
form.setValue('retriever_config', kbConfig.retriever_config);
// Load dynamic form for the selected retriever
const fullName = `${kbConfig.plugin_author}/${kbConfig.plugin_name}/${kbConfig.retriever_name}`;
loadDynamicFormConfig(fullName, configMap);
} catch (err) {
toast.error('Failed to load KB config: ' + (err as Error).message);
}
} else {
// Create mode: reset form
form.reset();
}
}
/**
* Load available retrievers from API and build config map
*/
async function loadAvailableRetrievers(): Promise<
Map<string, IDynamicFormItemSchema[]>
> {
const retrieversRes = await httpClient.listKnowledgeRetrievers();
setAvailableRetrievers((retrieversRes.retrievers || []) as RetrieverInfo[]);
// Build retriever name to config map
const configMap = new Map<string, IDynamicFormItemSchema[]>();
((retrieversRes.retrievers || []) as RetrieverInfo[]).forEach(
(retriever) => {
const fullName = `${retriever.plugin_author}/${retriever.plugin_name}/${retriever.retriever_name}`;
const configSchema = retriever.manifest?.manifest?.spec?.config || [];
configMap.set(
fullName,
configSchema.map(
(item) =>
new DynamicFormItemConfig({
default: item.default,
id: UUID.generate(),
label: item.label,
description: item.description,
name: item.name,
required: item.required,
type: parseDynamicFormItemType(item.type),
options: item.options,
}),
),
);
},
);
setRetrieverNameToConfigMap(configMap);
return configMap;
}
/**
* Load KB configuration from API
*/
async function loadKBConfig(
kbId: string,
): Promise<z.infer<typeof formSchema>> {
const res = await httpClient.getExternalKnowledgeBase(kbId);
const kb = res.base;
return {
name: kb.name,
description: kb.description,
plugin_author: kb.plugin_author,
plugin_name: kb.plugin_name,
retriever_name: kb.retriever_name,
retriever_config: kb.retriever_config || {},
};
}
/**
* Load dynamic form configuration for selected retriever
* @param fullRetrieverName - Full retriever name in format: plugin_author/plugin_name/retriever_name
* @param configMapOverride - Optional config map to use (for initial load)
*/
function loadDynamicFormConfig(
fullRetrieverName: string,
configMapOverride?: Map<string, IDynamicFormItemSchema[]>,
) {
if (!fullRetrieverName) {
setShowDynamicForm(false);
return;
}
// Use provided config map or fall back to state
const configMap = configMapOverride || retrieverNameToConfigMap;
const configList = configMap.get(fullRetrieverName);
if (configList && configList.length > 0) {
setDynamicFormConfigList(configList);
setShowDynamicForm(true);
// Only reset to default values when manually selecting (not initial load)
if (!configMapOverride) {
form.setValue('retriever_config', getDefaultValues(configList));
}
} else {
setShowDynamicForm(false);
if (!configMapOverride) {
form.setValue('retriever_config', {});
}
}
}
/**
* Handle retriever selection change
*/
function handleRetrieverSelect(fullRetrieverName: string) {
if (!fullRetrieverName) {
setShowDynamicForm(false);
return;
}
// Parse and update form fields
const parts = fullRetrieverName.split('/');
if (parts.length === 3) {
form.setValue('plugin_author', parts[0]);
form.setValue('plugin_name', parts[1]);
form.setValue('retriever_name', parts[2]);
}
// Load dynamic form configuration
loadDynamicFormConfig(fullRetrieverName);
}
/**
* Handle form submission (create or update)
*/
function handleFormSubmit() {
const formData: ExternalKnowledgeBase = {
name: form.getValues().name,
description: form.getValues().description || '',
plugin_author: form.getValues().plugin_author,
plugin_name: form.getValues().plugin_name,
retriever_name: form.getValues().retriever_name,
retriever_config: form.getValues().retriever_config,
};
if (initKBId) {
// Update existing KB
httpClient
.updateExternalKnowledgeBase(initKBId, { ...formData, uuid: initKBId })
.then(() => {
onFormSubmit(form.getValues());
toast.success(t('knowledge.updateExternalSuccess'));
})
.catch((err) => {
toast.error('Failed to update KB: ' + err.message);
});
} else {
// Create new KB
httpClient
.createExternalKnowledgeBase(formData)
.then((res) => {
toast.success(t('knowledge.createExternalSuccess'));
onNewKBCreated(res.uuid);
form.reset();
})
.catch((err) => {
toast.error('Failed to create KB: ' + err.message);
});
}
}
/**
* Handle KB deletion
*/
function handleDelete() {
if (!initKBId) return;
httpClient
.deleteExternalKnowledgeBase(initKBId)
.then(() => {
onKBDeleted();
toast.success(t('knowledge.deleteExternalSuccess'));
})
.catch((err) => {
toast.error('Failed to delete KB: ' + err.message);
});
}
/**
* Get retriever label with i18n support
*/
function getRetrieverLabel(fullName: string): string {
const retriever = availableRetrievers.find(
(r) =>
`${r.plugin_author}/${r.plugin_name}/${r.retriever_name}` === fullName,
);
return retriever?.manifest?.manifest?.metadata?.label
? extractI18nObject(retriever.manifest.manifest.metadata.label)
: fullName;
}
// Compute full retriever name for display
const currentRetrieverFullName =
form.watch('plugin_author') &&
form.watch('plugin_name') &&
form.watch('retriever_name')
? `${form.watch('plugin_author')}/${form.watch(
'plugin_name',
)}/${form.watch('retriever_name')}`
: '';
return (
<div>
{/* Delete Confirmation Dialog */}
<Dialog
open={showDeleteConfirmModal}
onOpenChange={setShowDeleteConfirmModal}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('common.confirmDelete')}</DialogTitle>
</DialogHeader>
<DialogDescription>
{t('knowledge.deleteConfirmation')}
</DialogDescription>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowDeleteConfirmModal(false)}
>
{t('common.cancel')}
</Button>
<Button
variant="destructive"
onClick={() => {
handleDelete();
setShowDeleteConfirmModal(false);
}}
>
{t('common.confirmDelete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Main Form */}
<Form {...form}>
<form
id="external-kb-form"
onSubmit={form.handleSubmit(handleFormSubmit)}
className="space-y-8"
>
<div className="space-y-4">
{/* KB Name */}
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('knowledge.kbName')}
<span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* KB Description */}
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>{t('knowledge.kbDescription')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Retriever Selector */}
<FormField
control={form.control}
name="retriever_name"
render={() => (
<FormItem>
<FormLabel>
{t('knowledge.retriever')}
<span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<Select
onValueChange={handleRetrieverSelect}
value={currentRetrieverFullName}
>
<SelectTrigger className="w-full bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue
placeholder={t('knowledge.selectRetriever')}
/>
</SelectTrigger>
<SelectContent className="fixed z-[1000]">
<SelectGroup>
{availableRetrievers.map((retriever) => {
const fullName = `${retriever.plugin_author}/${retriever.plugin_name}/${retriever.retriever_name}`;
const label = retriever.manifest?.manifest?.metadata
?.label
? extractI18nObject(
retriever.manifest.manifest.metadata.label,
)
: retriever.retriever_name;
const description = extractI18nObject(
retriever.retriever_description,
);
return (
<HoverCard
key={fullName}
openDelay={0}
closeDelay={0}
>
<HoverCardTrigger asChild>
<SelectItem value={fullName}>
{label}
</SelectItem>
</HoverCardTrigger>
<HoverCardContent
className="w-80 data-[state=open]:animate-none"
align="end"
side="right"
sideOffset={10}
>
<div className="space-y-2">
<div className="flex items-start gap-3">
<img
src={httpClient.getPluginIconURL(
retriever.plugin_author,
retriever.plugin_name,
)}
alt="plugin icon"
className="w-10 h-10 rounded-[8%] flex-shrink-0"
/>
<div className="flex flex-col gap-1 flex-1 min-w-0">
<h4 className="font-medium text-sm">
{label}
</h4>
<p className="text-xs text-muted-foreground">
{retriever.plugin_author} /{' '}
{retriever.plugin_name}
</p>
</div>
</div>
{description && (
<p className="text-sm text-muted-foreground">
{description}
</p>
)}
</div>
</HoverCardContent>
</HoverCard>
);
})}
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Selected Retriever Card */}
{currentRetrieverFullName && (
<div className="flex items-start gap-3 p-4 rounded-lg border">
<img
src={httpClient.getPluginIconURL(
form.watch('plugin_author'),
form.watch('plugin_name'),
)}
alt="plugin icon"
className="w-12 h-12 rounded-[8%] flex-shrink-0"
/>
<div className="flex flex-col gap-1">
<div className="font-medium">
{getRetrieverLabel(currentRetrieverFullName)}
</div>
<div className="text-sm text-gray-500">
{form.watch('plugin_author')} / {form.watch('plugin_name')}
</div>
</div>
</div>
)}
{/* Dynamic Retriever Configuration Form */}
{showDynamicForm && dynamicFormConfigList.length > 0 && (
<div className="space-y-4">
<div className="text-lg font-medium">
{t('knowledge.retrieverConfiguration')}
</div>
<DynamicFormComponent
itemConfigList={dynamicFormConfigList}
initialValues={form.watch('retriever_config')}
onSubmit={(values) => {
form.setValue('retriever_config', values);
}}
/>
</div>
)}
</div>
</form>
</Form>
</div>
);
}