完善客户端模块与工作区能力
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Search, Trash2 } from 'lucide-react';
|
||||
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';
|
||||
@@ -20,6 +21,7 @@ export type AgentCreationSkillOption = {
|
||||
export type AgentCreationInput = {
|
||||
name: string;
|
||||
avatarId: string;
|
||||
avatarDataUrl?: string;
|
||||
model: string;
|
||||
responsibility: string;
|
||||
prompt: string;
|
||||
@@ -45,6 +47,7 @@ function createInitialInput(modelOptions: ConfiguredModelOption[], agent?: Proje
|
||||
return {
|
||||
name: agent.name,
|
||||
avatarId: agent.avatarId,
|
||||
avatarDataUrl: agent.avatarDataUrl,
|
||||
model: agent.model ?? '',
|
||||
responsibility: agent.responsibility.mission,
|
||||
prompt: agent.prompt,
|
||||
@@ -55,6 +58,7 @@ function createInitialInput(modelOptions: ConfiguredModelOption[], agent?: Proje
|
||||
return {
|
||||
name: '',
|
||||
avatarId: agentAvatarOptions[0]?.id ?? 'avatar-01',
|
||||
avatarDataUrl: undefined,
|
||||
model: modelOptions[0]?.modelRef ?? '',
|
||||
responsibility: '',
|
||||
prompt: '',
|
||||
@@ -80,6 +84,8 @@ export function AgentCreationDialog({
|
||||
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;
|
||||
@@ -88,12 +94,30 @@ export function AgentCreationDialog({
|
||||
setAvatarPickerOpen(false);
|
||||
}, [agent, 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();
|
||||
@@ -131,7 +155,7 @@ export function AgentCreationDialog({
|
||||
<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={selectedAvatar?.src} alt={selectedAvatar?.label ?? '当前头像'} className="h-full w-full rounded-lg object-cover [image-rendering:pixelated]" />
|
||||
<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>
|
||||
@@ -169,9 +193,28 @@ export function AgentCreationDialog({
|
||||
<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.avatarId === option.id} onClick={() => { setInput((current) => ({ ...current, avatarId: option.id })); setAvatarPickerOpen(false); }} className={cn('rounded-lg border p-1', input.avatarId === option.id ? 'border-brand bg-brand-soft shadow-soft' : 'border-border bg-background')}>
|
||||
<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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user