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
+92 -51
View File
@@ -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"
+58 -13
View File
@@ -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">
+5 -2
View File
@@ -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);
}
+8
View File
@@ -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',
+8
View File
@@ -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: '安全制御',
+7
View File
@@ -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: '安全控制',