feat(F): F-UI-EDIT AgentEditor component + automation API client
This commit is contained in:
@@ -15,6 +15,9 @@ import type {
|
|||||||
AgentTool,
|
AgentTool,
|
||||||
AutomationSettings,
|
AutomationSettings,
|
||||||
MiniAppDef,
|
MiniAppDef,
|
||||||
|
AgentToolInfo,
|
||||||
|
AgentSkillInfo,
|
||||||
|
AgentRunFull,
|
||||||
} from '@/types/automation';
|
} from '@/types/automation';
|
||||||
|
|
||||||
// ── Automation Hooks ──
|
// ── Automation Hooks ──
|
||||||
@@ -243,6 +246,49 @@ export function useAgentTools() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Agent Editor / Chat / Log / Monitor Hooks ──
|
||||||
|
|
||||||
|
export function useAgentToolsFull() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['agentToolsFull'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiGet<AgentToolInfo[] | { items: AgentToolInfo[] }>('/agents/tools');
|
||||||
|
return Array.isArray(res) ? res : res.items ?? [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAgentSkills() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['agentSkills'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiGet<AgentSkillInfo[] | { items: AgentSkillInfo[] }>('/agents/skills');
|
||||||
|
return Array.isArray(res) ? res : res.items ?? [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAgentRunsFull(agentId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['agentRunsFull', agentId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiGet<AgentRunFull[] | { items: AgentRunFull[] }>(`/agents/${agentId}/runs`);
|
||||||
|
return Array.isArray(res) ? res : res.items ?? [];
|
||||||
|
},
|
||||||
|
enabled: !!agentId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRecentAgentRuns(limit = 20) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['recentAgentRuns', limit],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiGet<AgentRunFull[] | { items: AgentRunFull[] }>(`/agents/runs/recent?limit=${limit}`);
|
||||||
|
return Array.isArray(res) ? res : res.items ?? [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Agent Communication Hooks ──
|
// ── Agent Communication Hooks ──
|
||||||
|
|
||||||
export function useSendAgentMessage() {
|
export function useSendAgentMessage() {
|
||||||
|
|||||||
@@ -0,0 +1,396 @@
|
|||||||
|
import React, { useEffect, useMemo } from 'react';
|
||||||
|
import { useForm, Controller } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { Select } from '@/components/ui/Select';
|
||||||
|
import { useToast } from '@/components/ui/Toast';
|
||||||
|
import {
|
||||||
|
useCreateAgent,
|
||||||
|
useUpdateAgent,
|
||||||
|
useTestRunAgent,
|
||||||
|
useAgentToolsFull,
|
||||||
|
useAgentSkills,
|
||||||
|
} from '@/api/automation';
|
||||||
|
import type { AgentDefinitionFull } from '@/types/automation';
|
||||||
|
import { Play, Save, X, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
const agentEditorSchema = z.object({
|
||||||
|
name: z.string().min(1, 'required').max(120),
|
||||||
|
description: z.string().max(500).default(''),
|
||||||
|
system_prompt: z.string().default(''),
|
||||||
|
llm_model: z.string().min(1, 'required').max(100),
|
||||||
|
tool_ids: z.array(z.string()).default([]),
|
||||||
|
skill_ids: z.array(z.string()).default([]),
|
||||||
|
max_steps: z.coerce.number().int().min(1).max(100).default(20),
|
||||||
|
max_duration_seconds: z.coerce.number().int().min(1).max(86400).default(300),
|
||||||
|
budget_limit_usd: z.coerce.number().min(0).max(10000).default(1.0),
|
||||||
|
temperature: z.coerce.number().min(0).max(2).default(0.3),
|
||||||
|
max_tokens: z.coerce.number().int().min(1).max(100000).default(1000),
|
||||||
|
trace_mode: z.enum(['standard', 'extended']).default('standard'),
|
||||||
|
mode: z.enum(['proactive', 'reactive']).default('reactive'),
|
||||||
|
is_active: z.boolean().default(true),
|
||||||
|
trigger_config: z.record(z.string(), z.unknown()).default({}),
|
||||||
|
ai_use_case_metadata: z.record(z.string(), z.unknown()).default({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
type AgentEditorFormData = z.infer<typeof agentEditorSchema>;
|
||||||
|
|
||||||
|
export interface AgentEditorProps {
|
||||||
|
agent?: AgentDefinitionFull | null;
|
||||||
|
onSaved?: (agent: AgentDefinitionFull) => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modeOptions = [
|
||||||
|
{ value: 'reactive', label: 'Reactive' },
|
||||||
|
{ value: 'proactive', label: 'Proactive' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const traceModeOptions = [
|
||||||
|
{ value: 'standard', label: 'Standard' },
|
||||||
|
{ value: 'extended', label: 'Extended' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const commonModels = [
|
||||||
|
'ollama/deepseek-v4-flash',
|
||||||
|
'gpt-4',
|
||||||
|
'gpt-4-turbo',
|
||||||
|
'gpt-3.5-turbo',
|
||||||
|
'claude-3-opus',
|
||||||
|
'claude-3-sonnet',
|
||||||
|
'claude-3-haiku',
|
||||||
|
'llama-3-70b',
|
||||||
|
'llama-3-8b',
|
||||||
|
'mistral-large',
|
||||||
|
'mixtral-8x7b',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AgentEditor({ agent, onSaved, onCancel }: AgentEditorProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { data: tools = [] } = useAgentToolsFull();
|
||||||
|
const { data: skills = [] } = useAgentSkills();
|
||||||
|
const createAgent = useCreateAgent();
|
||||||
|
const updateAgent = useUpdateAgent();
|
||||||
|
const testRunAgent = useTestRunAgent();
|
||||||
|
|
||||||
|
const defaultValues = useMemo<AgentEditorFormData>(() => {
|
||||||
|
if (agent) {
|
||||||
|
return {
|
||||||
|
name: agent.name,
|
||||||
|
description: agent.description || '',
|
||||||
|
system_prompt: agent.system_prompt || '',
|
||||||
|
llm_model: agent.llm_model,
|
||||||
|
tool_ids: agent.tool_ids || [],
|
||||||
|
skill_ids: agent.skill_ids || [],
|
||||||
|
max_steps: agent.max_steps,
|
||||||
|
max_duration_seconds: agent.max_duration_seconds,
|
||||||
|
budget_limit_usd: agent.budget_limit_usd,
|
||||||
|
temperature: agent.temperature,
|
||||||
|
max_tokens: agent.max_tokens,
|
||||||
|
trace_mode: agent.trace_mode,
|
||||||
|
mode: agent.mode,
|
||||||
|
is_active: agent.is_active,
|
||||||
|
trigger_config: agent.trigger_config || {},
|
||||||
|
ai_use_case_metadata: agent.ai_use_case_metadata || {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
system_prompt: '',
|
||||||
|
llm_model: 'ollama/deepseek-v4-flash',
|
||||||
|
tool_ids: [],
|
||||||
|
skill_ids: [],
|
||||||
|
max_steps: 20,
|
||||||
|
max_duration_seconds: 300,
|
||||||
|
budget_limit_usd: 1.0,
|
||||||
|
temperature: 0.3,
|
||||||
|
max_tokens: 1000,
|
||||||
|
trace_mode: 'standard',
|
||||||
|
mode: 'reactive',
|
||||||
|
is_active: true,
|
||||||
|
trigger_config: {},
|
||||||
|
ai_use_case_metadata: {},
|
||||||
|
};
|
||||||
|
}, [agent]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
control,
|
||||||
|
reset,
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<AgentEditorFormData>({
|
||||||
|
resolver: zodResolver(agentEditorSchema),
|
||||||
|
defaultValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedToolIds = watch('tool_ids');
|
||||||
|
const selectedSkillIds = watch('skill_ids');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reset(defaultValues);
|
||||||
|
}, [reset, defaultValues]);
|
||||||
|
|
||||||
|
const onSubmit = async (data: AgentEditorFormData) => {
|
||||||
|
try {
|
||||||
|
if (agent) {
|
||||||
|
const updated = await updateAgent.mutateAsync({ id: agent.id, data });
|
||||||
|
toast({ title: t('agent.saved'), variant: 'success' });
|
||||||
|
onSaved?.(updated);
|
||||||
|
} else {
|
||||||
|
const created = await createAgent.mutateAsync(data);
|
||||||
|
toast({ title: t('agent.created'), variant: 'success' });
|
||||||
|
onSaved?.(created);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast({ title: t('agent.saveFailed'), variant: 'error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTestRun = async () => {
|
||||||
|
if (!agent) {
|
||||||
|
toast({ title: t('agent.saveBeforeTest'), variant: 'warning' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await testRunAgent.mutateAsync(agent.id);
|
||||||
|
toast({ title: t('agent.testRunOk'), variant: 'success' });
|
||||||
|
} catch {
|
||||||
|
toast({ title: t('agent.testRunFailed'), variant: 'error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleArrayValue = (field: 'tool_ids' | 'skill_ids', value: string) => {
|
||||||
|
const current = field === 'tool_ids' ? selectedToolIds : selectedSkillIds;
|
||||||
|
const next = (current || []).includes(value)
|
||||||
|
? (current || []).filter((v) => v !== value)
|
||||||
|
: [...(current || []), value];
|
||||||
|
setValue(field, next, { shouldDirty: true, shouldValidate: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6" data-testid="agent-editor">
|
||||||
|
{/* Basic info */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
label={t('agent.name')}
|
||||||
|
required
|
||||||
|
error={errors.name?.message}
|
||||||
|
placeholder="My Agent"
|
||||||
|
{...register('name')}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={t('agent.description')}
|
||||||
|
error={errors.description?.message}
|
||||||
|
placeholder={t('agent.descriptionPlaceholder')}
|
||||||
|
{...register('description')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* System prompt */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="agent-system-prompt" className="block text-sm font-medium text-secondary-700 mb-1">
|
||||||
|
{t('agent.systemPrompt')}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="agent-system-prompt"
|
||||||
|
rows={5}
|
||||||
|
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500 min-h-touch"
|
||||||
|
placeholder="You are a helpful assistant..."
|
||||||
|
aria-invalid={!!errors.system_prompt}
|
||||||
|
{...register('system_prompt')}
|
||||||
|
/>
|
||||||
|
{errors.system_prompt && (
|
||||||
|
<p className="mt-1 text-sm text-danger-600" role="alert">{errors.system_prompt.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Model + mode + trace */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="agent-llm-model" className="block text-sm font-medium text-secondary-700 mb-1">
|
||||||
|
{t('agent.model')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="agent-llm-model"
|
||||||
|
list="agent-model-suggestions"
|
||||||
|
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 min-h-touch"
|
||||||
|
aria-invalid={!!errors.llm_model}
|
||||||
|
{...register('llm_model')}
|
||||||
|
/>
|
||||||
|
<datalist id="agent-model-suggestions">
|
||||||
|
{commonModels.map((m) => (
|
||||||
|
<option key={m} value={m} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
{errors.llm_model && (
|
||||||
|
<p className="mt-1 text-sm text-danger-600" role="alert">{errors.llm_model.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="mode"
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select
|
||||||
|
label={t('agent.mode')}
|
||||||
|
options={modeOptions}
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="trace_mode"
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select
|
||||||
|
label={t('agent.traceMode')}
|
||||||
|
options={traceModeOptions}
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tools multi-select */}
|
||||||
|
<div>
|
||||||
|
<span className="block text-sm font-medium text-secondary-700 mb-2">{t('agent.tools')}</span>
|
||||||
|
{tools.length === 0 ? (
|
||||||
|
<p className="text-sm text-secondary-400 italic">{t('agent.noToolsAvailable')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 max-h-40 overflow-y-auto" role="group" aria-label={t('agent.tools')}>
|
||||||
|
{tools.map((tool) => (
|
||||||
|
<label key={tool.id} className="flex items-center gap-2 p-2 rounded-md hover:bg-secondary-50 cursor-pointer text-sm min-h-touch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedToolIds.includes(tool.id)}
|
||||||
|
onChange={() => toggleArrayValue('tool_ids', tool.id)}
|
||||||
|
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-secondary-700">{tool.name}</span>
|
||||||
|
{tool.description && (
|
||||||
|
<p className="text-xs text-secondary-400">{tool.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Skills multi-select */}
|
||||||
|
<div>
|
||||||
|
<span className="block text-sm font-medium text-secondary-700 mb-2">{t('agent.skills')}</span>
|
||||||
|
{skills.length === 0 ? (
|
||||||
|
<p className="text-sm text-secondary-400 italic">{t('agent.noSkillsAvailable')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 max-h-40 overflow-y-auto" role="group" aria-label={t('agent.skills')}>
|
||||||
|
{skills.map((skill) => (
|
||||||
|
<label key={skill.name} className="flex items-center gap-2 p-2 rounded-md hover:bg-secondary-50 cursor-pointer text-sm min-h-touch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedSkillIds.includes(skill.name)}
|
||||||
|
onChange={() => toggleArrayValue('skill_ids', skill.name)}
|
||||||
|
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-secondary-700">{skill.name}</span>
|
||||||
|
{skill.description && (
|
||||||
|
<p className="text-xs text-secondary-400">{skill.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Limits */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
label={t('agent.maxSteps')}
|
||||||
|
error={errors.max_steps?.message}
|
||||||
|
{...register('max_steps')}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
label={`${t('agent.maxDuration')} (s)`}
|
||||||
|
error={errors.max_duration_seconds?.message}
|
||||||
|
{...register('max_duration_seconds')}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
label={`${t('agent.budgetLimit')} ($)`}
|
||||||
|
error={errors.budget_limit_usd?.message}
|
||||||
|
{...register('budget_limit_usd')}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
label={t('agent.temperature')}
|
||||||
|
error={errors.temperature?.message}
|
||||||
|
{...register('temperature')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
label={t('agent.maxTokens')}
|
||||||
|
error={errors.max_tokens?.message}
|
||||||
|
{...register('max_tokens')}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
label={t('agent.maxExecutions')}
|
||||||
|
defaultValue={10}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active toggle */}
|
||||||
|
<label className="flex items-center gap-2 text-sm min-h-touch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
{...register('is_active')}
|
||||||
|
/>
|
||||||
|
{t('agent.active')}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
<div className="flex justify-end gap-3 pt-4 border-t border-secondary-200">
|
||||||
|
{agent && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleTestRun}
|
||||||
|
isLoading={testRunAgent.isPending}
|
||||||
|
icon={<Play className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
{t('agent.testRun')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{onCancel && (
|
||||||
|
<Button type="button" variant="ghost" onClick={onCancel} icon={<X className="w-4 h-4" />}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button type="submit" isLoading={isSubmitting} icon={<Save className="w-4 h-4" />}>
|
||||||
|
{agent ? t('common.save') : t('common.create')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user