refactor: split AgentEditor into module files; remove redundant local images

main
sp mac bookpro 2605 2026-06-01 13:06:32 +08:00
parent 6b8ccf0af2
commit 3cf6855dfe
25 changed files with 1191 additions and 1039 deletions

1
.gitignore vendored
View File

@ -5,3 +5,4 @@ dist
.env .env
.env.local .env.local
tsconfig.tsbuildinfo tsconfig.tsbuildinfo
.vscode

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,66 @@
import { Modal, Button, Upload } from 'antd';
import { UploadOutlined } from '@ant-design/icons';
import { Agent, AgentAPI } from '../../../api';
import { DEFAULT_AVATAR, PRESET_AVATARS } from '../constants';
interface AvatarSelectorProps {
open: boolean;
onCancel: () => void;
agent: Agent | null;
avatarUploading: boolean;
beforeUploadEditAvatar: (file: any) => Promise<boolean>;
onAvatarChange: () => Promise<void>;
}
export default function AvatarSelector({
open,
onCancel,
agent,
avatarUploading,
beforeUploadEditAvatar,
onAvatarChange,
}: AvatarSelectorProps) {
return (
<Modal title="选择头像形象" open={open} onCancel={onCancel} footer={null} width={520} centered>
<div className="py-4">
<div className="flex items-center justify-between gap-3 mb-4">
<div className="text-[11px] font-bold text-gray-400 uppercase tracking-widest">
</div>
<Upload accept="image/png,image/jpeg,image/webp,image/gif" showUploadList={false} beforeUpload={beforeUploadEditAvatar}>
<Button icon={<UploadOutlined />} loading={avatarUploading} style={{ borderRadius: 10 }}>
</Button>
</Upload>
</div>
<div className="grid grid-cols-6 gap-3 bg-gray-50 p-4 rounded-2xl max-h-[400px] overflow-y-auto monica-scrollbar">
{[DEFAULT_AVATAR, ...PRESET_AVATARS].map((url) => (
<div
key={url}
onClick={async () => {
if (!agent?.id) return;
await AgentAPI.update(agent.id, { avatar: url });
onCancel();
await onAvatarChange();
}}
className={`relative aspect-square rounded-full cursor-pointer transition-all duration-300 overflow-hidden border-2 ${agent?.avatar === url ? 'scale-110 shadow-lg z-10' : 'border-transparent opacity-70 hover:opacity-100 hover:scale-105'}`}
style={{ borderColor: agent?.avatar === url ? 'var(--color-brand)' : 'transparent' }}
>
<img src={url} className="w-full h-full object-cover" alt="preset" />
{agent?.avatar === url && (
<div
className="absolute inset-0 flex items-center justify-center"
style={{ background: 'rgba(8, 145, 178, 0.10)' }}
>
<div className="bg-white rounded-full p-0.5 shadow-sm">
<div className="w-2 h-2 rounded-full" style={{ background: 'var(--color-brand)' }} />
</div>
</div>
)}
</div>
))}
</div>
</div>
</Modal>
);
}

View File

@ -0,0 +1,287 @@
import { Form, Input, Select, Collapse, Button, List, Popconfirm, Tag, Switch, InputNumber } from 'antd';
import { DatabaseOutlined, RocketOutlined, SettingOutlined, ToolOutlined } from '@ant-design/icons';
import { Agent, Team, AgentAPI } from '../../../api';
import { STATUS_TAG, isImageUrl, parseModelSelections } from '../constants';
import ModelCheckboxDropdown from './ModelCheckboxDropdown';
interface CapabilitySettingsProps {
form: any;
agent: Agent | null;
teams: Team[];
models: any[];
currentName: string;
agentName: string;
selectedAvatar: string;
setAvatarSelectorOpen: (open: boolean) => void;
beforeUploadKnowledge: (file: any) => Promise<boolean>;
markDirty: () => void;
}
export default function CapabilitySettings({
form,
agent,
teams,
models,
currentName,
agentName,
selectedAvatar,
setAvatarSelectorOpen,
beforeUploadKnowledge,
markDirty,
}: CapabilitySettingsProps) {
return (
<div className="flex-1 agent-editor-pane">
<div className="agent-editor-pane-body">
<div className="agent-editor-pane-header">
<div>
<h3 className="agent-editor-pane-title"></h3>
<p className="agent-editor-pane-subtitle">
</p>
</div>
<span className="agent-editor-badge">
<ToolOutlined />
Capability
</span>
</div>
<Form form={form} layout="vertical" onValuesChange={markDirty}>
<div className="agent-editor-intro" style={{ marginBottom: 18 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<div
className="avatar-container"
style={{
width: 72,
height: 72,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 24,
color: 'white',
fontWeight: 'bold',
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
cursor: 'pointer',
overflow: 'hidden',
position: 'relative',
background: isImageUrl(agent?.avatar || selectedAvatar) ? '#f3f4f6' : agent?.avatar || selectedAvatar,
border: '3px solid rgba(255,255,255,0.92)',
}}
onClick={() => setAvatarSelectorOpen(true)}
>
{isImageUrl(agent?.avatar || selectedAvatar) ? (
<img src={agent?.avatar || selectedAvatar} style={{ width: '100%', height: '100%', objectFit: 'cover' }} alt="avatar" />
) : (
(agentName?.charAt(0) || '?').toUpperCase()
)}
<div className="avatar-overlay">
<span className="avatar-overlay-text"></span>
</div>
</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 700, color: 'var(--color-text)', marginBottom: 4 }}>
{currentName}
</div>
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', marginBottom: 8 }}>
{agent?.description || '补充基础资料后,这里会更像一个完整可运营的产品角色。'}
</div>
<Button size="small" type="default" className="rounded-lg h-8" onClick={() => setAvatarSelectorOpen(true)}>
</Button>
</div>
</div>
</div>
<Collapse
ghost
expandIconPosition="end"
className="monica-collapse-bordered mb-4"
defaultActiveKey={['basic']}
items={[
{
key: 'basic',
label: (
<div className="flex items-center gap-2 font-medium text-gray-700" style={{ color: 'var(--color-text)' }}>
<SettingOutlined style={{ color: 'var(--color-brand)' }} />
</div>
),
children: (
<div className="space-y-4">
<div className="flex items-center gap-4 mb-2">
<div className="flex flex-col gap-1">
<span className="text-[11px] text-gray-400"></span>
</div>
</div>
<Form.Item name="name" label="名称" rules={[{ required: true }]} className="mb-3">
<Input placeholder="智能体名称" style={{ borderRadius: 12, height: 42 }} />
</Form.Item>
<Form.Item name="description" label="描述" className="mb-3">
<Input.TextArea rows={3} placeholder="简短描述这个智能体是做什么的..." style={{ borderRadius: 12 }} />
</Form.Item>
<div className="flex gap-2">
<Form.Item name="visibility" label="可见性" className="flex-1 mb-0">
<Select
size="middle"
style={{ minHeight: 42 }}
options={[
{ value: 'private', label: '🔒 私有' },
{ value: 'team', label: '👥 团队' },
{ value: 'public', label: '🌐 公开' },
]}
/>
</Form.Item>
<Form.Item name="teamId" label="归属团队" className="flex-1 mb-0" tooltip="团队功能暂未开放">
<Select
size="middle"
placeholder="暂不开放"
disabled
allowClear
style={{ minHeight: 42 }}
options={teams.map((t) => ({ value: t.id, label: t.name }))}
/>
</Form.Item>
</div>
</div>
),
},
]}
/>
<div className="monica-card !mb-4" style={{ borderRadius: 18 }}>
<div className="flex items-center gap-2 mb-4 font-medium text-gray-700">
<SettingOutlined style={{ color: 'var(--color-brand)' }} />
</div>
<div className="space-y-4">
<Form.Item
name="model"
label="模型"
className="mb-0"
getValueProps={(value) => ({ value: parseModelSelections(value) })}
normalize={(value) =>
Array.isArray(value) ? value.map((item) => String(item).trim()).filter(Boolean).join(', ') : ''
}
>
<ModelCheckboxDropdown models={models} />
</Form.Item>
<Form.Item name="temperature" label="Temperature" className="mb-0" hidden>
<div className="flex items-center gap-4">
<InputNumber min={0} max={2} step={0.1} className="w-20" style={{ borderRadius: 12, height: 42 }} />
<span className="text-[11px] text-gray-400"></span>
</div>
</Form.Item>
</div>
</div>
<Collapse
ghost
expandIconPosition="end"
className="monica-collapse"
items={[
{
key: 'knowledge',
label: (
<div className="flex items-center gap-2 font-medium text-gray-700" style={{ color: 'var(--color-text)' }}>
<DatabaseOutlined style={{ color: 'var(--color-brand)' }} />
({agent?.knowledge?.length ?? 0})
</div>
),
children: (
<div className="px-1">
<div className="flex justify-between items-center mb-4">
<span className="text-xs text-gray-500"> AI </span>
<Input
type="file"
multiple
style={{ display: 'none' }}
id="knowledge-upload"
onChange={async (e) => {
const files = e.target.files;
if (files) {
for (let i = 0; i < files.length; i++) {
await beforeUploadKnowledge(files[i]);
}
}
}}
/>
<Button
type="primary"
size="small"
ghost
icon={<DatabaseOutlined />}
style={{ borderRadius: 10 }}
onClick={() => document.getElementById('knowledge-upload')?.click()}
>
</Button>
</div>
<List
size="small"
dataSource={agent?.knowledge ?? []}
renderItem={(item) => (
<List.Item
className="bg-white mb-2 rounded-lg border border-gray-100 p-2"
actions={[
<Popconfirm
key="del"
title="确认删除?"
onConfirm={() => agent && AgentAPI.deleteKnowledge(agent.id, item.id)}
>
<Button type="text" danger size="small" style={{ borderRadius: 8 }}>
</Button>
</Popconfirm>,
]}
>
<div className="flex flex-col gap-1 overflow-hidden">
<span className="text-sm font-medium truncate">{item.originalName}</span>
<span className="text-[10px] text-gray-400">
{(item.size / 1024).toFixed(1)} KB ·{' '}
<Tag color={STATUS_TAG[item.status].color} className="m-0 text-[10px] px-1">
{STATUS_TAG[item.status].text}
</Tag>
</span>
</div>
</List.Item>
)}
/>
</div>
),
},
{
key: 'skills',
collapsible: 'disabled',
label: (
<div
className="flex items-center gap-2 font-medium text-gray-400 cursor-not-allowed"
title="技能功能开发中"
>
<ToolOutlined />
& ()
</div>
),
children: null,
},
]}
/>
<div className="monica-card mt-6" style={{ borderRadius: 18 }}>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2 font-medium text-gray-700" style={{ color: 'var(--color-text)' }}>
<RocketOutlined style={{ color: 'var(--color-brand)' }} />
</div>
<Form.Item name="webSearchEnabled" valuePropName="checked" className="mb-0">
<Switch size="small" />
</Form.Item>
</div>
<div className="text-[11px] text-gray-400">
DuckDuckGo
</div>
</div>
</Form>
</div>
</div>
);
}

View File

@ -0,0 +1,61 @@
import { Button } from 'antd';
import { ArrowLeftOutlined, FileTextOutlined, SaveOutlined, RocketOutlined } from '@ant-design/icons';
interface HeaderProps {
isNew: boolean;
currentName: string;
navigate: any;
autoSaveStatus: 'saved' | 'dirty' | 'saving' | 'error';
saving: boolean;
onSave: () => Promise<void>;
}
export default function Header({ isNew, currentName, navigate, autoSaveStatus, saving, onSave }: HeaderProps) {
return (
<header className="monica-header agent-editor-header">
<div className="flex items-center gap-4">
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/agents')}
className="hover:bg-gray-100"
style={{ borderRadius: 12, width: 40, height: 40 }}
/>
<div>
<div className="flex items-center gap-2">
<RocketOutlined style={{ color: 'var(--color-brand)', fontSize: 18 }} />
<span className="font-bold text-lg text-gray-800" style={{ color: 'var(--color-text)' }}>
{isNew ? '创建新智能体' : currentName}
</span>
</div>
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', marginTop: 2 }}>
AI
</div>
</div>
</div>
<div className="flex items-center gap-4">
<span className="text-xs text-gray-400" style={{ color: 'var(--color-text-tertiary)' }}>
{autoSaveStatus === 'saving'
? '正在保存...'
: autoSaveStatus === 'dirty'
? '有未保存更改'
: autoSaveStatus === 'error'
? '保存失败'
: '已保存'}
</span>
<Button icon={<FileTextOutlined />} style={{ borderRadius: 12, height: 40 }}>
</Button>
<Button
type="primary"
icon={<SaveOutlined />}
loading={saving}
onClick={onSave}
style={{ borderRadius: 12, height: 40, paddingInline: 16, fontWeight: 600 }}
>
</Button>
</div>
</header>
);
}

View File

@ -0,0 +1,231 @@
import { Form, Input, Modal, Button, Upload } from 'antd';
import { RocketOutlined, UploadOutlined } from '@ant-design/icons';
import { DEFAULT_AVATAR, PRESET_AVATARS, isImageUrl } from '../constants';
interface InitModalProps {
open: boolean;
onCancel: () => void;
onConfirm: (values: any) => Promise<void>;
avatarUploading: boolean;
selectedAvatar: string;
setSelectedAvatar: (url: string) => void;
agentName: string;
setAgentName: (name: string) => void;
saving: boolean;
beforeUploadInitAvatar: (file: any) => Promise<boolean>;
}
export default function InitModal({
open,
onCancel,
onConfirm,
avatarUploading,
selectedAvatar,
setSelectedAvatar,
agentName,
setAgentName,
saving,
beforeUploadInitAvatar,
}: InitModalProps) {
const [initForm] = Form.useForm();
return (
<Modal
title={null}
open={open}
onCancel={onCancel}
footer={null}
width={720}
centered
maskClosable={false}
destroyOnHidden
>
<div className="py-2">
<div style={{ marginBottom: 18 }}>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 8,
padding: '6px 12px',
borderRadius: 999,
background: 'rgba(8, 145, 178, 0.08)',
color: 'var(--color-brand)',
fontSize: 12,
fontWeight: 600,
marginBottom: 14,
}}
>
<RocketOutlined />
·
</div>
<div style={{ fontSize: 28, fontWeight: 700, color: 'var(--color-text)', marginBottom: 8, letterSpacing: '-0.02em' }}>
</div>
<div style={{ fontSize: 14.5, color: 'var(--color-text-secondary)', lineHeight: 1.75 }}>
</div>
</div>
<div className="agent-editor-modal-hero">
<div
className="agent-editor-modal-card"
style={{
padding: '22px 20px',
background: 'linear-gradient(180deg, rgba(236,253,245,0.96) 0%, rgba(240,249,255,0.96) 100%)',
}}
>
<div
className="rounded-full flex items-center justify-center text-3xl text-white font-bold shadow-xl relative transition-all duration-500 overflow-hidden ring-4 ring-white mx-auto"
style={{
width: 88,
height: 88,
background: isImageUrl(selectedAvatar) ? '#f3f4f6' : selectedAvatar,
}}
>
{isImageUrl(selectedAvatar) ? (
<img src={selectedAvatar} className="w-full h-full object-cover" alt="avatar" />
) : (
(agentName?.charAt(0) || '?').toUpperCase()
)}
</div>
<div style={{ textAlign: 'center', marginTop: 16 }}>
<div style={{ fontSize: 17, fontWeight: 700, color: 'var(--color-text)', marginBottom: 4 }}>
{agentName || '你的新智能体'}
</div>
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', lineHeight: 1.7 }}>
</div>
</div>
</div>
<Form
form={initForm}
layout="vertical"
onFinish={onConfirm}
onValuesChange={(changed) => {
if (changed.name !== undefined) setAgentName(changed.name);
}}
className="agent-editor-modal-card"
style={{ padding: 20 }}
>
<Form.Item
name="name"
label={
<span className="text-gray-500 font-medium" style={{ color: 'var(--color-text-secondary)' }}>
</span>
}
rules={[{ required: true, message: '请输入智能体名称' }]}
>
<Input
placeholder="给你的智能体起个名字"
size="large"
autoFocus
className="rounded-xl h-12 border-gray-200 focus:border-cyan-500"
/>
</Form.Item>
<Form.Item
name="description"
label={
<span className="text-gray-500 font-medium" style={{ color: 'var(--color-text-secondary)' }}>
</span>
}
>
<Input.TextArea
placeholder="介绍一下这个智能体是做什么的..."
rows={3}
className="rounded-xl border-gray-200 focus:border-cyan-500"
/>
</Form.Item>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginBottom: 16 }}>
{['客服助理', '内容创作', '数据分析', '私人教练'].map((label) => (
<span
key={label}
onClick={() => initForm.setFieldsValue({ description: label })}
style={{
padding: '6px 10px',
borderRadius: 999,
background: 'var(--color-surface-2)',
color: 'var(--color-text-secondary)',
fontSize: 12.5,
cursor: 'pointer',
transition: 'all 0.2s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--color-border)';
e.currentTarget.style.color = 'var(--color-text)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--color-surface-2)';
e.currentTarget.style.color = 'var(--color-text-secondary)';
}}
>
{label}
</span>
))}
</div>
<div className="flex gap-4 pt-2">
<Button
onClick={onCancel}
className="flex-1 h-12 rounded-xl border-gray-200 text-gray-500 font-semibold hover:bg-gray-50"
>
</Button>
<Button
type="primary"
htmlType="submit"
loading={saving}
className="flex-1 h-12 rounded-xl border-none font-semibold"
>
</Button>
</div>
</Form>
</div>
<div>
<div className="text-[11px] font-bold text-gray-400 uppercase tracking-widest mb-3 px-1">
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12, gap: 12 }}>
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)' }}>
使
</div>
<Upload accept="image/png,image/jpeg,image/webp,image/gif" showUploadList={false} beforeUpload={beforeUploadInitAvatar}>
<Button icon={<UploadOutlined />} loading={avatarUploading} style={{ borderRadius: 10 }}>
</Button>
</Upload>
</div>
<div className="agent-editor-avatar-grid monica-scrollbar">
{[DEFAULT_AVATAR, ...PRESET_AVATARS].map((url) => (
<div
key={url}
onClick={() => setSelectedAvatar(url)}
className={`relative aspect-square rounded-full cursor-pointer transition-all duration-300 overflow-hidden border-2 ${selectedAvatar === url ? 'scale-110 shadow-lg z-10' : 'border-transparent opacity-70 hover:opacity-100 hover:scale-105'}`}
style={{ borderColor: selectedAvatar === url ? 'var(--color-brand)' : 'transparent' }}
>
<img src={url} className="w-full h-full object-cover" alt="preset" />
{selectedAvatar === url && (
<div
className="absolute inset-0 flex items-center justify-center"
style={{ background: 'rgba(8, 145, 178, 0.10)' }}
>
<div className="bg-white rounded-full p-0.5 shadow-sm">
<div className="w-2 h-2 rounded-full" style={{ background: 'var(--color-brand)' }} />
</div>
</div>
)}
</div>
))}
</div>
</div>
</div>
</Modal>
);
}

View File

@ -0,0 +1,64 @@
import { useState } from 'react';
import { Button, Checkbox, Dropdown } from 'antd';
import { DownOutlined } from '@ant-design/icons';
import { AiModel } from '../../../api';
import { DEFAULT_RH_40X40_GRAY } from '../../../constants';
interface ModelCheckboxDropdownProps {
value?: string[];
onChange?: (value: string[]) => void;
models: AiModel[];
}
export default function ModelCheckboxDropdown({ value = [], onChange, models }: ModelCheckboxDropdownProps) {
const [open, setOpen] = useState(false);
const summary = value.length ? `${value.length} 个已选` : '选择模型';
return (
<Dropdown
trigger={['click']}
open={open}
onOpenChange={setOpen}
popupRender={() => (
<div className="agent-model-dropdown-panel" onClick={(e) => e.stopPropagation()}>
<Checkbox.Group
value={value}
onChange={(checked) => onChange?.(checked.map((item) => String(item)))}
className="agent-model-checkbox-group"
>
{models.map((m) => {
const inputPrice = 2 * m.model_ratio;
const outputPrice = inputPrice * m.completion_ratio;
return (
<Checkbox key={m.model_name} value={m.model_name} className="agent-model-checkbox-item">
<div className="agent-model-checkbox-content">
<div className="agent-model-checkbox-meta">
<img
src={m.icon || DEFAULT_RH_40X40_GRAY}
alt={m.model_name}
className="agent-model-checkbox-icon"
/>
<span className="agent-model-checkbox-name">{m.model_name}</span>
</div>
<div className="agent-model-checkbox-price">
<span>: ${inputPrice.toFixed(2)}/M</span>
<span>: ${outputPrice.toFixed(2)}/M</span>
</div>
</div>
</Checkbox>
);
})}
</Checkbox.Group>
</div>
)}
>
<Button type="text" block className="agent-model-dropdown-trigger">
<span className="agent-model-dropdown-summary">{summary}</span>
<span className="agent-model-dropdown-values">
{value.length ? value.join(', ') : '未选择'}
</span>
<DownOutlined className="agent-model-dropdown-arrow" />
</Button>
</Dropdown>
);
}

View File

@ -0,0 +1,38 @@
import { RocketOutlined } from '@ant-design/icons';
import { Agent } from '../../../api';
import ChatPreview from '../../../components/ChatPreview';
interface PreviewPaneProps {
liveAgent: Agent;
agentId?: string;
}
export default function PreviewPane({ liveAgent, agentId }: PreviewPaneProps) {
return (
<div className="flex-1 h-full agent-editor-pane">
<div className="agent-editor-pane-body agent-editor-preview-shell">
<div className="agent-editor-pane-header">
<div>
<h3 className="agent-editor-pane-title"></h3>
<p className="agent-editor-pane-subtitle">
</p>
</div>
<span className="agent-editor-badge">
<RocketOutlined />
Live Preview
</span>
</div>
<div className="agent-editor-intro" style={{ marginBottom: 14 }}>
<div className="agent-editor-intro-title"></div>
<div className="agent-editor-intro-text">
{liveAgent?.name || '未命名智能体'} Prompt
</div>
</div>
<div className="agent-editor-surface" style={{ flex: 1, overflow: 'hidden' }}>
<ChatPreview agent={liveAgent} agentId={agentId} />
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,60 @@
import { Form, Input } from 'antd';
import { FileTextOutlined } from '@ant-design/icons';
interface PromptEditorProps {
form: any;
markDirty: () => void;
}
export default function PromptEditor({ form, markDirty }: PromptEditorProps) {
return (
<div className="flex-1 agent-editor-pane">
<div className="agent-editor-pane-body">
<div className="agent-editor-pane-header">
<div>
<h3 className="agent-editor-pane-title"></h3>
<p className="agent-editor-pane-subtitle">
</p>
</div>
<span className="agent-editor-badge">
<FileTextOutlined />
System Prompt
</span>
</div>
<div className="agent-editor-intro">
<div className="agent-editor-intro-title"></div>
<div className="agent-editor-intro-text">
</div>
</div>
<Form
form={form}
layout="vertical"
onValuesChange={markDirty}
>
<div className="agent-editor-surface agent-editor-prompt-wrap">
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', marginBottom: 10, paddingInline: 4 }}>
</div>
<div className="agent-editor-prompt">
<Form.Item name="prompt" noStyle>
<Input.TextArea
rows={30}
placeholder="在这里输入智能体的人设、技能、风格和输出规范..."
className="border-none focus:ring-0 bg-transparent p-4 font-mono text-sm leading-relaxed"
style={{ height: 'calc(100vh - 290px)', resize: 'none', borderRadius: 16 }}
/>
</Form.Item>
</div>
</div>
<div className="text-[11px] text-gray-400 mt-2 px-2" style={{ color: 'var(--color-text-tertiary)' }}>
</div>
</Form>
</div>
</div>
);
}

View File

@ -0,0 +1,41 @@
import { KnowledgeStatus, SkillType } from '../../api';
export const DEFAULT_AVATAR = 'https://static.svipdata.com/hoyidata/materials/B7lNeTYQM1_0/materials.jpg';
export const PRESET_AVATARS: string[] = [
'https://static.svipdata.com/hoyidata/materials/PkM0iQCaAY_0/materials.png',
'https://static.svipdata.com/hoyidata/materials/tmPDm2FhJY_1/materials.png',
'https://static.svipdata.com/hoyidata/materials/hNCyP4RvJL_2/materials.png',
'https://static.svipdata.com/hoyidata/materials/l2OMyoIVff_3/materials.png',
'https://static.svipdata.com/hoyidata/materials/CINYRp0JJS_4/materials.png',
'https://static.svipdata.com/hoyidata/materials/oUFVVgRpJa_5/materials.png',
'https://static.svipdata.com/hoyidata/materials/WZccHWHK40_6/materials.png',
'https://static.svipdata.com/hoyidata/materials/sLmZKx5GoI_7/materials.png',
'https://static.svipdata.com/hoyidata/materials/ZPHkYQHOE0_8/materials.png',
'https://static.svipdata.com/hoyidata/materials/kpMcS2ekGq_9/materials.png',
'https://static.svipdata.com/hoyidata/materials/1vkwcmV5MC_10/materials.png',
'https://static.svipdata.com/hoyidata/materials/nLLWLA0eLZ_11/materials.png',
];
export const STATUS_TAG: Record<KnowledgeStatus, { color: string; text: string }> = {
pending: { color: 'default', text: '待处理' },
indexing: { color: 'processing', text: '索引中…' },
ready: { color: 'success', text: '已就绪' },
failed: { color: 'error', text: '失败' },
};
export const TYPE_TAG: Record<SkillType, { color: string; icon: string; label: string }> = {
prompt: { color: 'blue', icon: '📝', label: 'Prompt' },
http: { color: 'green', icon: '🌐', label: 'HTTP' },
js: { color: 'volcano', icon: '⚙️', label: 'JS' },
};
export const isImageUrl = (url: string) => url?.startsWith('http') || url?.startsWith('/');
export const parseModelSelections = (value?: string | string[]) =>
Array.isArray(value)
? value
: String(value || '')
.split(',')
.map((item) => item.trim())
.filter(Boolean);

View File

@ -0,0 +1,218 @@
import { useEffect, useRef, useState } from 'react';
import { FormInstance } from 'antd';
import { Agent, AgentAPI, Team, TeamAPI, AiModel, ModelAPI, ImageAPI } from '../../../api';
import { DEFAULT_AVATAR } from '../constants';
interface UseAgentEditorOptions {
id?: string;
isNew: boolean;
form: FormInstance;
message: any;
navigate: any;
}
export function useAgentEditor({ id, isNew, form, message, navigate }: UseAgentEditorOptions) {
const [agent, setAgent] = useState<Agent | null>(null);
const [saving, setSaving] = useState(false);
const [teams, setTeams] = useState<Team[]>([]);
const [models, setModels] = useState<AiModel[]>([]);
const [autoSaveStatus, setAutoSaveStatus] = useState<'saved' | 'dirty' | 'saving' | 'error'>('saved');
const [initModalOpen, setInitModalOpen] = useState(isNew);
const [selectedAvatar, setSelectedAvatar] = useState(DEFAULT_AVATAR);
const [avatarSelectorOpen, setAvatarSelectorOpen] = useState(false);
const [avatarUploading, setAvatarUploading] = useState(false);
const [agentName, setAgentName] = useState('');
const [skillEditorOpen, setSkillEditorOpen] = useState(false);
const [editingSkillId, setEditingSkillId] = useState<string | null>(null);
const pollTimer = useRef<number | null>(null);
const hydratingRef = useRef(false);
const refresh = async (force = false) => {
if (!id) return;
const data = await AgentAPI.detail(id);
setAgent(data);
if (force || autoSaveStatus !== 'dirty') {
hydratingRef.current = true;
form.setFieldsValue(data);
window.setTimeout(() => {
hydratingRef.current = false;
}, 0);
setAutoSaveStatus('saved');
setAgentName(data.name);
setSelectedAvatar(data.avatar || DEFAULT_AVATAR);
}
const indexing = data.knowledge?.some((k) => k.status === 'pending' || k.status === 'indexing');
if (indexing && !pollTimer.current) {
pollTimer.current = window.setInterval(refresh, 2000);
} else if (!indexing && pollTimer.current) {
window.clearInterval(pollTimer.current);
pollTimer.current = null;
}
};
useEffect(() => {
TeamAPI.list()
.then(setTeams)
.catch(() => setTeams([]));
ModelAPI.list()
.then(setModels)
.catch(() => setModels([]));
if (isNew) {
setInitModalOpen(true);
setSelectedAvatar(DEFAULT_AVATAR);
setAgentName('');
form.setFieldsValue({
name: '',
description: '',
prompt: 'You are a helpful AI assistant.',
model: '',
temperature: 0.7,
visibility: 'private',
teamId: null,
});
} else {
setInitModalOpen(false);
refresh(true);
}
return () => {
if (pollTimer.current) {
window.clearInterval(pollTimer.current);
pollTimer.current = null;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
const handleInitConfirm = async (values: any) => {
setSaving(true);
try {
const created = await AgentAPI.create({
...values,
avatar: selectedAvatar,
prompt: 'You are a helpful AI assistant.',
temperature: 0.7,
visibility: 'private',
});
message.success('初始化成功');
setInitModalOpen(false);
navigate(`/agents/${created.id}`, { replace: true });
} catch (e) {
message.error('创建失败');
} finally {
setSaving(false);
}
};
const handleSave = async (silent = false) => {
if (isNew) return;
const values = await form.validateFields();
if (!silent) setSaving(true);
setAutoSaveStatus('saving');
try {
await AgentAPI.update(id!, values);
if (!silent) message.success('已保存');
await refresh(true);
setAutoSaveStatus('saved');
} catch (e) {
setAutoSaveStatus('error');
if (!silent) message.error('保存失败');
} finally {
if (!silent) setSaving(false);
}
};
const uploadAvatar = async (file: File) => {
setAvatarUploading(true);
try {
const res = await ImageAPI.upload([file]);
const url = res.files?.[0]?.url;
if (!url) {
throw new Error('未获取到图片地址');
}
return url;
} finally {
setAvatarUploading(false);
}
};
const beforeUploadKnowledge = async (file: any) => {
if (!id) {
message.warning('请先保存智能体基础信息后再上传');
return false;
}
try {
await AgentAPI.uploadKnowledge(id, [file as File]);
message.success(`${file.name} 已上传,正在建索引…`);
refresh();
} catch (e: any) {
message.error('上传失败:' + (e?.message ?? e));
}
return false;
};
const beforeUploadInitAvatar = async (file: any) => {
try {
const url = await uploadAvatar(file as File);
setSelectedAvatar(url);
message.success('头像上传成功');
} catch (e: any) {
message.error('头像上传失败:' + (e?.message ?? e));
}
return false;
};
const beforeUploadEditAvatar = async (file: any) => {
if (!id) return false;
try {
const url = await uploadAvatar(file as File);
await AgentAPI.update(id, { avatar: url });
message.success('头像已更新');
setAvatarSelectorOpen(false);
refresh();
} catch (e: any) {
message.error('头像上传失败:' + (e?.message ?? e));
}
return false;
};
const liveAgent = { ...(agent ?? {}), ...(form.getFieldsValue() as Agent) } as Agent;
const currentName = liveAgent?.name || agentName || '未命名智能体';
const markDirty = () => {
if (hydratingRef.current) return;
setAutoSaveStatus('dirty');
};
return {
agent,
saving,
teams,
models,
autoSaveStatus,
initModalOpen,
setInitModalOpen,
selectedAvatar,
setSelectedAvatar,
avatarSelectorOpen,
setAvatarSelectorOpen,
avatarUploading,
agentName,
setAgentName,
skillEditorOpen,
setSkillEditorOpen,
editingSkillId,
setEditingSkillId,
hydratingRef,
refresh,
handleInitConfirm,
handleSave,
beforeUploadKnowledge,
beforeUploadInitAvatar,
beforeUploadEditAvatar,
liveAgent,
currentName,
markDirty,
};
}

View File

@ -0,0 +1,124 @@
import { useEffect, useRef, useState } from 'react';
import { Form } from 'antd';
import { useNavigate, useParams } from 'react-router-dom';
import { App as AntApp } from 'antd';
import SkillEditor from '../../components/SkillEditor';
import { useAgentEditor } from './hooks/useAgentEditor';
import Header from './components/Header';
import PromptEditor from './components/PromptEditor';
import CapabilitySettings from './components/CapabilitySettings';
import PreviewPane from './components/PreviewPane';
import InitModal from './components/InitModal';
import AvatarSelector from './components/AvatarSelector';
export default function AgentEditor() {
const { id } = useParams();
const isNew = !id;
const navigate = useNavigate();
const { message } = AntApp.useApp();
const [form] = Form.useForm();
const pollTimer = useRef<number | null>(null);
const {
agent,
saving,
teams,
models,
autoSaveStatus,
initModalOpen,
setInitModalOpen,
selectedAvatar,
setSelectedAvatar,
avatarSelectorOpen,
setAvatarSelectorOpen,
avatarUploading,
agentName,
setAgentName,
skillEditorOpen,
setSkillEditorOpen,
editingSkillId,
setEditingSkillId,
refresh,
handleInitConfirm,
handleSave,
beforeUploadKnowledge,
beforeUploadInitAvatar,
beforeUploadEditAvatar,
liveAgent,
currentName,
markDirty,
} = useAgentEditor({ id, isNew, form, message, navigate });
useEffect(() => {
return () => {
if (pollTimer.current) {
window.clearInterval(pollTimer.current);
pollTimer.current = null;
}
};
}, []);
return (
<>
<div className="fixed inset-0 flex flex-col bg-white z-100 agent-editor-shell" style={{ background: 'var(--color-bg)' }}>
<Header
isNew={isNew}
currentName={currentName}
navigate={navigate}
autoSaveStatus={autoSaveStatus}
saving={saving}
onSave={handleSave}
/>
<div className="flex-1 overflow-hidden agent-editor-workbench" style={{ background: 'var(--color-bg)' }}>
<PromptEditor form={form} markDirty={markDirty} />
<CapabilitySettings
form={form}
agent={agent}
teams={teams}
models={models}
currentName={currentName}
agentName={agentName}
selectedAvatar={agent?.avatar || selectedAvatar}
setAvatarSelectorOpen={setAvatarSelectorOpen}
beforeUploadKnowledge={beforeUploadKnowledge}
markDirty={markDirty}
/>
<PreviewPane liveAgent={liveAgent} agentId={id} />
</div>
{!isNew && (
<SkillEditor
open={skillEditorOpen}
agentId={id!}
skillId={editingSkillId}
onClose={() => setSkillEditorOpen(false)}
onSaved={refresh}
/>
)}
</div>
<InitModal
open={initModalOpen}
onCancel={() => navigate('/agents')}
onConfirm={handleInitConfirm}
avatarUploading={avatarUploading}
selectedAvatar={selectedAvatar}
setSelectedAvatar={setSelectedAvatar}
agentName={agentName}
setAgentName={setAgentName}
saving={saving}
beforeUploadInitAvatar={beforeUploadInitAvatar}
/>
<AvatarSelector
open={avatarSelectorOpen}
onCancel={() => setAvatarSelectorOpen(false)}
agent={agent}
avatarUploading={avatarUploading}
beforeUploadEditAvatar={beforeUploadEditAvatar}
onAvatarChange={refresh}
/>
</>
);
}