Refine personal Agent cards and configuration

This commit is contained in:
鲨鱼辣椒 committed 2026-10-12 17:45:02 +08:00
1 parent c14d996c05
commit 8b40463a9f
17 files changed
+555 -412

No files matched your search

+1 -1
View File
@@ -78,7 +78,7 @@ export function MainLayout() {
{/* Title bar: drag region on macOS, icon + controls on Windows */}
<TitleBar
integrated
pageTitle={isCloudAgents ? '智能体工作区' : undefined}
pageTitle={isCloudAgents ? '我的智能体' : undefined}
workspaceLayout={isChatWorkspace}
overlay={isPaintingModule}
showSidebarControls={!isCanvasWorkspace}
+9 -11
View File
@@ -5,24 +5,22 @@ import { agentAvatarOptions } from '@/lib/agent-avatars';
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
import type { CloudAgentAvatar } from '../../../shared/cloud-agents';
function defaultAvatar(name: string) {
let seed = 0;
for (const character of name) seed = (seed * 31 + character.codePointAt(0)!) >>> 0;
return agentAvatarOptions[seed % agentAvatarOptions.length]!;
}
// The default portrait is the same before and after an assistant is created.
// Only an explicitly selected or uploaded avatar changes its appearance.
const defaultAvatar = agentAvatarOptions[0]!;
export function CloudAvatar({ src, name, className = 'h-12 w-12' }: {
src?: string | null; name: string; className?: string;
src?: string | null; name: string; identity?: 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)} />
: <img src={defaultAvatar(name).src} alt={`${name}默认头像`} className={style + ' object-cover'} style={{ imageRendering: 'pixelated' }} />;
: <img src={defaultAvatar.src} alt={`${name}默认头像`} className={style + ' object-cover'} style={{ imageRendering: 'pixelated' }} />;
}
export function CloudAvatarEditor({ value, name, disabled, uploading, onUploading, onChange }: {
value: CloudAgentAvatar; name: string; disabled: boolean; uploading: boolean;
export function CloudAvatarEditor({ value, name, identity, disabled, uploading, onUploading, onChange }: {
value: CloudAgentAvatar; name: string; identity?: string; disabled: boolean; uploading: boolean;
onUploading: (busy: boolean) => void; onChange: (avatar: CloudAgentAvatar) => void;
}) {
const [error, setError] = useState('');
@@ -47,10 +45,10 @@ export function CloudAvatarEditor({ value, name, disabled, uploading, onUploadin
if (alive.current) { setPendingPreset(null); onUploading(false); }
}
};
const selectedId = value.icon ? (selection?.icon === value.icon ? selection.id : null) : defaultAvatar(name || '智能体').id;
const selectedId = value.icon ? (selection?.icon === value.icon ? selection.id : null) : defaultAvatar.id;
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" />
<CloudAvatar src={value.icon_url} name={name || '智能体'} identity={identity} className="h-16 w-16" />
<div className="space-y-2">
<p className="text-sm font-medium">头像</p>
<div className="flex flex-wrap gap-2">
+8 -14
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useState } from 'react';
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -7,9 +7,8 @@ import { CloudResources } from './CloudResources';
import { CloudBudgetEditor } from './CloudCosts';
import type { CloudAgentConfiguration, CloudCatalog } from '../../../shared/cloud-agents';
export function ConfigurationFields({ slug, value, disabled, onChange, section = 'all', chooseInitialModel = false }: {
chooseInitialModel?: boolean;
section?: 'all' | 'instructions' | 'capabilities' | 'knowledge' | 'limits';
export function ConfigurationFields({ slug, value, disabled, onChange, section = 'all' }: {
section?: 'all' | 'instructions' | 'model' | 'capabilities' | 'knowledge' | 'limits';
slug: string; value: CloudAgentConfiguration; disabled: boolean; onChange: (value: CloudAgentConfiguration) => void;
}) {
const [catalog, setCatalog] = useState<CloudCatalog | null>(null);
@@ -17,30 +16,25 @@ export function ConfigurationFields({ slug, value, disabled, onChange, section =
const [refresh, setRefresh] = useState(0);
const [search, setSearch] = useState('');
const [choosing, setChoosing] = useState<string | null>(null);
const initialModelChosen = useRef(false);
useEffect(() => {
let live = true;
cloudAgentsApi.call('catalog', {}).then(v => { if (live) { setCatalog(v); setError(''); } })
.catch(e => { if (live) setError(e instanceof Error ? e.message : '配置目录暂不可用'); });
return () => { live = false; };
}, [refresh]);
useEffect(() => {
if (!chooseInitialModel || initialModelChosen.current || !catalog?.models.length || disabled) return;
initialModelChosen.current = true;
if (!value.model) onChange({ ...value, model: catalog.models[0].id });
}, [catalog, chooseInitialModel, disabled, onChange, value]);
const change = <K extends keyof CloudAgentConfiguration>(key: K, next: CloudAgentConfiguration[K]) => onChange({ ...value, [key]: next });
return <div className="space-y-6">
<div hidden={section === 'instructions' || section === 'limits'} className="flex items-center justify-between"><h2 className="font-medium">{section === 'knowledge' ? '知识库' : '能力'}</h2>
<div hidden={section === 'instructions' || section === 'limits' || section === 'model'} className="flex items-center justify-between"><h2 className="font-medium">{section === 'knowledge' ? '知识库' : '能力'}</h2>
<Button type="button" variant="ghost" disabled={disabled} onClick={() => setRefresh(v => v + 1)}>刷新目录</Button></div>
{error && <p role="alert" className="text-sm text-destructive">{error} <Button type="button" variant="outline" disabled={disabled} onClick={() => setRefresh(v => v + 1)}>重新读取模型</Button></p>}
<div hidden={section !== 'all' && section !== 'capabilities' && section !== 'instructions'} className="space-y-3"><label className="block space-y-2 text-sm"><span>模型</span>
<select aria-label="模型" className="h-10 w-full rounded-md border border-input bg-background px-3" disabled={disabled || !catalog}
{error && <p hidden={section === 'instructions'} role="alert" className="text-sm text-destructive">{error} <Button type="button" variant="outline" disabled={disabled} onClick={() => setRefresh(v => v + 1)}>重新读取模型</Button></p>}
<div hidden={section !== 'all' && section !== 'model'} className="space-y-3"><label className="block space-y-2 text-sm"><span>模型 <span className="text-xs text-primary">必选</span></span>
<select aria-label="模型" required className="h-10 w-full rounded-md border border-input bg-background px-3" disabled={disabled || !catalog}
value={value.model} onChange={e => change('model', e.target.value)}>
<option value="">选择可用模型</option>
{value.model && !catalog?.models.some(m => m.id === value.model) && <option value={value.model}>{value.model}(目录中不可用)</option>}
{catalog?.models.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
</select></label>
{!value.model && <p className="text-sm text-muted-foreground">先选一个模型,再开始使用。</p>}
{catalog && catalog.models.length === 0 && <p className="text-sm text-muted-foreground">暂时没有可用模型,请稍后刷新目录或请老师协助。<Button type="button" variant="ghost" disabled={disabled} onClick={() => setRefresh(v => v + 1)}>刷新目录</Button></p>}
</div>
{(['tools', 'knowledges', 'mcps', 'skills', 'subagents'] as const).map(key => {
+34 -35
View File
@@ -1,24 +1,23 @@
import { useEffect, useRef, useState } from 'react';
import { useBlocker } from 'react-router-dom';
import { ArrowLeft, Check, Loader2, Save } from 'lucide-react';
import { ArrowLeft, Check, ImagePlus, Loader2, Pencil, Save } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog';
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
import { EMPTY_CLOUD_CONFIGURATION, type CloudAgentDraft, type CloudRecent, type CloudThread, type CloudScheduleProposal } from '../../../shared/cloud-agents';
import { EMPTY_CLOUD_CONFIGURATION, type CloudAgentDraft, type CloudRecent } from '../../../shared/cloud-agents';
import { ConfigurationFields } from './ConfigurationFields';
import { CloudChat } from './CloudChat';
import { CloudChannelConversations } from './CloudChannelConversations';
import { CloudAccessPanel } from './CloudAccess';
import { CloudSchedules } from './CloudSchedules';
import { CloudPendingContext, useCloudPendingState } from './CloudPending';
import { AgentRequirements } from './AgentRequirements';
import { CloudAvatar, CloudAvatarEditor } from './CloudAvatar';
import { useCloudPublication } from './useCloudPublication';
type Tab = 'configuration' | 'chat' | 'tasks' | 'access';
type AdvancedCategory = 'capabilities' | 'knowledge' | 'limits';
type Tab = 'configuration' | 'chat' | 'access';
type AdvancedCategory = 'model' | 'capabilities' | 'knowledge' | 'limits';
const hasDraftChanges = (draft: CloudAgentDraft, saved: CloudAgentDraft) => draft.name !== saved.name || (draft.icon ?? null) !== (saved.icon ?? null) || draft.purpose !== saved.purpose || draft.system_prompt !== saved.system_prompt
|| JSON.stringify(draft.configuration) !== JSON.stringify(saved.configuration);
const errorMessage = (error: unknown) => error instanceof Error ? error.message : '暂时无法完成操作';
@@ -31,7 +30,7 @@ export function DraftEditor({ initial, recent, onBack, onSaved, startingPrompt,
const [saved, setSaved] = useState(initial);
const [draft, setDraft] = useState(() => startingPrompt === undefined ? initial : { ...initial, system_prompt: startingPrompt });
const [editorTab, setEditorTab] = useState('instructions');
const [advancedCategory, setAdvancedCategory] = useState<AdvancedCategory>('capabilities');
const [advancedCategory, setAdvancedCategory] = useState<AdvancedCategory>('model');
const [uploading, setUploading] = useState(false);
const [editingAvatar, setEditingAvatar] = useState(false);
const [remote, setRemote] = useState<CloudAgentDraft | null>(null);
@@ -42,15 +41,14 @@ export function DraftEditor({ initial, recent, onBack, onSaved, startingPrompt,
const [tab, setTab] = useState<Tab>(initialTab);
const [visited, setVisited] = useState<Set<Tab>>(() => new Set([initialTab]));
const category = editorTab === 'instructions' ? 'instructions' : advancedCategory;
const [openedThread, setOpenedThread] = useState<CloudThread>();
const [conversationSource, setConversationSource] = useState<'desktop' | 'wechat'>('desktop');
const [wechatVisited, setWechatVisited] = useState(false);
const [scheduleProposal, setScheduleProposal] = useState<CloudScheduleProposal>();
const pending = useCloudPendingState();
const formalPending = useCloudPendingState();
const alive = useRef(true);
const saving = useRef(false);
const configurationScroll = useRef<HTMLDivElement>(null);
const nameInput = useRef<HTMLInputElement>(null);
const dirty = hasDraftChanges(draft, saved);
const navigate = (next: Tab) => { setVisited(current => new Set([...current, next])); setTab(next); };
const publication = useCloudPublication(saved.slug, saved.draft_revision, result => {
@@ -62,7 +60,7 @@ export function DraftEditor({ initial, recent, onBack, onSaved, startingPrompt,
});
const busy = savingDraft || publication.busy || uploading;
const hasPending = pending.hasPending || formalPending.hasPending || Boolean(publication.pending);
const canSave = dirty && !busy && !remote && Boolean(draft.name.trim() && draft.purpose.trim());
const canSave = dirty && !busy && !remote && Boolean(draft.name.trim());
const needsApply = Boolean(saved.published_version) && publication.appliedRevision !== undefined
&& publication.appliedRevision !== saved.draft_revision;
const applicationStatus = publication.busy ? '正在应用…' : publication.pending ? '应用结果待确认'
@@ -118,13 +116,17 @@ 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>
<Button variant="ghost" className="h-12 w-12 shrink-0 p-0" aria-label="修改头像" disabled={busy} onClick={() => setEditingAvatar(true)}>
<CloudAvatar src={draft.icon_url} name={draft.name} />
<Button variant="ghost" className="relative h-12 w-12 shrink-0 p-0" aria-label="修改头像" title="修改头像" disabled={busy} onClick={() => setEditingAvatar(true)}>
<CloudAvatar src={draft.icon_url} name={draft.name} identity={saved.slug} />
<span className="absolute -bottom-1 -right-1 rounded-full border-2 border-background bg-primary p-1 text-white"><ImagePlus aria-hidden="true" className="h-3 w-3" /></span>
</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"
<div className="agent-workspace-identity min-w-0 flex-1">
<div className="flex max-w-96 items-center gap-1">
<Input ref={nameInput} aria-label="名称" maxLength={100} value={draft.name} disabled={busy}
className="h-8 min-w-0 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 })} />
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 shrink-0 text-muted-foreground" aria-label="修改名称" title="修改名称" disabled={busy} onClick={() => nameInput.current?.focus()}><Pencil aria-hidden="true" className="h-4 w-4" /></Button>
</div>
<p className="px-2 text-xs text-muted-foreground">{saved.published_version ? '我的智能体' : '配置智能体'}</p>
</div>
<span role="status" className={`shrink-0 text-xs ${needsApply || dirty ? 'text-amber-800' : 'text-muted-foreground'}`}>{savingDraft ? '正在保存…' : dirty ? '有未保存修改' : saved.published_version ? applicationStatus : '云端已保存'}</span>
@@ -132,19 +134,21 @@ export function DraftEditor({ initial, recent, onBack, onSaved, startingPrompt,
{savingDraft ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : dirty ? <Save className="mr-2 h-4 w-4" /> : <Check className="mr-2 h-4 w-4" />}
{savingDraft ? '保存中…' : dirty ? '保存草稿' : '已保存'}
</Button>
{saved.published_version && <Button className="min-h-10 shrink-0" disabled={busy || dirty || remote !== null || !publication.access || (!publication.pending && publication.appliedRevision === saved.draft_revision)}
{saved.published_version && <Button className="min-h-10 shrink-0" disabled={busy || dirty || remote !== null || !saved.configuration.model || !publication.access || (!publication.pending && publication.appliedRevision === saved.draft_revision)}
title={dirty ? '请先保存草稿,再应用修改' : '将已保存的修改应用到正在使用的助手'}
onClick={() => void publication.publish()}>
{publication.busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{publication.busy ? '正在应用…' : publication.pending ? '重试本次应用' : !dirty && publication.appliedRevision === saved.draft_revision ? '已应用' : '应用最新修改'}
</Button>}
{!saved.published_version && <Button className="min-h-10" variant="outline" disabled={busy} onClick={() => navigate('access')}>开始使用</Button>}
{!saved.published_version && <Button className="min-h-10" variant="outline" disabled={busy} onClick={() => {
if (!draft.configuration.model) { setEditorTab('advanced'); setAdvancedCategory('model'); setError('请先在高级设置中选择模型。'); return; }
setError(''); navigate('access');
}}>开始使用</Button>}
</header>
<nav aria-label="智能体工作区" className="agent-workspace-nav">
{([['configuration', '配置'], ['chat', '聊天'], ['tasks', '自动任务']] as const).map(([key, label]) =>
<Button key={key} variant={tab === key ? 'secondary' : 'ghost'} aria-current={tab === key ? 'page' : undefined} onClick={() => navigate(key)}>{label}</Button>)}
<Button className="ml-auto" variant={tab === 'access' ? 'secondary' : 'ghost'} aria-current={tab === 'access' ? 'page' : undefined} onClick={() => navigate('access')}>使用与接入</Button>
</nav>
{tab !== 'configuration' && <nav aria-label="智能体操作" className="agent-workspace-nav"><Button variant="ghost" onClick={() => {
if (formalPending.hasPending) { setError('对话中还有未完成的内容,请先发送或处理后再调整智能体。'); return; }
setError(''); navigate('configuration');
}}><Pencil aria-hidden="true" className="mr-2 h-4 w-4" />调整智能体</Button></nav>}
{saved.published_version && (needsApply || dirty) && <div className="shrink-0 border-b border-amber-200 bg-amber-50 px-6 py-3 text-sm text-amber-900">
<p className="font-medium">{dirty ? '修改尚未保存和应用' : '这次修改还没有应用到正在使用的助手'}</p>
<p className="mt-1 text-xs leading-5">保存只更新草稿。请点击顶部“应用最新修改”,再到微信等渠道使用。已开始的任务保持原配置。</p>
@@ -164,20 +168,22 @@ export function DraftEditor({ initial, recent, onBack, onSaved, startingPrompt,
}} className="flex min-h-0 flex-1 flex-col">
<div className="agent-config-tabs">
<TabsList aria-label="编辑设置" className="h-auto w-full justify-start">
<TabsTrigger value="instructions" className="flex-1 data-[state=active]:text-primary">模型与提示词</TabsTrigger>
<TabsTrigger value="instructions" className="flex-1 data-[state=active]:text-primary">介绍与提示词</TabsTrigger>
<TabsTrigger value="advanced" className="flex-1 data-[state=active]:text-primary">高级设置</TabsTrigger>
</TabsList>
</div>
{/* Keep the shared form mounted so switching tabs preserves unfinished resource and budget edits. */}
<TabsContent value={editorTab} className="m-0 flex min-h-0 flex-1 flex-col">
{editorTab === 'advanced' && <nav aria-label="配置分类" className="agent-config-tabs pt-2">{([['capabilities', '能力'], ['knowledge', '知识'], ['limits', '限制']] as const).map(([key, label]) =>
{editorTab === 'advanced' && <nav aria-label="配置分类" className="agent-config-tabs pt-2">{([['model', '模型'], ['capabilities', '能力'], ['knowledge', '知识'], ['limits', '限制']] as const).map(([key, label]) =>
<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="agent-configuration-form">
<ConfigurationFields slug={saved.slug} section={category} chooseInitialModel={startingPrompt !== undefined} value={draft.configuration ?? EMPTY_CLOUD_CONFIGURATION} disabled={busy}
onChange={configuration => setDraft({ ...draft, configuration })} />
<div hidden={category !== 'instructions'} className="mt-6">
<div hidden={category !== 'instructions'} className="space-y-6">
<label className="block space-y-2 text-sm"><span>一句话介绍</span><Input aria-label="一句话介绍" maxLength={2000} value={draft.purpose}
placeholder="简单介绍一下它,可以不填" onChange={event => setDraft({ ...draft, purpose: event.target.value })} /></label>
<AgentRequirements value={draft.system_prompt} onChange={system_prompt => setDraft({ ...draft, system_prompt })} />
</div>
<ConfigurationFields slug={saved.slug} section={category} value={draft.configuration ?? EMPTY_CLOUD_CONFIGURATION} disabled={busy}
onChange={configuration => setDraft({ ...draft, configuration })} />
</fieldset></div>
</TabsContent>
</Tabs>}
@@ -189,18 +195,11 @@ export function DraftEditor({ initial, recent, onBack, onSaved, startingPrompt,
<Button variant={conversationSource === 'wechat' ? 'secondary' : 'ghost'} disabled={formalPending.hasPending && conversationSource !== 'wechat'} onClick={() => { setWechatVisited(true); setConversationSource('wechat'); }}>微信对话</Button>
</nav>
<div hidden={conversationSource !== 'desktop'} className="flex min-h-0 flex-1 flex-col">
<CloudChat key={openedThread?.thread_id ?? 'published'} slug={saved.slug} initialThread={openedThread} activeView={tab === 'chat' && conversationSource === 'desktop'}
onSchedule={proposal => { setScheduleProposal(proposal); navigate('tasks'); }} /></div>
<CloudChat key="published" slug={saved.slug} activeView={tab === 'chat' && conversationSource === 'desktop'} /></div>
<div hidden={conversationSource !== 'wechat'} className="min-h-0 flex-1 overflow-auto">{wechatVisited && <CloudChannelConversations slug={saved.slug} activeView={tab === 'chat' && conversationSource === 'wechat'} />}</div>
</CloudPendingContext.Provider>
: <div className="m-auto space-y-4 text-center text-sm"><p>保存配置后,开始使用这个智能体。</p><Button onClick={() => navigate('access')}>开始使用</Button></div>)}
</div>
<div hidden={tab !== 'tasks'} className="agent-management-view">
{visited.has('tasks') && <CloudSchedules slug={saved.slug} proposal={scheduleProposal} onProposalClosed={() => setScheduleProposal(undefined)} onOpen={thread => {
if (formalPending.hasPending) { setError('正式对话还有未完成的输入,请先处理后再打开任务对话。'); navigate('chat'); return; }
setOpenedThread(thread); setConversationSource('desktop'); navigate('chat');
}} />}
</div>
<div hidden={tab !== 'access'} className="agent-management-view">
{dirty && <div className="mb-4 space-y-3 text-sm"><p>先保存配置,再开始使用。</p><Button disabled={!canSave} onClick={() => void save()}>保存修改</Button></div>}
<fieldset disabled={dirty || busy || remote !== null}>
@@ -209,7 +208,7 @@ export function DraftEditor({ initial, recent, onBack, onSaved, startingPrompt,
</div>
<Dialog open={editingAvatar} onOpenChange={open => { if (!uploading) setEditingAvatar(open); }}>
<DialogContent><DialogTitle>修改头像</DialogTitle><DialogDescription>选择这个智能体的头像。</DialogDescription>
<CloudAvatarEditor value={draft} name={draft.name} disabled={savingDraft || publication.busy} uploading={uploading}
<CloudAvatarEditor value={draft} name={draft.name} identity={saved.slug} disabled={savingDraft || publication.busy} uploading={uploading}
onUploading={setUploading} onChange={avatar => setDraft(current => ({ ...current, ...avatar }))} />
<Button disabled={uploading} onClick={() => setEditingAvatar(false)}>完成</Button>
</DialogContent>
+22 -18
View File
@@ -10,14 +10,20 @@
.agent-home-header { display: flex; align-items: center; justify-content: space-between; gap: 24px; margin-bottom: 36px; }
.agent-home-intro { min-width: 0; }
.agent-home-intro h1 { margin: 0; font-size: 30px; font-weight: 700; line-height: 1.3; letter-spacing: -.5px; }
.agent-home-intro p { margin-top: 12px; font-size: 15px; line-height: 1.8; color: #606c7e; }
.agent-home-intro-break { display: none; }
.agent-home-greeting { position: relative; display: flex; align-items: center; gap: 14px; padding-block: 12px; }
.agent-home-greeting h1 { font-size: clamp(24px, 2.4vw, 32px); line-height: 1.7; letter-spacing: -.6px; }
.agent-home-hello { display: inline-block; margin-right: 5px; color: #7263a6; transform: rotate(-6deg); }
.agent-home-create-word, .agent-home-chat-word { position: relative; isolation: isolate; display: inline-block; padding-inline: 4px; white-space: nowrap; }
.agent-home-create-word::before, .agent-home-chat-word::before { position: absolute; z-index: -1; inset: 12px 0 4px; border-radius: 8px; transform: rotate(-3deg); background: #ffe7bb; content: ''; }
.agent-home-chat-word::before { background: #e3eee8; transform: rotate(3deg); }
.agent-home-greeting-spark { display: grid; flex-shrink: 0; width: 42px; height: 42px; place-items: center; border-radius: 14px; background: #f0eafc; color: #8a72ba; transform: rotate(-8deg); }
.agent-home-greeting-spark svg { width: 27px; height: 27px; }
.agent-home-greeting-doodle { flex-shrink: 0; width: 38px; height: 38px; color: #729785; animation: agent-home-doodle-bob 5s ease-in-out infinite; }
@keyframes agent-home-doodle-bob { 0%, 100% { transform: translateY(0) rotate(5deg); } 50% { transform: translateY(-3px) rotate(-3deg); } }
.agent-home-header-actions { display: flex; flex-shrink: 0; align-items: center; gap: 8px; }
.agent-home button { min-height: 44px; }
.agent-home-create { gap: 8px; min-height: 48px !important; padding-inline: 20px; border-radius: 14px; font-size: 15px; box-shadow: 0 3px 0 #af340d; }
.agent-home-create svg { width: 19px; height: 19px; }
.agent-home-more { gap: 5px; padding-inline: 10px; color: #606c7e; font-weight: 500; }
.agent-home-more svg { width: 15px; height: 15px; }
.agent-home-back { gap: 6px; margin: -12px 0 16px -12px; color: #606c7e; }
.agent-home-back svg { width: 17px; height: 17px; }
.agent-home-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 292px), 1fr)); gap: 20px; }
@@ -37,11 +43,15 @@
.agent-home-card[data-state="inactive"] .agent-home-status::before { background: #778294; }
.agent-home-card h2 { overflow: hidden; font-size: 21px; font-weight: 650; line-height: 1.5; text-overflow: ellipsis; white-space: nowrap; }
.agent-home-purpose { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; min-height: 78px; overflow: hidden; margin-top: 8px; font-size: 15px; line-height: 26px; color: #606c7e; white-space: pre-wrap; overflow-wrap: anywhere; }
.agent-home-card-actions { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: auto; padding-top: 20px; }
.agent-home-card-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: auto; padding-top: 20px; }
.agent-home-card-primary { gap: 7px; border-radius: 12px; font-size: 14px; }
.agent-home-card-primary svg { width: 17px; height: 17px; }
.agent-home-card-primary.border { border-color: #c6ccda; background: #ffffffb3; color: #35435b; }
.agent-home-adjust { padding-inline: 12px; border-radius: 12px; color: #546176; }
.agent-home-adjust { padding-inline: 9px; border-radius: 12px; color: #546176; }
.agent-home-delete { gap: 5px; margin-left: auto; padding-inline: 8px; border-radius: 12px; color: #836b73; }
.agent-home-delete svg { width: 16px; height: 16px; }
.agent-home-delete:hover { color: #ac3d49; background: #fce9e9; }
.agent-home-delete-dialog { border-radius: 24px; }
.agent-home-empty { display: flex; min-height: 410px; align-items: center; justify-content: center; flex-direction: column; padding: 48px 24px; border: 1px solid #e7e3f1; border-radius: 28px; background: #f7f5fd; text-align: center; }
.agent-home-empty-icon { display: grid; width: 96px; height: 96px; place-items: center; margin-bottom: 24px; border-radius: 30px; background: #e8e5fa; color: #7161a5; }
.agent-home-empty-icon svg { width: 52px; height: 52px; }
@@ -49,31 +59,25 @@
.agent-home-empty p { margin: 12px 0 28px; font-size: 16px; line-height: 1.9; color: #606c7e; }
.agent-home-loading { display: flex; min-height: 280px; align-items: center; justify-content: center; flex-direction: column; gap: 16px; color: #606c7e; }
.agent-home-loading svg { width: 38px; height: 38px; color: #8273a9; }
.agent-home-error, .agent-home-filter { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 24px; padding: 12px 16px; border: 1px solid #e3dfd6; border-radius: 14px; background: #fff; font-size: 14px; }
.agent-home-error { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 24px; padding: 12px 16px; border: 1px solid #e3dfd6; border-radius: 14px; background: #fff; font-size: 14px; }
.agent-home-error p { overflow-wrap: anywhere; }
.agent-home-filter { background: #f0eee9; border: 0; color: #606c7e; }
.agent-home-filter span { display: flex; align-items: center; gap: 8px; }
.agent-home-filter svg { width: 17px; height: 17px; flex-shrink: 0; }
.agent-home-menu { z-index: 60; min-width: 226px; padding: 6px; border: 1px solid #e5e4e0; border-radius: 14px; background: #fffdf9; box-shadow: 0 8px 28px #2732461a; color: #35435b; }
.agent-home-menu-item { display: flex; min-height: 44px; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 8px; outline: none; font-size: 14px; cursor: pointer; user-select: none; }
.agent-home-menu-item[data-highlighted] { background: #f0eef9; }
.agent-home-menu-item svg { width: 17px; height: 17px; flex-shrink: 0; color: #716785; }
.agent-home-menu-separator { height: 1px; margin: 5px 8px; background: #e9e6e0; }
.agent-home-menu-check { margin-left: auto; }
@media (max-width: 1150px) {
.agent-home { padding: 32px 28px 40px; }
.agent-home-intro-break { display: block; }
}
@media (max-width: 760px) {
.agent-home { padding: 24px 20px 32px; }
.agent-home-header { align-items: flex-start; flex-wrap: wrap; gap: 18px; margin-bottom: 28px; }
.agent-home-intro h1 { font-size: 27px; }
.agent-home-greeting { gap: 10px; }
.agent-home-greeting h1 { font-size: 26px; }
.agent-home-greeting-doodle { display: none; }
.agent-home-header-actions { flex-wrap: wrap; }
.agent-home-card { padding: 22px; }
.agent-home-empty { min-height: 360px; padding-inline: 20px; }
.agent-home-empty h2 { font-size: 22px; }
.agent-home-filter, .agent-home-error { align-items: flex-start; flex-wrap: wrap; }
.agent-home-error { align-items: flex-start; flex-wrap: wrap; }
}
@media (prefers-reduced-motion: reduce) {
.agent-home-card { transition: none; }
.agent-home-greeting-doodle { animation: none; }
}
+2 -1
View File
@@ -1,7 +1,8 @@
.agent-desktop { display: flex; flex: 1; flex-direction: column; height: 100%; min-height: 0; min-width: 0; overflow: hidden; }
.agent-desktop > aside { max-height: 25%; min-height: 0; overflow: auto; }
.agent-desktop [hidden] { display: none !important; }
.agent-workspace-header { display: flex; flex-shrink: 0; align-items: center; gap: 12px; min-height: 64px; padding: 8px 20px; border-bottom: 1px solid hsl(var(--border)); }
.agent-workspace-header { display: flex; flex-wrap: wrap; flex-shrink: 0; align-items: center; gap: 12px; min-height: 64px; padding: 8px 20px; border-bottom: 1px solid hsl(var(--border)); }
.agent-workspace-identity { min-width: min(160px, 100%); }
.agent-workspace-nav { display: flex; flex-shrink: 0; align-items: center; gap: 4px; padding: 6px 20px; border-bottom: 1px solid hsl(var(--border)); }
.agent-workspace-view { display: flex; flex: 1; flex-direction: column; min-height: 0; min-width: 0; overflow: hidden; }
.agent-management-view { flex: 1; min-height: 0; overflow: auto; padding: 24px; }
+76 -32
View File
@@ -1,9 +1,9 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useBlocker, useLocation } from 'react-router-dom';
import { Archive, ArrowLeft, Bot, Check, ChevronDown, History, MessageCircle, Pencil, Plus, Share2 } from 'lucide-react';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { ArrowLeft, Bot, MessageCircle, Pencil, Plus, Sparkles, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
import { useAuthStore } from '@/stores/auth';
import { type CloudAgentAvatar, type CloudAgentDraft, type CreateCloudAgent, type CloudAgentEntry, type CloudThread, type CloudRecent, type CloudPendingOperation } from '../../../shared/cloud-agents';
@@ -36,7 +36,14 @@ function AgentWorkspace() {
const [activity, setActivity] = useState<CloudThread | null>(null);
const [recent, setRecent] = useState<CloudRecent | null>(null);
const [pending, setPending] = useState<CloudPendingOperation[]>([]);
const [archived, setArchived] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<CloudAgentDraft | null>(null);
const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState('');
const cancelDelete = useRef<HTMLButtonElement>(null);
const deleteTrigger = useRef<HTMLButtonElement | null>(null);
const createButton = useRef<HTMLButtonElement>(null);
const deletion = useRef<string | null>(null);
const deletedSlugs = useRef(new Set<string>());
const mounted = useRef(true);
const request = useRef(0);
@@ -45,16 +52,17 @@ function AgentWorkspace() {
setLoading(true);
setError('');
try {
const page = await cloudAgentsApi.list(next, archived);
const page = await cloudAgentsApi.list(next);
if (!mounted.current || generation !== request.current) return;
setAgents((previous) => next ? [...previous, ...page.agents] : page.agents);
const visible = page.agents.filter(agent => !agent.archived && !deletedSlugs.current.has(agent.slug));
setAgents((previous) => next ? [...previous, ...visible] : visible);
setCursor(page.next_cursor);
} catch (failure) {
if (mounted.current && generation === request.current) setError(errorMessage(failure));
} finally {
if (mounted.current && generation === request.current) setLoading(false);
}
}, [archived]);
}, []);
useEffect(() => {
mounted.current = true;
@@ -83,6 +91,12 @@ function AgentWorkspace() {
try { draft = await cloudAgentsApi.get(pointer.slug); }
catch { entry = await cloudAgentsApi.call('entry', { slug: pointer.slug }); }
if (!live) return;
// A deleted/archived Agent must not reopen through the last-workspace pointer.
if (draft?.archived || deletedSlugs.current.has(pointer.slug)) {
void cloudAgentsApi.remember(null).catch(() => undefined);
return;
}
if (deletion.current === pointer.slug) return;
setRecent(pointer);
if (draft) setSelected(draft); else setShared(entry);
}).catch(failure => { if (live) setError('上次工作区未能恢复:' + errorMessage(failure)); });
@@ -112,6 +126,33 @@ function AgentWorkspace() {
setEntryView(view);
setSelected(agent);
};
const removeAgent = async () => {
if (!deleteTarget || deletion.current || !mounted.current) return;
const slug = deleteTarget.slug;
deletion.current = slug;
setDeleting(true);
setDeleteError('');
try {
await cloudAgentsApi.call('archiveAgent', { slug, archived: true });
if (!mounted.current) return;
deletedSlugs.current.add(slug);
// Invalidate an older list request so it cannot put the card back.
request.current += 1;
setLoading(false);
setAgents(previous => previous.filter(agent => agent.slug !== slug));
setDeleteTarget(null);
const state = await cloudAgentsApi.recovery();
if (mounted.current && state.recent?.slug === slug) {
await cloudAgentsApi.remember(null);
}
} catch (failure) {
// Once archived successfully, a failed pointer cleanup must not undo removal.
if (mounted.current && !deletedSlugs.current.has(slug)) setDeleteError(errorMessage(failure));
} finally {
deletion.current = null;
if (mounted.current) setDeleting(false);
}
};
if (selected) return <div className="agent-desktop">{recovery}<DraftEditor key={selected.slug} initial={selected} entryView={entryView} startingPrompt={startingPrompt} recent={recent} onBack={() => {
setSelected(null);
@@ -133,40 +174,28 @@ function AgentWorkspace() {
<header className="agent-home-header">
<div className="agent-home-intro">
{section !== 'mine' && <Button variant="ghost" className="agent-home-back" onClick={() => setSection('mine')}><ArrowLeft aria-hidden="true" />返回我的智能体</Button>}
<h1>{section === 'mine' ? '我的智能体' : section === 'received' ? '收到的分享' : '活动'}</h1>
{section === 'mine' && <p>创建自己的 AI 助手,告诉它怎样帮助你。<br className="agent-home-intro-break" />也可以继续和已有的助手聊天。</p>}
{section !== 'mine' && <h1>{section === 'received' ? '收到的分享' : '活动'}</h1>}
{section === 'mine' && <div className="agent-home-greeting">
<span className="agent-home-greeting-spark" aria-hidden="true"><Sparkles strokeWidth={1.7} /></span>
<h1 aria-label="Hi,今天想创造还是想聊聊?"><span className="agent-home-hello">Hi,</span>今天想<span className="agent-home-create-word">创造</span>还是想<span className="agent-home-chat-word">聊聊</span>?</h1>
<svg className="agent-home-greeting-doodle" aria-hidden="true" viewBox="0 0 40 40" fill="none"><path d="M6 19C10 9 24 8 31 17C38 26 28 34 18 31L10 35L12 28C7 26 5 23 6 19Z" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" /><path d="M14 20H14.1M24 20H24.1M16 25C18 27 21 27 23 25" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" /></svg>
</div>}
</div>
<div className="agent-home-header-actions">
{(section !== 'mine' || loading || error || agents.length > 0 || archived) &&
<Button className="agent-home-create" onClick={() => { setEntryView(undefined); setCreating(true); }}><Plus aria-hidden="true" />创建智能体</Button>}
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<Button variant="ghost" className="agent-home-more" aria-label="更多">更多<ChevronDown aria-hidden="true" /></Button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content className="agent-home-menu" align="end" sideOffset={8}>
<DropdownMenu.Item className="agent-home-menu-item" onSelect={() => setSection('received')}><Share2 aria-hidden="true" />收到的分享</DropdownMenu.Item>
<DropdownMenu.Item className="agent-home-menu-item" onSelect={() => setSection('activity')}><History aria-hidden="true" />活动</DropdownMenu.Item>
<DropdownMenu.Separator className="agent-home-menu-separator" />
<DropdownMenu.CheckboxItem className="agent-home-menu-item" checked={archived} onCheckedChange={value => { setArchived(value); setSection('mine'); }}>
<Archive aria-hidden="true" />查看已归档智能体<DropdownMenu.ItemIndicator className="agent-home-menu-check"><Check aria-hidden="true" /></DropdownMenu.ItemIndicator>
</DropdownMenu.CheckboxItem>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
{(section !== 'mine' || loading || error || agents.length > 0) &&
<Button ref={createButton} className="agent-home-create" onClick={() => { setEntryView(undefined); setCreating(true); }}><Plus aria-hidden="true" />创建智能体</Button>}
</div>
</header>
{error && <div role="alert" className="agent-home-error">
<p>{error}</p><Button variant="outline" onClick={() => void load()}>重新连接</Button>
</div>}
{section !== 'mine' ? <CloudOverview key={section} section={section} onAgent={setShared} onThread={setActivity} /> : <>
{archived && <div className="agent-home-filter"><span><Archive aria-hidden="true" />正在显示全部智能体,包含已归档</span><Button variant="ghost" onClick={() => setArchived(false)}>收起已归档</Button></div>}
{loading && agents.length === 0 ? <div role="status" className="agent-home-loading"><Bot aria-hidden="true" /><p>正在找回你的智能体…</p></div>
: !error && agents.length === 0 ? <div className="agent-home-empty">
<div className="agent-home-empty-icon"><Bot aria-hidden="true" strokeWidth={1.6} /></div>
<h2>做一个属于你的 AI 助手吧</h2>
<p>给它起个名字、选好头像,<br />再设置它怎样帮助你。</p>
<Button className="agent-home-create" onClick={() => { setEntryView(undefined); setCreating(true); }}><Plus aria-hidden="true" />创建我的第一个智能体</Button>
<Button ref={createButton} className="agent-home-create" onClick={() => { setEntryView(undefined); setCreating(true); }}><Plus aria-hidden="true" />创建我的第一个智能体</Button>
</div> : null}
<div className="agent-home-grid">
{agents.map(agent => {
@@ -175,16 +204,17 @@ function AgentWorkspace() {
const unavailable = Boolean(agent.archived || !agent.enabled);
return <article key={agent.slug} aria-label={agent.name} className="agent-home-card" data-tone={appearance % 3} data-state={unavailable ? 'inactive' : ready ? 'ready' : 'draft'}>
<div className="agent-home-card-top">
<div className="agent-home-avatar"><CloudAvatar src={agent.icon_url} name={agent.name} className="h-full w-full" /></div>
<div className="agent-home-avatar"><CloudAvatar src={agent.icon_url} name={agent.name} identity={agent.slug} className="h-full w-full" /></div>
<span className="agent-home-status">{agent.archived ? '已归档' : !agent.enabled ? '已停用' : ready ? '可以使用' : '还在制作'}</span>
</div>
<h2 title={agent.name}>{agent.name}</h2>
<p className="agent-home-purpose">{agent.purpose}</p>
{agent.purpose.trim() && <p className="agent-home-purpose">{agent.purpose}</p>}
<div className="agent-home-card-actions">
<Button variant={ready ? 'default' : 'outline'} className="agent-home-card-primary" onClick={() => openAgent(agent, ready ? 'chat' : 'configuration')}>
{ready ? <MessageCircle aria-hidden="true" /> : <Pencil aria-hidden="true" />}{ready ? '和它聊聊' : unavailable ? '查看与调整' : '继续制作'}
</Button>
{ready && <Button variant="ghost" className="agent-home-adjust" onClick={() => openAgent(agent, 'configuration')}>调整</Button>}
<Button variant="ghost" className="agent-home-delete" aria-label={`删除${agent.name}`} disabled={deleting} onClick={event => { deleteTrigger.current = event.currentTarget; setDeleteTarget(agent); setDeleteError(''); }}><Trash2 aria-hidden="true" />删除</Button>
</div>
</article>;
})}
@@ -192,6 +222,21 @@ function AgentWorkspace() {
{cursor && <Button className="mt-6" variant="outline" disabled={loading} onClick={() => void load(cursor)}>{loading ? '正在读取…' : '加载更多'}</Button>}
</>}
</div>
<Dialog open={deleteTarget !== null} onOpenChange={open => { if (!open && !deletion.current) { setDeleteTarget(null); setDeleteError(''); } }}>
<DialogContent className="agent-home-delete-dialog max-w-md" onOpenAutoFocus={event => { event.preventDefault(); cancelDelete.current?.focus(); }}
onCloseAutoFocus={event => { event.preventDefault(); (deleteTrigger.current?.isConnected ? deleteTrigger.current : createButton.current)?.focus(); }}
onEscapeKeyDown={event => { if (deletion.current) event.preventDefault(); }} onInteractOutside={event => event.preventDefault()}>
<DialogHeader>
<DialogTitle>删除「{deleteTarget?.name}」?</DialogTitle>
<DialogDescription>它会从「我的智能体」中移除,并停止后续使用。已有的聊天记录会保留。</DialogDescription>
</DialogHeader>
{deleteError && <p role="alert" className="text-sm text-destructive">{deleteError}。智能体仍在列表中,你可以重试。</p>}
<DialogFooter>
<Button ref={cancelDelete} variant="outline" disabled={deleting} onClick={() => { setDeleteTarget(null); setDeleteError(''); }}>取消</Button>
<Button variant="destructive" disabled={deleting} onClick={() => void removeAgent()}>{deleting ? '正在删除…' : deleteError ? '重试删除' : '删除'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
);
}
@@ -217,8 +262,7 @@ function CreateAgentForm({ onCreated, onCancel }: { onCreated: (agent: CloudAgen
const submit = async () => {
if (busy || uploading || !name.trim()) return;
// The existing service requires a purpose; actual instructions are configured on the next page.
const input = intent ?? { operation_id: crypto.randomUUID(), name: name.trim(), purpose: '按照我的提示词提供帮助。', icon: avatar.icon ?? null };
const input = intent ?? { operation_id: crypto.randomUUID(), name: name.trim(), purpose: '', icon: avatar.icon ?? null };
setIntent(input);
setBusy(true);
setError('');
@@ -271,7 +315,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 flex items-center gap-3 text-xl font-semibold">{shared && <CloudAvatar src={shared.icon_url} name={shared.name} />}{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} identity={shared.slug} />}{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} />