diff --git a/.gitignore b/.gitignore index bb18b22..a5954e1 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ dist .env .env.local tsconfig.tsbuildinfo +.vscode diff --git a/public/avatars/avatar-01.png b/public/avatars/avatar-01.png deleted file mode 100644 index 811b3ab..0000000 Binary files a/public/avatars/avatar-01.png and /dev/null differ diff --git a/public/avatars/avatar-02.png b/public/avatars/avatar-02.png deleted file mode 100644 index 90ab5c6..0000000 Binary files a/public/avatars/avatar-02.png and /dev/null differ diff --git a/public/avatars/avatar-03.png b/public/avatars/avatar-03.png deleted file mode 100644 index 6030539..0000000 Binary files a/public/avatars/avatar-03.png and /dev/null differ diff --git a/public/avatars/avatar-04.png b/public/avatars/avatar-04.png deleted file mode 100644 index 1e7e764..0000000 Binary files a/public/avatars/avatar-04.png and /dev/null differ diff --git a/public/avatars/avatar-05.png b/public/avatars/avatar-05.png deleted file mode 100644 index cfed182..0000000 Binary files a/public/avatars/avatar-05.png and /dev/null differ diff --git a/public/avatars/avatar-06.png b/public/avatars/avatar-06.png deleted file mode 100644 index a53ea55..0000000 Binary files a/public/avatars/avatar-06.png and /dev/null differ diff --git a/public/avatars/avatar-07.png b/public/avatars/avatar-07.png deleted file mode 100644 index 7350004..0000000 Binary files a/public/avatars/avatar-07.png and /dev/null differ diff --git a/public/avatars/avatar-08.png b/public/avatars/avatar-08.png deleted file mode 100644 index 75d7e13..0000000 Binary files a/public/avatars/avatar-08.png and /dev/null differ diff --git a/public/avatars/avatar-09.png b/public/avatars/avatar-09.png deleted file mode 100644 index bf23796..0000000 Binary files a/public/avatars/avatar-09.png and /dev/null differ diff --git a/public/avatars/avatar-10.png b/public/avatars/avatar-10.png deleted file mode 100644 index 34fd5f6..0000000 Binary files a/public/avatars/avatar-10.png and /dev/null differ diff --git a/public/avatars/avatar-11.png b/public/avatars/avatar-11.png deleted file mode 100644 index 6080535..0000000 Binary files a/public/avatars/avatar-11.png and /dev/null differ diff --git a/public/avatars/avatar-12.png b/public/avatars/avatar-12.png deleted file mode 100644 index 9ec21d6..0000000 Binary files a/public/avatars/avatar-12.png and /dev/null differ diff --git a/public/default_bot_icon.jpg b/public/default_bot_icon.jpg deleted file mode 100644 index a5b8b29..0000000 Binary files a/public/default_bot_icon.jpg and /dev/null differ diff --git a/src/pages/AgentEditor.tsx b/src/pages/AgentEditor.tsx deleted file mode 100644 index 49508c0..0000000 --- a/src/pages/AgentEditor.tsx +++ /dev/null @@ -1,1039 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { Button, Form, Input, InputNumber, Modal, Upload, App as AntApp, List, Popconfirm, Tag, Switch, Select, Collapse, Checkbox, Dropdown } from 'antd'; -import type { UploadFile } from 'antd/es/upload/interface'; -import { useNavigate, useParams } from 'react-router-dom'; -import { Agent, AgentAPI, ImageAPI, KnowledgeStatus, SkillType, Team, TeamAPI, AiModel, ModelAPI } from '../api'; -import { DEFAULT_RH_40X40_GRAY } from '../constants'; -import SkillEditor from '../components/SkillEditor'; -import McpPanel from '../components/McpPanel'; -import ChatPreview from '../components/ChatPreview'; -import { ArrowLeftOutlined, SaveOutlined, FileTextOutlined, RocketOutlined, ToolOutlined, DatabaseOutlined, SettingOutlined, UploadOutlined, DownOutlined } from '@ant-design/icons'; - -const STATUS_TAG: Record = { - pending: { color: 'default', text: '待处理' }, - indexing: { color: 'processing', text: '索引中…' }, - ready: { color: 'success', text: '已就绪' }, - failed: { color: 'error', text: '失败' }, -}; - -const TYPE_TAG: Record = { - prompt: { color: 'blue', icon: '📝', label: 'Prompt' }, - http: { color: 'green', icon: '🌐', label: 'HTTP' }, - js: { color: 'volcano', icon: '⚙️', label: 'JS' }, -}; - -const DEFAULT_AVATAR = '/default_bot_icon.jpg'; - -const PRESET_AVATARS: string[] = Array.from({ length: 12 }, (_, index) => `/avatars/avatar-${String(index + 1).padStart(2, '0')}.png`); - -const isImageUrl = (url: string) => url?.startsWith('http') || url?.startsWith('/'); -const parseModelSelections = (value?: string | string[]) => - Array.isArray(value) - ? value - : String(value || '') - .split(',') - .map((item) => item.trim()) - .filter(Boolean); - -function ModelCheckboxDropdown({ - value = [], - onChange, - models, -}: { - value?: string[]; - onChange?: (value: string[]) => void; - models: AiModel[]; -}) { - const [open, setOpen] = useState(false); - const summary = value.length ? `${value.length} 个已选` : '选择模型'; - - return ( - ( -
e.stopPropagation()}> - 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 ( - -
-
- {m.model_name} - {m.model_name} -
-
- 输入: ${inputPrice.toFixed(2)}/M - 输出: ${outputPrice.toFixed(2)}/M -
-
-
- ); - })} -
-
- )} - > - -
- ); -} - -export default function AgentEditor() { - const { id } = useParams(); - const isNew = !id; - const navigate = useNavigate(); - const { message } = AntApp.useApp(); - const [form] = Form.useForm(); - const [initForm] = Form.useForm(); - const [agent, setAgent] = useState(null); - const [saving, setSaving] = useState(false); - const [teams, setTeams] = useState([]); - const [models, setModels] = useState([]); - 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(''); - - // skill editor - const [skillEditorOpen, setSkillEditorOpen] = useState(false); - const [editingSkillId, setEditingSkillId] = useState(null); - - const pollTimer = useRef(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 () => { - const values = await initForm.validateFields(); - 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 beforeUploadKnowledge = async (file: UploadFile) => { - if (!id) { - message.warning('请先保存智能体基础信息后再上传'); - return Upload.LIST_IGNORE; - } - try { - await AgentAPI.uploadKnowledge(id, [file as unknown as File]); - message.success(`${file.name} 已上传,正在建索引…`); - refresh(); - } catch (e: any) { - message.error('上传失败:' + (e?.message ?? e)); - } - return Upload.LIST_IGNORE; - }; - - 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 beforeUploadInitAvatar = async (file: UploadFile) => { - try { - const url = await uploadAvatar(file as unknown as File); - setSelectedAvatar(url); - message.success('头像上传成功'); - } catch (e: any) { - message.error('头像上传失败:' + (e?.message ?? e)); - } - return Upload.LIST_IGNORE; - }; - - const beforeUploadEditAvatar = async (file: UploadFile) => { - if (!id) return Upload.LIST_IGNORE; - try { - const url = await uploadAvatar(file as unknown as File); - await AgentAPI.update(id, { avatar: url }); - message.success('头像已更新'); - setAvatarSelectorOpen(false); - refresh(); - } catch (e: any) { - message.error('头像上传失败:' + (e?.message ?? e)); - } - return Upload.LIST_IGNORE; - }; - - const liveAgent = { ...(agent ?? {}), ...(form.getFieldsValue() as Agent) } as Agent; - const currentName = liveAgent?.name || agentName || '未命名智能体'; - - return ( - <> -
- {/* Header */} -
-
-
-
- - {autoSaveStatus === 'saving' - ? '正在保存...' - : autoSaveStatus === 'dirty' - ? '有未保存更改' - : autoSaveStatus === 'error' - ? '保存失败' - : '已保存'} - - - -
-
- - {/* Main Content */} -
- {/* Left Column: Personalization (System Prompt) */} -
-
-
-
-

个性化

-

定义这个智能体的身份、语气、边界和输出规范,让它更像一个稳定的角色,而不是随机回复的模型。

-
- - - System Prompt - -
- -
-
提示词是这个智能体最重要的灵魂设定
-
可以从身份定位、擅长任务、回应风格、拒答边界和输出格式这五个维度去描述,让预览区的表现更稳定。
-
- -
{ - if (hydratingRef.current) return; - setAutoSaveStatus('dirty'); - }} - > -
-
建议写清楚角色设定、目标用户、语气和回答结构。
-
- - - -
-
-
- 提示:写得越具体,预览区里的回答风格越稳定。 -
-
-
-
- - {/* Middle Column: Capabilities & Basic Info */} -
-
-
-
-

能力与设置

-

这里决定它用什么模型、拥有哪些知识和技能,以及它会以怎样的方式被别人看到。

-
- - - Capability - -
- -
{ - if (hydratingRef.current) return; - setAutoSaveStatus('dirty'); - }} - > -
-
-
setAvatarSelectorOpen(true)} - > - {isImageUrl(agent?.avatar || selectedAvatar) ? ( - avatar - ) : ( - (agentName?.charAt(0) || '?').toUpperCase() - )} -
- 更换形象 -
-
-
-
{currentName}
-
{liveAgent?.description || '补充基础资料后,这里会更像一个完整可运营的产品角色。'}
- -
-
-
- - - - 基础设置 -
- ), - children: ( -
-
-
- 名称、描述和可见性决定了这个智能体如何被理解和管理。 -
-
- - setAgentName(e.target.value)} - style={{ borderRadius: 12, height: 42 }} - /> - - - - -
- - ({ value: t.id, label: t.name }))} - /> - -
-
- ), - }, - ]} - /> - -
-
- - 模型设置 -
-
- ({ value: parseModelSelections(value) })} - normalize={(value) => - Array.isArray(value) ? value.map((item) => String(item).trim()).filter(Boolean).join(', ') : '' - } - > - - - -
-
- - - - 知识库 ({agent?.knowledge?.length ?? 0}) -
- ), - children: ( -
-
- 上传文档以增强 AI 知识 - - - -
- ( - AgentAPI.deleteKnowledge(id!, item.id).then(refresh)} - > - - , - ]} - > -
- {item.originalName} - - {(item.size / 1024).toFixed(1)} KB ·{' '} - - {STATUS_TAG[item.status].text} - - -
-
- )} - /> -
- ), - }, - { - key: 'skills', - collapsible: 'disabled', - label: ( -
- - 技能 & 工具 (开发中) -
- ), - children: null, - } - ]} - /> - -
-
-
- - 联网搜索 -
- - - -
-
启用后,智能体可以在回答时通过 DuckDuckGo 搜索实时信息。
-
- -
-
- - {/* Right Column: Preview */} -
-
-
-
-

预览

-

一边调整人设和能力,一边看对话气质是否符合你的预期。

-
- - - Live Preview - -
-
-
当前预览角色
-
{currentName} 会基于左侧 Prompt 与中栏设置立即更新。你可以直接在这里检查语气、头像和整体观感。
-
-
- -
-
-
- - - {!isNew && ( - setSkillEditorOpen(false)} - onSaved={refresh} - /> - )} - - - navigate('/marketplace')} - footer={null} - width={720} - centered - maskClosable={false} - destroyOnHidden - > -
-
-
- - 第一步 · 定义智能体形象 -
-
先给你的智能体一个更完整的开场
-
先决定它的形象、名字和一句话定位。确认后会进入三栏工作台,继续完成个性化、能力配置和实时预览。
-
- -
-
-
- {isImageUrl(selectedAvatar) ? ( - avatar - ) : ( - (agentName?.charAt(0) || '?').toUpperCase() - )} -
-
-
{agentName || '你的新智能体'}
-
这里会实时映射你输入的名字与选择的形象。
-
-
- -
{ - if (changed.name !== undefined) setAgentName(changed.name); - }} - className="agent-editor-modal-card" - style={{ padding: 20 }} - > - - 智能体名称 - - } - rules={[{ required: true, message: '请输入智能体名称' }]} - > - - - - - 描述(选填) - - } - > - - - -
- {['客服助理', '内容创作', '数据分析', '私人教练'].map((label) => ( - { - // 给 initForm 赋值(因为第一步向导是 initForm,不是主页面的 form) - 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} - - ))} -
- -
- - -
-
-
- -
-
选择你的智能体形象
-
-
默认会使用系统头像,你也可以上传自己的图片替换。
- - - -
-
- {[DEFAULT_AVATAR, ...PRESET_AVATARS].map((url) => ( -
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' }} - > - preset - {selectedAvatar === url && ( -
-
-
-
-
- )} -
- ))} -
-
-
- - setAvatarSelectorOpen(false)} - footer={null} - width={520} - centered - > -
-
-
点击即可更换
- - - -
-
- {/* 默认与内置图片 */} - {[DEFAULT_AVATAR, ...PRESET_AVATARS].map((url) => ( -
{ - await AgentAPI.update(id!, { avatar: url }); - setAvatarSelectorOpen(false); - refresh(); - }} - 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' }} - > - preset - {agent?.avatar === url && ( -
-
-
-
-
- )} -
- ))} -
-
- - - ); -} diff --git a/src/pages/AgentEditor/components/AvatarSelector.tsx b/src/pages/AgentEditor/components/AvatarSelector.tsx new file mode 100644 index 0000000..2010a7f --- /dev/null +++ b/src/pages/AgentEditor/components/AvatarSelector.tsx @@ -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; + onAvatarChange: () => Promise; +} + +export default function AvatarSelector({ + open, + onCancel, + agent, + avatarUploading, + beforeUploadEditAvatar, + onAvatarChange, +}: AvatarSelectorProps) { + return ( + +
+
+
+ 点击即可更换 +
+ + + +
+
+ {[DEFAULT_AVATAR, ...PRESET_AVATARS].map((url) => ( +
{ + 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' }} + > + preset + {agent?.avatar === url && ( +
+
+
+
+
+ )} +
+ ))} +
+
+ + ); +} diff --git a/src/pages/AgentEditor/components/CapabilitySettings.tsx b/src/pages/AgentEditor/components/CapabilitySettings.tsx new file mode 100644 index 0000000..c9ad4f7 --- /dev/null +++ b/src/pages/AgentEditor/components/CapabilitySettings.tsx @@ -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; + markDirty: () => void; +} + +export default function CapabilitySettings({ + form, + agent, + teams, + models, + currentName, + agentName, + selectedAvatar, + setAvatarSelectorOpen, + beforeUploadKnowledge, + markDirty, +}: CapabilitySettingsProps) { + return ( +
+
+
+
+

能力与设置

+

+ 这里决定它用什么模型、拥有哪些知识和技能,以及它会以怎样的方式被别人看到。 +

+
+ + + Capability + +
+ +
+
+
+
setAvatarSelectorOpen(true)} + > + {isImageUrl(agent?.avatar || selectedAvatar) ? ( + avatar + ) : ( + (agentName?.charAt(0) || '?').toUpperCase() + )} +
+ 更换形象 +
+
+
+
+ {currentName} +
+
+ {agent?.description || '补充基础资料后,这里会更像一个完整可运营的产品角色。'} +
+ +
+
+
+ + + + 基础设置 +
+ ), + children: ( +
+
+
+ 名称、描述和可见性决定了这个智能体如何被理解和管理。 +
+
+ + + + + + +
+ + ({ value: t.id, label: t.name }))} + /> + +
+
+ ), + }, + ]} + /> + +
+
+ + 模型设置 +
+
+ ({ value: parseModelSelections(value) })} + normalize={(value) => + Array.isArray(value) ? value.map((item) => String(item).trim()).filter(Boolean).join(', ') : '' + } + > + + + +
+
+ + + + 知识库 ({agent?.knowledge?.length ?? 0}) +
+ ), + children: ( +
+
+ 上传文档以增强 AI 知识 + { + const files = e.target.files; + if (files) { + for (let i = 0; i < files.length; i++) { + await beforeUploadKnowledge(files[i]); + } + } + }} + /> + +
+ ( + agent && AgentAPI.deleteKnowledge(agent.id, item.id)} + > + + , + ]} + > +
+ {item.originalName} + + {(item.size / 1024).toFixed(1)} KB ·{' '} + + {STATUS_TAG[item.status].text} + + +
+
+ )} + /> +
+ ), + }, + { + key: 'skills', + collapsible: 'disabled', + label: ( +
+ + 技能 & 工具 (开发中) +
+ ), + children: null, + }, + ]} + /> + +
+
+
+ + 联网搜索 +
+ + + +
+
+ 启用后,智能体可以在回答时通过 DuckDuckGo 搜索实时信息。 +
+
+ +
+
+ ); +} diff --git a/src/pages/AgentEditor/components/Header.tsx b/src/pages/AgentEditor/components/Header.tsx new file mode 100644 index 0000000..906af88 --- /dev/null +++ b/src/pages/AgentEditor/components/Header.tsx @@ -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; +} + +export default function Header({ isNew, currentName, navigate, autoSaveStatus, saving, onSave }: HeaderProps) { + return ( +
+
+
+
+ + {autoSaveStatus === 'saving' + ? '正在保存...' + : autoSaveStatus === 'dirty' + ? '有未保存更改' + : autoSaveStatus === 'error' + ? '保存失败' + : '已保存'} + + + +
+
+ ); +} diff --git a/src/pages/AgentEditor/components/InitModal.tsx b/src/pages/AgentEditor/components/InitModal.tsx new file mode 100644 index 0000000..ead6b62 --- /dev/null +++ b/src/pages/AgentEditor/components/InitModal.tsx @@ -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; + avatarUploading: boolean; + selectedAvatar: string; + setSelectedAvatar: (url: string) => void; + agentName: string; + setAgentName: (name: string) => void; + saving: boolean; + beforeUploadInitAvatar: (file: any) => Promise; +} + +export default function InitModal({ + open, + onCancel, + onConfirm, + avatarUploading, + selectedAvatar, + setSelectedAvatar, + agentName, + setAgentName, + saving, + beforeUploadInitAvatar, +}: InitModalProps) { + const [initForm] = Form.useForm(); + + return ( + +
+
+
+ + 第一步 · 定义智能体形象 +
+
+ 先给你的智能体一个更完整的开场 +
+
+ 先决定它的形象、名字和一句话定位。确认后会进入三栏工作台,继续完成个性化、能力配置和实时预览。 +
+
+ +
+
+
+ {isImageUrl(selectedAvatar) ? ( + avatar + ) : ( + (agentName?.charAt(0) || '?').toUpperCase() + )} +
+
+
+ {agentName || '你的新智能体'} +
+
+ 这里会实时映射你输入的名字与选择的形象。 +
+
+
+ +
{ + if (changed.name !== undefined) setAgentName(changed.name); + }} + className="agent-editor-modal-card" + style={{ padding: 20 }} + > + + 智能体名称 + + } + rules={[{ required: true, message: '请输入智能体名称' }]} + > + + + + + 描述(选填) + + } + > + + + +
+ {['客服助理', '内容创作', '数据分析', '私人教练'].map((label) => ( + 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} + + ))} +
+ +
+ + +
+
+
+ +
+
+ 选择你的智能体形象 +
+
+
+ 默认会使用系统头像,你也可以上传自己的图片替换。 +
+ + + +
+
+ {[DEFAULT_AVATAR, ...PRESET_AVATARS].map((url) => ( +
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' }} + > + preset + {selectedAvatar === url && ( +
+
+
+
+
+ )} +
+ ))} +
+
+
+ + ); +} diff --git a/src/pages/AgentEditor/components/ModelCheckboxDropdown.tsx b/src/pages/AgentEditor/components/ModelCheckboxDropdown.tsx new file mode 100644 index 0000000..b911757 --- /dev/null +++ b/src/pages/AgentEditor/components/ModelCheckboxDropdown.tsx @@ -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 ( + ( +
e.stopPropagation()}> + 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 ( + +
+
+ {m.model_name} + {m.model_name} +
+
+ 输入: ${inputPrice.toFixed(2)}/M + 输出: ${outputPrice.toFixed(2)}/M +
+
+
+ ); + })} +
+
+ )} + > + +
+ ); +} diff --git a/src/pages/AgentEditor/components/PreviewPane.tsx b/src/pages/AgentEditor/components/PreviewPane.tsx new file mode 100644 index 0000000..a113b03 --- /dev/null +++ b/src/pages/AgentEditor/components/PreviewPane.tsx @@ -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 ( +
+
+
+
+

预览

+

+ 一边调整人设和能力,一边看对话气质是否符合你的预期。 +

+
+ + + Live Preview + +
+
+
当前预览角色
+
+ {liveAgent?.name || '未命名智能体'} 会基于左侧 Prompt 与中栏设置立即更新。你可以直接在这里检查语气、头像和整体观感。 +
+
+
+ +
+
+
+ ); +} diff --git a/src/pages/AgentEditor/components/PromptEditor.tsx b/src/pages/AgentEditor/components/PromptEditor.tsx new file mode 100644 index 0000000..d752d26 --- /dev/null +++ b/src/pages/AgentEditor/components/PromptEditor.tsx @@ -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 ( +
+
+
+
+

个性化

+

+ 定义这个智能体的身份、语气、边界和输出规范,让它更像一个稳定的角色,而不是随机回复的模型。 +

+
+ + + System Prompt + +
+ +
+
提示词是这个智能体最重要的灵魂设定
+
+ 可以从身份定位、擅长任务、回应风格、拒答边界和输出格式这五个维度去描述,让预览区的表现更稳定。 +
+
+ +
+
+
+ 建议写清楚角色设定、目标用户、语气和回答结构。 +
+
+ + + +
+
+
+ 提示:写得越具体,预览区里的回答风格越稳定。 +
+
+
+
+ ); +} diff --git a/src/pages/AgentEditor/constants.ts b/src/pages/AgentEditor/constants.ts new file mode 100644 index 0000000..9ccaf0a --- /dev/null +++ b/src/pages/AgentEditor/constants.ts @@ -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 = { + pending: { color: 'default', text: '待处理' }, + indexing: { color: 'processing', text: '索引中…' }, + ready: { color: 'success', text: '已就绪' }, + failed: { color: 'error', text: '失败' }, +}; + +export const TYPE_TAG: Record = { + 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); diff --git a/src/pages/AgentEditor/hooks/useAgentEditor.ts b/src/pages/AgentEditor/hooks/useAgentEditor.ts new file mode 100644 index 0000000..2112699 --- /dev/null +++ b/src/pages/AgentEditor/hooks/useAgentEditor.ts @@ -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(null); + const [saving, setSaving] = useState(false); + const [teams, setTeams] = useState([]); + const [models, setModels] = useState([]); + 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(null); + + const pollTimer = useRef(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, + }; +} diff --git a/src/pages/AgentEditor/index.tsx b/src/pages/AgentEditor/index.tsx new file mode 100644 index 0000000..4ed5393 --- /dev/null +++ b/src/pages/AgentEditor/index.tsx @@ -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(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 ( + <> +
+
+ +
+ + + +
+ + {!isNew && ( + setSkillEditorOpen(false)} + onSaved={refresh} + /> + )} +
+ + navigate('/agents')} + onConfirm={handleInitConfirm} + avatarUploading={avatarUploading} + selectedAvatar={selectedAvatar} + setSelectedAvatar={setSelectedAvatar} + agentName={agentName} + setAgentName={setAgentName} + saving={saving} + beforeUploadInitAvatar={beforeUploadInitAvatar} + /> + + setAvatarSelectorOpen(false)} + agent={agent} + avatarUploading={avatarUploading} + beforeUploadEditAvatar={beforeUploadEditAvatar} + onAvatarChange={refresh} + /> + + ); +}