feat(agents): 复用 Yuxi 头像上传与展示

This commit is contained in:
brother7 committed 2026-10-12 13:48:38 +08:00
1 parent 0ca744fb9b
commit 235af24d6c
16 files changed
+381 -32

No files matched your search

+1
View File
@@ -3,6 +3,7 @@ import type { CloudAgentOperations, CloudUpload, CloudKnowledgeFile, CloudRecove
import { CLOUD_AGENTS_PATH, type CloudAgentDraft, type CloudAgentPage, type CreateCloudAgent, type SaveCloudAgentDraft } from '../../shared/cloud-agents';
export const cloudAgentsApi = {
uploadAvatar: () => hostApiFetch<{ icon: string; icon_url: string | null } | null>(CLOUD_AGENTS_PATH + '/avatars/pick', { method: 'POST' }),
recovery: () => hostApiFetch<CloudRecoveryState>(CLOUD_AGENTS_PATH + '/recovery'),
remember: (recent: CloudRecent | null) => hostApiFetch(CLOUD_AGENTS_PATH + '/recovery/recent', {
method: 'PUT', body: JSON.stringify(recent),
+56
View File
@@ -0,0 +1,56 @@
import { useEffect, useRef, useState } from 'react';
import { ImagePlus, Loader2, Sparkles } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
import type { CloudAgentAvatar } from '../../../shared/cloud-agents';
export function CloudAvatar({ src, name, className = 'h-12 w-12' }: {
src?: string | null; name: string; className?: string;
}) {
const [failed, setFailed] = useState<string | null>(null);
const style = `inline-flex shrink-0 items-center justify-center overflow-hidden rounded-xl bg-violet-50 text-violet-500 ${className}`;
return src && failed !== src
? <img src={src} alt={`${name}头像`} className={style + ' object-cover'} onError={() => setFailed(src)} />
: <span role="img" aria-label={`${name}默认头像`} className={style}><Sparkles className="h-1/2 w-1/2" strokeWidth={1.5} /></span>;
}
export function CloudAvatarEditor({ value, name, disabled, uploading, onUploading, onChange }: {
value: CloudAgentAvatar; name: string; disabled: boolean; uploading: boolean;
onUploading: (busy: boolean) => void; onChange: (avatar: CloudAgentAvatar) => void;
}) {
const [error, setError] = useState('');
const alive = useRef(true);
const pending = useRef(false);
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
const upload = async () => {
if (disabled || pending.current) return;
pending.current = true; onUploading(true); setError('');
try {
const avatar = await cloudAgentsApi.uploadAvatar();
if (alive.current && avatar) onChange(avatar);
} catch (failure) {
if (alive.current) setError(failure instanceof Error ? failure.message : '头像上传失败,请重试');
} finally {
pending.current = false;
if (alive.current) onUploading(false);
}
};
return <div className="space-y-2" role="group" aria-label="智能体头像">
<div className="flex items-center gap-4">
<CloudAvatar src={value.icon_url} name={name || '智能体'} className="h-16 w-16" />
<div className="space-y-2">
<p className="text-sm font-medium">头像</p>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" size="sm" disabled={disabled || uploading} onClick={() => void upload()}>
{uploading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <ImagePlus className="mr-2 h-4 w-4" />}
{uploading ? '正在上传…' : value.icon ? '更换头像' : '上传头像'}
</Button>
{value.icon && <Button type="button" variant="ghost" size="sm" disabled={disabled || uploading}
onClick={() => { setError(''); onChange({ icon: null, icon_url: null }); }}>移除头像</Button>}
</div>
<p className="text-xs text-muted-foreground">PNG、JPEG、WebP 或 GIF,最大 5 MB。保存后生效。</p>
</div>
</div>
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
</div>;
}
+6 -1
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
import { CloudAvatar } from './CloudAvatar';
import type { CloudAgentDraft, CloudVersion } from '../../../shared/cloud-agents';
import { usePendingCloudInput } from './CloudPending';
@@ -38,7 +39,11 @@ export function CloudLifecycle({ slug, revision, versions, onChanged }: {
})}>比较版本 {v}</Button>)}</div>
{version && current && <div className="space-y-3 rounded-xl border border-border p-4 text-sm">
<h3 className="font-medium">当前草稿修订 {current.draft_revision} → 版本 {version.version}</h3>
{!rows.length && <p>配置相同。</p>}
{!rows.length && (current.icon ?? null) === (version.icon ?? null) && <p>配置相同。</p>}
{(current.icon ?? null) !== (version.icon ?? null) && <div className="flex gap-6">
<div><p className="mb-2 text-muted-foreground">当前草稿头像</p><CloudAvatar src={current.icon_url} name={current.name} /></div>
<div><p className="mb-2 text-muted-foreground">旧版本头像</p><CloudAvatar src={version.icon_url} name={version.name} /></div>
</div>}
<div className="max-h-96 overflow-auto"><table className="w-full table-fixed text-left"><thead><tr><th className="w-1/5 p-2">字段</th><th className="p-2">当前草稿</th><th className="p-2">旧版本</th></tr></thead>
<tbody>{rows.map(([key, before, after]) => <tr key={key} className="border-t align-top"><td className="break-words p-2">{key}</td><td className="whitespace-pre-wrap break-words p-2">{before}</td><td className="whitespace-pre-wrap break-words p-2">{after}</td></tr>)}</tbody></table></div>
<Button variant="outline" disabled={busy || current.draft_revision !== revision} onClick={() => void act(async () => {
+2
View File
@@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button';
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
import type { CloudAgentEntry, CloudThread } from '../../../shared/cloud-agents';
import { cloudStatus } from './CloudChat';
import { CloudAvatar } from './CloudAvatar';
export function CloudOverview({ section, onAgent, onThread }: {
section: 'received' | 'activity'; onAgent: (entry: CloudAgentEntry) => void; onThread: (thread: CloudThread) => void;
@@ -51,6 +52,7 @@ export function CloudOverview({ section, onAgent, onThread }: {
{!loading && !agents.length && <p className="py-12 text-center text-sm text-muted-foreground">还没有收到分享</p>}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">{agents.map(agent => <button key={agent.slug} onClick={() => onAgent(agent)}
className="rounded-xl border border-border p-5 text-left transition-colors hover:bg-muted/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<CloudAvatar src={agent.icon_url} name={agent.name} className="mb-4 h-12 w-12" />
<h2 className="font-medium">{agent.name}</h2><p className="mt-2 line-clamp-3 text-sm leading-6 text-muted-foreground">{agent.purpose}</p>
<p className="mt-4 text-xs text-muted-foreground">版本 {agent.published_version} · 打开对话</p>
</button>)}</div></> : <>
+16 -7
View File
@@ -18,10 +18,12 @@ import { DesktopSplit } from './DesktopSplit';
import { AgentRequirements } from './AgentRequirements';
import { agentExamples } from './agent-guidance';
import { useCloudPublication } from './useCloudPublication';
import { CloudAvatar, CloudAvatarEditor } from './CloudAvatar';
type Tab = 'configuration' | 'chat' | 'tasks' | 'access';
type AdvancedCategory = 'capabilities' | 'knowledge' | 'limits';
const hasDraftChanges = (draft: CloudAgentDraft, saved: CloudAgentDraft) => draft.name !== saved.name || draft.purpose !== saved.purpose || draft.system_prompt !== saved.system_prompt
|| (draft.icon ?? null) !== (saved.icon ?? null)
|| JSON.stringify(draft.configuration) !== JSON.stringify(saved.configuration);
const errorMessage = (error: unknown) => error instanceof Error ? error.message : '暂时无法完成操作';
@@ -37,6 +39,7 @@ export function DraftEditor({ initial, recent, onBack, onSaved, startingPrompt }
const [previewCreated, setPreviewCreated] = useState(Boolean(initial.configuration?.model));
const [remote, setRemote] = useState<CloudAgentDraft | null>(null);
const [savingDraft, setBusy] = useState(false);
const [uploadingAvatar, setUploadingAvatar] = useState(false);
const [error, setError] = useState('');
const [leaving, setLeaving] = useState(false);
const initialTab = recent?.mode === 'preview' ? 'configuration' : initial.published_version ? 'chat' : 'configuration';
@@ -61,7 +64,7 @@ export function DraftEditor({ initial, recent, onBack, onSaved, startingPrompt }
onSaved(next);
if (!saved.published_version) navigate('chat');
});
const busy = savingDraft || publication.busy;
const busy = savingDraft || publication.busy || uploadingAvatar;
const hasPending = pending.hasPending || formalPending.hasPending || Boolean(publication.pending);
const canSave = dirty && !busy && !remote && Boolean(draft.name.trim() && draft.purpose.trim());
const needsApply = Boolean(saved.published_version) && publication.appliedRevision !== undefined
@@ -90,10 +93,11 @@ export function DraftEditor({ initial, recent, onBack, onSaved, startingPrompt }
const result = await cloudAgentsApi.save(saved.slug, {
expected_revision: saved.draft_revision, name: draft.name, purpose: draft.purpose, system_prompt: draft.system_prompt,
configuration: draft.configuration ?? EMPTY_CLOUD_CONFIGURATION,
...(draft.icon === undefined ? {} : { icon: draft.icon }),
});
if (!alive.current) return;
setSaved(result); if (result.configuration?.model) setPreviewCreated(true);
setDraft(current => !hasDraftChanges(current, draft) ? result : { ...result, name: current.name, purpose: current.purpose, system_prompt: current.system_prompt, configuration: current.configuration });
setDraft(current => !hasDraftChanges(current, draft) ? result : { ...result, name: current.name, purpose: current.purpose, icon: current.icon, icon_url: current.icon_url, system_prompt: current.system_prompt, configuration: current.configuration });
setRemote(null); onSaved(result);
if (exit) finishLeaving();
return result.draft_revision;
@@ -118,11 +122,14 @@ export function DraftEditor({ initial, recent, onBack, onSaved, startingPrompt }
<header className="agent-workspace-header">
<Button variant="ghost" size="icon" className="h-10 w-10 shrink-0" aria-label="我的智能体" disabled={busy}
onClick={() => dirty || hasPending ? setLeaving(true) : onBack()}><ArrowLeft className="h-4 w-4" /></Button>
<div className="min-w-0 flex-1">
<Input aria-label="名称" maxLength={100} value={draft.name} disabled={busy}
className="h-8 max-w-96 border-transparent bg-transparent px-2 font-semibold shadow-none hover:border-input focus:border-input"
onChange={event => setDraft({ ...draft, name: event.target.value })} />
<p className="px-2 text-xs text-muted-foreground">{saved.published_version ? '我的助手' : startingPrompt !== undefined ? '第 3 步,共 3 步 · 试一试' : '制作中的助手 · 仅自己可见'}</p>
<div className="flex min-w-0 flex-1 items-center gap-2">
<CloudAvatar src={draft.icon_url} name={draft.name} className="h-10 w-10" />
<div className="min-w-0 flex-1">
<Input aria-label="名称" maxLength={100} value={draft.name} disabled={busy}
className="h-8 max-w-96 border-transparent bg-transparent px-2 font-semibold shadow-none hover:border-input focus:border-input"
onChange={event => setDraft({ ...draft, name: event.target.value })} />
<p className="px-2 text-xs text-muted-foreground">{saved.published_version ? '我的助手' : startingPrompt !== undefined ? '第 3 步,共 3 步 · 试一试' : '制作中的助手 · 仅自己可见'}</p>
</div>
</div>
<span role="status" className={`shrink-0 text-xs ${needsApply || dirty ? 'text-amber-800' : 'text-muted-foreground'}`}>{savingDraft ? '正在保存…' : dirty ? '有未保存修改' : saved.published_version ? applicationStatus : '云端已保存'}</span>
<Button className="min-h-10" size="sm" variant={dirty ? 'default' : 'outline'} disabled={!canSave} onClick={() => void save()} title="保存草稿(Ctrl / ⌘ S)">
@@ -171,6 +178,8 @@ export function DraftEditor({ initial, recent, onBack, onSaved, startingPrompt }
<Button key={key} variant={advancedCategory === key ? 'secondary' : 'ghost'} aria-current={advancedCategory === key ? 'page' : undefined} onClick={() => setAdvancedCategory(key)}>{label}</Button>)}</nav>}
<div ref={configurationScroll} className="agent-config-scroll"><fieldset disabled={busy} className="min-w-0">
<div hidden={category !== 'instructions'} className="mb-6 space-y-6">
<CloudAvatarEditor value={draft} name={draft.name} disabled={busy} uploading={uploadingAvatar} onUploading={setUploadingAvatar}
onChange={avatar => setDraft(current => ({ ...current, ...avatar }))} />
<label className="block space-y-2 text-sm"><span className="font-medium">用途</span><Textarea aria-label="用途" className="min-h-20 resize-y" maxLength={2000} value={draft.purpose}
onChange={event => setDraft({ ...draft, purpose: event.target.value })} placeholder="一句话介绍,它可以帮你做什么。" /></label>
<AgentRequirements value={draft.system_prompt} onChange={system_prompt => setDraft({ ...draft, system_prompt })} />
+15 -8
View File
@@ -16,6 +16,8 @@ import { CloudRecovery } from './CloudRecovery';
import { ChannelAccounts } from './ChannelAccounts';
import { AgentRequirements } from './AgentRequirements';
import { agentStarters } from './agent-guidance';
import { CloudAvatar, CloudAvatarEditor } from './CloudAvatar';
import type { CloudAgentAvatar } from '../../../shared/cloud-agents';
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : '暂时无法连接智能体服务,请重试';
@@ -149,7 +151,7 @@ function AgentWorkspace() {
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{agents.map((agent) => <button key={agent.slug} disabled={creating} onClick={() => setSelected(agent)}
className="rounded-xl border border-border bg-background p-5 text-left transition-colors enabled:hover:bg-violet-50/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50">
<span className="mb-4 flex items-center justify-between"><Sparkles className="h-5 w-5 text-violet-500" />
<span className="mb-4 flex items-center justify-between"><CloudAvatar src={agent.icon_url} name={agent.name} />
<span className="text-xs text-muted-foreground">{agent.archived ? '已归档' : !agent.enabled ? '已停用' : agent.published_version ? '可以使用' : '还在制作'}</span></span>
<h2 className="truncate font-medium">{agent.name}</h2>
<p className="mt-2 line-clamp-3 whitespace-pre-wrap text-sm leading-6 text-muted-foreground">{agent.purpose}</p>
@@ -168,22 +170,25 @@ function CreateAgentForm({ onCreated, onCancel }: { onCreated: (agent: CloudAgen
const [purpose, setPurpose] = useState('');
const [prompt, setPrompt] = useState('');
const [intent, setIntent] = useState<CreateCloudAgent | null>(null);
const [busy, setBusy] = useState(false);
const [creatingAgent, setBusy] = useState(false);
const [avatar, setAvatar] = useState<CloudAgentAvatar>({});
const [uploadingAvatar, setUploadingAvatar] = useState(false);
const busy = creatingAgent || uploadingAvatar;
const [error, setError] = useState('');
const alive = useRef(true);
const blocker = useBlocker(busy || intent !== null || Boolean(name || purpose || prompt));
const blocker = useBlocker(busy || intent !== null || Boolean(name || purpose || prompt || avatar.icon));
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
useEffect(() => {
const warn = (event: BeforeUnloadEvent) => {
if (busy || intent || name || purpose || prompt) { event.preventDefault(); event.returnValue = ''; }
if (busy || intent || name || purpose || prompt || avatar.icon) { event.preventDefault(); event.returnValue = ''; }
};
window.addEventListener('beforeunload', warn);
return () => window.removeEventListener('beforeunload', warn);
}, [busy, intent, name, purpose, prompt]);
}, [busy, intent, name, purpose, prompt, avatar.icon]);
const submit = async () => {
if (busy || !name.trim() || !purpose.trim()) return;
const input = intent ?? { operation_id: crypto.randomUUID(), name: name.trim(), purpose: purpose.trim() };
const input = intent ?? { operation_id: crypto.randomUUID(), name: name.trim(), purpose: purpose.trim(), ...(avatar.icon ? { icon: avatar.icon } : {}) };
setIntent(input);
setBusy(true);
setError('');
@@ -210,6 +215,8 @@ function CreateAgentForm({ onCreated, onCancel }: { onCreated: (agent: CloudAgen
<div className="flex gap-2"><Button type="button" variant="outline" onClick={() => setStep(2)}>我自己想一个</Button>
<Button type="button" variant="ghost" onClick={onCancel}>取消</Button></div>
</> : <fieldset disabled={busy || intent !== null} className="space-y-4">
<CloudAvatarEditor value={avatar} name={name} disabled={busy || intent !== null} uploading={uploadingAvatar}
onUploading={setUploadingAvatar} onChange={setAvatar} />
<label className="block space-y-2 text-sm"><span>名称</span><Input autoFocus maxLength={100} value={name}
disabled={busy || intent !== null} onChange={(event) => setName(event.target.value)} placeholder="例如:我的写作搭档" /></label>
<label className="block space-y-2 text-sm"><span>用途</span><Textarea aria-label="用途" maxLength={2000} value={purpose}
@@ -218,7 +225,7 @@ function CreateAgentForm({ onCreated, onCancel }: { onCreated: (agent: CloudAgen
</fieldset>}
{error && <p role="alert" className="text-sm text-destructive">{error}。可重试确认本次创建结果。</p>}
{step === 2 && <div className="flex flex-wrap gap-3">
<Button type="submit" disabled={busy || !name.trim() || !purpose.trim()}>{busy ? '正在创建…' : intent ? '重试创建' : '下一步:试一试'}</Button>
<Button type="submit" disabled={busy || !name.trim() || !purpose.trim()}>{creatingAgent ? '正在创建…' : uploadingAvatar ? '等待头像上传…' : intent ? '重试创建' : '下一步:试一试'}</Button>
{!intent && <Button type="button" variant="outline" disabled={busy} onClick={() => setStep(1)}>上一步</Button>}
<Button type="button" variant="ghost" disabled={busy} onClick={onCancel}>{intent ? '返回列表确认' : '取消'}</Button>
</div>}
@@ -248,7 +255,7 @@ function SharedConversation({ shared, activity, recent, onBack }: {
}, [pending.hasPending]);
return <CloudPendingContext.Provider value={pending.report}><section className="agent-desktop p-6">
<Button variant="ghost" className="mb-5" onClick={() => pending.hasPending ? setLeaving(true) : onBack()}><ArrowLeft className="mr-2 h-4 w-4" />返回智能体</Button>
<h1 className="mb-5 text-xl font-semibold">{shared?.name ?? activity?.title}</h1>
<h1 className="mb-5 flex items-center gap-3 text-xl font-semibold">{shared && <CloudAvatar src={shared.icon_url} name={shared.name} />}{shared?.name ?? activity?.title}</h1>
<CloudChat key={shared?.slug ?? activity?.thread_id} slug={shared?.slug ?? activity!.agent_slug} initialThread={activity ?? undefined}
initialThreadId={recent?.slug === shared?.slug ? recent?.thread_id : undefined}
previewRevision={activity?.mode === 'preview' ? activity.draft_revision : undefined} />