Files
makelore/src/components/coding/AgentCreationDialog.tsx

231 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useMemo, useRef, useState, type ChangeEvent } from 'react';
import { Search, Trash2, Upload } from 'lucide-react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { prepareAgentAvatar } from '@/lib/agent-avatar-upload';
import { agentAvatarOptions } from '@/lib/agent-avatars';
import { codingModelKey, type CodingModelOption } from '@/lib/coding-model-options';
import { cn } from '@/lib/utils';
import type { CodingProjectAgent } from '@/types/coding-project';
export type AgentCreationSkillOption = {
id: string;
name: string;
description: string;
available?: boolean;
effective?: boolean;
};
export type AgentCreationInput = {
name: string;
avatarId: string;
avatarDataUrl?: string;
model: string;
responsibility: string;
prompt: string;
skillIds: string[];
};
type AgentCreationDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
modelOptions: CodingModelOption[];
skills?: AgentCreationSkillOption[];
existingAgentNames?: string[];
agent?: CodingProjectAgent | null;
onCreate?: (input: AgentCreationInput) => Promise<void>;
onUpdate?: (input: AgentCreationInput) => Promise<void>;
onArchive?: () => void;
title?: string;
submitLabel?: string;
};
function createInitialInput(modelOptions: CodingModelOption[], agent?: CodingProjectAgent | null): AgentCreationInput {
if (agent) {
return {
name: agent.name,
avatarId: agent.avatarId,
avatarDataUrl: agent.avatarDataUrl,
model: agent.model ? codingModelKey(agent.model) : '',
responsibility: agent.responsibility.mission,
prompt: agent.prompt,
skillIds: [...agent.skillIds],
};
}
return {
name: '',
avatarId: agentAvatarOptions[0]?.id ?? 'avatar-01',
avatarDataUrl: undefined,
model: modelOptions[0]?.key ?? '',
responsibility: '',
prompt: '',
skillIds: [],
};
}
export function AgentCreationDialog({
open,
onOpenChange,
modelOptions,
skills = [],
existingAgentNames = [],
agent = null,
onCreate,
onUpdate,
onArchive,
title,
submitLabel,
}: AgentCreationDialogProps) {
const editing = Boolean(agent);
const [input, setInput] = useState<AgentCreationInput>(() => createInitialInput(modelOptions, agent));
const [submitting, setSubmitting] = useState(false);
const [skillQuery, setSkillQuery] = useState('');
const [avatarPickerOpen, setAvatarPickerOpen] = useState(false);
const [avatarProcessing, setAvatarProcessing] = useState(false);
const avatarUploadInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (!open) {
return;
}
setInput(createInitialInput(modelOptions, agent));
setSkillQuery('');
setAvatarPickerOpen(false);
}, [agent, editing, modelOptions, open]);
const handleAvatarFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
setAvatarProcessing(true);
try {
const prepared = await prepareAgentAvatar(file);
setInput((current) => ({ ...current, avatarDataUrl: prepared.previewUrl }));
setAvatarPickerOpen(false);
} catch (error) {
toast.error(error instanceof Error ? error.message : '智能体头像图片处理失败');
} finally {
setAvatarProcessing(false);
}
};
const filteredSkills = useMemo(() => {
const query = skillQuery.trim().toLocaleLowerCase();
if (!query) return skills;
return skills.filter((skill) => `${skill.name} ${skill.description}`.toLocaleLowerCase().includes(query));
}, [skillQuery, skills]);
const selectedAvatar = agentAvatarOptions.find((option) => option.id === input.avatarId) ?? agentAvatarOptions[0];
const selectedAvatarSrc = input.avatarDataUrl ?? selectedAvatar?.src;
const handleSubmit = async () => {
const name = input.name.trim();
const responsibility = input.responsibility.trim();
if (!name || !responsibility || !input.model) {
toast.error('请填写智能体名称和职责说明,并配置默认模型。');
return;
}
if (existingAgentNames.some((agentName) => agentName.trim() === name)) {
toast.error('智能体名称需要在当前项目内唯一。');
return;
}
const submit = editing ? onUpdate : onCreate;
if (!submit) return;
setSubmitting(true);
try {
await submit({ ...input, name, responsibility });
onOpenChange(false);
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
setSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[min(820px,calc(100vh-2rem))] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>{title ?? (editing ? '编辑项目智能体' : '创建项目智能体')}</DialogTitle>
<DialogDescription>配置智能体的身份、默认模型、职责、系统指令和插件能力。</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-1">
<div>
<label htmlFor="agent-create-name" className="text-sm font-semibold">智能体名称 <span className="text-destructive">*</span></label>
<div className="mt-2 flex items-center gap-3">
<button type="button" aria-label="更换头像" title="更换头像" aria-haspopup="dialog" onClick={() => setAvatarPickerOpen(true)} className="motion-press h-10 w-10 shrink-0 overflow-hidden rounded-xl border border-brand bg-brand-soft p-0.5 shadow-soft">
<img src={selectedAvatarSrc} alt={input.avatarDataUrl ? '当前上传头像' : selectedAvatar?.label ?? '当前头像'} className="h-full w-full rounded-lg object-cover [image-rendering:pixelated]" />
</button>
<Input id="agent-create-name" value={input.name} maxLength={30} placeholder="例如:前端开发智能体" onChange={(event) => setInput((current) => ({ ...current, name: event.target.value }))} className="mt-0 flex-1" />
</div>
</div>
<div>
<label htmlFor="agent-create-model" className="text-sm font-semibold">默认模型 <span className="text-destructive">*</span></label>
<p className="mt-1 text-xs font-medium text-muted-foreground">新对话默认使用此模型;在对话中临时切换不会修改这里的配置。</p>
<select id="agent-create-model" value={input.model} onChange={(event) => setInput((current) => ({ ...current, model: event.target.value }))} className="mt-2 h-10 w-full rounded-xl border border-border bg-surface-input px-3 text-sm">
<option value="" disabled>请选择已配置模型</option>
{modelOptions.map((option) => <option key={option.key} value={option.key}>{option.label}</option>)}
</select>
</div>
<div>
<label htmlFor="agent-create-responsibility" className="text-sm font-semibold">职责说明 <span className="text-destructive">*</span></label>
<Input id="agent-create-responsibility" value={input.responsibility} maxLength={200} placeholder="例如:负责把想法拆成清晰的产品计划。" onChange={(event) => setInput((current) => ({ ...current, responsibility: event.target.value }))} className="mt-2" />
</div>
<section className="space-y-4 rounded-lg border border-border/80 bg-surface-subtle p-4">
<h3 className="font-semibold">高级设置</h3>
<div><label htmlFor="agent-create-system-instructions" className="text-sm font-semibold">系统指令</label><Textarea id="agent-create-system-instructions" value={input.prompt} onChange={(event) => setInput((current) => ({ ...current, prompt: event.target.value }))} className="mt-2 min-h-28 bg-background font-mono text-xs leading-5" placeholder="可选:定义智能体的工作方式、约束和输出要求。" /></div>
<div className="flex items-center justify-between gap-3"><h4 className="text-sm font-semibold">插件能力</h4><Badge className="border border-border/80 bg-background text-foreground">已选 {input.skillIds.length}</Badge></div>
{skills.length > 0 ? <>
<div className="relative"><Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /><Input aria-label="搜索插件能力" value={skillQuery} onChange={(event) => setSkillQuery(event.target.value)} placeholder="搜索插件能力" className="bg-background pl-9" /></div>
<div className="space-y-2">{filteredSkills.map((skill) => { const checked = input.skillIds.includes(skill.id); const unavailable = skill.available === false; return <label key={skill.id} className={cn('flex items-start gap-2 rounded-md border border-border/80 p-2.5', unavailable ? 'cursor-not-allowed opacity-70' : 'cursor-pointer', checked ? 'bg-background' : 'bg-surface-subtle')}><input type="checkbox" aria-label={`绑定插件能力:${skill.name}`} checked={checked} disabled={unavailable} onChange={() => setInput((current) => ({ ...current, skillIds: checked ? current.skillIds.filter((id) => id !== skill.id) : [...current.skillIds, skill.id] }))} /><span><span className="text-sm font-semibold">{skill.name}</span><span className="mt-0.5 block text-xs text-muted-foreground">{skill.description}</span>{unavailable ? <span className="mt-1 block text-xs font-medium text-amber-700">插件未启用;现有分配会保留,但当前不可用。</span> : null}</span></label>; })}{filteredSkills.length === 0 ? <p className="rounded-md border border-dashed border-border/80 p-3 text-center text-xs font-medium text-muted-foreground">没有匹配的插件能力</p> : null}</div>
</> : <p className="mt-3 rounded-md border border-dashed border-border/80 p-3 text-xs font-medium text-muted-foreground">当前暂无可分配能力,请先在插件模块完成添加与启用。</p>}
</section>
</div>
<DialogFooter className={cn(agent && onArchive && 'sm:justify-between')}>
{agent && onArchive ? <Button type="button" variant="outline" aria-label="归档智能体" className="border-border/80 bg-accent-soft" onClick={onArchive} disabled={submitting}><Trash2 className="mr-2 h-4 w-4" />归档智能体</Button> : null}
<div className="flex flex-col-reverse gap-2 sm:ml-auto sm:flex-row">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>取消</Button>
<Button type="button" onClick={() => void handleSubmit()} disabled={submitting || modelOptions.length === 0}>{submitting ? (editing ? '保存中…' : '创建中…') : (submitLabel ?? (editing ? '保存智能体' : '创建并开始对话'))}</Button>
</div>
</DialogFooter>
</DialogContent>
<Dialog open={avatarPickerOpen} onOpenChange={setAvatarPickerOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader><DialogTitle>选择头像</DialogTitle><DialogDescription>点击一个头像替换当前头像。</DialogDescription></DialogHeader>
<input
ref={avatarUploadInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
aria-label="上传智能体头像"
data-testid="agent-avatar-upload-input"
className="sr-only"
onChange={(event) => { void handleAvatarFileChange(event); }}
/>
<div className="flex flex-col gap-2 rounded-lg border border-dashed border-brand/35 bg-brand-soft/40 p-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm font-semibold">上传本地图片</p>
<p className="mt-1 text-xs font-medium text-muted-foreground">自动裁剪为 256×256,并优先压缩为 WebP,减少项目配置体积。</p>
</div>
<Button type="button" variant="outline" className="shrink-0 border-brand/30 bg-background" onClick={() => avatarUploadInputRef.current?.click()} disabled={avatarProcessing}>
<Upload className="mr-2 h-4 w-4" />{avatarProcessing ? '处理中…' : '从本地上传'}
</Button>
</div>
{input.avatarDataUrl ? <Button type="button" variant="ghost" className="w-full text-xs text-muted-foreground" onClick={() => { setInput((current) => ({ ...current, avatarDataUrl: undefined })); setAvatarPickerOpen(false); }}>恢复内置头像</Button> : null}
<div className="grid grid-cols-8 gap-2">
{agentAvatarOptions.map((option) => (
<button key={option.id} type="button" aria-label={`选择头像:${option.label}`} aria-pressed={!input.avatarDataUrl && input.avatarId === option.id} onClick={() => { setInput((current) => ({ ...current, avatarId: option.id, avatarDataUrl: undefined })); setAvatarPickerOpen(false); }} className={cn('rounded-lg border p-1', !input.avatarDataUrl && input.avatarId === option.id ? 'border-brand bg-brand-soft shadow-soft' : 'border-border bg-background')}>
<img src={option.src} alt="" className="aspect-square w-full rounded object-cover [image-rendering:pixelated]" />
</button>
))}
</div>
</DialogContent>
</Dialog>
</Dialog>
);
}