feat(web): streamline management and knowledge settings

This commit is contained in:
RockChinQ
2026-08-25 23:02:33 +08:00
parent db2a9155f8
commit 22d9053bf1
6 changed files with 203 additions and 107 deletions
@@ -132,7 +132,7 @@ function AgentFormComponent(
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [activeSection, setActiveSection] = const [activeSection, setActiveSection] =
useState<AgentConfigSection>('basic'); useState<AgentConfigSection>('events');
const isSavingRef = useRef(false); const isSavingRef = useRef(false);
const hasUnsavedChangesRef = useRef(false); const hasUnsavedChangesRef = useRef(false);
@@ -270,11 +270,6 @@ function AgentFormComponent(
label: string; label: string;
icon: React.ElementType; icon: React.ElementType;
}> = [ }> = [
{
name: 'basic',
label: t('common.management'),
icon: Power,
},
{ {
name: 'events', name: 'events',
label: t('agents.bindableEvents'), label: t('agents.bindableEvents'),
@@ -293,6 +288,11 @@ function AgentFormComponent(
icon: SlidersHorizontal, icon: SlidersHorizontal,
}, },
]; ];
const managementSection = {
name: 'basic' as const,
label: t('common.management'),
icon: Power,
};
const runnerStatus = useMemo<AgentRunnerStatus>(() => { const runnerStatus = useMemo<AgentRunnerStatus>(() => {
if (pluginStatusLoading) { if (pluginStatusLoading) {
@@ -568,7 +568,7 @@ function AgentFormComponent(
} }
> >
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<TabsList className="grid min-w-[44rem] w-full grid-cols-4"> <TabsList className="grid min-w-[34rem] w-full grid-cols-3">
{primarySections.map((section) => { {primarySections.map((section) => {
const Icon = section.icon; const Icon = section.icon;
return ( return (
@@ -580,6 +580,21 @@ function AgentFormComponent(
})} })}
</TabsList> </TabsList>
</div> </div>
<div className="flex justify-end pt-2">
<Button
type="button"
variant={
activeSection === managementSection.name
? 'secondary'
: 'ghost'
}
size="sm"
onClick={() => setActiveSection(managementSection.name)}
>
<Power />
{managementSection.label}
</Button>
</div>
</Tabs> </Tabs>
</nav> </nav>
+61 -3
View File
@@ -28,6 +28,10 @@ import { CustomApiError } from '@/app/infra/entities/common';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { FileText, FolderOpen, Search, Trash2 } from 'lucide-react'; import { FileText, FolderOpen, Search, Trash2 } from 'lucide-react';
import { useCurrentWorkspace } from '@/app/infra/http'; import { useCurrentWorkspace } from '@/app/infra/http';
import EntityBasicInfoDialog, {
EntityBasicInfoValues,
} from '@/app/home/components/entity-basic-info/EntityBasicInfoDialog';
import EntityTitleEditButton from '@/app/home/components/entity-basic-info/EntityTitleEditButton';
export default function KBDetailContent({ id }: { id: string }) { export default function KBDetailContent({ id }: { id: string }) {
const isCreateMode = id === 'new'; const isCreateMode = id === 'new';
@@ -52,8 +56,10 @@ 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 [showBasicInfoDialog, setShowBasicInfoDialog] = useState(false);
const [kbInfo, setKbInfo] = useState<KnowledgeBase | null>(null); const [kbInfo, setKbInfo] = useState<KnowledgeBase | null>(null);
const [formDirty, setFormDirty] = useState(false); const [formDirty, setFormDirty] = useState(false);
const [formVersion, setFormVersion] = useState(0);
const loadKbInfo = useCallback( const loadKbInfo = useCallback(
async (kbId: string) => { async (kbId: string) => {
@@ -99,6 +105,34 @@ export default function KBDetailContent({ id }: { id: string }) {
loadKbInfo(id); loadKbInfo(id);
} }
async function handleBasicInfoSave(values: EntityBasicInfoValues) {
if (!kbInfo) return;
const updateData: KnowledgeBase = {
name: values.name,
description: values.description,
emoji: values.emoji || '📚',
knowledge_engine_plugin_id: kbInfo.knowledge_engine_plugin_id,
creation_settings: kbInfo.creation_settings,
retrieval_settings: kbInfo.retrieval_settings,
};
try {
await httpClient.updateKnowledgeBase(id, updateData);
setKbInfo({ ...kbInfo, ...updateData });
setDetailEntityName(values.name);
setFormDirty(false);
setFormVersion((version) => version + 1);
refreshKnowledgeBases();
toast.success(t('knowledge.updateKnowledgeBaseSuccess'));
} catch (err) {
toast.error(
t('knowledge.updateKnowledgeBaseFailed') + (err as CustomApiError).msg,
);
throw err;
}
}
async function confirmDelete() { async function confirmDelete() {
try { try {
await httpClient.deleteKnowledgeBase(id); await httpClient.deleteKnowledgeBase(id);
@@ -151,9 +185,18 @@ export default function KBDetailContent({ id }: { id: string }) {
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
{/* Sticky Header: title + save button */} {/* Sticky Header: title + save button */}
<div className="flex items-center justify-between pb-4 shrink-0"> <div className="flex items-center justify-between pb-4 shrink-0">
<h1 className="text-xl font-semibold"> <div className="flex min-w-0 items-center gap-1">
{t('knowledge.editKnowledgeBase')} <h1 className="truncate text-xl font-semibold">
</h1> {kbInfo
? `${kbInfo.emoji || '📚'} ${kbInfo.name}`
: t('knowledge.editKnowledgeBase')}
</h1>
{canManage && kbInfo && (
<EntityTitleEditButton
onClick={() => setShowBasicInfoDialog(true)}
/>
)}
</div>
{canManage && ( {canManage && (
<Button <Button
type="submit" type="submit"
@@ -198,6 +241,7 @@ export default function KBDetailContent({ id }: { id: string }) {
<div className="mx-auto max-w-3xl space-y-6 pb-8"> <div className="mx-auto max-w-3xl space-y-6 pb-8">
<fieldset className="contents" disabled={!canManage}> <fieldset className="contents" disabled={!canManage}>
<KBForm <KBForm
key={`${id}-${formVersion}`}
initKbId={id} initKbId={id}
onNewKbCreated={handleNewKbCreated} onNewKbCreated={handleNewKbCreated}
onKbUpdated={handleKbUpdated} onKbUpdated={handleKbUpdated}
@@ -268,6 +312,20 @@ export default function KBDetailContent({ id }: { id: string }) {
</Tabs> </Tabs>
</div> </div>
{kbInfo && (
<EntityBasicInfoDialog
open={showBasicInfoDialog}
onOpenChange={setShowBasicInfoDialog}
values={{
name: kbInfo.name,
description: kbInfo.description,
emoji: kbInfo.emoji,
}}
defaultEmoji="📚"
onSave={handleBasicInfoSave}
/>
)}
{/* Delete confirmation dialog */} {/* Delete confirmation dialog */}
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}> <Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent> <DialogContent>
@@ -31,6 +31,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import { KnowledgeBase, KnowledgeEngine } from '@/app/infra/entities/api'; import { KnowledgeBase, KnowledgeEngine } 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';
@@ -100,7 +101,7 @@ export default function KBForm({
const [retrievalSettings, setRetrievalSettings] = useState< const [retrievalSettings, setRetrievalSettings] = useState<
Record<string, unknown> Record<string, unknown>
>({}); >({});
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(Boolean(initKbId));
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// Dirty tracking: snapshot of saved state for comparison // Dirty tracking: snapshot of saved state for comparison
@@ -341,26 +342,59 @@ export default function KBForm({
id="kb-form" id="kb-form"
className="space-y-6" className="space-y-6"
> >
{/* Card 1: Basic Information */} {/* Basic information is entered here only during creation. */}
<Card> {!isEditing && (
<CardHeader> <Card>
<CardTitle>{t('knowledge.basicInfo')}</CardTitle> <CardHeader>
<CardDescription> <CardTitle>{t('knowledge.basicInfo')}</CardTitle>
{t('knowledge.basicInfoDescription')} <CardDescription>
</CardDescription> {t('knowledge.basicInfoDescription')}
</CardHeader> </CardDescription>
<CardContent className="space-y-4"> </CardHeader>
{/* Name and Emoji in same row */} <CardContent className="space-y-4">
<div className="flex gap-4 items-start"> {/* 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 <FormField
control={form.control} control={form.control}
name="name" name="description"
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex-1"> <FormItem>
<FormLabel> <FormLabel>{t('knowledge.kbDescription')}</FormLabel>
{t('knowledge.kbName')}
<span className="text-destructive">*</span>
</FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
@@ -368,40 +402,19 @@ export default function KBForm({
</FormItem> </FormItem>
)} )}
/> />
<FormField </CardContent>
control={form.control} </Card>
name="emoji" )}
render={({ field }) => (
<FormItem>
<FormLabel>{t('common.icon')}</FormLabel>
<FormControl>
<EmojiPicker
value={field.value}
onChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Description */} {/* Knowledge engine selection and settings stay together. */}
<FormField <Card>
control={form.control} <CardHeader>
name="description" <CardTitle>{t('knowledge.engineSettings')}</CardTitle>
render={({ field }) => ( <CardDescription>
<FormItem> {t('knowledge.engineSettingsDescription')}
<FormLabel>{t('knowledge.kbDescription')}</FormLabel> </CardDescription>
<FormControl> </CardHeader>
<Input {...field} /> <CardContent className="space-y-6">
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Knowledge Engine Selector */}
<FormField <FormField
control={form.control} control={form.control}
name="ragEngineId" name="ragEngineId"
@@ -484,36 +497,28 @@ export default function KBForm({
</FormItem> </FormItem>
)} )}
/> />
{configFormItems.length > 0 && (
<>
<Separator />
<DynamicFormComponent
itemConfigList={configFormItems}
initialValues={configSettings as Record<string, object>}
onSubmit={(val) =>
setConfigSettings(val as Record<string, unknown>)
}
isEditing={isEditing}
externalDependentValues={retrievalSettings}
onValidate={(validateFn) =>
(configValidateRef.current = validateFn)
}
/>
</>
)}
</CardContent> </CardContent>
</Card> </Card>
{/* Card 2: Engine Settings (dynamic form from creation_schema) */} {/* Retrieval Settings (dynamic form from retrieval_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}
onValidate={(validateFn) =>
(configValidateRef.current = validateFn)
}
/>
</CardContent>
</Card>
)}
{/* Card 3: Retrieval Settings (dynamic form from retrieval_schema) */}
{retrievalFormItems.length > 0 && ( {retrievalFormItems.length > 0 && (
<Card> <Card>
<CardHeader> <CardHeader>
@@ -187,9 +187,13 @@ const PipelineFormComponent = forwardRef<
const primarySections = primarySectionNames const primarySections = primarySectionNames
.map((name) => formLabelList.find((section) => section.name === name)) .map((name) => formLabelList.find((section) => section.name === name))
.filter((section): section is SectionItem => Boolean(section)); .filter((section): section is SectionItem => Boolean(section));
const secondarySections = formLabelList.filter( const secondarySections = formLabelList
(section) => !primarySectionNames.includes(section.name), .filter((section) => !primarySectionNames.includes(section.name))
); .sort((left, right) => {
if (left.name === 'basic') return 1;
if (right.name === 'basic') return -1;
return 0;
});
const [aiConfigTabSchema, setAIConfigTabSchema] = const [aiConfigTabSchema, setAIConfigTabSchema] =
useState<PipelineConfigTab>(); useState<PipelineConfigTab>();
+17 -10
View File
@@ -244,18 +244,25 @@ test.describe('frontend CRUD smoke flows', () => {
await expect(page).toHaveURL(/\/home\/knowledge\?id=knowledge-1$/); await expect(page).toHaveURL(/\/home\/knowledge\?id=knowledge-1$/);
await page.reload(); await page.reload();
await expect(page.locator('input[name="name"]')).toHaveValue( await expect(
'Support Knowledge', page.getByRole('heading', { name: /Support Knowledge/ }),
); ).toBeVisible();
await page.waitForTimeout(600); await expect(page.locator('input[name="name"]')).toHaveCount(0);
const engineSettings = page.locator('[data-slot="card"]').filter({
has: page.getByText('Engine Settings', { exact: true }),
});
await expect(engineSettings.getByRole('combobox')).toBeVisible();
await page await page.getByRole('button', { name: 'Edit basic information' }).click();
.locator('input[name="description"]') const kbInfoDialog = page.getByRole('dialog');
await kbInfoDialog.getByLabel('Name').fill('Support Knowledge Updated');
await kbInfoDialog
.getByLabel('Description')
.fill('Updated source material for support answers.'); .fill('Updated source material for support answers.');
await save(page); await kbInfoDialog.getByRole('button', { name: 'Save' }).click();
await expect(page.locator('input[name="description"]')).toHaveValue( await expect(
'Updated source material for support answers.', page.getByRole('heading', { name: /Support Knowledge Updated/ }),
); ).toBeVisible();
await page.getByRole('button', { name: /^Delete$/ }).click(); await page.getByRole('button', { name: /^Delete$/ }).click();
await confirmDelete(page); await confirmDelete(page);
@@ -45,12 +45,16 @@ test.describe('processor detail workbench', () => {
expect(debugBox!.y).toBeGreaterThanOrEqual(0); expect(debugBox!.y).toBeGreaterThanOrEqual(0);
const flow = configPanel.getByRole('tablist'); const flow = configPanel.getByRole('tablist');
await expect(flow.getByRole('tab').nth(0)).toContainText('Management'); await expect(flow.getByRole('tab').nth(0)).toContainText(
await expect(flow.getByRole('tab').nth(1)).toContainText(
'Bindable Event Range', 'Bindable Event Range',
); );
await expect(flow.getByRole('tab').nth(2)).toContainText('Runner'); await expect(flow.getByRole('tab').nth(1)).toContainText('Runner');
await expect(flow.getByRole('tab').nth(3)).toContainText('Local Agent'); await expect(flow.getByRole('tab').nth(2)).toContainText('Local Agent');
const agentManagement = configPanel.getByRole('button', {
name: 'Management',
});
await expect(agentManagement).toBeVisible();
await expect(flow.getByText('Management')).toHaveCount(0);
await expect( await expect(
page.getByRole('heading', { name: /agent-workbench/ }), page.getByRole('heading', { name: /agent-workbench/ }),
@@ -73,11 +77,11 @@ test.describe('processor detail workbench', () => {
.last(), .last(),
).toBeVisible(); ).toBeVisible();
await flow.getByRole('tab').nth(1).click(); await flow.getByRole('tab').nth(0).click();
await expect( await expect(
configPanel.getByText('Bindable Event Range', { exact: true }).last(), configPanel.getByText('Bindable Event Range', { exact: true }).last(),
).toBeVisible(); ).toBeVisible();
await flow.getByRole('tab').nth(3).click(); await flow.getByRole('tab').nth(2).click();
await expect( await expect(
configPanel.getByText('Local Agent', { exact: true }).last(), configPanel.getByText('Local Agent', { exact: true }).last(),
).toBeVisible(); ).toBeVisible();
@@ -217,6 +221,9 @@ test.describe('processor detail workbench', () => {
page.getByRole('heading', { name: /Renamed Pipeline/ }), page.getByRole('heading', { name: /Renamed Pipeline/ }),
).toBeVisible(); ).toBeVisible();
const secondaryNavigation = configPanel.locator('nav').getByRole('button');
await expect(secondaryNavigation.last()).toHaveText('Management');
const debugBox = await debugPanel.boundingBox(); const debugBox = await debugPanel.boundingBox();
const configBox = await configPanel.boundingBox(); const configBox = await configPanel.boundingBox();
expect(debugBox).not.toBeNull(); expect(debugBox).not.toBeNull();