Files
makelore/src/components/opencode/AgentCreationDialog.tsx
inman c809002180
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled
feat: add bundled OpenCode skills and refine module flow
2026-08-14 18:15:55 +08:00

243 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 { formatModelRefLabel, type ConfiguredModelOption } from '@/lib/model-options';
import { cn } from '@/lib/utils';
import type { ProjectAgentConfig } from '../../../shared/project-config';
import { DEFAULT_PROJECT_AGENT_SKILL_IDS } from '../../../shared/opencode-skills';
export type AgentCreationSkillOption = {
id: string;
name: string;
description: string;
};
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: ConfiguredModelOption[];
skills?: AgentCreationSkillOption[];
existingAgentNames?: string[];
agent?: ProjectAgentConfig | null;
onCreate?: (input: AgentCreationInput) => Promise<void>;
onUpdate?: (input: AgentCreationInput) => Promise<void>;
onArchive?: () => void;
title?: string;
submitLabel?: string;
};
function createInitialInput(modelOptions: ConfiguredModelOption[], agent?: ProjectAgentConfig | null): AgentCreationInput {
if (agent) {
return {
name: agent.name,
avatarId: agent.avatarId,
avatarDataUrl: agent.avatarDataUrl,
model: 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]?.modelRef ?? '',
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.modelRef} value={option.modelRef}>{formatModelRefLabel(option.modelRef)} · {option.runtimeProviderKey}</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); return <label key={skill.id} className={cn('flex cursor-pointer items-start gap-2 rounded-md border border-border/80 p-2.5', checked ? 'bg-background' : 'bg-surface-subtle')}><input type="checkbox" aria-label={`绑定技能:${skill.name}`} checked={checked} 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></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>
);
}