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

View File

@@ -14,6 +14,7 @@ import DynamicFormItemComponent from '@/app/home/components/dynamic-form/Dynamic
import { useEffect, useRef } from 'react';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
export default function DynamicFormComponent({
itemConfigList,
@@ -273,6 +274,46 @@ export default function DynamicFormComponent({
// All fields are disabled when editing (creation_settings are immutable)
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 (
<FormField
key={config.id}

View File

@@ -37,7 +37,7 @@ import {
DialogFooter,
} from '@/components/ui/dialog';
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({
config,
@@ -181,27 +181,28 @@ export default function DynamicFormItemComponent({
return (
<Input
type="number"
className="max-w-xs"
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
);
case DynamicFormItemType.STRING:
return <Input {...field} />;
return <Input className="max-w-md" {...field} />;
case DynamicFormItemType.TEXT:
return <Textarea {...field} className="min-h-[120px]" />;
return <Textarea {...field} className="min-h-[120px] max-w-2xl" />;
case DynamicFormItemType.BOOLEAN:
return <Switch checked={field.value} onCheckedChange={field.onChange} />;
case DynamicFormItemType.STRING_ARRAY:
return (
<div className="space-y-2">
<div className="space-y-2 max-w-md">
{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
className="w-[200px]"
className="flex-1"
value={item}
onChange={(e) => {
const newValue = [...field.value];
@@ -209,9 +210,11 @@ export default function DynamicFormItemComponent({
field.onChange(newValue);
}}
/>
<button
<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={() => {
const newValue = field.value.filter(
(_: string, i: number) => i !== index,
@@ -219,24 +222,19 @@ export default function DynamicFormItemComponent({
field.onChange(newValue);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
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>
<Trash2 className="size-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
className="w-full border-dashed text-muted-foreground hover:text-foreground"
onClick={() => {
field.onChange([...field.value, '']);
}}
>
<Plus className="size-4 mr-1.5" />
{t('common.add')}
</Button>
</div>
@@ -245,7 +243,7 @@ export default function DynamicFormItemComponent({
case DynamicFormItemType.SELECT:
return (
<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')} />
</SelectTrigger>
<SelectContent>
@@ -274,31 +272,33 @@ export default function DynamicFormItemComponent({
);
return (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('models.selectModel')} />
</SelectTrigger>
<SelectContent>
{Object.entries(groupedModels).map(([providerName, models]) => (
<SelectGroup key={providerName}>
<SelectLabel>{providerName}</SelectLabel>
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
<span className="inline-flex items-center gap-1">
{model.name}
{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" />
)}
</span>
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
<div className="max-w-md">
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('models.selectModel')} />
</SelectTrigger>
<SelectContent>
{Object.entries(groupedModels).map(([providerName, models]) => (
<SelectGroup key={providerName}>
<SelectLabel>{providerName}</SelectLabel>
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
<span className="inline-flex items-center gap-1">
{model.name}
{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" />
)}
</span>
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
</div>
);
case DynamicFormItemType.EMBEDDING_MODEL_SELECTOR:
@@ -314,25 +314,27 @@ export default function DynamicFormItemComponent({
);
return (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('knowledge.selectEmbeddingModel')} />
</SelectTrigger>
<SelectContent>
{Object.entries(groupedEmbeddingModels).map(
([providerName, models]) => (
<SelectGroup key={providerName}>
<SelectLabel>{providerName}</SelectLabel>
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
{model.name}
</SelectItem>
))}
</SelectGroup>
),
)}
</SelectContent>
</Select>
<div className="max-w-md">
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('knowledge.selectEmbeddingModel')} />
</SelectTrigger>
<SelectContent>
{Object.entries(groupedEmbeddingModels).map(
([providerName, models]) => (
<SelectGroup key={providerName}>
<SelectLabel>{providerName}</SelectLabel>
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
{model.name}
</SelectItem>
))}
</SelectGroup>
),
)}
</SelectContent>
</Select>
</div>
);
case DynamicFormItemType.MODEL_FALLBACK_SELECTOR: {
@@ -495,11 +497,11 @@ export default function DynamicFormItemComponent({
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-destructive"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => removeFallbackModel(index)}
>
<X className="h-4 w-4" />
<Trash2 className="size-4" />
</Button>
</div>
</div>
@@ -512,10 +514,10 @@ export default function DynamicFormItemComponent({
type="button"
variant="outline"
size="sm"
className="w-full"
className="w-full border-dashed text-muted-foreground hover:text-foreground"
onClick={addFallbackModel}
>
<Plus className="h-4 w-4 mr-1" />
<Plus className="size-4 mr-1.5" />
{t('models.fallback.addFallback')}
</Button>
</div>

View File

@@ -77,6 +77,11 @@ import {
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { ChevronRight, Plus } from 'lucide-react';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import { useSidebarData, SidebarEntityItem } from './SidebarDataContext';
@@ -346,7 +351,7 @@ function NavItems({
{canCreate && (
<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) => {
e.stopPropagation();
router.push(`${routePrefix}?id=new`);
@@ -395,49 +400,66 @@ function NavItems({
isPlugin ? 'group/plugin-item relative' : ''
}
>
<SidebarMenuSubButton
asChild
isActive={isItemActive}
>
<a
href={itemRoute}
className={cn(
isPlugin && !item.debug ? 'pr-6' : '',
)}
onClick={(e) => {
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',
)}
/>
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<SidebarMenuSubButton
asChild
isActive={isItemActive}
>
<a
href={itemRoute}
className={cn(
isPlugin && !item.debug ? 'pr-6' : '',
)}
</span>
) : null}
<span className="truncate">{item.name}</span>
{item.debug && (
<Bug className="size-3.5 shrink-0 text-orange-400" />
)}
</a>
</SidebarMenuSubButton>
onClick={(e) => {
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>
) : 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) */}
{isPlugin && !item.debug && (
<PluginItemMenu

View File

@@ -15,6 +15,7 @@ import { isNewerVersion } from '@/app/utils/versionCompare';
export interface SidebarEntityItem {
id: string;
name: string;
description?: string;
emoji?: string;
iconURL?: string;
updatedAt?: string; // ISO timestamp for sorting by most recently edited
@@ -63,6 +64,7 @@ export function SidebarDataProvider({
resp.bots.map((bot) => ({
id: bot.uuid || '',
name: bot.name,
description: bot.description,
iconURL: httpClient.getAdapterIconURL(bot.adapter),
updatedAt: bot.updated_at,
enabled: bot.enable ?? true,
@@ -80,6 +82,7 @@ export function SidebarDataProvider({
resp.pipelines.map((p) => ({
id: p.uuid || '',
name: p.name,
description: p.description,
emoji: p.emoji,
updatedAt: p.updated_at,
})),
@@ -96,6 +99,7 @@ export function SidebarDataProvider({
resp.bases.map((kb) => ({
id: kb.uuid || '',
name: kb.name,
description: kb.description,
emoji: kb.emoji,
updatedAt: kb.updated_at,
})),

View File

@@ -4,6 +4,13 @@ import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Dialog,
DialogContent,
@@ -21,7 +28,7 @@ import { useTranslation } from 'react-i18next';
import { KnowledgeBase } from '@/app/infra/entities/api';
import { CustomApiError } from '@/app/infra/entities/common';
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 }) {
const isCreateMode = id === 'new';
@@ -44,6 +51,7 @@ export default function KBDetailContent({ id }: { id: string }) {
const [activeTab, setActiveTab] = useState('metadata');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [kbInfo, setKbInfo] = useState<KnowledgeBase | null>(null);
const [formDirty, setFormDirty] = useState(false);
const loadKbInfo = useCallback(
async (kbId: string) => {
@@ -81,7 +89,6 @@ export default function KBDetailContent({ id }: { id: string }) {
function handleNewKbCreated(newKbId: string) {
refreshKnowledgeBases();
// Navigate to the newly created KB's detail view via query param
router.push(`/home/knowledge?id=${encodeURIComponent(newKbId)}`);
}
@@ -106,45 +113,47 @@ export default function KBDetailContent({ id }: { id: string }) {
return await httpClient.retrieveKnowledgeBase(kbId, query);
};
// Create mode: simple form layout
// ==================== Create Mode ====================
if (isCreateMode) {
return (
<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">
{t('knowledge.createKnowledgeBase')}
</h1>
<Button type="submit" form="kb-form">
{t('common.submit')}
</Button>
</div>
<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
initKbId={undefined}
onNewKbCreated={handleNewKbCreated}
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>
);
}
// Edit mode: tabbed layout with metadata, documents (conditional), retrieve
// ==================== Edit Mode ====================
return (
<>
<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">
{t('knowledge.editKnowledgeBase')}
</h1>
<Button type="submit" form="kb-form" disabled={!formDirty}>
{t('common.save')}
</Button>
</div>
{/* Horizontal Tabs */}
<Tabs
key={id}
value={activeTab}
@@ -168,32 +177,55 @@ export default function KBDetailContent({ id }: { id: string }) {
</TabsTrigger>
</TabsList>
{/* Tab: Metadata */}
<TabsContent
value="metadata"
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
initKbId={id}
onNewKbCreated={handleNewKbCreated}
onKbUpdated={handleKbUpdated}
onDirtyChange={setFormDirty}
/>
<div className="flex justify-end gap-2 mt-6 pb-4">
<Button
type="button"
variant="destructive"
onClick={() => setShowDeleteConfirm(true)}
>
{t('common.delete')}
</Button>
<Button type="submit" form="kb-form">
{t('common.save')}
</Button>
</div>
{/* Danger Zone Card */}
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="text-destructive">
{t('knowledge.dangerZone')}
</CardTitle>
<CardDescription>
{t('knowledge.dangerZoneDescription')}
</CardDescription>
</CardHeader>
<CardContent>
<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>
</TabsContent>
{/* Tab: Documents */}
{hasDocumentCapability() && (
<TabsContent
value="documents"
@@ -207,6 +239,7 @@ export default function KBDetailContent({ id }: { id: string }) {
</TabsContent>
)}
{/* Tab: Retrieve */}
<TabsContent
value="retrieve"
className="flex-1 min-h-0 overflow-y-auto mt-4"

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import Link from 'next/link';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -15,6 +15,13 @@ import {
FormMessage,
FormDescription,
} from '@/components/ui/form';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { httpClient } from '@/app/infra/http/HttpClient';
import {
Select,
@@ -50,17 +57,13 @@ const getFormSchema = (t: (key: string) => string) =>
/**
* Parse creation schema from Knowledge Engine to IDynamicFormItemSchema[]
* Same pattern as ExternalKBForm uses for retriever config
*/
function parseCreationSchema(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
schemaItems: any | any[] | undefined,
): IDynamicFormItemSchema[] {
if (!schemaItems) return [];
// Handle wrapped schema (e.g. { schema: [...] }) which might be returned by the API
const items = Array.isArray(schemaItems) ? schemaItems : schemaItems.schema;
if (!items || !Array.isArray(items)) return [];
return items.map(
@@ -83,10 +86,12 @@ export default function KBForm({
initKbId,
onNewKbCreated,
onKbUpdated,
onDirtyChange,
}: {
initKbId?: string;
onNewKbCreated: (kbId: string) => void;
onKbUpdated: (kbId: string) => void;
onDirtyChange?: (dirty: boolean) => void;
}) {
const { t } = useTranslation();
const [ragEngines, setRagEngines] = useState<KnowledgeEngine[]>([]);
@@ -100,6 +105,10 @@ export default function KBForm({
const [isEditing, setIsEditing] = useState(false);
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 form = useForm<z.infer<typeof formSchema>>({
@@ -117,6 +126,27 @@ export default function KBForm({
(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(() => {
loadRagEngines().then(() => {
if (initKbId) {
@@ -131,7 +161,6 @@ export default function KBForm({
const firstEngine = ragEngines[0];
setSelectedEngineId(firstEngine.plugin_id);
form.setValue('ragEngineId', firstEngine.plugin_id);
// Initialize config settings with defaults
const formItems = parseCreationSchema(firstEngine.creation_schema);
if (formItems.length > 0) {
setConfigSettings(getDefaultValues(formItems));
@@ -157,6 +186,7 @@ export default function KBForm({
const loadKbConfig = async (kbId: string) => {
try {
isInitializing.current = true;
setIsEditing(true);
const res = await httpClient.getKnowledgeBase(kbId);
@@ -165,15 +195,24 @@ export default function KBForm({
const engineId = kb.knowledge_engine_plugin_id || '';
setSelectedEngineId(engineId);
form.setValue('name', kb.name);
form.setValue('description', kb.description);
form.setValue('emoji', kb.emoji || '📚');
form.setValue('ragEngineId', engineId);
form.reset({
name: kb.name,
description: kb.description,
emoji: kb.emoji || '📚',
ragEngineId: engineId,
});
setConfigSettings(kb.creation_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) {
console.error('Failed to load KB config:', err);
isInitializing.current = false;
}
};
@@ -181,7 +220,6 @@ export default function KBForm({
setSelectedEngineId(engineId);
form.setValue('ragEngineId', engineId);
// Find engine and initialize config settings with defaults from schema
const engine = ragEngines.find((e) => e.plugin_id === engineId);
if (engine) {
const formItems = parseCreationSchema(engine.creation_schema);
@@ -210,10 +248,11 @@ export default function KBForm({
};
if (initKbId) {
// Update knowledge base
httpClient
.updateKnowledgeBase(initKbId, kbData)
.then((res) => {
captureSnapshot();
onDirtyChange?.(false);
onKbUpdated(res.uuid);
toast.success(t('knowledge.updateKnowledgeBaseSuccess'));
})
@@ -225,7 +264,6 @@ export default function KBForm({
);
});
} else {
// Create knowledge base
httpClient
.createKnowledgeBase(kbData)
.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(
() => parseCreationSchema(selectedEngine?.creation_schema),
[selectedEngine?.creation_schema],
);
// Convert retrieval schema to dynamic form items
const retrievalFormItems = useMemo(
() => parseCreationSchema(selectedEngine?.retrieval_schema),
[selectedEngine?.retrieval_schema],
@@ -282,14 +316,75 @@ export default function KBForm({
}
return (
<>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
id="kb-form"
className="space-y-8"
>
<div className="space-y-4">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
id="kb-form"
className="space-y-6"
>
{/* Card 1: Basic Information */}
<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 */}
<FormField
control={form.control}
@@ -298,7 +393,7 @@ export default function KBForm({
<FormItem>
<FormLabel>
{t('knowledge.knowledgeEngine')}
<span className="text-red-500">*</span>
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Select
@@ -379,102 +474,54 @@ export default function KBForm({
</FormItem>
)}
/>
</CardContent>
</Card>
{/* 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-red-500">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
{/* Card 2: Engine Settings (dynamic form from creation_schema) */}
{configFormItems.length > 0 && (
<Card>
<CardHeader>
<CardTitle>{t('knowledge.engineSettings')}</CardTitle>
<CardDescription>
{t('knowledge.engineSettingsDescription')}
</CardDescription>
</CardHeader>
<CardContent>
<DynamicFormComponent
itemConfigList={configFormItems}
initialValues={configSettings as Record<string, object>}
onSubmit={(val) =>
setConfigSettings(val as Record<string, unknown>)
}
isEditing={isEditing}
externalDependentValues={retrievalSettings}
/>
<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>
)}
</CardContent>
</Card>
)}
{/* Card 3: Retrieval Settings (dynamic form from retrieval_schema) */}
{retrievalFormItems.length > 0 && (
<Card>
<CardHeader>
<CardTitle>{t('knowledge.retrievalSettings')}</CardTitle>
<CardDescription>
{t('knowledge.retrievalSettingsDescription')}
</CardDescription>
</CardHeader>
<CardContent>
<DynamicFormComponent
itemConfigList={retrievalFormItems}
initialValues={retrievalSettings as Record<string, object>}
onSubmit={(val) =>
setRetrievalSettings(val as Record<string, unknown>)
}
externalDependentValues={configSettings}
/>
</div>
{/* Description */}
<FormField
control={form.control}
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>
</>
</CardContent>
</Card>
)}
</form>
</Form>
);
}

View File

@@ -4,22 +4,12 @@ import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
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 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 { httpClient } from '@/app/infra/http/HttpClient';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
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 }) {
const isCreateMode = id === 'new';
@@ -39,35 +29,29 @@ export default function PipelineDetailContent({ id }: { id: string }) {
}, [id, isCreateMode, pipelines, setDetailEntityName, t]);
const [activeTab, setActiveTab] = useState('config');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [isWebSocketConnected, setIsWebSocketConnected] = useState(false);
const [formDirty, setFormDirty] = useState(false);
function handleFinish() {
refreshPipelines();
}
function handleDeletePipeline() {
httpClient.deletePipeline(id).then(() => {
refreshPipelines();
setShowDeleteConfirm(false);
router.push('/home/pipelines');
});
}
function handleNewPipelineCreated(newPipelineId: string) {
refreshPipelines();
// Navigate to the newly created pipeline's detail view via query param
router.push(`/home/pipelines?id=${encodeURIComponent(newPipelineId)}`);
}
// Create mode: simple form layout
// ==================== Create Mode ====================
if (isCreateMode) {
return (
<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">
{t('pipelines.createPipeline')}
</h1>
<Button type="submit" form="pipeline-form">
{t('common.submit')}
</Button>
</div>
<div className="flex-1 overflow-y-auto min-h-0">
@@ -76,7 +60,7 @@ export default function PipelineDetailContent({ id }: { id: string }) {
pipelineId={undefined}
isEditMode={false}
disableForm={false}
showButtons={true}
showButtons={false}
onFinish={handleFinish}
onNewPipelineCreated={handleNewPipelineCreated}
onDeletePipeline={() => {}}
@@ -87,115 +71,85 @@ export default function PipelineDetailContent({ id }: { id: string }) {
);
}
// Edit mode: tabbed layout with config, extensions, debug, monitoring
// ==================== Edit Mode ====================
return (
<>
<div className="flex h-full flex-col">
<div className="flex items-center gap-3 pb-4 shrink-0">
<h1 className="text-xl font-semibold">
{t('pipelines.editPipeline')}
</h1>
</div>
<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 className="flex h-full flex-col">
{/* Sticky Header: title + save button */}
<div className="flex items-center justify-between pb-4 shrink-0">
<h1 className="text-xl font-semibold">{t('pipelines.editPipeline')}</h1>
<Button type="submit" form="pipeline-form" disabled={!formDirty}>
{t('common.save')}
</Button>
</div>
{/* Delete confirmation dialog */}
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('common.confirmDelete')}</DialogTitle>
<DialogDescription className="sr-only">
{t('pipelines.deleteConfirmation')}
</DialogDescription>
</DialogHeader>
<div className="py-4">{t('pipelines.deleteConfirmation')}</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowDeleteConfirm(false)}
>
{t('common.cancel')}
</Button>
<Button variant="destructive" onClick={handleDeletePipeline}>
{t('common.confirmDelete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
{/* Horizontal Tabs */}
<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="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>
{/* 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>
);
}

View File

@@ -32,7 +32,24 @@ import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { extractI18nObject } from '@/i18n/I18nProvider';
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({
onFinish,
@@ -42,6 +59,7 @@ export default function PipelineFormComponent({
showButtons = true,
onDeletePipeline,
onCancel,
onDirtyChange,
}: {
pipelineId?: string;
isEditMode: boolean;
@@ -51,6 +69,7 @@ export default function PipelineFormComponent({
onNewPipelineCreated: (pipelineId: string) => void;
onDeletePipeline: () => void;
onCancel?: () => void;
onDirtyChange?: (dirty: boolean) => void;
}) {
const { t } = useTranslation();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
@@ -93,6 +112,7 @@ export default function PipelineFormComponent({
trigger: Zap,
safety: Shield,
output: FileOutput,
extensions: Puzzle,
};
const formLabelList: SectionItem[] = isEditMode
@@ -122,6 +142,11 @@ export default function PipelineFormComponent({
name: '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;
}, [isEditMode, watchedValues]);
// Notify parent when dirty state changes
useEffect(() => {
onDirtyChange?.(hasUnsavedChanges);
}, [hasUnsavedChanges, onDirtyChange]);
useEffect(() => {
// get config schema from metadata
httpClient.getGeneralPipelineMetadata().then((resp) => {
@@ -314,27 +344,29 @@ export default function PipelineFormComponent({
// If this is the runner selector stage, render it directly
if (stage.name === 'runner') {
return (
<div key={stage.name} className="space-y-4 mb-6">
<div className="text-lg font-medium">
{extractI18nObject(stage.label)}
</div>
{stage.description && (
<div className="text-sm text-muted-foreground">
{extractI18nObject(stage.description)}
</div>
)}
<DynamicFormComponent
itemConfigList={stage.config}
initialValues={
// 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);
}}
/>
</div>
<Card key={stage.name}>
<CardHeader>
<CardTitle>{extractI18nObject(stage.label)}</CardTitle>
{stage.description && (
<CardDescription>
{extractI18nObject(stage.description)}
</CardDescription>
)}
</CardHeader>
<CardContent className="space-y-6">
<DynamicFormComponent
itemConfigList={stage.config}
initialValues={
// 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);
}}
/>
</CardContent>
</Card>
);
}
@@ -346,52 +378,56 @@ export default function PipelineFormComponent({
// For n8n-service-api config, use N8nAuthFormComponent for form linkage
if (stage.name === 'n8n-service-api') {
return (
<div key={stage.name} className="space-y-4 mb-6">
<div className="text-lg font-medium">
{extractI18nObject(stage.label)}
</div>
{stage.description && (
<div className="text-sm text-muted-foreground">
{extractI18nObject(stage.description)}
</div>
)}
<N8nAuthFormComponent
itemConfigList={stage.config}
initialValues={
// 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);
}}
/>
</div>
<Card key={stage.name}>
<CardHeader>
<CardTitle>{extractI18nObject(stage.label)}</CardTitle>
{stage.description && (
<CardDescription>
{extractI18nObject(stage.description)}
</CardDescription>
)}
</CardHeader>
<CardContent className="space-y-6">
<N8nAuthFormComponent
itemConfigList={stage.config}
initialValues={
// 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);
}}
/>
</CardContent>
</Card>
);
}
}
return (
<div key={stage.name} className="space-y-4 mb-6">
<div className="text-lg font-medium">
{extractI18nObject(stage.label)}
</div>
{stage.description && (
<div className="text-sm text-muted-foreground">
{extractI18nObject(stage.description)}
</div>
)}
<DynamicFormComponent
itemConfigList={stage.config}
initialValues={
// 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);
}}
/>
</div>
<Card key={stage.name}>
<CardHeader>
<CardTitle>{extractI18nObject(stage.label)}</CardTitle>
{stage.description && (
<CardDescription>
{extractI18nObject(stage.description)}
</CardDescription>
)}
</CardHeader>
<CardContent className="space-y-6">
<DynamicFormComponent
itemConfigList={stage.config}
initialValues={
// 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);
}}
/>
</CardContent>
</Card>
);
}
@@ -477,58 +513,130 @@ export default function PipelineFormComponent({
{/* Basic info section */}
{activeSection === 'basic' && (
<div className="space-y-6">
{/* Name and Emoji in same row */}
<div className="flex gap-4 items-start">
<FormField
control={form.control}
name="basic.name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
{t('common.name')}
<span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="basic.emoji"
render={({ field }) => (
<FormItem>
<FormLabel>{t('common.icon')}</FormLabel>
<FormControl>
<EmojiPicker
value={field.value}
onChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Basic Information Card */}
<Card>
<CardHeader>
<CardTitle>{t('pipelines.basicInfo')}</CardTitle>
<CardDescription>
{t('pipelines.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="basic.name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
{t('common.name')}
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="basic.emoji"
render={({ field }) => (
<FormItem>
<FormLabel>{t('common.icon')}</FormLabel>
<FormControl>
<EmojiPicker
value={field.value}
onChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="basic.description"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('common.description')}
<span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="basic.description"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('common.description')}
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</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>
)}
@@ -566,6 +674,10 @@ export default function PipelineFormComponent({
)}
</div>
)}
{activeSection === 'extensions' && pipelineId && (
<PipelineExtension pipelineId={pipelineId} />
)}
</>
)}
</div>

View File

@@ -632,7 +632,8 @@ const enUS = {
earliestCreated: 'Earliest Created',
recentlyEdited: 'Recently Edited',
earliestEdited: 'Earliest Edited',
basicInfo: 'Basic',
basicInfo: 'Basic Information',
basicInfoDescription: 'Set the pipeline name, icon and description',
aiCapabilities: 'AI',
triggerConditions: 'Trigger',
safetyControls: 'Safety',
@@ -653,6 +654,14 @@ const enUS = {
copyConfirmation:
'Are you sure you want to copy this pipeline? This will create a new pipeline with all configurations.',
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: {
title: 'Extensions',
loadError: 'Failed to load plugins',
@@ -801,9 +810,20 @@ const enUS = {
builtInEngine: 'Built-in Engine',
cannotChangeKnowledgeEngine:
'Knowledge engine cannot be changed after creation',
basicInfo: 'Basic Information',
basicInfoDescription: 'Set the knowledge base name, icon and description',
engineSettings: 'Engine Settings',
engineSettingsDescription:
'Configuration for the selected knowledge engine',
engineSettingsReadonly: 'read-only in edit mode',
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',
installEngineHint: 'Please install a "Knowledge Engine" plugin first',
createKnowledgeBaseFailed: 'Failed to create knowledge base: ',

View File

@@ -634,6 +634,7 @@
recentlyEdited: '最近編集',
earliestEdited: '最古編集',
basicInfo: '基本情報',
basicInfoDescription: 'パイプラインの名前、アイコン、説明を設定',
aiCapabilities: 'AI機能',
triggerConditions: 'トリガー条件',
safetyControls: '安全制御',
@@ -655,6 +656,13 @@
copyConfirmation:
'このパイプラインをコピーしますか?すべての設定を含む新しいパイプラインが作成されます。',
unsavedChanges: '未保存の変更があります',
dangerZone: '危険ゾーン',
dangerZoneDescription: '元に戻せない操作',
deletePipelineAction: 'このパイプラインを削除',
deletePipelineHint:
'削除すると、このパイプラインに紐付けられたボットは動作しなくなります。',
copyPipelineAction: 'このパイプラインをコピー',
copyPipelineHint: 'すべての設定を複製した新しいパイプラインを作成します。',
extensions: {
title: 'プラグイン統合',
loadError: 'プラグインリストの読み込みに失敗しました',
@@ -797,6 +805,18 @@
fileName: 'ファイル名',
noResults: '検索結果がありません',
retrieveError: '検索に失敗しました:',
basicInfo: '基本情報',
basicInfoDescription: 'ナレッジベースの名前、アイコン、説明を設定',
engineSettings: 'エンジン設定',
engineSettingsDescription: '選択したナレッジエンジンの設定',
engineSettingsReadonly: '編集モードでは変更できません',
retrievalSettings: '検索設定',
retrievalSettingsDescription: 'このナレッジベースからの文書検索方法を設定',
dangerZone: '危険ゾーン',
dangerZoneDescription: '元に戻せない操作',
deleteKbAction: 'このナレッジベースを削除',
deleteKbHint:
'削除すると、このナレッジベース内のすべての文書とデータが完全に削除されます。',
noEnginesAvailable: '利用可能なナレッジエンジンがありません',
installEngineHint:
'先に「ナレッジエンジン」プラグインをインストールしてください',

View File

@@ -604,6 +604,7 @@ const zhHans = {
recentlyEdited: '最近编辑',
earliestEdited: '最早编辑',
basicInfo: '基础信息',
basicInfoDescription: '设置流水线名称、图标和描述',
aiCapabilities: 'AI 能力',
triggerConditions: '触发条件',
safetyControls: '安全控制',
@@ -624,6 +625,12 @@ const zhHans = {
copyConfirmation:
'确定要复制这个流水线吗?复制将创建一个包含完整配置的新流水线。',
unsavedChanges: '有未保存的更改',
dangerZone: '危险区域',
dangerZoneDescription: '不可逆的操作',
deletePipelineAction: '删除此流水线',
deletePipelineHint: '删除后,绑定此流水线的机器人将无法正常工作。',
copyPipelineAction: '复制此流水线',
copyPipelineHint: '创建一条新的流水线,并复制所有配置。',
extensions: {
title: '扩展集成',
loadError: '加载插件列表失败',
@@ -765,9 +772,17 @@ const zhHans = {
selectKnowledgeEngine: '选择知识引擎',
builtInEngine: '内置引擎',
cannotChangeKnowledgeEngine: '知识库创建后不可修改知识引擎',
basicInfo: '基础信息',
basicInfoDescription: '设置知识库名称、图标和描述',
engineSettings: '引擎设置',
engineSettingsDescription: '所选知识引擎的配置',
engineSettingsReadonly: '编辑模式下不可修改',
retrievalSettings: '检索设置',
retrievalSettingsDescription: '配置从此知识库检索文档的方式',
dangerZone: '危险区域',
dangerZoneDescription: '不可逆的操作',
deleteKbAction: '删除此知识库',
deleteKbHint: '删除后,此知识库中的所有文档和数据将被永久移除。',
noEnginesAvailable: '没有可用的知识库引擎',
installEngineHint: '请先安装「知识引擎」插件',
createKnowledgeBaseFailed: '知识库创建失败:',

View File

@@ -597,6 +597,7 @@ const zhHant = {
recentlyEdited: '最近編輯',
earliestEdited: '最早編輯',
basicInfo: '基本資訊',
basicInfoDescription: '設定流程線名稱、圖示和描述',
aiCapabilities: 'AI 能力',
triggerConditions: '觸發條件',
safetyControls: '安全控制',
@@ -617,6 +618,12 @@ const zhHant = {
copyConfirmation:
'確定要複製這個流程線嗎?複製將創建一個包含完整配置的新流程線。',
unsavedChanges: '有未儲存的變更',
dangerZone: '危險區域',
dangerZoneDescription: '不可逆的操作',
deletePipelineAction: '刪除此流程線',
deletePipelineHint: '刪除後,綁定此流程線的機器人將無法正常運作。',
copyPipelineAction: '複製此流程線',
copyPipelineHint: '建立一條新的流程線,並複製所有設定。',
extensions: {
title: '擴展集成',
loadError: '載入插件清單失敗',
@@ -752,6 +759,17 @@ const zhHant = {
fileName: '文檔名稱',
noResults: '暫無結果',
retrieveError: '檢索失敗:',
basicInfo: '基本資訊',
basicInfoDescription: '設定知識庫名稱、圖示和描述',
engineSettings: '引擎設定',
engineSettingsDescription: '所選知識引擎的設定',
engineSettingsReadonly: '編輯模式下不可修改',
retrievalSettings: '檢索設定',
retrievalSettingsDescription: '設定從此知識庫檢索文件的方式',
dangerZone: '危險區域',
dangerZoneDescription: '不可逆的操作',
deleteKbAction: '刪除此知識庫',
deleteKbHint: '刪除後,此知識庫中的所有文件和資料將被永久移除。',
noEnginesAvailable: '沒有可用的知識庫引擎',
installEngineHint: '請先安裝「知識引擎」插件',
unknownEngine: '未知引擎',