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

245 lines
14 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';
import { DEFAULT_PROJECT_AGENT_SKILL_IDS } from '../../../shared/coding-skills';
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 defaultSkillsAppliedRef = useRef(false);
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) {
defaultSkillsAppliedRef.current = false;
return;
}
defaultSkillsAppliedRef.current = editing;
setInput(createInitialInput(modelOptions, agent));
setSkillQuery('');
setAvatarPickerOpen(false);
}, [agent, editing, modelOptions, open]);
useEffect(() => {
if (!open || editing || defaultSkillsAppliedRef.current) return;
const defaultSkillIds = DEFAULT_PROJECT_AGENT_SKILL_IDS.filter((skillId) => skills.some((skill) => skill.id === skillId));
if (defaultSkillIds.length === 0) return;
setInput((current) => current.skillIds.length > 0
? current
: { ...current, skillIds: [...defaultSkillIds] });
defaultSkillsAppliedRef.current = true;
}, [editing, open, skills]);
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>
<Textarea aria-label="Agent 提示词" value={input.prompt} onChange={(event) => setInput((current) => ({ ...current, prompt: event.target.value }))} className="mt-3 min-h-28 bg-background font-mono text-xs leading-5" placeholder="可选:补充这个伙伴的工作方式与边界。" />
<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>
);
}