This commit is contained in:
Junyan Qin
2026-03-27 17:22:24 +08:00
parent cad259fe39
commit 244e16c491
12 changed files with 790 additions and 502 deletions
@@ -14,6 +14,7 @@ import DynamicFormItemComponent from '@/app/home/components/dynamic-form/Dynamic
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { extractI18nObject } from '@/i18n/I18nProvider'; import { extractI18nObject } from '@/i18n/I18nProvider';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
export default function DynamicFormComponent({ export default function DynamicFormComponent({
itemConfigList, itemConfigList,
@@ -273,6 +274,46 @@ export default function DynamicFormComponent({
// All fields are disabled when editing (creation_settings are immutable) // All fields are disabled when editing (creation_settings are immutable)
const isFieldDisabled = !!isEditing; const isFieldDisabled = !!isEditing;
// Boolean fields use a special inline layout
if (config.type === 'boolean') {
return (
<FormField
key={config.id}
control={form.control}
name={config.name as keyof FormValues}
render={({ field }) => (
<FormItem>
<div
className={cn(
'flex flex-row items-center justify-between rounded-lg border p-4 max-w-2xl',
isFieldDisabled && 'pointer-events-none opacity-60',
)}
>
<div className="space-y-0.5">
<FormLabel className="text-base">
{extractI18nObject(config.label)}
</FormLabel>
{config.description && (
<p className="text-sm text-muted-foreground">
{extractI18nObject(config.description)}
</p>
)}
</div>
<FormControl>
<DynamicFormItemComponent
config={config}
field={field}
onFileUploaded={onFileUploaded}
/>
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
);
}
return ( return (
<FormField <FormField
key={config.id} key={config.id}
@@ -37,7 +37,7 @@ import {
DialogFooter, DialogFooter,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Plus, X, Eye, Wrench } from 'lucide-react'; import { Plus, X, Eye, Wrench, Trash2 } from 'lucide-react';
export default function DynamicFormItemComponent({ export default function DynamicFormItemComponent({
config, config,
@@ -181,27 +181,28 @@ export default function DynamicFormItemComponent({
return ( return (
<Input <Input
type="number" type="number"
className="max-w-xs"
{...field} {...field}
onChange={(e) => field.onChange(Number(e.target.value))} onChange={(e) => field.onChange(Number(e.target.value))}
/> />
); );
case DynamicFormItemType.STRING: case DynamicFormItemType.STRING:
return <Input {...field} />; return <Input className="max-w-md" {...field} />;
case DynamicFormItemType.TEXT: case DynamicFormItemType.TEXT:
return <Textarea {...field} className="min-h-[120px]" />; return <Textarea {...field} className="min-h-[120px] max-w-2xl" />;
case DynamicFormItemType.BOOLEAN: case DynamicFormItemType.BOOLEAN:
return <Switch checked={field.value} onCheckedChange={field.onChange} />; return <Switch checked={field.value} onCheckedChange={field.onChange} />;
case DynamicFormItemType.STRING_ARRAY: case DynamicFormItemType.STRING_ARRAY:
return ( return (
<div className="space-y-2"> <div className="space-y-2 max-w-md">
{field.value.map((item: string, index: number) => ( {field.value.map((item: string, index: number) => (
<div key={index} className="flex gap-2 items-center"> <div key={index} className="flex gap-1.5 items-center">
<Input <Input
className="w-[200px]" className="flex-1"
value={item} value={item}
onChange={(e) => { onChange={(e) => {
const newValue = [...field.value]; const newValue = [...field.value];
@@ -209,9 +210,11 @@ export default function DynamicFormItemComponent({
field.onChange(newValue); field.onChange(newValue);
}} }}
/> />
<button <Button
type="button" type="button"
className="p-2 hover:bg-gray-100 rounded" variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => { onClick={() => {
const newValue = field.value.filter( const newValue = field.value.filter(
(_: string, i: number) => i !== index, (_: string, i: number) => i !== index,
@@ -219,24 +222,19 @@ export default function DynamicFormItemComponent({
field.onChange(newValue); field.onChange(newValue);
}} }}
> >
<svg <Trash2 className="size-4" />
xmlns="http://www.w3.org/2000/svg" </Button>
viewBox="0 0 24 24"
fill="currentColor"
className="w-5 h-5 text-red-500"
>
<path d="M7 4V2H17V4H22V6H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V6H2V4H7ZM6 6V20H18V6H6ZM9 9H11V17H9V9ZM13 9H15V17H13V9Z"></path>
</svg>
</button>
</div> </div>
))} ))}
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
className="w-full border-dashed text-muted-foreground hover:text-foreground"
onClick={() => { onClick={() => {
field.onChange([...field.value, '']); field.onChange([...field.value, '']);
}} }}
> >
<Plus className="size-4 mr-1.5" />
{t('common.add')} {t('common.add')}
</Button> </Button>
</div> </div>
@@ -245,7 +243,7 @@ export default function DynamicFormItemComponent({
case DynamicFormItemType.SELECT: case DynamicFormItemType.SELECT:
return ( return (
<Select value={field.value} onValueChange={field.onChange}> <Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]"> <SelectTrigger className="max-w-md bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('common.select')} /> <SelectValue placeholder={t('common.select')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -274,31 +272,33 @@ export default function DynamicFormItemComponent({
); );
return ( return (
<Select value={field.value} onValueChange={field.onChange}> <div className="max-w-md">
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]"> <Select value={field.value} onValueChange={field.onChange}>
<SelectValue placeholder={t('models.selectModel')} /> <SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
</SelectTrigger> <SelectValue placeholder={t('models.selectModel')} />
<SelectContent> </SelectTrigger>
{Object.entries(groupedModels).map(([providerName, models]) => ( <SelectContent>
<SelectGroup key={providerName}> {Object.entries(groupedModels).map(([providerName, models]) => (
<SelectLabel>{providerName}</SelectLabel> <SelectGroup key={providerName}>
{models.map((model) => ( <SelectLabel>{providerName}</SelectLabel>
<SelectItem key={model.uuid} value={model.uuid}> {models.map((model) => (
<span className="inline-flex items-center gap-1"> <SelectItem key={model.uuid} value={model.uuid}>
{model.name} <span className="inline-flex items-center gap-1">
{model.abilities?.includes('vision') && ( {model.name}
<Eye className="h-3 w-3 text-muted-foreground" /> {model.abilities?.includes('vision') && (
)} <Eye className="h-3 w-3 text-muted-foreground" />
{model.abilities?.includes('func_call') && ( )}
<Wrench className="h-3 w-3 text-muted-foreground" /> {model.abilities?.includes('func_call') && (
)} <Wrench className="h-3 w-3 text-muted-foreground" />
</span> )}
</SelectItem> </span>
))} </SelectItem>
</SelectGroup> ))}
))} </SelectGroup>
</SelectContent> ))}
</Select> </SelectContent>
</Select>
</div>
); );
case DynamicFormItemType.EMBEDDING_MODEL_SELECTOR: case DynamicFormItemType.EMBEDDING_MODEL_SELECTOR:
@@ -314,25 +314,27 @@ export default function DynamicFormItemComponent({
); );
return ( return (
<Select value={field.value} onValueChange={field.onChange}> <div className="max-w-md">
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]"> <Select value={field.value} onValueChange={field.onChange}>
<SelectValue placeholder={t('knowledge.selectEmbeddingModel')} /> <SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
</SelectTrigger> <SelectValue placeholder={t('knowledge.selectEmbeddingModel')} />
<SelectContent> </SelectTrigger>
{Object.entries(groupedEmbeddingModels).map( <SelectContent>
([providerName, models]) => ( {Object.entries(groupedEmbeddingModels).map(
<SelectGroup key={providerName}> ([providerName, models]) => (
<SelectLabel>{providerName}</SelectLabel> <SelectGroup key={providerName}>
{models.map((model) => ( <SelectLabel>{providerName}</SelectLabel>
<SelectItem key={model.uuid} value={model.uuid}> {models.map((model) => (
{model.name} <SelectItem key={model.uuid} value={model.uuid}>
</SelectItem> {model.name}
))} </SelectItem>
</SelectGroup> ))}
), </SelectGroup>
)} ),
</SelectContent> )}
</Select> </SelectContent>
</Select>
</div>
); );
case DynamicFormItemType.MODEL_FALLBACK_SELECTOR: { case DynamicFormItemType.MODEL_FALLBACK_SELECTOR: {
@@ -495,11 +497,11 @@ export default function DynamicFormItemComponent({
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" size="icon"
className="h-8 w-8 p-0 text-destructive" className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => removeFallbackModel(index)} onClick={() => removeFallbackModel(index)}
> >
<X className="h-4 w-4" /> <Trash2 className="size-4" />
</Button> </Button>
</div> </div>
</div> </div>
@@ -512,10 +514,10 @@ export default function DynamicFormItemComponent({
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
className="w-full" className="w-full border-dashed text-muted-foreground hover:text-foreground"
onClick={addFallbackModel} onClick={addFallbackModel}
> >
<Plus className="h-4 w-4 mr-1" /> <Plus className="size-4 mr-1.5" />
{t('models.fallback.addFallback')} {t('models.fallback.addFallback')}
</Button> </Button>
</div> </div>
@@ -77,6 +77,11 @@ import {
CollapsibleTrigger, CollapsibleTrigger,
} from '@/components/ui/collapsible'; } from '@/components/ui/collapsible';
import { ChevronRight, Plus } from 'lucide-react'; import { ChevronRight, Plus } from 'lucide-react';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useSidebarData, SidebarEntityItem } from './SidebarDataContext'; import { useSidebarData, SidebarEntityItem } from './SidebarDataContext';
@@ -346,7 +351,7 @@ function NavItems({
{canCreate && ( {canCreate && (
<button <button
type="button" type="button"
className="p-1 rounded-sm hover:bg-sidebar-accent opacity-0 group-hover/category-header:opacity-100 transition-opacity" className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground opacity-0 group-hover/category-header:opacity-100 transition-all"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
router.push(`${routePrefix}?id=new`); router.push(`${routePrefix}?id=new`);
@@ -395,49 +400,66 @@ function NavItems({
isPlugin ? 'group/plugin-item relative' : '' isPlugin ? 'group/plugin-item relative' : ''
} }
> >
<SidebarMenuSubButton <Tooltip delayDuration={500}>
asChild <TooltipTrigger asChild>
isActive={isItemActive} <SidebarMenuSubButton
> asChild
<a isActive={isItemActive}
href={itemRoute} >
className={cn( <a
isPlugin && !item.debug ? 'pr-6' : '', href={itemRoute}
)} className={cn(
onClick={(e) => { isPlugin && !item.debug ? 'pr-6' : '',
e.preventDefault();
router.push(itemRoute);
}}
>
{item.emoji ? (
<span className="text-sm shrink-0">
{item.emoji}
</span>
) : item.iconURL ? (
<span className="relative shrink-0">
<img
src={item.iconURL}
alt=""
className="size-4 rounded"
/>
{isBot && (
<span
className={cn(
'absolute -bottom-0.5 -right-0.5 size-2 rounded-full border-2 border-sidebar',
item.enabled === false
? 'bg-muted-foreground/40'
: 'bg-green-500',
)}
/>
)} )}
</span> onClick={(e) => {
) : null} e.preventDefault();
<span className="truncate">{item.name}</span> router.push(itemRoute);
{item.debug && ( }}
<Bug className="size-3.5 shrink-0 text-orange-400" /> >
)} {item.emoji ? (
</a> <span className="text-sm shrink-0">
</SidebarMenuSubButton> {item.emoji}
</span>
) : item.iconURL ? (
<span className="relative shrink-0">
<img
src={item.iconURL}
alt=""
className="size-4 rounded"
/>
{isBot && (
<span
className={cn(
'absolute -bottom-0.5 -right-0.5 size-2 rounded-full border-2 border-sidebar',
item.enabled === false
? 'bg-muted-foreground/40'
: 'bg-green-500',
)}
/>
)}
</span>
) : null}
<span className="truncate">
{item.name}
</span>
{item.debug && (
<Bug className="size-3.5 shrink-0 text-orange-400" />
)}
</a>
</SidebarMenuSubButton>
</TooltipTrigger>
{item.description && (
<TooltipContent
side="right"
align="center"
className="max-w-64"
>
{item.description.length > 80
? item.description.slice(0, 80) + '…'
: item.description}
</TooltipContent>
)}
</Tooltip>
{/* Plugin context menu - shown on hover (not for debug plugins) */} {/* Plugin context menu - shown on hover (not for debug plugins) */}
{isPlugin && !item.debug && ( {isPlugin && !item.debug && (
<PluginItemMenu <PluginItemMenu
@@ -15,6 +15,7 @@ import { isNewerVersion } from '@/app/utils/versionCompare';
export interface SidebarEntityItem { export interface SidebarEntityItem {
id: string; id: string;
name: string; name: string;
description?: string;
emoji?: string; emoji?: string;
iconURL?: string; iconURL?: string;
updatedAt?: string; // ISO timestamp for sorting by most recently edited updatedAt?: string; // ISO timestamp for sorting by most recently edited
@@ -63,6 +64,7 @@ export function SidebarDataProvider({
resp.bots.map((bot) => ({ resp.bots.map((bot) => ({
id: bot.uuid || '', id: bot.uuid || '',
name: bot.name, name: bot.name,
description: bot.description,
iconURL: httpClient.getAdapterIconURL(bot.adapter), iconURL: httpClient.getAdapterIconURL(bot.adapter),
updatedAt: bot.updated_at, updatedAt: bot.updated_at,
enabled: bot.enable ?? true, enabled: bot.enable ?? true,
@@ -80,6 +82,7 @@ export function SidebarDataProvider({
resp.pipelines.map((p) => ({ resp.pipelines.map((p) => ({
id: p.uuid || '', id: p.uuid || '',
name: p.name, name: p.name,
description: p.description,
emoji: p.emoji, emoji: p.emoji,
updatedAt: p.updated_at, updatedAt: p.updated_at,
})), })),
@@ -96,6 +99,7 @@ export function SidebarDataProvider({
resp.bases.map((kb) => ({ resp.bases.map((kb) => ({
id: kb.uuid || '', id: kb.uuid || '',
name: kb.name, name: kb.name,
description: kb.description,
emoji: kb.emoji, emoji: kb.emoji,
updatedAt: kb.updated_at, updatedAt: kb.updated_at,
})), })),
+59 -26
View File
@@ -4,6 +4,13 @@ import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -21,7 +28,7 @@ import { useTranslation } from 'react-i18next';
import { KnowledgeBase } from '@/app/infra/entities/api'; import { KnowledgeBase } from '@/app/infra/entities/api';
import { CustomApiError } from '@/app/infra/entities/common'; import { CustomApiError } from '@/app/infra/entities/common';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { FileText, FolderOpen, Search } from 'lucide-react'; import { FileText, FolderOpen, Search, Trash2 } from 'lucide-react';
export default function KBDetailContent({ id }: { id: string }) { export default function KBDetailContent({ id }: { id: string }) {
const isCreateMode = id === 'new'; const isCreateMode = id === 'new';
@@ -44,6 +51,7 @@ export default function KBDetailContent({ id }: { id: string }) {
const [activeTab, setActiveTab] = useState('metadata'); const [activeTab, setActiveTab] = useState('metadata');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [kbInfo, setKbInfo] = useState<KnowledgeBase | null>(null); const [kbInfo, setKbInfo] = useState<KnowledgeBase | null>(null);
const [formDirty, setFormDirty] = useState(false);
const loadKbInfo = useCallback( const loadKbInfo = useCallback(
async (kbId: string) => { async (kbId: string) => {
@@ -81,7 +89,6 @@ export default function KBDetailContent({ id }: { id: string }) {
function handleNewKbCreated(newKbId: string) { function handleNewKbCreated(newKbId: string) {
refreshKnowledgeBases(); refreshKnowledgeBases();
// Navigate to the newly created KB's detail view via query param
router.push(`/home/knowledge?id=${encodeURIComponent(newKbId)}`); router.push(`/home/knowledge?id=${encodeURIComponent(newKbId)}`);
} }
@@ -106,45 +113,47 @@ export default function KBDetailContent({ id }: { id: string }) {
return await httpClient.retrieveKnowledgeBase(kbId, query); return await httpClient.retrieveKnowledgeBase(kbId, query);
}; };
// Create mode: simple form layout // ==================== Create Mode ====================
if (isCreateMode) { if (isCreateMode) {
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className="flex items-center gap-3 pb-4 shrink-0"> <div className="flex items-center justify-between pb-4 shrink-0">
<h1 className="text-xl font-semibold"> <h1 className="text-xl font-semibold">
{t('knowledge.createKnowledgeBase')} {t('knowledge.createKnowledgeBase')}
</h1> </h1>
<Button type="submit" form="kb-form">
{t('common.submit')}
</Button>
</div> </div>
<div className="flex-1 overflow-y-auto min-h-0"> <div className="flex-1 overflow-y-auto min-h-0">
<div className="mx-auto max-w-2xl space-y-6"> <div className="mx-auto max-w-3xl pb-8">
<KBForm <KBForm
initKbId={undefined} initKbId={undefined}
onNewKbCreated={handleNewKbCreated} onNewKbCreated={handleNewKbCreated}
onKbUpdated={handleKbUpdated} onKbUpdated={handleKbUpdated}
/> />
<div className="flex justify-end gap-2 pb-4">
<Button type="submit" form="kb-form">
{t('common.submit')}
</Button>
</div>
</div> </div>
</div> </div>
</div> </div>
); );
} }
// Edit mode: tabbed layout with metadata, documents (conditional), retrieve // ==================== Edit Mode ====================
return ( return (
<> <>
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className="flex items-center gap-3 pb-4 shrink-0"> {/* Sticky Header: title + save button */}
<div className="flex items-center justify-between pb-4 shrink-0">
<h1 className="text-xl font-semibold"> <h1 className="text-xl font-semibold">
{t('knowledge.editKnowledgeBase')} {t('knowledge.editKnowledgeBase')}
</h1> </h1>
<Button type="submit" form="kb-form" disabled={!formDirty}>
{t('common.save')}
</Button>
</div> </div>
{/* Horizontal Tabs */}
<Tabs <Tabs
key={id} key={id}
value={activeTab} value={activeTab}
@@ -168,32 +177,55 @@ export default function KBDetailContent({ id }: { id: string }) {
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
{/* Tab: Metadata */}
<TabsContent <TabsContent
value="metadata" value="metadata"
className="flex-1 min-h-0 overflow-y-auto mt-4" className="flex-1 min-h-0 overflow-y-auto mt-4"
> >
<div className="mx-auto max-w-2xl"> <div className="mx-auto max-w-3xl space-y-6 pb-8">
<KBForm <KBForm
initKbId={id} initKbId={id}
onNewKbCreated={handleNewKbCreated} onNewKbCreated={handleNewKbCreated}
onKbUpdated={handleKbUpdated} onKbUpdated={handleKbUpdated}
onDirtyChange={setFormDirty}
/> />
<div className="flex justify-end gap-2 mt-6 pb-4"> {/* Danger Zone Card */}
<Button <Card className="border-destructive/50">
type="button" <CardHeader>
variant="destructive" <CardTitle className="text-destructive">
onClick={() => setShowDeleteConfirm(true)} {t('knowledge.dangerZone')}
> </CardTitle>
{t('common.delete')} <CardDescription>
</Button> {t('knowledge.dangerZoneDescription')}
<Button type="submit" form="kb-form"> </CardDescription>
{t('common.save')} </CardHeader>
</Button> <CardContent>
</div> <div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">
{t('knowledge.deleteKbAction')}
</p>
<p className="text-sm text-muted-foreground">
{t('knowledge.deleteKbHint')}
</p>
</div>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => setShowDeleteConfirm(true)}
>
<Trash2 className="size-4 mr-1.5" />
{t('common.delete')}
</Button>
</div>
</CardContent>
</Card>
</div> </div>
</TabsContent> </TabsContent>
{/* Tab: Documents */}
{hasDocumentCapability() && ( {hasDocumentCapability() && (
<TabsContent <TabsContent
value="documents" value="documents"
@@ -207,6 +239,7 @@ export default function KBDetailContent({ id }: { id: string }) {
</TabsContent> </TabsContent>
)} )}
{/* Tab: Retrieve */}
<TabsContent <TabsContent
value="retrieve" value="retrieve"
className="flex-1 min-h-0 overflow-y-auto mt-4" className="flex-1 min-h-0 overflow-y-auto mt-4"
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
@@ -15,6 +15,13 @@ import {
FormMessage, FormMessage,
FormDescription, FormDescription,
} from '@/components/ui/form'; } from '@/components/ui/form';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { import {
Select, Select,
@@ -50,17 +57,13 @@ const getFormSchema = (t: (key: string) => string) =>
/** /**
* Parse creation schema from Knowledge Engine to IDynamicFormItemSchema[] * Parse creation schema from Knowledge Engine to IDynamicFormItemSchema[]
* Same pattern as ExternalKBForm uses for retriever config
*/ */
function parseCreationSchema( function parseCreationSchema(
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
schemaItems: any | any[] | undefined, schemaItems: any | any[] | undefined,
): IDynamicFormItemSchema[] { ): IDynamicFormItemSchema[] {
if (!schemaItems) return []; if (!schemaItems) return [];
// Handle wrapped schema (e.g. { schema: [...] }) which might be returned by the API
const items = Array.isArray(schemaItems) ? schemaItems : schemaItems.schema; const items = Array.isArray(schemaItems) ? schemaItems : schemaItems.schema;
if (!items || !Array.isArray(items)) return []; if (!items || !Array.isArray(items)) return [];
return items.map( return items.map(
@@ -83,10 +86,12 @@ export default function KBForm({
initKbId, initKbId,
onNewKbCreated, onNewKbCreated,
onKbUpdated, onKbUpdated,
onDirtyChange,
}: { }: {
initKbId?: string; initKbId?: string;
onNewKbCreated: (kbId: string) => void; onNewKbCreated: (kbId: string) => void;
onKbUpdated: (kbId: string) => void; onKbUpdated: (kbId: string) => void;
onDirtyChange?: (dirty: boolean) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [ragEngines, setRagEngines] = useState<KnowledgeEngine[]>([]); const [ragEngines, setRagEngines] = useState<KnowledgeEngine[]>([]);
@@ -100,6 +105,10 @@ export default function KBForm({
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// Dirty tracking: snapshot of saved state for comparison
const savedSnapshotRef = useRef<string>('');
const isInitializing = useRef(true);
const formSchema = getFormSchema(t); const formSchema = getFormSchema(t);
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
@@ -117,6 +126,27 @@ export default function KBForm({
(e) => e.plugin_id === selectedEngineId, (e) => e.plugin_id === selectedEngineId,
); );
// Dirty tracking: compare current form + dynamic settings against saved snapshot
const watchedFormValues = form.watch();
useEffect(() => {
if (!savedSnapshotRef.current || isInitializing.current) return;
const currentSnapshot = JSON.stringify({
form: watchedFormValues,
config: configSettings,
retrieval: retrievalSettings,
});
const dirty = currentSnapshot !== savedSnapshotRef.current;
onDirtyChange?.(dirty);
}, [watchedFormValues, configSettings, retrievalSettings, onDirtyChange]);
const captureSnapshot = () => {
savedSnapshotRef.current = JSON.stringify({
form: form.getValues(),
config: configSettings,
retrieval: retrievalSettings,
});
};
useEffect(() => { useEffect(() => {
loadRagEngines().then(() => { loadRagEngines().then(() => {
if (initKbId) { if (initKbId) {
@@ -131,7 +161,6 @@ export default function KBForm({
const firstEngine = ragEngines[0]; const firstEngine = ragEngines[0];
setSelectedEngineId(firstEngine.plugin_id); setSelectedEngineId(firstEngine.plugin_id);
form.setValue('ragEngineId', firstEngine.plugin_id); form.setValue('ragEngineId', firstEngine.plugin_id);
// Initialize config settings with defaults
const formItems = parseCreationSchema(firstEngine.creation_schema); const formItems = parseCreationSchema(firstEngine.creation_schema);
if (formItems.length > 0) { if (formItems.length > 0) {
setConfigSettings(getDefaultValues(formItems)); setConfigSettings(getDefaultValues(formItems));
@@ -157,6 +186,7 @@ export default function KBForm({
const loadKbConfig = async (kbId: string) => { const loadKbConfig = async (kbId: string) => {
try { try {
isInitializing.current = true;
setIsEditing(true); setIsEditing(true);
const res = await httpClient.getKnowledgeBase(kbId); const res = await httpClient.getKnowledgeBase(kbId);
@@ -165,15 +195,24 @@ export default function KBForm({
const engineId = kb.knowledge_engine_plugin_id || ''; const engineId = kb.knowledge_engine_plugin_id || '';
setSelectedEngineId(engineId); setSelectedEngineId(engineId);
form.setValue('name', kb.name); form.reset({
form.setValue('description', kb.description); name: kb.name,
form.setValue('emoji', kb.emoji || '📚'); description: kb.description,
form.setValue('ragEngineId', engineId); emoji: kb.emoji || '📚',
ragEngineId: engineId,
});
setConfigSettings(kb.creation_settings || {}); setConfigSettings(kb.creation_settings || {});
setRetrievalSettings(kb.retrieval_settings || {}); setRetrievalSettings(kb.retrieval_settings || {});
// Capture snapshot after a tick so dynamic forms have emitted initial values
setTimeout(() => {
captureSnapshot();
isInitializing.current = false;
}, 500);
} catch (err) { } catch (err) {
console.error('Failed to load KB config:', err); console.error('Failed to load KB config:', err);
isInitializing.current = false;
} }
}; };
@@ -181,7 +220,6 @@ export default function KBForm({
setSelectedEngineId(engineId); setSelectedEngineId(engineId);
form.setValue('ragEngineId', engineId); form.setValue('ragEngineId', engineId);
// Find engine and initialize config settings with defaults from schema
const engine = ragEngines.find((e) => e.plugin_id === engineId); const engine = ragEngines.find((e) => e.plugin_id === engineId);
if (engine) { if (engine) {
const formItems = parseCreationSchema(engine.creation_schema); const formItems = parseCreationSchema(engine.creation_schema);
@@ -210,10 +248,11 @@ export default function KBForm({
}; };
if (initKbId) { if (initKbId) {
// Update knowledge base
httpClient httpClient
.updateKnowledgeBase(initKbId, kbData) .updateKnowledgeBase(initKbId, kbData)
.then((res) => { .then((res) => {
captureSnapshot();
onDirtyChange?.(false);
onKbUpdated(res.uuid); onKbUpdated(res.uuid);
toast.success(t('knowledge.updateKnowledgeBaseSuccess')); toast.success(t('knowledge.updateKnowledgeBaseSuccess'));
}) })
@@ -225,7 +264,6 @@ export default function KBForm({
); );
}); });
} else { } else {
// Create knowledge base
httpClient httpClient
.createKnowledgeBase(kbData) .createKnowledgeBase(kbData)
.then((res) => { .then((res) => {
@@ -241,15 +279,11 @@ export default function KBForm({
} }
}; };
// Convert creation schema to dynamic form items (same as ExternalKBForm)
// Memoize to avoid regenerating UUIDs on every render, which would cause
// DynamicFormComponent's useEffect to re-fire and trigger an infinite loop.
const configFormItems = useMemo( const configFormItems = useMemo(
() => parseCreationSchema(selectedEngine?.creation_schema), () => parseCreationSchema(selectedEngine?.creation_schema),
[selectedEngine?.creation_schema], [selectedEngine?.creation_schema],
); );
// Convert retrieval schema to dynamic form items
const retrievalFormItems = useMemo( const retrievalFormItems = useMemo(
() => parseCreationSchema(selectedEngine?.retrieval_schema), () => parseCreationSchema(selectedEngine?.retrieval_schema),
[selectedEngine?.retrieval_schema], [selectedEngine?.retrieval_schema],
@@ -282,14 +316,75 @@ export default function KBForm({
} }
return ( return (
<> <Form {...form}>
<Form {...form}> <form
<form onSubmit={form.handleSubmit(onSubmit)}
onSubmit={form.handleSubmit(onSubmit)} id="kb-form"
id="kb-form" className="space-y-6"
className="space-y-8" >
> {/* Card 1: Basic Information */}
<div className="space-y-4"> <Card>
<CardHeader>
<CardTitle>{t('knowledge.basicInfo')}</CardTitle>
<CardDescription>
{t('knowledge.basicInfoDescription')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Name and Emoji in same row */}
<div className="flex gap-4 items-start">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
{t('knowledge.kbName')}
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="emoji"
render={({ field }) => (
<FormItem>
<FormLabel>{t('common.icon')}</FormLabel>
<FormControl>
<EmojiPicker
value={field.value}
onChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Description */}
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('knowledge.kbDescription')}
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Knowledge Engine Selector */} {/* Knowledge Engine Selector */}
<FormField <FormField
control={form.control} control={form.control}
@@ -298,7 +393,7 @@ export default function KBForm({
<FormItem> <FormItem>
<FormLabel> <FormLabel>
{t('knowledge.knowledgeEngine')} {t('knowledge.knowledgeEngine')}
<span className="text-red-500">*</span> <span className="text-destructive">*</span>
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Select <Select
@@ -379,102 +474,54 @@ export default function KBForm({
</FormItem> </FormItem>
)} )}
/> />
</CardContent>
</Card>
{/* Name and Emoji in same row */} {/* Card 2: Engine Settings (dynamic form from creation_schema) */}
<div className="flex gap-4 items-start"> {configFormItems.length > 0 && (
<FormField <Card>
control={form.control} <CardHeader>
name="name" <CardTitle>{t('knowledge.engineSettings')}</CardTitle>
render={({ field }) => ( <CardDescription>
<FormItem className="flex-1"> {t('knowledge.engineSettingsDescription')}
<FormLabel> </CardDescription>
{t('knowledge.kbName')} </CardHeader>
<span className="text-red-500">*</span> <CardContent>
</FormLabel> <DynamicFormComponent
<FormControl> itemConfigList={configFormItems}
<Input {...field} /> initialValues={configSettings as Record<string, object>}
</FormControl> onSubmit={(val) =>
<FormMessage /> setConfigSettings(val as Record<string, unknown>)
</FormItem> }
)} isEditing={isEditing}
externalDependentValues={retrievalSettings}
/> />
<FormField </CardContent>
control={form.control} </Card>
name="emoji" )}
render={({ field }) => (
<FormItem> {/* Card 3: Retrieval Settings (dynamic form from retrieval_schema) */}
<FormLabel>{t('common.icon')}</FormLabel> {retrievalFormItems.length > 0 && (
<FormControl> <Card>
<EmojiPicker <CardHeader>
value={field.value} <CardTitle>{t('knowledge.retrievalSettings')}</CardTitle>
onChange={field.onChange} <CardDescription>
/> {t('knowledge.retrievalSettingsDescription')}
</FormControl> </CardDescription>
<FormMessage /> </CardHeader>
</FormItem> <CardContent>
)} <DynamicFormComponent
itemConfigList={retrievalFormItems}
initialValues={retrievalSettings as Record<string, object>}
onSubmit={(val) =>
setRetrievalSettings(val as Record<string, unknown>)
}
externalDependentValues={configSettings}
/> />
</div> </CardContent>
</Card>
{/* Description */} )}
<FormField </form>
control={form.control} </Form>
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('knowledge.kbDescription')}
<span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Engine specific fields (dynamic form from creation_schema) */}
{configFormItems.length > 0 && (
<div className="space-y-4 pt-2 border-t">
<div className="text-sm font-medium text-muted-foreground">
{t('knowledge.engineSettings')}
</div>
<div>
<DynamicFormComponent
itemConfigList={configFormItems}
initialValues={configSettings as Record<string, object>}
onSubmit={(val) =>
setConfigSettings(val as Record<string, unknown>)
}
isEditing={isEditing}
externalDependentValues={retrievalSettings}
/>
</div>
</div>
)}
{/* Retrieval settings (dynamic form from retrieval_schema) */}
{retrievalFormItems.length > 0 && (
<div className="space-y-4 pt-2 border-t">
<div className="text-sm font-medium text-muted-foreground">
{t('knowledge.retrievalSettings')}
</div>
<div>
<DynamicFormComponent
itemConfigList={retrievalFormItems}
initialValues={retrievalSettings as Record<string, object>}
onSubmit={(val) =>
setRetrievalSettings(val as Record<string, unknown>)
}
externalDependentValues={configSettings}
/>
</div>
</div>
)}
</div>
</form>
</Form>
</>
); );
} }
@@ -4,22 +4,12 @@ import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import PipelineFormComponent from '@/app/home/pipelines/components/pipeline-form/PipelineFormComponent'; import PipelineFormComponent from '@/app/home/pipelines/components/pipeline-form/PipelineFormComponent';
import DebugDialog from '@/app/home/pipelines/components/debug-dialog/DebugDialog'; import DebugDialog from '@/app/home/pipelines/components/debug-dialog/DebugDialog';
import PipelineExtension from '@/app/home/pipelines/components/pipeline-extensions/PipelineExtension';
import PipelineMonitoringTab from '@/app/home/pipelines/components/monitoring-tab/PipelineMonitoringTab'; import PipelineMonitoringTab from '@/app/home/pipelines/components/monitoring-tab/PipelineMonitoringTab';
import { httpClient } from '@/app/infra/http/HttpClient';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext'; import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Settings, Puzzle, Bug, BarChart3 } from 'lucide-react'; import { Settings, Bug, BarChart3 } from 'lucide-react';
export default function PipelineDetailContent({ id }: { id: string }) { export default function PipelineDetailContent({ id }: { id: string }) {
const isCreateMode = id === 'new'; const isCreateMode = id === 'new';
@@ -39,35 +29,29 @@ export default function PipelineDetailContent({ id }: { id: string }) {
}, [id, isCreateMode, pipelines, setDetailEntityName, t]); }, [id, isCreateMode, pipelines, setDetailEntityName, t]);
const [activeTab, setActiveTab] = useState('config'); const [activeTab, setActiveTab] = useState('config');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [isWebSocketConnected, setIsWebSocketConnected] = useState(false); const [isWebSocketConnected, setIsWebSocketConnected] = useState(false);
const [formDirty, setFormDirty] = useState(false);
function handleFinish() { function handleFinish() {
refreshPipelines(); refreshPipelines();
} }
function handleDeletePipeline() {
httpClient.deletePipeline(id).then(() => {
refreshPipelines();
setShowDeleteConfirm(false);
router.push('/home/pipelines');
});
}
function handleNewPipelineCreated(newPipelineId: string) { function handleNewPipelineCreated(newPipelineId: string) {
refreshPipelines(); refreshPipelines();
// Navigate to the newly created pipeline's detail view via query param
router.push(`/home/pipelines?id=${encodeURIComponent(newPipelineId)}`); router.push(`/home/pipelines?id=${encodeURIComponent(newPipelineId)}`);
} }
// Create mode: simple form layout // ==================== Create Mode ====================
if (isCreateMode) { if (isCreateMode) {
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className="flex items-center gap-3 pb-4 shrink-0"> <div className="flex items-center justify-between pb-4 shrink-0">
<h1 className="text-xl font-semibold"> <h1 className="text-xl font-semibold">
{t('pipelines.createPipeline')} {t('pipelines.createPipeline')}
</h1> </h1>
<Button type="submit" form="pipeline-form">
{t('common.submit')}
</Button>
</div> </div>
<div className="flex-1 overflow-y-auto min-h-0"> <div className="flex-1 overflow-y-auto min-h-0">
@@ -76,7 +60,7 @@ export default function PipelineDetailContent({ id }: { id: string }) {
pipelineId={undefined} pipelineId={undefined}
isEditMode={false} isEditMode={false}
disableForm={false} disableForm={false}
showButtons={true} showButtons={false}
onFinish={handleFinish} onFinish={handleFinish}
onNewPipelineCreated={handleNewPipelineCreated} onNewPipelineCreated={handleNewPipelineCreated}
onDeletePipeline={() => {}} onDeletePipeline={() => {}}
@@ -87,115 +71,85 @@ export default function PipelineDetailContent({ id }: { id: string }) {
); );
} }
// Edit mode: tabbed layout with config, extensions, debug, monitoring // ==================== Edit Mode ====================
return ( return (
<> <div className="flex h-full flex-col">
<div className="flex h-full flex-col"> {/* Sticky Header: title + save button */}
<div className="flex items-center gap-3 pb-4 shrink-0"> <div className="flex items-center justify-between pb-4 shrink-0">
<h1 className="text-xl font-semibold"> <h1 className="text-xl font-semibold">{t('pipelines.editPipeline')}</h1>
{t('pipelines.editPipeline')} <Button type="submit" form="pipeline-form" disabled={!formDirty}>
</h1> {t('common.save')}
</div> </Button>
<Tabs
key={id}
value={activeTab}
onValueChange={setActiveTab}
className="flex flex-1 flex-col min-h-0"
>
<TabsList className="shrink-0">
<TabsTrigger value="config" className="gap-1.5">
<Settings className="size-3.5" />
{t('pipelines.configuration')}
</TabsTrigger>
<TabsTrigger value="extensions" className="gap-1.5">
<Puzzle className="size-3.5" />
{t('pipelines.extensions.title')}
</TabsTrigger>
<TabsTrigger value="debug" className="gap-1.5">
<Bug className="size-3.5" />
{t('pipelines.debugChat')}
<span
className={`inline-block size-2 rounded-full ${
isWebSocketConnected ? 'bg-green-500' : 'bg-red-500'
}`}
/>
</TabsTrigger>
<TabsTrigger value="monitoring" className="gap-1.5">
<BarChart3 className="size-3.5" />
{t('pipelines.monitoring.title')}
</TabsTrigger>
</TabsList>
<TabsContent
value="config"
className="flex-1 min-h-0 overflow-y-auto mt-4"
>
<PipelineFormComponent
pipelineId={id}
isEditMode={true}
disableForm={false}
showButtons={true}
onFinish={handleFinish}
onNewPipelineCreated={handleNewPipelineCreated}
onDeletePipeline={() => setShowDeleteConfirm(true)}
onCancel={() => router.push('/home/pipelines')}
/>
</TabsContent>
<TabsContent
value="extensions"
className="flex-1 min-h-0 overflow-y-auto mt-4"
>
<PipelineExtension pipelineId={id} />
</TabsContent>
<TabsContent value="debug" className="flex-1 min-h-0 mt-4">
<DebugDialog
open={activeTab === 'debug'}
pipelineId={id}
isEmbedded={true}
onConnectionStatusChange={setIsWebSocketConnected}
/>
</TabsContent>
<TabsContent
value="monitoring"
className="flex-1 min-h-0 overflow-y-auto mt-4"
>
<PipelineMonitoringTab
pipelineId={id}
onNavigateToMonitoring={() => {
router.push('/home/monitoring');
}}
/>
</TabsContent>
</Tabs>
</div> </div>
{/* Delete confirmation dialog */} {/* Horizontal Tabs */}
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}> <Tabs
<DialogContent> key={id}
<DialogHeader> value={activeTab}
<DialogTitle>{t('common.confirmDelete')}</DialogTitle> onValueChange={setActiveTab}
<DialogDescription className="sr-only"> className="flex flex-1 flex-col min-h-0"
{t('pipelines.deleteConfirmation')} >
</DialogDescription> <TabsList className="shrink-0">
</DialogHeader> <TabsTrigger value="config" className="gap-1.5">
<div className="py-4">{t('pipelines.deleteConfirmation')}</div> <Settings className="size-3.5" />
<DialogFooter> {t('pipelines.configuration')}
<Button </TabsTrigger>
variant="outline" <TabsTrigger value="debug" className="gap-1.5">
onClick={() => setShowDeleteConfirm(false)} <Bug className="size-3.5" />
> {t('pipelines.debugChat')}
{t('common.cancel')} <span
</Button> className={`inline-block size-2 rounded-full ${
<Button variant="destructive" onClick={handleDeletePipeline}> isWebSocketConnected ? 'bg-green-500' : 'bg-red-500'
{t('common.confirmDelete')} }`}
</Button> />
</DialogFooter> </TabsTrigger>
</DialogContent> <TabsTrigger value="monitoring" className="gap-1.5">
</Dialog> <BarChart3 className="size-3.5" />
</> {t('pipelines.monitoring.title')}
</TabsTrigger>
</TabsList>
{/* Tab: Configuration */}
<TabsContent
value="config"
className="flex-1 min-h-0 overflow-y-auto mt-4"
>
<PipelineFormComponent
pipelineId={id}
isEditMode={true}
disableForm={false}
showButtons={false}
onFinish={handleFinish}
onNewPipelineCreated={handleNewPipelineCreated}
onDeletePipeline={() => {}}
onCancel={() => router.push('/home/pipelines')}
onDirtyChange={setFormDirty}
/>
</TabsContent>
{/* Tab: Debug */}
<TabsContent value="debug" className="flex-1 min-h-0 mt-4">
<DebugDialog
open={activeTab === 'debug'}
pipelineId={id}
isEmbedded={true}
onConnectionStatusChange={setIsWebSocketConnected}
/>
</TabsContent>
{/* Tab: Monitoring */}
<TabsContent
value="monitoring"
className="flex-1 min-h-0 overflow-y-auto mt-4"
>
<PipelineMonitoringTab
pipelineId={id}
onNavigateToMonitoring={() => {
router.push('/home/monitoring');
}}
/>
</TabsContent>
</Tabs>
</div>
); );
} }
@@ -32,7 +32,24 @@ import { toast } from 'sonner';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { extractI18nObject } from '@/i18n/I18nProvider'; import { extractI18nObject } from '@/i18n/I18nProvider';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Info, Brain, Zap, Shield, FileOutput } from 'lucide-react'; import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Info,
Brain,
Zap,
Shield,
FileOutput,
Puzzle,
Trash2,
Copy,
} from 'lucide-react';
import PipelineExtension from '@/app/home/pipelines/components/pipeline-extensions/PipelineExtension';
export default function PipelineFormComponent({ export default function PipelineFormComponent({
onFinish, onFinish,
@@ -42,6 +59,7 @@ export default function PipelineFormComponent({
showButtons = true, showButtons = true,
onDeletePipeline, onDeletePipeline,
onCancel, onCancel,
onDirtyChange,
}: { }: {
pipelineId?: string; pipelineId?: string;
isEditMode: boolean; isEditMode: boolean;
@@ -51,6 +69,7 @@ export default function PipelineFormComponent({
onNewPipelineCreated: (pipelineId: string) => void; onNewPipelineCreated: (pipelineId: string) => void;
onDeletePipeline: () => void; onDeletePipeline: () => void;
onCancel?: () => void; onCancel?: () => void;
onDirtyChange?: (dirty: boolean) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
@@ -93,6 +112,7 @@ export default function PipelineFormComponent({
trigger: Zap, trigger: Zap,
safety: Shield, safety: Shield,
output: FileOutput, output: FileOutput,
extensions: Puzzle,
}; };
const formLabelList: SectionItem[] = isEditMode const formLabelList: SectionItem[] = isEditMode
@@ -122,6 +142,11 @@ export default function PipelineFormComponent({
name: 'output', name: 'output',
icon: SECTION_ICONS.output, icon: SECTION_ICONS.output,
}, },
{
label: t('pipelines.extensions.title'),
name: 'extensions',
icon: SECTION_ICONS.extensions,
},
] ]
: [ : [
{ {
@@ -165,6 +190,11 @@ export default function PipelineFormComponent({
return JSON.stringify(watchedValues) !== savedSnapshotRef.current; return JSON.stringify(watchedValues) !== savedSnapshotRef.current;
}, [isEditMode, watchedValues]); }, [isEditMode, watchedValues]);
// Notify parent when dirty state changes
useEffect(() => {
onDirtyChange?.(hasUnsavedChanges);
}, [hasUnsavedChanges, onDirtyChange]);
useEffect(() => { useEffect(() => {
// get config schema from metadata // get config schema from metadata
httpClient.getGeneralPipelineMetadata().then((resp) => { httpClient.getGeneralPipelineMetadata().then((resp) => {
@@ -314,27 +344,29 @@ export default function PipelineFormComponent({
// If this is the runner selector stage, render it directly // If this is the runner selector stage, render it directly
if (stage.name === 'runner') { if (stage.name === 'runner') {
return ( return (
<div key={stage.name} className="space-y-4 mb-6"> <Card key={stage.name}>
<div className="text-lg font-medium"> <CardHeader>
{extractI18nObject(stage.label)} <CardTitle>{extractI18nObject(stage.label)}</CardTitle>
</div> {stage.description && (
{stage.description && ( <CardDescription>
<div className="text-sm text-muted-foreground"> {extractI18nObject(stage.description)}
{extractI18nObject(stage.description)} </CardDescription>
</div> )}
)} </CardHeader>
<DynamicFormComponent <CardContent className="space-y-6">
itemConfigList={stage.config} <DynamicFormComponent
initialValues={ itemConfigList={stage.config}
// eslint-disable-next-line @typescript-eslint/no-explicit-any initialValues={
(form.watch(formName) as Record<string, any>)?.[stage.name] || // eslint-disable-next-line @typescript-eslint/no-explicit-any
{} (form.watch(formName) as Record<string, any>)?.[stage.name] ||
} {}
onSubmit={(values) => { }
handleDynamicFormEmit(formName, stage.name, values); onSubmit={(values) => {
}} handleDynamicFormEmit(formName, stage.name, values);
/> }}
</div> />
</CardContent>
</Card>
); );
} }
@@ -346,52 +378,56 @@ export default function PipelineFormComponent({
// For n8n-service-api config, use N8nAuthFormComponent for form linkage // For n8n-service-api config, use N8nAuthFormComponent for form linkage
if (stage.name === 'n8n-service-api') { if (stage.name === 'n8n-service-api') {
return ( return (
<div key={stage.name} className="space-y-4 mb-6"> <Card key={stage.name}>
<div className="text-lg font-medium"> <CardHeader>
{extractI18nObject(stage.label)} <CardTitle>{extractI18nObject(stage.label)}</CardTitle>
</div> {stage.description && (
{stage.description && ( <CardDescription>
<div className="text-sm text-muted-foreground"> {extractI18nObject(stage.description)}
{extractI18nObject(stage.description)} </CardDescription>
</div> )}
)} </CardHeader>
<N8nAuthFormComponent <CardContent className="space-y-6">
itemConfigList={stage.config} <N8nAuthFormComponent
initialValues={ itemConfigList={stage.config}
// eslint-disable-next-line @typescript-eslint/no-explicit-any initialValues={
(form.watch(formName) as Record<string, any>)?.[stage.name] || // eslint-disable-next-line @typescript-eslint/no-explicit-any
{} (form.watch(formName) as Record<string, any>)?.[stage.name] ||
} {}
onSubmit={(values) => { }
handleDynamicFormEmit(formName, stage.name, values); onSubmit={(values) => {
}} handleDynamicFormEmit(formName, stage.name, values);
/> }}
</div> />
</CardContent>
</Card>
); );
} }
} }
return ( return (
<div key={stage.name} className="space-y-4 mb-6"> <Card key={stage.name}>
<div className="text-lg font-medium"> <CardHeader>
{extractI18nObject(stage.label)} <CardTitle>{extractI18nObject(stage.label)}</CardTitle>
</div> {stage.description && (
{stage.description && ( <CardDescription>
<div className="text-sm text-muted-foreground"> {extractI18nObject(stage.description)}
{extractI18nObject(stage.description)} </CardDescription>
</div> )}
)} </CardHeader>
<DynamicFormComponent <CardContent className="space-y-6">
itemConfigList={stage.config} <DynamicFormComponent
initialValues={ itemConfigList={stage.config}
// eslint-disable-next-line @typescript-eslint/no-explicit-any initialValues={
(form.watch(formName) as Record<string, any>)?.[stage.name] || {} // eslint-disable-next-line @typescript-eslint/no-explicit-any
} (form.watch(formName) as Record<string, any>)?.[stage.name] || {}
onSubmit={(values) => { }
handleDynamicFormEmit(formName, stage.name, values); onSubmit={(values) => {
}} handleDynamicFormEmit(formName, stage.name, values);
/> }}
</div> />
</CardContent>
</Card>
); );
} }
@@ -477,58 +513,130 @@ export default function PipelineFormComponent({
{/* Basic info section */} {/* Basic info section */}
{activeSection === 'basic' && ( {activeSection === 'basic' && (
<div className="space-y-6"> <div className="space-y-6">
{/* Name and Emoji in same row */} {/* Basic Information Card */}
<div className="flex gap-4 items-start"> <Card>
<FormField <CardHeader>
control={form.control} <CardTitle>{t('pipelines.basicInfo')}</CardTitle>
name="basic.name" <CardDescription>
render={({ field }) => ( {t('pipelines.basicInfoDescription')}
<FormItem className="flex-1"> </CardDescription>
<FormLabel> </CardHeader>
{t('common.name')} <CardContent className="space-y-4">
<span className="text-red-500">*</span> {/* Name and Emoji in same row */}
</FormLabel> <div className="flex gap-4 items-start">
<FormControl> <FormField
<Input {...field} /> control={form.control}
</FormControl> name="basic.name"
<FormMessage /> render={({ field }) => (
</FormItem> <FormItem className="flex-1">
)} <FormLabel>
/> {t('common.name')}
<FormField <span className="text-destructive">*</span>
control={form.control} </FormLabel>
name="basic.emoji" <FormControl>
render={({ field }) => ( <Input {...field} />
<FormItem> </FormControl>
<FormLabel>{t('common.icon')}</FormLabel> <FormMessage />
<FormControl> </FormItem>
<EmojiPicker )}
value={field.value} />
onChange={field.onChange} <FormField
/> control={form.control}
</FormControl> name="basic.emoji"
<FormMessage /> render={({ field }) => (
</FormItem> <FormItem>
)} <FormLabel>{t('common.icon')}</FormLabel>
/> <FormControl>
</div> <EmojiPicker
value={field.value}
onChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField <FormField
control={form.control} control={form.control}
name="basic.description" name="basic.description"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
{t('common.description')} {t('common.description')}
<span className="text-red-500">*</span> <span className="text-destructive">*</span>
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
{/* Copy pipeline (edit mode only) */}
{isEditMode && (
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<p className="text-sm font-medium">
{t('pipelines.copyPipelineAction')}
</p>
<p className="text-sm text-muted-foreground">
{t('pipelines.copyPipelineHint')}
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleCopy}
>
<Copy className="size-4 mr-1.5" />
{t('common.copy')}
</Button>
</div>
)}
</CardContent>
</Card>
{/* Danger Zone (edit mode only) */}
{isEditMode && (
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="text-destructive">
{t('pipelines.dangerZone')}
</CardTitle>
<CardDescription>
{t('pipelines.dangerZoneDescription')}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">
{t('pipelines.deletePipelineAction')}
</p>
<p className="text-sm text-muted-foreground">
{isDefaultPipeline
? t('pipelines.defaultPipelineCannotDelete')
: t('pipelines.deletePipelineHint')}
</p>
</div>
<Button
type="button"
variant="destructive"
size="sm"
disabled={isDefaultPipeline}
onClick={handleDelete}
>
<Trash2 className="size-4 mr-1.5" />
{t('common.delete')}
</Button>
</div>
</CardContent>
</Card>
)}
</div> </div>
)} )}
@@ -566,6 +674,10 @@ export default function PipelineFormComponent({
)} )}
</div> </div>
)} )}
{activeSection === 'extensions' && pipelineId && (
<PipelineExtension pipelineId={pipelineId} />
)}
</> </>
)} )}
</div> </div>
+21 -1
View File
@@ -632,7 +632,8 @@ const enUS = {
earliestCreated: 'Earliest Created', earliestCreated: 'Earliest Created',
recentlyEdited: 'Recently Edited', recentlyEdited: 'Recently Edited',
earliestEdited: 'Earliest Edited', earliestEdited: 'Earliest Edited',
basicInfo: 'Basic', basicInfo: 'Basic Information',
basicInfoDescription: 'Set the pipeline name, icon and description',
aiCapabilities: 'AI', aiCapabilities: 'AI',
triggerConditions: 'Trigger', triggerConditions: 'Trigger',
safetyControls: 'Safety', safetyControls: 'Safety',
@@ -653,6 +654,14 @@ const enUS = {
copyConfirmation: copyConfirmation:
'Are you sure you want to copy this pipeline? This will create a new pipeline with all configurations.', 'Are you sure you want to copy this pipeline? This will create a new pipeline with all configurations.',
unsavedChanges: 'You have unsaved changes', unsavedChanges: 'You have unsaved changes',
dangerZone: 'Danger Zone',
dangerZoneDescription: 'Irreversible and destructive actions',
deletePipelineAction: 'Delete this pipeline',
deletePipelineHint:
'Once deleted, bots bound to this pipeline will stop working.',
copyPipelineAction: 'Copy this pipeline',
copyPipelineHint:
'Create a new pipeline with all configurations duplicated.',
extensions: { extensions: {
title: 'Extensions', title: 'Extensions',
loadError: 'Failed to load plugins', loadError: 'Failed to load plugins',
@@ -801,9 +810,20 @@ const enUS = {
builtInEngine: 'Built-in Engine', builtInEngine: 'Built-in Engine',
cannotChangeKnowledgeEngine: cannotChangeKnowledgeEngine:
'Knowledge engine cannot be changed after creation', 'Knowledge engine cannot be changed after creation',
basicInfo: 'Basic Information',
basicInfoDescription: 'Set the knowledge base name, icon and description',
engineSettings: 'Engine Settings', engineSettings: 'Engine Settings',
engineSettingsDescription:
'Configuration for the selected knowledge engine',
engineSettingsReadonly: 'read-only in edit mode', engineSettingsReadonly: 'read-only in edit mode',
retrievalSettings: 'Retrieval Settings', retrievalSettings: 'Retrieval Settings',
retrievalSettingsDescription:
'Configure how documents are retrieved from this knowledge base',
dangerZone: 'Danger Zone',
dangerZoneDescription: 'Irreversible and destructive actions',
deleteKbAction: 'Delete this knowledge base',
deleteKbHint:
'Once deleted, all documents and data in this knowledge base will be permanently removed.',
noEnginesAvailable: 'No knowledge base engines available', noEnginesAvailable: 'No knowledge base engines available',
installEngineHint: 'Please install a "Knowledge Engine" plugin first', installEngineHint: 'Please install a "Knowledge Engine" plugin first',
createKnowledgeBaseFailed: 'Failed to create knowledge base: ', createKnowledgeBaseFailed: 'Failed to create knowledge base: ',
+20
View File
@@ -634,6 +634,7 @@
recentlyEdited: '最近編集', recentlyEdited: '最近編集',
earliestEdited: '最古編集', earliestEdited: '最古編集',
basicInfo: '基本情報', basicInfo: '基本情報',
basicInfoDescription: 'パイプラインの名前、アイコン、説明を設定',
aiCapabilities: 'AI機能', aiCapabilities: 'AI機能',
triggerConditions: 'トリガー条件', triggerConditions: 'トリガー条件',
safetyControls: '安全制御', safetyControls: '安全制御',
@@ -655,6 +656,13 @@
copyConfirmation: copyConfirmation:
'このパイプラインをコピーしますか?すべての設定を含む新しいパイプラインが作成されます。', 'このパイプラインをコピーしますか?すべての設定を含む新しいパイプラインが作成されます。',
unsavedChanges: '未保存の変更があります', unsavedChanges: '未保存の変更があります',
dangerZone: '危険ゾーン',
dangerZoneDescription: '元に戻せない操作',
deletePipelineAction: 'このパイプラインを削除',
deletePipelineHint:
'削除すると、このパイプラインに紐付けられたボットは動作しなくなります。',
copyPipelineAction: 'このパイプラインをコピー',
copyPipelineHint: 'すべての設定を複製した新しいパイプラインを作成します。',
extensions: { extensions: {
title: 'プラグイン統合', title: 'プラグイン統合',
loadError: 'プラグインリストの読み込みに失敗しました', loadError: 'プラグインリストの読み込みに失敗しました',
@@ -797,6 +805,18 @@
fileName: 'ファイル名', fileName: 'ファイル名',
noResults: '検索結果がありません', noResults: '検索結果がありません',
retrieveError: '検索に失敗しました:', retrieveError: '検索に失敗しました:',
basicInfo: '基本情報',
basicInfoDescription: 'ナレッジベースの名前、アイコン、説明を設定',
engineSettings: 'エンジン設定',
engineSettingsDescription: '選択したナレッジエンジンの設定',
engineSettingsReadonly: '編集モードでは変更できません',
retrievalSettings: '検索設定',
retrievalSettingsDescription: 'このナレッジベースからの文書検索方法を設定',
dangerZone: '危険ゾーン',
dangerZoneDescription: '元に戻せない操作',
deleteKbAction: 'このナレッジベースを削除',
deleteKbHint:
'削除すると、このナレッジベース内のすべての文書とデータが完全に削除されます。',
noEnginesAvailable: '利用可能なナレッジエンジンがありません', noEnginesAvailable: '利用可能なナレッジエンジンがありません',
installEngineHint: installEngineHint:
'先に「ナレッジエンジン」プラグインをインストールしてください', '先に「ナレッジエンジン」プラグインをインストールしてください',
+15
View File
@@ -604,6 +604,7 @@ const zhHans = {
recentlyEdited: '最近编辑', recentlyEdited: '最近编辑',
earliestEdited: '最早编辑', earliestEdited: '最早编辑',
basicInfo: '基础信息', basicInfo: '基础信息',
basicInfoDescription: '设置流水线名称、图标和描述',
aiCapabilities: 'AI 能力', aiCapabilities: 'AI 能力',
triggerConditions: '触发条件', triggerConditions: '触发条件',
safetyControls: '安全控制', safetyControls: '安全控制',
@@ -624,6 +625,12 @@ const zhHans = {
copyConfirmation: copyConfirmation:
'确定要复制这个流水线吗?复制将创建一个包含完整配置的新流水线。', '确定要复制这个流水线吗?复制将创建一个包含完整配置的新流水线。',
unsavedChanges: '有未保存的更改', unsavedChanges: '有未保存的更改',
dangerZone: '危险区域',
dangerZoneDescription: '不可逆的操作',
deletePipelineAction: '删除此流水线',
deletePipelineHint: '删除后,绑定此流水线的机器人将无法正常工作。',
copyPipelineAction: '复制此流水线',
copyPipelineHint: '创建一条新的流水线,并复制所有配置。',
extensions: { extensions: {
title: '扩展集成', title: '扩展集成',
loadError: '加载插件列表失败', loadError: '加载插件列表失败',
@@ -765,9 +772,17 @@ const zhHans = {
selectKnowledgeEngine: '选择知识引擎', selectKnowledgeEngine: '选择知识引擎',
builtInEngine: '内置引擎', builtInEngine: '内置引擎',
cannotChangeKnowledgeEngine: '知识库创建后不可修改知识引擎', cannotChangeKnowledgeEngine: '知识库创建后不可修改知识引擎',
basicInfo: '基础信息',
basicInfoDescription: '设置知识库名称、图标和描述',
engineSettings: '引擎设置', engineSettings: '引擎设置',
engineSettingsDescription: '所选知识引擎的配置',
engineSettingsReadonly: '编辑模式下不可修改', engineSettingsReadonly: '编辑模式下不可修改',
retrievalSettings: '检索设置', retrievalSettings: '检索设置',
retrievalSettingsDescription: '配置从此知识库检索文档的方式',
dangerZone: '危险区域',
dangerZoneDescription: '不可逆的操作',
deleteKbAction: '删除此知识库',
deleteKbHint: '删除后,此知识库中的所有文档和数据将被永久移除。',
noEnginesAvailable: '没有可用的知识库引擎', noEnginesAvailable: '没有可用的知识库引擎',
installEngineHint: '请先安装「知识引擎」插件', installEngineHint: '请先安装「知识引擎」插件',
createKnowledgeBaseFailed: '知识库创建失败:', createKnowledgeBaseFailed: '知识库创建失败:',
+18
View File
@@ -597,6 +597,7 @@ const zhHant = {
recentlyEdited: '最近編輯', recentlyEdited: '最近編輯',
earliestEdited: '最早編輯', earliestEdited: '最早編輯',
basicInfo: '基本資訊', basicInfo: '基本資訊',
basicInfoDescription: '設定流程線名稱、圖示和描述',
aiCapabilities: 'AI 能力', aiCapabilities: 'AI 能力',
triggerConditions: '觸發條件', triggerConditions: '觸發條件',
safetyControls: '安全控制', safetyControls: '安全控制',
@@ -617,6 +618,12 @@ const zhHant = {
copyConfirmation: copyConfirmation:
'確定要複製這個流程線嗎?複製將創建一個包含完整配置的新流程線。', '確定要複製這個流程線嗎?複製將創建一個包含完整配置的新流程線。',
unsavedChanges: '有未儲存的變更', unsavedChanges: '有未儲存的變更',
dangerZone: '危險區域',
dangerZoneDescription: '不可逆的操作',
deletePipelineAction: '刪除此流程線',
deletePipelineHint: '刪除後,綁定此流程線的機器人將無法正常運作。',
copyPipelineAction: '複製此流程線',
copyPipelineHint: '建立一條新的流程線,並複製所有設定。',
extensions: { extensions: {
title: '擴展集成', title: '擴展集成',
loadError: '載入插件清單失敗', loadError: '載入插件清單失敗',
@@ -752,6 +759,17 @@ const zhHant = {
fileName: '文檔名稱', fileName: '文檔名稱',
noResults: '暫無結果', noResults: '暫無結果',
retrieveError: '檢索失敗:', retrieveError: '檢索失敗:',
basicInfo: '基本資訊',
basicInfoDescription: '設定知識庫名稱、圖示和描述',
engineSettings: '引擎設定',
engineSettingsDescription: '所選知識引擎的設定',
engineSettingsReadonly: '編輯模式下不可修改',
retrievalSettings: '檢索設定',
retrievalSettingsDescription: '設定從此知識庫檢索文件的方式',
dangerZone: '危險區域',
dangerZoneDescription: '不可逆的操作',
deleteKbAction: '刪除此知識庫',
deleteKbHint: '刪除後,此知識庫中的所有文件和資料將被永久移除。',
noEnginesAvailable: '沒有可用的知識庫引擎', noEnginesAvailable: '沒有可用的知識庫引擎',
installEngineHint: '請先安裝「知識引擎」插件', installEngineHint: '請先安裝「知識引擎」插件',
unknownEngine: '未知引擎', unknownEngine: '未知引擎',