mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-26 12:17:14 +00:00
feat(web): edit entity details from page titles
This commit is contained in:
@@ -692,6 +692,17 @@ class BotService:
|
||||
)
|
||||
if getattr(result, 'rowcount', None) == 0:
|
||||
raise WorkspaceNotFoundError('Bot not found')
|
||||
|
||||
runtime_fields = {'adapter', 'adapter_config', 'enable', 'event_bindings'}
|
||||
if not runtime_fields.intersection(update_data):
|
||||
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(context, bot_uuid)
|
||||
if runtime_bot is not None:
|
||||
if 'name' in update_data:
|
||||
runtime_bot.bot_entity.name = update_data['name']
|
||||
if 'description' in update_data:
|
||||
runtime_bot.bot_entity.description = update_data['description']
|
||||
return
|
||||
|
||||
await self.ap.platform_mgr.remove_bot(context, bot_uuid)
|
||||
|
||||
# select from db
|
||||
|
||||
@@ -443,6 +443,7 @@ class TestBotServiceUpdateBot:
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
ap.platform_mgr = SimpleNamespace()
|
||||
ap.platform_mgr.remove_bot = AsyncMock()
|
||||
ap.platform_mgr.get_bot_by_uuid = AsyncMock(return_value=None)
|
||||
|
||||
# Mock pipeline query - not updating pipeline
|
||||
ap.persistence_mgr.execute_async = AsyncMock()
|
||||
@@ -473,6 +474,7 @@ class TestBotServiceUpdateBot:
|
||||
|
||||
ap.persistence_mgr.execute_async = AsyncMock(return_value=Mock())
|
||||
ap.platform_mgr = SimpleNamespace(
|
||||
get_bot_by_uuid=AsyncMock(return_value=None),
|
||||
remove_bot=AsyncMock(),
|
||||
load_bot=AsyncMock(return_value=SimpleNamespace(enable=False)),
|
||||
)
|
||||
@@ -496,6 +498,29 @@ class TestBotServiceUpdateBot:
|
||||
assert 'use_pipeline_uuid' not in update_params
|
||||
assert 'use_pipeline_name' not in update_params
|
||||
|
||||
async def test_basic_info_update_does_not_restart_platform_adapter(self):
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace(execute_async=AsyncMock(return_value=SimpleNamespace(rowcount=1)))
|
||||
runtime_entity = SimpleNamespace(name='Old name', description='Old description')
|
||||
runtime_bot = SimpleNamespace(bot_entity=runtime_entity)
|
||||
ap.platform_mgr = SimpleNamespace(
|
||||
get_bot_by_uuid=AsyncMock(return_value=runtime_bot),
|
||||
remove_bot=AsyncMock(),
|
||||
load_bot=AsyncMock(),
|
||||
)
|
||||
|
||||
service = BotService(ap)
|
||||
await service.update_bot(
|
||||
WORKSPACE_UUID,
|
||||
'test-uuid',
|
||||
{'name': 'New name', 'description': 'New description'},
|
||||
)
|
||||
|
||||
assert runtime_entity.name == 'New name'
|
||||
assert runtime_entity.description == 'New description'
|
||||
ap.platform_mgr.remove_bot.assert_not_awaited()
|
||||
ap.platform_mgr.load_bot.assert_not_awaited()
|
||||
|
||||
|
||||
class TestBotServiceDeleteBot:
|
||||
"""Tests for delete_bot method."""
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||
import { Agent } from '@/app/infra/entities/api';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
import ProcessorDetailWorkbench from '@/app/home/components/processor-detail/ProcessorDetailWorkbench';
|
||||
import EntityBasicInfoDialog, {
|
||||
EntityBasicInfoValues,
|
||||
} from '@/app/home/components/entity-basic-info/EntityBasicInfoDialog';
|
||||
import EntityTitleEditButton from '@/app/home/components/entity-basic-info/EntityTitleEditButton';
|
||||
import PipelineDetailContent from '@/app/home/pipelines/PipelineDetailContent';
|
||||
import AgentCreateContent from './components/AgentCreateContent';
|
||||
import AgentDebugPanel from './components/AgentDebugPanel';
|
||||
@@ -28,6 +33,7 @@ export default function AgentDetailContent({ id }: { id: string }) {
|
||||
const [loading, setLoading] = useState(!isCreateMode);
|
||||
const [formDirty, setFormDirty] = useState(false);
|
||||
const [formSaving, setFormSaving] = useState(false);
|
||||
const [basicInfoOpen, setBasicInfoOpen] = useState(false);
|
||||
const [runnerStatus, setRunnerStatus] = useState<AgentRunnerStatus | null>(
|
||||
null,
|
||||
);
|
||||
@@ -88,58 +94,93 @@ export default function AgentDetailContent({ id }: { id: string }) {
|
||||
return <PipelineDetailContent id={id} routeBase="/home/agents" />;
|
||||
}
|
||||
|
||||
async function saveBasicInfo(values: EntityBasicInfoValues) {
|
||||
try {
|
||||
await httpClient.updateAgent(id, values);
|
||||
setAgent((current) => (current ? { ...current, ...values } : current));
|
||||
agentFormRef.current?.syncBasicInfo(values);
|
||||
await refreshPipelines();
|
||||
toast.success(t('agents.saveSuccess'));
|
||||
} catch (error) {
|
||||
const message =
|
||||
typeof error === 'object' && error && 'msg' in error
|
||||
? String((error as { msg?: string }).msg || '')
|
||||
: '';
|
||||
toast.error(t('agents.saveError') + message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ProcessorDetailWorkbench
|
||||
key={id}
|
||||
title={`${agent.emoji || '🤖'} ${agent.name}`}
|
||||
status={runnerStatus}
|
||||
saveLabel={t('common.save')}
|
||||
saveFormId="agent-form"
|
||||
canSave={canManage}
|
||||
isDirty={formDirty}
|
||||
isSaving={formSaving}
|
||||
configTitle={t('pipelines.configuration')}
|
||||
configContent={
|
||||
<fieldset className="contents" disabled={!canManage}>
|
||||
<AgentFormComponent
|
||||
ref={agentFormRef}
|
||||
agentId={id}
|
||||
onFinish={(updatedAgent) => {
|
||||
if (updatedAgent) {
|
||||
setAgent((current) =>
|
||||
current ? { ...current, ...updatedAgent } : current,
|
||||
);
|
||||
<>
|
||||
<ProcessorDetailWorkbench
|
||||
key={id}
|
||||
title={`${agent.emoji || '🤖'} ${agent.name}`}
|
||||
titleAction={
|
||||
canManage ? (
|
||||
<EntityTitleEditButton onClick={() => setBasicInfoOpen(true)} />
|
||||
) : undefined
|
||||
}
|
||||
status={runnerStatus}
|
||||
saveLabel={t('common.save')}
|
||||
saveFormId="agent-form"
|
||||
canSave={canManage}
|
||||
isDirty={formDirty}
|
||||
isSaving={formSaving}
|
||||
configTitle={t('pipelines.configuration')}
|
||||
configContent={
|
||||
<fieldset className="contents" disabled={!canManage}>
|
||||
<AgentFormComponent
|
||||
ref={agentFormRef}
|
||||
agentId={id}
|
||||
onFinish={(updatedAgent) => {
|
||||
if (updatedAgent) {
|
||||
setAgent((current) =>
|
||||
current ? { ...current, ...updatedAgent } : current,
|
||||
);
|
||||
}
|
||||
refreshPipelines();
|
||||
}}
|
||||
onDeleted={() => {
|
||||
refreshPipelines();
|
||||
navigate('/home/agents');
|
||||
}}
|
||||
onDirtyChange={setFormDirty}
|
||||
onSavingChange={setFormSaving}
|
||||
onRunnerStatusChange={setRunnerStatus}
|
||||
/>
|
||||
</fieldset>
|
||||
}
|
||||
debugTitle={canOperate ? t('agents.debugTab') : undefined}
|
||||
debugContent={
|
||||
canOperate ? (
|
||||
<AgentDebugPanel
|
||||
agentId={id}
|
||||
hasUnsavedChanges={formDirty}
|
||||
beforeRun={async () => agentFormRef.current?.save() ?? false}
|
||||
onOpenRunnerConfig={() =>
|
||||
agentFormRef.current?.openSection('runner_config')
|
||||
}
|
||||
refreshPipelines();
|
||||
}}
|
||||
onDeleted={() => {
|
||||
refreshPipelines();
|
||||
navigate('/home/agents');
|
||||
}}
|
||||
onDirtyChange={setFormDirty}
|
||||
onSavingChange={setFormSaving}
|
||||
onRunnerStatusChange={setRunnerStatus}
|
||||
/>
|
||||
</fieldset>
|
||||
}
|
||||
debugTitle={canOperate ? t('agents.debugTab') : undefined}
|
||||
debugContent={
|
||||
canOperate ? (
|
||||
<AgentDebugPanel
|
||||
agentId={id}
|
||||
hasUnsavedChanges={formDirty}
|
||||
beforeRun={async () => agentFormRef.current?.save() ?? false}
|
||||
onOpenRunnerConfig={() =>
|
||||
agentFormRef.current?.openSection('runner_config')
|
||||
}
|
||||
supportedEventPatterns={
|
||||
agent.supported_event_patterns ??
|
||||
agent.capability?.supported_event_patterns ?? ['*']
|
||||
}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
unsavedLabel={t('pipelines.unsavedChanges')}
|
||||
/>
|
||||
supportedEventPatterns={
|
||||
agent.supported_event_patterns ??
|
||||
agent.capability?.supported_event_patterns ?? ['*']
|
||||
}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
unsavedLabel={t('pipelines.unsavedChanges')}
|
||||
/>
|
||||
<EntityBasicInfoDialog
|
||||
open={basicInfoOpen}
|
||||
onOpenChange={setBasicInfoOpen}
|
||||
values={{
|
||||
name: agent.name,
|
||||
description: agent.description || '',
|
||||
emoji: agent.emoji || '🤖',
|
||||
}}
|
||||
defaultEmoji="🤖"
|
||||
onSave={saveBasicInfo}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import { Bot, Info, Power, SlidersHorizontal, Trash2, Zap } from 'lucide-react';
|
||||
import { Bot, Power, SlidersHorizontal, Trash2, Zap } from 'lucide-react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { Agent, ApiRespPluginSystemStatus } from '@/app/infra/entities/api';
|
||||
import {
|
||||
@@ -25,9 +25,7 @@ import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import EmojiPicker from '@/components/ui/emoji-picker';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -68,11 +66,19 @@ interface AgentFormComponentProps {
|
||||
}
|
||||
|
||||
export type AgentConfigSection =
|
||||
'events' | 'runner' | 'runner_config' | 'basic';
|
||||
| 'events'
|
||||
| 'runner'
|
||||
| 'runner_config'
|
||||
| 'basic';
|
||||
|
||||
export interface AgentFormHandle {
|
||||
openSection: (section: AgentConfigSection) => void;
|
||||
save: () => Promise<boolean>;
|
||||
syncBasicInfo: (values: {
|
||||
name: string;
|
||||
description: string;
|
||||
emoji?: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
function isRequiredRunnerValueMissing(value: unknown): boolean {
|
||||
@@ -266,8 +272,8 @@ function AgentFormComponent(
|
||||
}> = [
|
||||
{
|
||||
name: 'basic',
|
||||
label: t('agents.basicInfo'),
|
||||
icon: Info,
|
||||
label: t('common.management'),
|
||||
icon: Power,
|
||||
},
|
||||
{
|
||||
name: 'events',
|
||||
@@ -503,6 +509,24 @@ function AgentFormComponent(
|
||||
ref,
|
||||
() => ({
|
||||
openSection: setActiveSection,
|
||||
syncBasicInfo(values) {
|
||||
form.setValue('basic', {
|
||||
...form.getValues('basic'),
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
emoji: values.emoji || '🤖',
|
||||
});
|
||||
if (savedSnapshotRef.current) {
|
||||
const snapshot = JSON.parse(savedSnapshotRef.current) as FormValues;
|
||||
snapshot.basic = {
|
||||
...snapshot.basic,
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
emoji: values.emoji || '🤖',
|
||||
};
|
||||
savedSnapshotRef.current = JSON.stringify(snapshot);
|
||||
}
|
||||
},
|
||||
async save() {
|
||||
if (!hasUnsavedChangesRef.current) return true;
|
||||
if (isSavingRef.current) return false;
|
||||
@@ -634,62 +658,12 @@ function AgentFormComponent(
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('agents.basicInfo')}</CardTitle>
|
||||
<CardTitle>{t('agents.availability')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('agents.basicInfoDescription')}
|
||||
{t('agents.availabilityDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<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} value={field.value ?? ''} />
|
||||
</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}
|
||||
ariaLabel={t('common.icon')}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="basic.description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('common.description')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value ?? ''} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<CardContent>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="basic.enabled"
|
||||
|
||||
@@ -19,7 +19,9 @@ import {
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import BotForm from '@/app/home/bots/components/bot-form/BotForm';
|
||||
import BotForm, {
|
||||
BotFormHandle,
|
||||
} from '@/app/home/bots/components/bot-form/BotForm';
|
||||
import { BotLogListComponent } from '@/app/home/bots/components/bot-log/view/BotLogListComponent';
|
||||
import BotSessionMonitor from '@/app/home/bots/components/bot-session/BotSessionMonitor';
|
||||
import type { BotSessionMonitorHandle } from '@/app/home/bots/components/bot-session/BotSessionMonitor';
|
||||
@@ -30,6 +32,11 @@ import { Settings, FileText, Users, RefreshCw, Trash2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||
import { Bot } from '@/app/infra/entities/api';
|
||||
import EntityBasicInfoDialog, {
|
||||
EntityBasicInfoValues,
|
||||
} from '@/app/home/components/entity-basic-info/EntityBasicInfoDialog';
|
||||
import EntityTitleEditButton from '@/app/home/components/entity-basic-info/EntityTitleEditButton';
|
||||
|
||||
export default function BotDetailContent({ id }: { id: string }) {
|
||||
const isCreateMode = id === 'new';
|
||||
@@ -55,8 +62,11 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
|
||||
const [activeTab, setActiveTab] = useState('config');
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [basicInfoOpen, setBasicInfoOpen] = useState(false);
|
||||
const [bot, setBot] = useState<Bot | null>(null);
|
||||
const [isRefreshingSessions, setIsRefreshingSessions] = useState(false);
|
||||
const sessionMonitorRef = useRef<BotSessionMonitorHandle>(null);
|
||||
const botFormRef = useRef<BotFormHandle>(null);
|
||||
|
||||
// Track whether the form has unsaved changes
|
||||
const [formDirty, setFormDirty] = useState(false);
|
||||
@@ -69,6 +79,7 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
useEffect(() => {
|
||||
if (!isCreateMode) {
|
||||
httpClient.getBot(id).then((res) => {
|
||||
setBot(res.bot);
|
||||
setBotEnabled(res.bot.enable ?? true);
|
||||
setEnableLoaded(true);
|
||||
});
|
||||
@@ -80,16 +91,10 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
const prev = botEnabled;
|
||||
setBotEnabled(checked);
|
||||
try {
|
||||
// Fetch current bot data to send a complete update
|
||||
const res = await httpClient.getBot(id);
|
||||
const bot = res.bot;
|
||||
await httpClient.updateBot(id, {
|
||||
name: bot.name,
|
||||
description: bot.description,
|
||||
adapter: bot.adapter,
|
||||
adapter_config: bot.adapter_config,
|
||||
enable: checked,
|
||||
});
|
||||
await httpClient.updateBot(id, { enable: checked });
|
||||
setBot((current) =>
|
||||
current ? { ...current, enable: checked } : current,
|
||||
);
|
||||
refreshBots();
|
||||
} catch {
|
||||
setBotEnabled(prev);
|
||||
@@ -102,6 +107,7 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
function handleFormSubmit() {
|
||||
// Re-sync enable state after form save (form may update enable too)
|
||||
httpClient.getBot(id).then((res) => {
|
||||
setBot(res.bot);
|
||||
setBotEnabled(res.bot.enable ?? true);
|
||||
});
|
||||
refreshBots();
|
||||
@@ -117,6 +123,26 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
navigate(`/home/bots?id=${encodeURIComponent(newBotId)}`);
|
||||
}
|
||||
|
||||
async function saveBasicInfo(values: EntityBasicInfoValues) {
|
||||
try {
|
||||
await httpClient.updateBot(id, {
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
});
|
||||
setBot((current) => (current ? { ...current, ...values } : current));
|
||||
botFormRef.current?.syncBasicInfo(values);
|
||||
await refreshBots();
|
||||
toast.success(t('bots.saveSuccess'));
|
||||
} catch (error) {
|
||||
const message =
|
||||
typeof error === 'object' && error && 'msg' in error
|
||||
? String((error as { msg?: string }).msg || '')
|
||||
: '';
|
||||
toast.error(t('bots.saveError') + message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
httpClient
|
||||
.deleteBot(id)
|
||||
@@ -166,8 +192,15 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
<div className="flex h-full min-w-0 flex-col">
|
||||
{/* Sticky Header: title + enable switch + save button */}
|
||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-xl font-semibold">{t('bots.editBot')}</h1>
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<h1 className="truncate text-xl font-semibold">
|
||||
{bot?.name || t('bots.editBot')}
|
||||
</h1>
|
||||
{canManage && (
|
||||
<EntityTitleEditButton onClick={() => setBasicInfoOpen(true)} />
|
||||
)}
|
||||
</div>
|
||||
{enableLoaded && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
@@ -258,6 +291,7 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
<div className="mx-auto flex w-full min-w-0 max-w-3xl flex-col gap-6 pb-8">
|
||||
<fieldset className="contents" disabled={!canManage}>
|
||||
<BotForm
|
||||
ref={botFormRef}
|
||||
initBotId={id}
|
||||
onFormSubmit={handleFormSubmit}
|
||||
onNewBotCreated={handleNewBotCreated}
|
||||
@@ -344,6 +378,17 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<EntityBasicInfoDialog
|
||||
open={basicInfoOpen}
|
||||
onOpenChange={setBasicInfoOpen}
|
||||
values={{
|
||||
name: bot?.name || '',
|
||||
description: bot?.description || '',
|
||||
}}
|
||||
showEmoji={false}
|
||||
onSave={saveBasicInfo}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import i18n from 'i18next';
|
||||
import { IChooseAdapterEntity } from '@/app/home/bots/components/bot-form/ChooseEntity';
|
||||
import {
|
||||
@@ -79,17 +86,21 @@ const getFormSchema = (t: (key: string) => string) =>
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export default function BotForm({
|
||||
initBotId,
|
||||
onFormSubmit,
|
||||
onNewBotCreated,
|
||||
onDirtyChange,
|
||||
}: {
|
||||
export interface BotFormHandle {
|
||||
syncBasicInfo: (values: { name: string; description: string }) => void;
|
||||
}
|
||||
|
||||
interface BotFormProps {
|
||||
initBotId?: string;
|
||||
onFormSubmit: (value: z.infer<ReturnType<typeof getFormSchema>>) => void;
|
||||
onNewBotCreated: (botId: string) => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}) {
|
||||
}
|
||||
|
||||
const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
|
||||
{ initBotId, onFormSubmit, onNewBotCreated, onDirtyChange },
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const formSchema = getFormSchema(t);
|
||||
|
||||
@@ -174,6 +185,19 @@ export default function BotForm({
|
||||
onDirtyChange?.(isDirty);
|
||||
}, [isDirty, onDirtyChange]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
syncBasicInfo(values) {
|
||||
form.reset(
|
||||
{
|
||||
...form.getValues(),
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
},
|
||||
{ keepDirtyValues: true },
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
setBotFormValues();
|
||||
}, []);
|
||||
@@ -416,46 +440,47 @@ export default function BotForm({
|
||||
className="w-full min-w-0 max-w-full space-y-6"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{/* Card 1: Basic Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('bots.basicInfo')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('bots.basicInfoDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('bots.botName')}
|
||||
<span className="text-destructive">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('bots.botDescription')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{!initBotId && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('bots.basicInfo')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('bots.basicInfoDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('bots.botName')}
|
||||
<span className="text-destructive">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('bots.botDescription')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Card 2: Adapter Configuration */}
|
||||
<Card>
|
||||
@@ -688,4 +713,6 @@ export default function BotForm({
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default BotForm;
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { FormEvent, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import EmojiPicker from '@/components/ui/emoji-picker';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
export interface EntityBasicInfoValues {
|
||||
name: string;
|
||||
description: string;
|
||||
emoji?: string;
|
||||
}
|
||||
|
||||
interface EntityBasicInfoDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
values: EntityBasicInfoValues;
|
||||
defaultEmoji?: string;
|
||||
showEmoji?: boolean;
|
||||
onSave: (values: EntityBasicInfoValues) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function EntityBasicInfoDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
values,
|
||||
defaultEmoji,
|
||||
showEmoji = true,
|
||||
onSave,
|
||||
}: EntityBasicInfoDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [draft, setDraft] = useState(values);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [nameError, setNameError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setDraft({
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
emoji: values.emoji || defaultEmoji,
|
||||
});
|
||||
setNameError(false);
|
||||
}, [defaultEmoji, open, values.description, values.emoji, values.name]);
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const name = draft.name.trim();
|
||||
if (!name) {
|
||||
setNameError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onSave({
|
||||
name,
|
||||
description: draft.description.trim(),
|
||||
emoji: showEmoji ? draft.emoji || defaultEmoji : undefined,
|
||||
});
|
||||
onOpenChange(false);
|
||||
} catch {
|
||||
// The caller presents the entity-specific error message.
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('common.editBasicInfo')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
showEmoji
|
||||
? 'common.editBasicInfoDescription'
|
||||
: 'common.editBasicInfoDescriptionNoIcon',
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<Label htmlFor="entity-basic-name">{t('common.name')}</Label>
|
||||
<Input
|
||||
id="entity-basic-name"
|
||||
value={draft.name}
|
||||
aria-invalid={nameError}
|
||||
onChange={(event) => {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
name: event.target.value,
|
||||
}));
|
||||
if (event.target.value.trim()) setNameError(false);
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
{nameError && (
|
||||
<p className="text-sm text-destructive">
|
||||
{t('common.fieldRequired')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showEmoji && (
|
||||
<div className="space-y-2">
|
||||
<Label>{t('common.icon')}</Label>
|
||||
<EmojiPicker
|
||||
value={draft.emoji || defaultEmoji}
|
||||
onChange={(emoji) =>
|
||||
setDraft((current) => ({ ...current, emoji }))
|
||||
}
|
||||
ariaLabel={t('common.icon')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="entity-basic-description">
|
||||
{t('common.description')}
|
||||
</Label>
|
||||
<Input
|
||||
id="entity-basic-description"
|
||||
value={draft.description}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
description: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving ? t('common.saving') : t('common.save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Pencil } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
|
||||
export default function EntityTitleEditButton({
|
||||
onClick,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 shrink-0 text-muted-foreground"
|
||||
aria-label={t('common.editBasicInfo')}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('common.editBasicInfo')}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export interface ProcessorDetailStatus {
|
||||
|
||||
interface ProcessorDetailWorkbenchProps {
|
||||
title: string;
|
||||
titleAction?: ReactNode;
|
||||
status?: ProcessorDetailStatus | null;
|
||||
saveLabel: string;
|
||||
saveFormId: string;
|
||||
@@ -41,6 +42,7 @@ interface ProcessorDetailWorkbenchProps {
|
||||
|
||||
export default function ProcessorDetailWorkbench({
|
||||
title,
|
||||
titleAction,
|
||||
status,
|
||||
saveLabel,
|
||||
saveFormId,
|
||||
@@ -67,6 +69,7 @@ export default function ProcessorDetailWorkbench({
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 pb-4">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h1 className="truncate text-xl font-semibold">{title}</h1>
|
||||
{titleAction}
|
||||
{status && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -139,7 +142,10 @@ export default function ProcessorDetailWorkbench({
|
||||
</div>
|
||||
|
||||
{activeView === 'monitoring' && monitoring ? (
|
||||
<section className="min-h-0 flex-1 overflow-y-auto rounded-xl border bg-card p-4">
|
||||
<section
|
||||
aria-label={monitoring.label}
|
||||
className="min-h-0 flex-1 overflow-y-auto rounded-xl border bg-card p-4"
|
||||
>
|
||||
{monitoring.content}
|
||||
</section>
|
||||
) : (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import PipelineFormComponent, {
|
||||
PipelineFormHandle,
|
||||
@@ -7,9 +8,15 @@ import PipelineFormComponent, {
|
||||
import DebugDialog from '@/app/home/pipelines/components/debug-dialog/DebugDialog';
|
||||
import PipelineMonitoringTab from '@/app/home/pipelines/components/monitoring-tab/PipelineMonitoringTab';
|
||||
import ProcessorDetailWorkbench from '@/app/home/components/processor-detail/ProcessorDetailWorkbench';
|
||||
import EntityBasicInfoDialog, {
|
||||
EntityBasicInfoValues,
|
||||
} from '@/app/home/components/entity-basic-info/EntityBasicInfoDialog';
|
||||
import EntityTitleEditButton from '@/app/home/components/entity-basic-info/EntityTitleEditButton';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { Pipeline } from '@/app/infra/entities/api';
|
||||
|
||||
export default function PipelineDetailContent({
|
||||
id,
|
||||
@@ -44,13 +51,47 @@ export default function PipelineDetailContent({
|
||||
const [isWebSocketConnected, setIsWebSocketConnected] = useState(false);
|
||||
const [formDirty, setFormDirty] = useState(false);
|
||||
const [formSaving, setFormSaving] = useState(false);
|
||||
const [basicInfoOpen, setBasicInfoOpen] = useState(false);
|
||||
const [pipelineDetails, setPipelineDetails] = useState<Pipeline | null>(null);
|
||||
const pipelineFormRef = useRef<PipelineFormHandle>(null);
|
||||
const pipeline = pipelines.find((item) => item.id === id);
|
||||
const sidebarPipeline = pipelines.find((item) => item.id === id);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCreateMode) return;
|
||||
let cancelled = false;
|
||||
httpClient.getPipeline(id).then((response) => {
|
||||
if (!cancelled) setPipelineDetails(response.pipeline);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [id, isCreateMode]);
|
||||
|
||||
function handleFinish() {
|
||||
refreshPipelines();
|
||||
}
|
||||
|
||||
async function saveBasicInfo(values: EntityBasicInfoValues) {
|
||||
try {
|
||||
await httpClient.updatePipeline(id, values);
|
||||
setPipelineDetails((current) =>
|
||||
current
|
||||
? { ...current, ...values }
|
||||
: ({ ...values, config: {} } as Pipeline),
|
||||
);
|
||||
pipelineFormRef.current?.syncBasicInfo(values);
|
||||
await refreshPipelines();
|
||||
toast.success(t('pipelines.saveSuccess'));
|
||||
} catch (error) {
|
||||
const message =
|
||||
typeof error === 'object' && error && 'msg' in error
|
||||
? String((error as { msg?: string }).msg || '')
|
||||
: '';
|
||||
toast.error(t('pipelines.saveError') + message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function handleNewPipelineCreated(newPipelineId: string) {
|
||||
refreshPipelines();
|
||||
navigate(`${routeBase}?id=${encodeURIComponent(newPipelineId)}`);
|
||||
@@ -97,66 +138,91 @@ export default function PipelineDetailContent({
|
||||
}
|
||||
|
||||
// ==================== Edit Mode ====================
|
||||
const pipelineName =
|
||||
pipelineDetails?.name ||
|
||||
sidebarPipeline?.name ||
|
||||
t('pipelines.editPipeline');
|
||||
const pipelineEmoji =
|
||||
pipelineDetails?.emoji || sidebarPipeline?.emoji || '⚙️';
|
||||
|
||||
return (
|
||||
<ProcessorDetailWorkbench
|
||||
key={id}
|
||||
title={`${pipeline?.emoji || '⚙️'} ${pipeline?.name || t('pipelines.editPipeline')}`}
|
||||
saveLabel={t('common.save')}
|
||||
saveFormId="pipeline-form"
|
||||
canSave={canManage}
|
||||
isDirty={formDirty}
|
||||
isSaving={formSaving}
|
||||
configTitle={t('pipelines.configuration')}
|
||||
configContent={
|
||||
<fieldset className="contents" disabled={!canManage}>
|
||||
<PipelineFormComponent
|
||||
ref={pipelineFormRef}
|
||||
pipelineId={id}
|
||||
isEditMode={true}
|
||||
disableForm={!canManage}
|
||||
showButtons={false}
|
||||
onFinish={handleFinish}
|
||||
onNewPipelineCreated={handleNewPipelineCreated}
|
||||
onDeletePipeline={handleDeletePipeline}
|
||||
onCancel={() => navigate(routeBase)}
|
||||
onDirtyChange={setFormDirty}
|
||||
onSavingChange={setFormSaving}
|
||||
/>
|
||||
</fieldset>
|
||||
}
|
||||
debugTitle={canOperate ? t('pipelines.debugChat') : undefined}
|
||||
debugConnected={canOperate ? isWebSocketConnected : undefined}
|
||||
debugConnectedLabel={t('pipelines.debugDialog.connected')}
|
||||
debugDisconnectedLabel={t('pipelines.debugDialog.disconnected')}
|
||||
debugContent={
|
||||
canOperate ? (
|
||||
<DebugDialog
|
||||
open={true}
|
||||
pipelineId={id}
|
||||
isEmbedded={true}
|
||||
compact={true}
|
||||
hasUnsavedChanges={formDirty}
|
||||
beforeSend={async () => pipelineFormRef.current?.save() ?? false}
|
||||
onConnectionStatusChange={setIsWebSocketConnected}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
unsavedLabel={t('pipelines.unsavedChanges')}
|
||||
monitoring={
|
||||
canViewMonitoring
|
||||
? {
|
||||
label: t('pipelines.monitoring.title'),
|
||||
content: (
|
||||
<PipelineMonitoringTab
|
||||
pipelineId={id}
|
||||
onNavigateToMonitoring={() => {
|
||||
navigate('/home/monitoring');
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<>
|
||||
<ProcessorDetailWorkbench
|
||||
key={id}
|
||||
title={`${pipelineEmoji} ${pipelineName}`}
|
||||
titleAction={
|
||||
canManage ? (
|
||||
<EntityTitleEditButton onClick={() => setBasicInfoOpen(true)} />
|
||||
) : undefined
|
||||
}
|
||||
saveLabel={t('common.save')}
|
||||
saveFormId="pipeline-form"
|
||||
canSave={canManage}
|
||||
isDirty={formDirty}
|
||||
isSaving={formSaving}
|
||||
configTitle={t('pipelines.configuration')}
|
||||
configContent={
|
||||
<fieldset className="contents" disabled={!canManage}>
|
||||
<PipelineFormComponent
|
||||
ref={pipelineFormRef}
|
||||
pipelineId={id}
|
||||
isEditMode={true}
|
||||
disableForm={!canManage}
|
||||
showButtons={false}
|
||||
onFinish={handleFinish}
|
||||
onNewPipelineCreated={handleNewPipelineCreated}
|
||||
onDeletePipeline={handleDeletePipeline}
|
||||
onCancel={() => navigate(routeBase)}
|
||||
onDirtyChange={setFormDirty}
|
||||
onSavingChange={setFormSaving}
|
||||
/>
|
||||
</fieldset>
|
||||
}
|
||||
debugTitle={canOperate ? t('pipelines.debugChat') : undefined}
|
||||
debugConnected={canOperate ? isWebSocketConnected : undefined}
|
||||
debugConnectedLabel={t('pipelines.debugDialog.connected')}
|
||||
debugDisconnectedLabel={t('pipelines.debugDialog.disconnected')}
|
||||
debugContent={
|
||||
canOperate ? (
|
||||
<DebugDialog
|
||||
open={true}
|
||||
pipelineId={id}
|
||||
isEmbedded={true}
|
||||
compact={true}
|
||||
hasUnsavedChanges={formDirty}
|
||||
beforeSend={async () => pipelineFormRef.current?.save() ?? false}
|
||||
onConnectionStatusChange={setIsWebSocketConnected}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
unsavedLabel={t('pipelines.unsavedChanges')}
|
||||
monitoring={
|
||||
canViewMonitoring
|
||||
? {
|
||||
label: t('pipelines.monitoring.title'),
|
||||
content: (
|
||||
<PipelineMonitoringTab
|
||||
pipelineId={id}
|
||||
onNavigateToMonitoring={() => {
|
||||
navigate('/home/monitoring');
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<EntityBasicInfoDialog
|
||||
open={basicInfoOpen}
|
||||
onOpenChange={setBasicInfoOpen}
|
||||
values={{
|
||||
name: pipelineName,
|
||||
description: pipelineDetails?.description || '',
|
||||
emoji: pipelineEmoji,
|
||||
}}
|
||||
defaultEmoji="⚙️"
|
||||
onSave={saveBasicInfo}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -73,6 +73,11 @@ interface PipelineFormComponentProps {
|
||||
|
||||
export interface PipelineFormHandle {
|
||||
save: () => Promise<boolean>;
|
||||
syncBasicInfo: (values: {
|
||||
name: string;
|
||||
description: string;
|
||||
emoji?: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
const PipelineFormComponent = forwardRef<
|
||||
@@ -137,7 +142,7 @@ const PipelineFormComponent = forwardRef<
|
||||
const formLabelList: SectionItem[] = isEditMode
|
||||
? [
|
||||
{
|
||||
label: t('pipelines.basicInfo'),
|
||||
label: t('common.management'),
|
||||
name: 'basic',
|
||||
icon: SECTION_ICONS.basic,
|
||||
},
|
||||
@@ -367,6 +372,24 @@ const PipelineFormComponent = forwardRef<
|
||||
}
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
syncBasicInfo(values) {
|
||||
form.setValue('basic', {
|
||||
...form.getValues('basic'),
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
emoji: values.emoji || '⚙️',
|
||||
});
|
||||
if (savedSnapshotRef.current) {
|
||||
const snapshot = JSON.parse(savedSnapshotRef.current) as FormValues;
|
||||
snapshot.basic = {
|
||||
...snapshot.basic,
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
emoji: values.emoji || '⚙️',
|
||||
};
|
||||
savedSnapshotRef.current = JSON.stringify(snapshot);
|
||||
}
|
||||
},
|
||||
async save() {
|
||||
if (!hasUnsavedChangesRef.current) return true;
|
||||
if (isSavingRef.current || !isEditMode) return false;
|
||||
@@ -656,69 +679,85 @@ const PipelineFormComponent = forwardRef<
|
||||
|
||||
{/* Content panel */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
{/* Basic info section */}
|
||||
{activeSection === 'basic' && (
|
||||
<div className="space-y-6">
|
||||
{/* Basic Information Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('pipelines.basicInfo')}</CardTitle>
|
||||
<CardTitle>
|
||||
{isEditMode
|
||||
? t('common.management')
|
||||
: t('pipelines.basicInfo')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('pipelines.basicInfoDescription')}
|
||||
{isEditMode
|
||||
? t('pipelines.managementDescription')
|
||||
: 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} value={field.value ?? ''} />
|
||||
</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>
|
||||
{!isEditMode && (
|
||||
<>
|
||||
<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}
|
||||
value={field.value ?? ''}
|
||||
/>
|
||||
</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')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value ?? ''} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="basic.description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('common.description')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
/>
|
||||
</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">
|
||||
|
||||
@@ -329,7 +329,10 @@ export class BackendClient extends BaseHttpClient {
|
||||
return this.post('/api/v1/pipelines', pipeline);
|
||||
}
|
||||
|
||||
public updatePipeline(uuid: string, pipeline: Pipeline): Promise<object> {
|
||||
public updatePipeline(
|
||||
uuid: string,
|
||||
pipeline: Partial<Pipeline>,
|
||||
): Promise<object> {
|
||||
return this.put(`/api/v1/pipelines/${uuid}`, pipeline);
|
||||
}
|
||||
|
||||
@@ -489,7 +492,7 @@ export class BackendClient extends BaseHttpClient {
|
||||
return this.post('/api/v1/platform/bots', bot);
|
||||
}
|
||||
|
||||
public updateBot(uuid: string, bot: Bot): Promise<object> {
|
||||
public updateBot(uuid: string, bot: Partial<Bot>): Promise<object> {
|
||||
return this.put(`/api/v1/platform/bots/${uuid}`, bot);
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,10 @@ const enUS = {
|
||||
joinDiscord: 'Join our Discord',
|
||||
create: 'Create',
|
||||
edit: 'Edit',
|
||||
editBasicInfo: 'Edit basic information',
|
||||
editBasicInfoDescription: 'Change the name, description, and icon.',
|
||||
editBasicInfoDescriptionNoIcon: 'Change the name and description.',
|
||||
management: 'Management',
|
||||
delete: 'Delete',
|
||||
add: 'Add',
|
||||
select: 'Select',
|
||||
@@ -688,6 +692,9 @@ const enUS = {
|
||||
messageEventsOnly: 'Message events only',
|
||||
basicInfo: 'Basic Information',
|
||||
basicInfoDescription: 'Set the name, icon, description and enabled state',
|
||||
availability: 'Availability',
|
||||
availabilityDescription:
|
||||
'Control whether this Agent can receive and process events.',
|
||||
runnerSettings: 'Runner',
|
||||
advanced: 'Advanced',
|
||||
bindableEvents: 'Bindable Event Range',
|
||||
@@ -1233,6 +1240,7 @@ const enUS = {
|
||||
earliestEdited: 'Earliest Edited',
|
||||
basicInfo: 'Basic Information',
|
||||
basicInfoDescription: 'Set the pipeline name, icon and description',
|
||||
managementDescription: 'Copy or delete this pipeline.',
|
||||
aiCapabilities: 'AI',
|
||||
triggerConditions: 'Trigger',
|
||||
safetyControls: 'Safety',
|
||||
|
||||
@@ -43,6 +43,10 @@ const jaJP = {
|
||||
joinDiscord: 'Discord に参加',
|
||||
create: '作成',
|
||||
edit: '編集',
|
||||
editBasicInfo: '基本情報を編集',
|
||||
editBasicInfoDescription: '名前、説明、アイコンを変更します。',
|
||||
editBasicInfoDescriptionNoIcon: '名前と説明を変更します。',
|
||||
management: '管理',
|
||||
delete: '削除',
|
||||
add: '追加',
|
||||
select: '選択してください',
|
||||
@@ -703,6 +707,9 @@ const jaJP = {
|
||||
messageEventsOnly: 'メッセージイベントのみ',
|
||||
basicInfo: '基本情報',
|
||||
basicInfoDescription: '名前、アイコン、説明、有効状態を設定します',
|
||||
availability: '有効状態',
|
||||
availabilityDescription:
|
||||
'この Agent がイベントを受信して処理できるかを制御します。',
|
||||
runnerSettings: 'Runner',
|
||||
advanced: '詳細',
|
||||
bindableEvents: '紐付け可能なイベント範囲',
|
||||
@@ -1198,6 +1205,7 @@ const jaJP = {
|
||||
earliestEdited: '最古編集',
|
||||
basicInfo: '基本情報',
|
||||
basicInfoDescription: 'パイプラインの名前、アイコン、説明を設定',
|
||||
managementDescription: 'このパイプラインを複製または削除します。',
|
||||
aiCapabilities: 'AI機能',
|
||||
triggerConditions: 'トリガー条件',
|
||||
safetyControls: '安全制御',
|
||||
|
||||
@@ -41,6 +41,10 @@ const zhHans = {
|
||||
joinDiscord: '加入 Discord 社区',
|
||||
create: '创建',
|
||||
edit: '编辑',
|
||||
editBasicInfo: '编辑基本信息',
|
||||
editBasicInfoDescription: '修改名称、描述和图标。',
|
||||
editBasicInfoDescriptionNoIcon: '修改名称和描述。',
|
||||
management: '管理',
|
||||
delete: '删除',
|
||||
add: '添加',
|
||||
select: '请选择',
|
||||
@@ -658,6 +662,8 @@ const zhHans = {
|
||||
messageEventsOnly: '仅支持消息事件',
|
||||
basicInfo: '基础信息',
|
||||
basicInfoDescription: '设置名称、图标、描述和启用状态',
|
||||
availability: '启用状态',
|
||||
availabilityDescription: '控制此 Agent 是否可以接收并处理事件。',
|
||||
runnerSettings: '运行器',
|
||||
advanced: '高级',
|
||||
bindableEvents: '可绑定事件范围',
|
||||
@@ -1175,6 +1181,7 @@ const zhHans = {
|
||||
earliestEdited: '最早编辑',
|
||||
basicInfo: '基础信息',
|
||||
basicInfoDescription: '设置流水线名称、图标和描述',
|
||||
managementDescription: '复制或删除此流水线。',
|
||||
aiCapabilities: 'AI 能力',
|
||||
triggerConditions: '触发条件',
|
||||
safetyControls: '安全控制',
|
||||
|
||||
@@ -116,7 +116,7 @@ test.describe('frontend CRUD smoke flows', () => {
|
||||
await expect(page.getByText('No logs yet')).toBeVisible();
|
||||
|
||||
await page.goto('/home/agents?id=pipeline-1');
|
||||
await expect(page.getByRole('tab', { name: 'Dashboard' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Dashboard' })).toBeVisible();
|
||||
await expect(page.getByRole('tab', { name: 'Debug Chat' })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: /^Save$/ })).toHaveCount(0);
|
||||
|
||||
@@ -144,15 +144,21 @@ test.describe('frontend CRUD smoke flows', () => {
|
||||
|
||||
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
|
||||
await page.reload();
|
||||
await expect(page.locator('input[name="name"]')).toHaveValue('Support Bot');
|
||||
|
||||
await page
|
||||
.locator('input[name="description"]')
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Support Bot' }),
|
||||
).toBeVisible();
|
||||
await expect(page.locator('input[name="name"]')).toHaveCount(0);
|
||||
await page.getByRole('button', { name: 'Edit basic information' }).click();
|
||||
const botInfoDialog = page.getByRole('dialog');
|
||||
await expect(botInfoDialog.getByLabel('Icon')).toHaveCount(0);
|
||||
await botInfoDialog.getByLabel('Name').fill('Support Bot Updated');
|
||||
await botInfoDialog
|
||||
.getByLabel('Description')
|
||||
.fill('Answers customer support questions with context.');
|
||||
await save(page);
|
||||
await expect(page.locator('input[name="description"]')).toHaveValue(
|
||||
'Answers customer support questions with context.',
|
||||
);
|
||||
await botInfoDialog.getByRole('button', { name: 'Save' }).click();
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Support Bot Updated' }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: /^Delete$/ }).click();
|
||||
await confirmDelete(page);
|
||||
@@ -176,18 +182,18 @@ test.describe('frontend CRUD smoke flows', () => {
|
||||
|
||||
await expect(page).toHaveURL(/\/home\/agents\?id=pipeline-1$/);
|
||||
await page.reload();
|
||||
await expect(page.locator('input[name="basic.name"]')).toHaveValue(
|
||||
'Escalation Pipeline',
|
||||
);
|
||||
|
||||
await page
|
||||
.locator('input[name="basic.description"]')
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /Escalation Pipeline/ }),
|
||||
).toBeVisible();
|
||||
await expect(page.locator('input[name="basic.name"]')).toHaveCount(0);
|
||||
await page.getByRole('button', { name: 'Edit basic information' }).click();
|
||||
const pipelineInfoDialog = page.getByRole('dialog');
|
||||
await pipelineInfoDialog
|
||||
.getByLabel('Description')
|
||||
.fill('Routes urgent customer issues to operators.');
|
||||
await save(page);
|
||||
await expect(page.locator('input[name="basic.description"]')).toHaveValue(
|
||||
'Routes urgent customer issues to operators.',
|
||||
);
|
||||
await pipelineInfoDialog.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Management' }).click();
|
||||
await page.getByRole('button', { name: /^Delete$/ }).click();
|
||||
await confirmDelete(page);
|
||||
|
||||
@@ -204,8 +210,10 @@ test.describe('frontend CRUD smoke flows', () => {
|
||||
|
||||
await page.goto('/home/agents?id=pipeline-ai');
|
||||
|
||||
await expect(page.locator('input[name="basic.name"]')).toBeVisible();
|
||||
await page.getByRole('button', { name: /^AI$/ }).click();
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /pipeline-ai/ }),
|
||||
).toBeVisible();
|
||||
await page.getByRole('tab', { name: /^AI$/ }).click();
|
||||
|
||||
await expect(page.getByText('Runtime')).toBeVisible();
|
||||
await expect(
|
||||
@@ -512,7 +520,9 @@ test.describe('bot advanced flows', () => {
|
||||
await expect(
|
||||
page.getByRole('tab', { name: /Configuration/ }),
|
||||
).toHaveAttribute('data-state', 'active');
|
||||
await expect(page.locator('input[name="name"]')).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Edit basic information' }),
|
||||
).toBeVisible();
|
||||
|
||||
// Switch to Logs tab
|
||||
await page.getByRole('tab', { name: /Logs/ }).click();
|
||||
@@ -530,7 +540,9 @@ test.describe('bot advanced flows', () => {
|
||||
|
||||
// Switch back to Configuration
|
||||
await page.getByRole('tab', { name: /Configuration/ }).click();
|
||||
await expect(page.locator('input[name="name"]')).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Edit basic information' }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('save button is disabled when form is clean', async ({ page }) => {
|
||||
@@ -541,23 +553,22 @@ test.describe('bot advanced flows', () => {
|
||||
await selectPlaywrightAdapter(page);
|
||||
await page.locator('input[name="name"]').fill('Clean Form Bot');
|
||||
await submit(page);
|
||||
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
|
||||
|
||||
// Reload the persisted record so post-create initialization has completed.
|
||||
await page.reload();
|
||||
await expect(page.locator('input[name="name"]')).toHaveValue(
|
||||
'Clean Form Bot',
|
||||
);
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Clean Form Bot' }),
|
||||
).toBeVisible();
|
||||
|
||||
// After loading, save button should be disabled (form is clean)
|
||||
const saveButton = page.getByRole('button', { name: /^Save$/ });
|
||||
await expect(saveButton).toBeDisabled();
|
||||
|
||||
// Edit the form
|
||||
await page.locator('input[name="description"]').fill('New description');
|
||||
await expect(saveButton).toBeEnabled();
|
||||
|
||||
// Save
|
||||
await saveButton.click();
|
||||
await page.getByRole('button', { name: 'Edit basic information' }).click();
|
||||
const infoDialog = page.getByRole('dialog');
|
||||
await infoDialog.getByLabel('Description').fill('New description');
|
||||
await infoDialog.getByRole('button', { name: 'Save' }).click();
|
||||
await expect(saveButton).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -593,7 +604,7 @@ test.describe('pipeline advanced flows', () => {
|
||||
});
|
||||
|
||||
await page.goto('/home/agents?id=pipeline-scope');
|
||||
await page.getByRole('button', { name: /^AI$/ }).click();
|
||||
await page.getByRole('tab', { name: /^AI$/ }).click();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Edit tools' }),
|
||||
).toBeVisible();
|
||||
@@ -621,22 +632,26 @@ test.describe('pipeline advanced flows', () => {
|
||||
await page.locator('input[name="name"]').fill('Tab Test Pipeline');
|
||||
await submit(page);
|
||||
|
||||
// Verify we're on the Configuration tab
|
||||
await expect(
|
||||
page.getByRole('tab', { name: /Configuration/ }),
|
||||
).toHaveAttribute('data-state', 'active');
|
||||
page.getByRole('region', { name: 'Configuration' }),
|
||||
).toBeVisible();
|
||||
|
||||
// Switch to Monitoring tab (labeled "Dashboard" in the pipeline context)
|
||||
// Skip Debug tab as it requires WebSocket connection
|
||||
await page.getByRole('tab', { name: /Dashboard/ }).click();
|
||||
await expect(page.getByRole('tab', { name: /Dashboard/ })).toHaveAttribute(
|
||||
'data-state',
|
||||
'active',
|
||||
);
|
||||
await page
|
||||
.getByRole('button', { name: 'Dashboard', exact: true })
|
||||
.last()
|
||||
.click();
|
||||
await expect(page.getByRole('region', { name: /Dashboard/ })).toBeVisible();
|
||||
|
||||
// Switch back to Configuration
|
||||
await page.getByRole('tab', { name: /Configuration/ }).click();
|
||||
await expect(page.locator('input[name="basic.name"]')).toBeVisible();
|
||||
await page
|
||||
.getByRole('button', { name: 'Dashboard', exact: true })
|
||||
.last()
|
||||
.click();
|
||||
await expect(
|
||||
page.getByRole('region', { name: 'Configuration' }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('save button reflects form dirty state', async ({ page }) => {
|
||||
@@ -648,20 +663,16 @@ test.describe('pipeline advanced flows', () => {
|
||||
await page.locator('input[name="name"]').fill('Dirty Form Pipeline');
|
||||
await submit(page);
|
||||
|
||||
// Wait for the page to fully load and form to reset
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Edit the form - use the name field which definitely triggers dirty state
|
||||
await page
|
||||
.locator('input[name="basic.name"]')
|
||||
.fill('Dirty Form Pipeline Updated');
|
||||
const saveButton = page.getByRole('button', { name: /^Save$/ });
|
||||
await expect(saveButton).toBeEnabled();
|
||||
|
||||
// Save
|
||||
await saveButton.click();
|
||||
// Wait for save to complete
|
||||
await page.waitForTimeout(500);
|
||||
await expect(saveButton).toBeDisabled();
|
||||
await page.getByRole('button', { name: 'Edit basic information' }).click();
|
||||
const infoDialog = page.getByRole('dialog');
|
||||
await infoDialog.getByLabel('Name').fill('Dirty Form Pipeline Updated');
|
||||
await infoDialog.getByRole('button', { name: 'Save' }).click();
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /Dirty Form Pipeline Updated/ }),
|
||||
).toBeVisible();
|
||||
await expect(saveButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test('shows validation error when pipeline name is empty', async ({
|
||||
@@ -705,7 +716,8 @@ test.describe('agent runner resource selectors', () => {
|
||||
});
|
||||
|
||||
await page.goto('/home/agents?id=agent-scope');
|
||||
await page.getByRole('button', { name: /^Runner$/ }).click();
|
||||
await page.getByRole('tab', { name: /^Runner$/ }).click();
|
||||
await page.getByRole('tab', { name: 'Local Agent' }).click();
|
||||
await page.getByRole('button', { name: 'Edit tools' }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
@@ -747,16 +759,16 @@ test.describe('agent and pipeline save concurrency', () => {
|
||||
|
||||
await page.goto('/home/agents?id=agent-save-race');
|
||||
const saveButton = page.getByRole('button', { name: /^Save$/ });
|
||||
const nameInput = page.locator('input[name="basic.name"]');
|
||||
const descriptionInput = page.locator('input[name="basic.description"]');
|
||||
await expect(nameInput).toBeVisible();
|
||||
await page.getByRole('tab', { name: 'Bindable Event Range' }).click();
|
||||
const eventPatterns = page.getByLabel('Event Range');
|
||||
await expect(eventPatterns).toBeVisible();
|
||||
|
||||
await nameInput.fill('Submitted Agent');
|
||||
await eventPatterns.fill('message.received');
|
||||
await saveButton.click();
|
||||
await expect.poll(() => delayedSave.payloads.length).toBe(1);
|
||||
await expect(saveButton).toBeDisabled();
|
||||
|
||||
await descriptionInput.fill('Edited while the agent save is pending');
|
||||
await eventPatterns.fill('group.*');
|
||||
await forceFormSubmit(page, '#agent-form');
|
||||
expect(delayedSave.payloads).toHaveLength(1);
|
||||
await expect(saveButton).toBeDisabled();
|
||||
@@ -764,15 +776,13 @@ test.describe('agent and pipeline save concurrency', () => {
|
||||
delayedSave.releaseFirstSave();
|
||||
await expect(saveButton).toBeEnabled();
|
||||
expect(delayedSave.payloads[0]).toMatchObject({
|
||||
name: 'Submitted Agent',
|
||||
description: '',
|
||||
supported_event_patterns: ['message.received'],
|
||||
});
|
||||
|
||||
await saveButton.click();
|
||||
await expect.poll(() => delayedSave.payloads.length).toBe(2);
|
||||
expect(delayedSave.payloads[1]).toMatchObject({
|
||||
name: 'Submitted Agent',
|
||||
description: 'Edited while the agent save is pending',
|
||||
supported_event_patterns: ['group.*'],
|
||||
});
|
||||
await expect(saveButton).toBeDisabled();
|
||||
});
|
||||
@@ -787,35 +797,35 @@ test.describe('agent and pipeline save concurrency', () => {
|
||||
);
|
||||
|
||||
await page.goto('/home/agents?id=pipeline-save-race');
|
||||
const saveButton = page.getByRole('button', { name: /^Save$/ });
|
||||
const nameInput = page.locator('input[name="basic.name"]');
|
||||
const descriptionInput = page.locator('input[name="basic.description"]');
|
||||
await expect(nameInput).toBeVisible();
|
||||
|
||||
await nameInput.fill('Submitted Pipeline');
|
||||
await saveButton.click();
|
||||
await page.getByRole('button', { name: 'Edit basic information' }).click();
|
||||
let infoDialog = page.getByRole('dialog');
|
||||
await infoDialog.getByLabel('Name').fill('Submitted Pipeline');
|
||||
const dialogSaveButton = infoDialog.getByRole('button', { name: 'Save' });
|
||||
await dialogSaveButton.click();
|
||||
await expect.poll(() => delayedSave.payloads.length).toBe(1);
|
||||
await expect(saveButton).toBeDisabled();
|
||||
|
||||
await descriptionInput.fill('Edited while the pipeline save is pending');
|
||||
await forceFormSubmit(page, '#pipeline-form');
|
||||
expect(delayedSave.payloads).toHaveLength(1);
|
||||
await expect(saveButton).toBeDisabled();
|
||||
await expect(
|
||||
infoDialog.getByRole('button', { name: 'Saving...' }),
|
||||
).toBeDisabled();
|
||||
|
||||
delayedSave.releaseFirstSave();
|
||||
await expect(saveButton).toBeEnabled();
|
||||
await expect(infoDialog).toHaveCount(0);
|
||||
expect(delayedSave.payloads[0]).toMatchObject({
|
||||
name: 'Submitted Pipeline',
|
||||
description: '',
|
||||
});
|
||||
|
||||
await saveButton.click();
|
||||
await page.getByRole('button', { name: 'Edit basic information' }).click();
|
||||
infoDialog = page.getByRole('dialog');
|
||||
await infoDialog
|
||||
.getByLabel('Description')
|
||||
.fill('Edited in the next basic information save');
|
||||
await infoDialog.getByRole('button', { name: 'Save' }).click();
|
||||
await expect.poll(() => delayedSave.payloads.length).toBe(2);
|
||||
expect(delayedSave.payloads[1]).toMatchObject({
|
||||
name: 'Submitted Pipeline',
|
||||
description: 'Edited while the pipeline save is pending',
|
||||
description: 'Edited in the next basic information save',
|
||||
});
|
||||
await expect(saveButton).toBeDisabled();
|
||||
await expect(infoDialog).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -838,7 +848,9 @@ test.describe('cross-resource flows', () => {
|
||||
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
|
||||
|
||||
// Wait for form to fully load
|
||||
await expect(page.locator('input[name="name"]')).toHaveValue('Bound Bot');
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Bound Bot' }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Add behavior' }).click();
|
||||
await page.getByRole('menuitem', { name: /^Reply to messages/ }).click();
|
||||
|
||||
@@ -647,7 +647,12 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
|
||||
const botId = decodeURIComponent(botMatch[1]);
|
||||
|
||||
if (method === 'PUT') {
|
||||
const bot = makeBot(state, parseJsonBody(route), botId);
|
||||
const current = state.bots.find((item) => item.uuid === botId);
|
||||
const bot = makeBot(
|
||||
state,
|
||||
{ ...(current || {}), ...parseJsonBody(route) },
|
||||
botId,
|
||||
);
|
||||
state.bots = [...state.bots.filter((item) => item.uuid !== botId), bot];
|
||||
return fulfillJson(route, {});
|
||||
}
|
||||
@@ -729,7 +734,12 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
|
||||
const agentId = decodeURIComponent(agentMatch[1]);
|
||||
|
||||
if (method === 'PUT') {
|
||||
const agent = makePipeline(state, parseJsonBody(route), agentId);
|
||||
const current = state.pipelines.find((item) => item.uuid === agentId);
|
||||
const agent = makePipeline(
|
||||
state,
|
||||
{ ...(current || {}), ...parseJsonBody(route) },
|
||||
agentId,
|
||||
);
|
||||
state.pipelines = [
|
||||
...state.pipelines.filter((item) => item.uuid !== agentId),
|
||||
agent,
|
||||
@@ -789,7 +799,12 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
|
||||
const pipelineId = decodeURIComponent(pipelineMatch[1]);
|
||||
|
||||
if (method === 'PUT') {
|
||||
const pipeline = makePipeline(state, parseJsonBody(route), pipelineId);
|
||||
const current = state.pipelines.find((item) => item.uuid === pipelineId);
|
||||
const pipeline = makePipeline(
|
||||
state,
|
||||
{ ...(current || {}), ...parseJsonBody(route) },
|
||||
pipelineId,
|
||||
);
|
||||
state.pipelines = [
|
||||
...state.pipelines.filter((item) => item.uuid !== pipelineId),
|
||||
pipeline,
|
||||
|
||||
@@ -45,26 +45,32 @@ test.describe('processor detail workbench', () => {
|
||||
expect(debugBox!.y).toBeGreaterThanOrEqual(0);
|
||||
|
||||
const flow = configPanel.getByRole('tablist');
|
||||
await expect(flow.getByRole('tab').nth(0)).toContainText(
|
||||
'Basic Information',
|
||||
);
|
||||
await expect(flow.getByRole('tab').nth(0)).toContainText('Management');
|
||||
await expect(flow.getByRole('tab').nth(1)).toContainText(
|
||||
'Bindable Event Range',
|
||||
);
|
||||
await expect(flow.getByRole('tab').nth(2)).toContainText('Runner');
|
||||
await expect(flow.getByRole('tab').nth(3)).toContainText('Local Agent');
|
||||
|
||||
await expect(configPanel.getByLabel('Name')).toBeVisible();
|
||||
await expect(configPanel.getByLabel('Icon')).toBeVisible();
|
||||
await expect(configPanel.getByLabel('Description')).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /agent-workbench/ }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Edit basic information' }),
|
||||
).toBeVisible();
|
||||
await expect(configPanel.getByLabel('Name')).toHaveCount(0);
|
||||
await expect(configPanel.getByLabel('Icon')).toHaveCount(0);
|
||||
await expect(configPanel.getByLabel('Description')).toHaveCount(0);
|
||||
|
||||
const runnerStatus = page.getByRole('status', { name: 'Runner ready' });
|
||||
await expect(runnerStatus).toBeVisible();
|
||||
await runnerStatus.hover();
|
||||
await expect(
|
||||
page.getByText(
|
||||
'Local Agent is registered and the plugin runtime is connected.',
|
||||
),
|
||||
page
|
||||
.getByText(
|
||||
'Local Agent is registered and the plugin runtime is connected.',
|
||||
)
|
||||
.last(),
|
||||
).toBeVisible();
|
||||
|
||||
await flow.getByRole('tab').nth(1).click();
|
||||
@@ -99,11 +105,18 @@ test.describe('processor detail workbench', () => {
|
||||
});
|
||||
|
||||
await page.goto('/home/agents?id=agent-workbench');
|
||||
await page.getByLabel('Description').fill('Updated before debugging');
|
||||
await page.getByRole('button', { name: 'Edit basic information' }).click();
|
||||
const basicInfoDialog = page.getByRole('dialog');
|
||||
await expect(basicInfoDialog.getByLabel('Icon')).toBeVisible();
|
||||
await basicInfoDialog
|
||||
.getByLabel('Description')
|
||||
.fill('Updated before debugging');
|
||||
await basicInfoDialog.getByRole('button', { name: 'Save' }).click();
|
||||
await expect(basicInfoDialog).toHaveCount(0);
|
||||
await page
|
||||
.getByRole('textbox', { name: 'Conversation input' })
|
||||
.fill('Hello');
|
||||
await page.getByRole('button', { name: 'Save and run' }).click();
|
||||
await page.getByRole('button', { name: 'Run test' }).click();
|
||||
|
||||
await expect(page.getByText('Mock Agent response')).toBeVisible();
|
||||
expect(requests).toEqual(['save', 'debug']);
|
||||
@@ -186,6 +199,24 @@ test.describe('processor detail workbench', () => {
|
||||
page.getByText('Conversation reset successfully'),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /pipeline-workbench/ }),
|
||||
).toBeVisible();
|
||||
await expect(configPanel.locator('input[name="basic.name"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await page.getByRole('button', { name: 'Edit basic information' }).click();
|
||||
const basicInfoDialog = page.getByRole('dialog');
|
||||
await expect(basicInfoDialog.getByLabel('Icon')).toBeVisible();
|
||||
await basicInfoDialog.getByLabel('Name').fill('Renamed Pipeline');
|
||||
await basicInfoDialog
|
||||
.getByLabel('Description')
|
||||
.fill('Updated from the title dialog.');
|
||||
await basicInfoDialog.getByRole('button', { name: 'Save' }).click();
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /Renamed Pipeline/ }),
|
||||
).toBeVisible();
|
||||
|
||||
const debugBox = await debugPanel.boundingBox();
|
||||
const configBox = await configPanel.boundingBox();
|
||||
expect(debugBox).not.toBeNull();
|
||||
|
||||
Reference in New Issue
Block a user