feat(web): edit entity details from page titles

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