Files
deerflow-code/web-code/components/agent/AgentExpertEditPage.tsx
T
2026-05-27 19:43:15 +08:00

1102 lines
42 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import {
ArrowLeft,
Bot,
Copy,
Download,
ImagePlus,
Paperclip,
Pencil,
Save,
Sparkles,
Wrench,
Flower2,
CheckCircle2,
X,
ChevronDown,
Edit,
} from 'lucide-react';
import AgentConversationList, { type ConversationMessage } from './AgentConversationList';
import ChatInputComposer from './ChatInputComposer';
import { Input, Select, Tabs, Textarea, Dialog, MessagePlugin } from 'tdesign-react';
import { useTheme } from '../../contexts/ThemeContext';
import { useExpertAuthBootstrap } from '../../hooks';
import { knowledgetoolUrl } from '@/hooks/useExpertAuthBootstrap';
interface AgentExpertEditPageProps {
agentId?: string | null;
}
export default function AgentExpertEditPage({ agentId: propAgentId }: AgentExpertEditPageProps) {
const { theme } = useTheme();
const isDark = theme === 'dark';
const { llmList, authedFetch } = useExpertAuthBootstrap();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const agentId = propAgentId || searchParams.get('id') || '12';
const [tab, setTab] = useState('edit');
const [toolDialogVisible, setToolDialogVisible] = useState(false);
const [skillDialogVisible, setSkillDialogVisible] = useState(false);
const [selectedTools, setSelectedTools] = useState<string[]>([]);
const [pendingTools, setPendingTools] = useState<string[]>([]);
const [boundToolIdMap, setBoundToolIdMap] = useState<Record<string, string>>({});
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
const [skillKeyword, setSkillKeyword] = useState('');
const [skillOptions, setSkillOptions] = useState<{ id: string; name: string; desc: string }[]>([]);
const [boundSkillMap, setBoundSkillMap] = useState<Record<string, { name: string; desc: string }>>({});
const [isSkillLoading, setIsSkillLoading] = useState(false);
const [bindingSkillIds, setBindingSkillIds] = useState<string[]>([]);
const [unbindingSkillIds, setUnbindingSkillIds] = useState<string[]>([]);
const [isToolSubmitting, setIsToolSubmitting] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [toolOptions, setToolOptions] = useState<{ id: string; name: string; desc: string }[]>([]);
const [messages, setMessages] = useState<ConversationMessage[]>([]);
const [inputValue, setInputValue] = useState('');
const [threadId, setThreadId] = useState<number | null>(null);
const [isStreaming, setIsStreaming] = useState(false);
const thinkingStartRef = useRef<number>(0);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
const [showScrollButton, setShowScrollButton] = useState(false);
const router = useNavigate();
const scrollToBottom = (smooth = true) => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({
top: scrollContainerRef.current.scrollHeight,
behavior: smooth ? 'smooth' : 'auto',
});
}
};
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
const container = e.currentTarget;
const isAtBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 50;
setShowScrollButton(!isAtBottom && messages.length > 0);
if (!isAtBottom) {
setAutoScrollEnabled(false);
}
};
useEffect(() => {
if (autoScrollEnabled && messages.length > 0) {
const timer = setTimeout(() => scrollToBottom(false), 100);
return () => clearTimeout(timer);
}
}, [messages, autoScrollEnabled]);
const ensureThreadId = async () => {
if (threadId) return threadId;
const threadRes = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agent-chat/threads/no_real/${agentId}`, {
method: 'GET',
});
if (!threadRes.ok) {
throw new Error(`获取会话失败: ${threadRes.status}`);
}
const threadData = (await threadRes.json()) as { id: number };
setThreadId(threadData.id);
return threadData.id;
};
const sendMessage = async () => {
const text = inputValue.trim();
if (!text || isStreaming) return;
setInputValue('');
setIsStreaming(true);
const userMsg: ConversationMessage = {
id: `user-${Date.now()}`,
role: 'user',
content: text,
};
setMessages((prev) => [...prev, userMsg]);
try {
// 1. GET or reuse thread
let tid = threadId;
if (!tid) {
const threadRes = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agent-chat/threads/no_real/${agentId}`, {
method: 'GET',
});
if (!threadRes.ok) {
console.error('创建会话失败:', threadRes.status);
return;
}
const threadData = (await threadRes.json()) as { id: number };
tid = threadData.id;
setThreadId(tid);
}
// 2. POST user message
const msgRes = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agent-chat/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ thread_id: tid, role: 'user', content: { type: 'text', text } }),
});
if (!msgRes.ok) {
console.error('发送消息失败:', msgRes.status);
return;
}
// 3. Stream assistant response
const assistantId = `assistant-${Date.now()}`;
setMessages((prev) => [
...prev,
{ id: assistantId, role: 'assistant', content: '', steps: [], thinkings: [], toolCalls: [] },
]);
const streamRes = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agent-chat/run/stream/help_expert`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chat_id: tid, expert_agent_id: Number(agentId), user_query: text, messages: [] }),
});
if (!streamRes.ok || !streamRes.body) {
console.error('流式接口失败:', streamRes.status);
return;
}
const reader = streamRes.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const raw = line.slice(6).trim();
if (raw === '[DONE]') break;
let event: { type: string; data?: { id: string; title: string; status: string; items?: string[] }; id?: string; delta?: string } | null = null;
try { event = JSON.parse(raw) as typeof event; } catch { continue; }
if (!event) continue;
setMessages((prev) =>
prev.map((msg) => {
if (msg.id !== assistantId) return msg;
switch (event!.type) {
case 'data-thinking-step': {
const step = event!.data!;
const newStep = {
text: step.title,
subText: step.items?.[0]?.replace('正在处理: ', '') ?? undefined,
status: (step.status === 'completed' ? 'done' : 'processing') as 'done' | 'processing',
};
const steps = [...(msg.steps ?? [])];
const idx = steps.findIndex((s) => s.text === step.title);
if (idx >= 0) steps[idx] = newStep; else steps.push(newStep);
return { ...msg, steps };
}
case 'reasoning-start': {
thinkingStartRef.current = Date.now();
return { ...msg, thinkings: [...(msg.thinkings ?? []), { duration: 0, content: '' }] };
}
case 'reasoning-delta': {
const thinkings = [...(msg.thinkings ?? [])];
if (thinkings.length === 0) {
thinkings.push({ duration: 0, content: event!.delta ?? '' });
} else {
const last = thinkings[thinkings.length - 1];
thinkings[thinkings.length - 1] = { ...last, content: last.content + (event!.delta ?? '') };
}
return { ...msg, thinkings };
}
case 'reasoning-end': {
const duration = (Date.now() - thinkingStartRef.current) / 1000;
const thinkings = [...(msg.thinkings ?? [])];
if (thinkings.length > 0) {
thinkings[thinkings.length - 1] = { ...thinkings[thinkings.length - 1], duration };
}
return { ...msg, thinkings };
}
case 'text-delta': {
return { ...msg, content: msg.content + (event!.delta ?? '') };
}
default:
return msg;
}
}),
);
}
}
} catch (error) {
console.error('对话异常:', error);
} finally {
setIsStreaming(false);
}
};
const handleUploadFile = async (file: File) => {
try {
const tid = await ensureThreadId();
const formData = new FormData();
formData.append('file', file);
const response = await authedFetch(
`${knowledgetoolUrl}/api/v1/expert-agent-chat/threads/${tid}/files/upload`,
{
method: 'POST',
body: formData,
},
);
if (!response.ok) {
throw new Error(`上传失败: ${response.status} ${response.statusText}`);
}
} catch (error) {
console.error('上传专家编辑文件失败:', error);
}
};
const [formValues, setFormValues] = useState({
name: '未命名专家',
modelName: 'Qwen3.5-plus',
description: '',
systemPrompt: '你是一个专业的助手,能够回答用户的问题。',
});
const modelOptions = llmList.map((item) => ({ label: item.name, value: item.id }));
useEffect(() => {
const fetchExpertDetail = async () => {
try {
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/${agentId}`, {
method: 'GET',
});
if (!res.ok) {
console.error('查询专家详情失败:', res.status, res.statusText);
return;
}
const data: unknown = await res.json();
const detail = data as {
name?: string;
description?: string | null;
system_prompt?: string | null;
model_name?: string | null;
};
setFormValues((prev) => ({
...prev,
name: detail.name || '',
description: detail.description || '',
systemPrompt: detail.system_prompt || '',
modelName: detail.model_name || prev.modelName,
}));
} catch (error) {
console.error('查询专家详情异常:', error);
}
};
void fetchExpertDetail();
}, [authedFetch, agentId]);
useEffect(() => {
const fetchBuiltinTools = async () => {
try {
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/tools/builtin`, {
method: 'GET',
});
if (!res.ok) {
console.error('builtin tools接口调用失败:', res.status, res.statusText);
setToolOptions([]);
return;
}
const data: unknown = await res.json();
const list = Array.isArray(data)
? (data as { name: string; description: string }[]).map((item) => ({
id: item.name,
name: item.name,
desc: item.description,
}))
: [];
setToolOptions(list);
} catch (error) {
console.error('builtin tools接口异常:', error);
setToolOptions([]);
}
};
void fetchBuiltinTools();
}, [authedFetch]);
useEffect(() => {
const fetchSkills = async () => {
setIsSkillLoading(true);
try {
const keyword = encodeURIComponent(skillKeyword.trim());
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/skills?type=all&keyword=${keyword}&page=1&page_size=100`, {
method: 'GET',
});
if (!res.ok) {
console.error('skills接口调用失败:', res.status, res.statusText);
setSkillOptions([]);
return;
}
const data: unknown = await res.json();
const items =
data && typeof data === 'object' && Array.isArray((data as { items?: unknown[] }).items)
? ((data as { items: Array<{ id: number; name: string; description: string }> }).items || [])
: [];
setSkillOptions(
items.map((item) => ({
id: String(item.id),
name: item.name,
desc: item.description,
})),
);
} catch (error) {
console.error('skills接口异常:', error);
setSkillOptions([]);
} finally {
setIsSkillLoading(false);
}
};
void fetchSkills();
}, [authedFetch, skillKeyword]);
useEffect(() => {
const fetchBoundSkills = async () => {
try {
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/${agentId}/skills`, {
method: 'GET',
});
if (!res.ok) {
console.error('查询已绑定技能失败:', res.status, res.statusText);
return;
}
const data: unknown = await res.json();
const list = Array.isArray(data)
? (data as Array<{ id: number; name: string; description: string }>).map((item) => ({
id: String(item.id),
name: item.name,
desc: item.description,
}))
: [];
setSelectedSkills(list.map((item) => item.id));
setBoundSkillMap(
list.reduce<Record<string, { name: string; desc: string }>>((acc, item) => {
acc[item.id] = { name: item.name, desc: item.desc };
return acc;
}, {}),
);
} catch (error) {
console.error('查询已绑定技能异常:', error);
}
};
void fetchBoundSkills();
}, [authedFetch, agentId]);
useEffect(() => {
const fetchBoundTools = async () => {
try {
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/${agentId}/tools`, {
method: 'GET',
});
if (!res.ok) {
console.error('查询已绑定内置工具失败:', res.status, res.statusText);
return;
}
const data: unknown = await res.json();
const list = Array.isArray(data)
? (data as Array<{ id: number; name: string }>).map((item) => ({
id: String(item.id),
name: item.name,
}))
: [];
setSelectedTools(list.map((item) => item.name));
setBoundToolIdMap(
list.reduce<Record<string, string>>((acc, item) => {
acc[item.name] = item.id;
return acc;
}, {}),
);
} catch (error) {
console.error('查询已绑定内置工具异常:', error);
}
};
void fetchBoundTools();
}, [authedFetch, agentId]);
const refreshBoundTools = async () => {
try {
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/${agentId}/tools`, {
method: 'GET',
});
if (!res.ok) {
console.error('刷新已绑定内置工具失败:', res.status, res.statusText);
return;
}
const data: unknown = await res.json();
const list = Array.isArray(data)
? (data as Array<{ id: number; name: string }>).map((item) => ({ id: String(item.id), name: item.name }))
: [];
setSelectedTools(list.map((item) => item.name));
setBoundToolIdMap(
list.reduce<Record<string, string>>((acc, item) => {
acc[item.name] = item.id;
return acc;
}, {}),
);
} catch (error) {
console.error('刷新已绑定内置工具异常:', error);
}
};
const unbindTool = async (toolName: string) => {
const toolId = boundToolIdMap[toolName];
if (!toolId) return;
try {
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/${agentId}/tools/${toolId}`, {
method: 'DELETE',
});
if (!res.ok) {
console.error('删除内置工具失败:', res.status, res.statusText);
return;
}
await refreshBoundTools();
} catch (error) {
console.error('删除内置工具异常:', error);
}
};
const bindSelectedTools = async () => {
setIsToolSubmitting(true);
try {
const toAdd = pendingTools.filter((name) => !selectedTools.includes(name));
for (const toolName of toAdd) {
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/${agentId}/tools`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ tool_name: toolName }),
});
if (!res.ok) {
console.error('绑定内置工具失败:', res.status, res.statusText);
return;
}
}
await refreshBoundTools();
setToolDialogVisible(false);
} catch (error) {
console.error('绑定内置工具异常:', error);
} finally {
setIsToolSubmitting(false);
}
};
const bindSkill = async (skillId: string) => {
if (bindingSkillIds.includes(skillId)) return;
setBindingSkillIds((prev) => [...prev, skillId]);
try {
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/${agentId}/skills`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ skill_id: Number(skillId) }),
});
if (!res.ok) {
console.error('绑定技能失败:', res.status, res.statusText);
return;
}
const refreshRes = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/${agentId}/skills`, {
method: 'GET',
});
if (!refreshRes.ok) {
console.error('刷新已绑定技能失败:', refreshRes.status, refreshRes.statusText);
return;
}
const refreshData: unknown = await refreshRes.json();
const refreshList = Array.isArray(refreshData)
? (refreshData as Array<{ id: number; name: string; description: string }>).map((item) => ({
id: String(item.id),
name: item.name,
desc: item.description,
}))
: [];
setSelectedSkills(refreshList.map((item) => item.id));
setBoundSkillMap(
refreshList.reduce<Record<string, { name: string; desc: string }>>((acc, item) => {
acc[item.id] = { name: item.name, desc: item.desc };
return acc;
}, {}),
);
} catch (error) {
console.error('绑定技能异常:', error);
} finally {
setBindingSkillIds((prev) => prev.filter((id) => id !== skillId));
}
};
const unbindSkill = async (skillId: string) => {
if (unbindingSkillIds.includes(skillId)) return;
setUnbindingSkillIds((prev) => [...prev, skillId]);
try {
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/${agentId}/skills/${skillId}`, {
method: 'DELETE',
});
if (!res.ok) {
console.error('解除绑定技能失败:', res.status, res.statusText);
return;
}
const refreshRes = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/${agentId}/skills`, {
method: 'GET',
});
if (!refreshRes.ok) {
console.error('刷新已绑定技能失败:', refreshRes.status, refreshRes.statusText);
return;
}
const refreshData: unknown = await refreshRes.json();
const refreshList = Array.isArray(refreshData)
? (refreshData as Array<{ id: number; name: string; description: string }>).map((item) => ({
id: String(item.id),
name: item.name,
desc: item.description,
}))
: [];
setSelectedSkills(refreshList.map((item) => item.id));
setBoundSkillMap(
refreshList.reduce<Record<string, { name: string; desc: string }>>((acc, item) => {
acc[item.id] = { name: item.name, desc: item.desc };
return acc;
}, {}),
);
} catch (error) {
console.error('解除绑定技能异常:', error);
} finally {
setUnbindingSkillIds((prev) => prev.filter((id) => id !== skillId));
}
};
const saveExpert = async () => {
setIsSaving(true);
try {
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/${agentId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: formValues.name,
avatar_url: null,
description: formValues.description,
system_prompt: formValues.systemPrompt,
model_name: formValues.modelName,
}),
});
if (!res.ok) {
console.error('保存专家失败:', res.status, res.statusText);
return;
}
MessagePlugin.success('保存成功');
console.log('保存专家成功');
} catch (error) {
console.error('保存专家异常:', error);
} finally {
setIsSaving(false);
}
};
return (
<>
<style>{`
@keyframes marquee {
0% { background-position: 0% center; }
100% { background-position: 200% center; }
}
.animate-marquee {
background-size: 200% 100%;
animation: marquee 2s linear infinite;
}
@keyframes text-marquee {
0% { transform: translateX(100%); }
100% { transform: translateX(-100%); }
}
.animate-text-marquee {
display: inline-block;
animation: text-marquee 3s linear infinite;
white-space: nowrap;
}
`}</style>
<div className={`h-[calc(100vh-120px)] rounded-2xl border overflow-hidden ${isDark ? 'border-slate-700 bg-slate-900/40' : 'border-gray-200 bg-white'}`}>
<div className={`h-14 px-4 border-b flex items-center justify-between ${isDark ? 'border-slate-700' : 'border-gray-200'}`}>
<div className="flex items-center gap-3">
<button
onClick={() => router('/agent-expert-center')}
className={`p-1.5 rounded-md ${isDark ? 'hover:bg-slate-800 text-slate-300' : 'hover:bg-gray-100 text-gray-600'}`}
>
<ArrowLeft className="w-4 h-4" />
</button>
<div className="w-8 h-8 rounded-xl bg-gradient-to-br from-fuchsia-500 to-violet-600 text-white flex items-center justify-center">
<Edit className="w-4 h-4" />
</div>
<div>
<h2 className={`text-base font-semibold leading-5 ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>编辑专家</h2>
<p className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>修改 未命名专家 的配置</p>
</div>
</div>
<button
onClick={() => {
void saveExpert();
}}
disabled={isSaving}
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-lg bg-slate-900 text-white text-sm hover:bg-slate-800 disabled:opacity-60"
>
<Save className="w-4 h-4" /> 保存修改
</button>
</div>
<div className="grid grid-cols-2 h-[calc(100%-56px)]">
<section className={`border-r flex flex-col h-full min-h-0 relative ${isDark ? 'border-slate-700 bg-slate-900/20' : 'border-gray-200 bg-white'}`}>
<div
ref={scrollContainerRef}
onScroll={handleScroll}
className={`flex-1 ${ messages.length === 0 && 'flex items-center'} min-h-0 overflow-y-auto px-4 py-4`}
>
{messages.length === 0 ? (
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<div className="mx-auto w-16 h-16 rounded-2xl bg-gradient-to-br from-fuchsia-500 to-violet-600 text-white flex items-center justify-center shadow-lg shadow-violet-300/30">
<Bot className="w-7 h-7" />
</div>
<h3 className={`mt-6 text-3xl font-bold ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>你好,我是 {formValues.name || '未命名专家'}</h3>
<p className={`mt-3 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>创建你的专属 AI 助手</p>
<div className={`mt-4 inline-flex items-center gap-2 px-3 py-1.5 rounded-full border text-xs ${isDark ? 'border-slate-700 bg-slate-800/60 text-slate-300' : 'border-gray-200 bg-white text-gray-500'}`}>
<Sparkles className="w-3.5 h-3.5 text-emerald-400" /> 想和你聊些什么呀
</div>
<div className={`mt-7 flex items-center gap-8 text-xs ${isDark ? 'text-slate-500' : 'text-gray-500'}`}>
<span>告诉我你的想法</span>
<span>给我一些建议</span>
<span>帮我分析问题</span>
</div>
</div>
</div>
) : (
<>
<AgentConversationList messages={messages} />
</>
)}
</div>
{showScrollButton && (
<button
onClick={() => {
setAutoScrollEnabled(true);
scrollToBottom(true);
}}
className={`absolute bottom-20 left-1/2 -translate-x-1/2 w-10 h-10 rounded-full flex items-center justify-center backdrop-blur-sm text-xs font-medium transition ${
isDark
? 'bg-slate-300/25 hover:bg-slate-300/35 text-slate-100 border border-slate-200/25'
: 'bg-gray-300/45 hover:bg-gray-300/60 text-gray-700 border border-gray-200/70'
}`}
aria-label="自动下滑"
title="自动下滑"
>
<ChevronDown className="w-4 h-4" />
</button>
)}
<ChatInputComposer
value={inputValue}
onChange={setInputValue}
onSend={() => void sendMessage()}
onUploadFile={(file) => void handleUploadFile(file)}
isSending={isStreaming}
isDarkMode={isDark}
showBorderTop
placeholder={isStreaming ? '正在思考中...' : '输入你的问题...'}
trailingActions={
<Paperclip className={`h-4 w-4 ${isDark ? 'text-slate-500' : 'text-[#8a94a6]'}`} />
}
className={isStreaming ? 'ring-1 ring-violet-300/40' : ''}
/>
</section>
<section className={`${isDark ? 'bg-slate-900/10' : 'bg-white'} flex flex-col h-full min-h-0`}>
{/* <div className={`h-14 border-b px-4 flex items-center ${isDark ? 'border-slate-700' : 'border-gray-200'}`}>
<Tabs value={tab} onChange={(v) => setTab(String(v))} size="medium" className="w-full">
<Tabs.TabPanel value="preview" label="对话" />
<Tabs.TabPanel value="config" label="配置" />
</Tabs>
</div> */}
{tab === 'preview' ? (
<>
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<div className="mx-auto w-16 h-16 rounded-2xl bg-gradient-to-br from-fuchsia-500 to-violet-600 text-white flex items-center justify-center shadow-lg shadow-violet-300/30">
<Bot className="w-7 h-7" />
</div>
<h3 className={`mt-6 text-3xl font-bold ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>你好,我是 {formValues.name || '未命名专家'}</h3>
<p className={`mt-3 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>创建你的专属 AI 助手</p>
<div className={`mt-4 inline-flex items-center gap-2 px-3 py-1.5 rounded-full border text-xs ${isDark ? 'border-slate-700 bg-slate-800/60 text-slate-300' : 'border-gray-200 bg-white text-gray-500'}`}>
<Sparkles className="w-3.5 h-3.5 text-emerald-400" /> 想和你聊些什么呀
</div>
<div className={`mt-7 flex items-center gap-8 text-xs ${isDark ? 'text-slate-500' : 'text-gray-500'}`}>
<span>告诉我你的想法</span>
<span>给我一些建议</span>
<span>帮我分析问题</span>
</div>
</div>
</div>
<ChatInputComposer
value=""
isDarkMode={isDark}
placeholder="输入你的问题..."
readOnly
disabled
showUpload={false}
showBorderTop
trailingActions={
<>
<ImagePlus className={`h-4 w-4 ${isDark ? 'text-slate-500' : 'text-[#8a94a6]'}`} />
<Paperclip className={`h-4 w-4 ${isDark ? 'text-slate-500' : 'text-[#8a94a6]'}`} />
</>
}
/>
</>
) : (
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-5 space-y-6">
<div>
<h3 className={`text-sm font-semibold mb-3 ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>基本信息</h3>
<div className="grid grid-cols-[1fr_auto] gap-4 items-start">
<div>
<label className={`text-xs mb-1 block ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>专家名称 *</label>
<Input
value={formValues.name}
onChange={(value) => setFormValues((prev) => ({ ...prev, name: String(value) }))}
/>
</div>
<div>
<label className={`text-xs mb-1 block ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>头像 URL</label>
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-fuchsia-500 to-violet-600 text-white flex items-center justify-center mt-1">
<Bot className="w-4 h-4" />
</div>
</div>
</div>
<div className="mt-3">
<label className={`text-xs mb-1 block ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>LLM 模型</label>
<Select
value={formValues.modelName}
options={modelOptions}
placeholder="选择用于该专家的 LLM 模型"
onChange={(value) => setFormValues((prev) => ({ ...prev, modelName: String(value) }))}
/>
</div>
<div className="mt-3">
<label className={`text-xs mb-1 block ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>描述</label>
<Textarea
autosize={{ minRows: 3, maxRows: 3 }}
placeholder="简单描述这个专家的能力和场景..."
value={formValues.description}
onChange={(value) => setFormValues((prev) => ({ ...prev, description: String(value) }))}
/>
</div>
<div className="mt-3">
<label className={`text-xs mb-1 block ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>系统提示词</label>
<div className={`rounded-lg border ${isDark ? 'border-slate-700' : 'border-gray-200'}`}>
<div className={`h-8 border-b px-3 flex items-center justify-end gap-2 ${isDark ? 'border-slate-700' : 'border-gray-100'}`}>
<Copy className="w-3.5 h-3.5 text-gray-400" />
<Download className="w-3.5 h-3.5 text-gray-400" />
</div>
<div className="p-3">
<Textarea
autosize={{ minRows: 6, maxRows: 10 }}
value={formValues.systemPrompt}
onChange={(value) => setFormValues((prev) => ({ ...prev, systemPrompt: String(value) }))}
placeholder="请输入系统提示词"
/>
</div>
</div>
</div>
</div>
<div>
<h3 className={`text-sm font-semibold mb-3 ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>内置工具</h3>
<div
role="button"
tabIndex={0}
onClick={() => {
setPendingTools(selectedTools);
setToolDialogVisible(true);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
setPendingTools(selectedTools);
setToolDialogVisible(true);
}
}}
className={`w-full min-h-[170px] rounded-xl border px-3 py-3 text-left cursor-pointer ${isDark ? 'border-slate-700 hover:border-slate-500' : 'border-gray-200 hover:border-gray-300'}`}
>
<div className="flex flex-wrap gap-2">
{selectedTools.map((toolName) => (
<span
key={toolName}
className="inline-flex items-center gap-2 h-10 pl-2 pr-3 rounded-full border border-sky-200 bg-sky-50 text-[#0F2B6B] text-sm"
onClick={(e) => e.stopPropagation()}
>
<span className="w-5 h-5 rounded-full bg-sky-500 text-white flex items-center justify-center">
<Wrench className="w-3 h-3" />
</span>
<span className="text-sm leading-none">{toolName}</span>
<button
type="button"
className="text-slate-500 hover:text-slate-700"
onClick={(e) => {
e.stopPropagation();
void unbindTool(toolName);
}}
>
<X className="w-3.5 h-3.5" />
</button>
</span>
))}
</div>
<div className="mt-4 h-[90px] flex items-end justify-between">
<span className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
已选择 {selectedTools.length} 个工具
</span>
<Wrench className="w-5 h-5 text-[#2B78FF]" />
</div>
</div>
</div>
<div>
<h3 className={`text-sm font-semibold mb-1 ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>绑定的技能</h3>
<p className={`text-xs mb-3 ${isDark ? 'text-slate-500' : 'text-gray-500'}`}>为专家添加技能,增强其专业能力</p>
<div className={`min-h-[120px] rounded-lg border p-3 ${isDark ? 'border-slate-700' : 'border-gray-200'}`}>
<div className="flex flex-wrap gap-2">
{selectedSkills.map((skillId) => {
const skillName = boundSkillMap[skillId]?.name || skillOptions.find((item) => item.id === skillId)?.name || skillId;
return (
<span key={skillId} className="inline-flex items-center gap-2 h-8 px-3 rounded-full border border-emerald-200 bg-emerald-50 text-emerald-700 text-xs">
<span className="w-4 h-4 rounded-full bg-emerald-500 text-white flex items-center justify-center">✓</span>
{skillName}
<button
type="button"
className="text-emerald-600 hover:text-emerald-800"
onClick={() => {
void unbindSkill(skillId);
}}
>
<X className="w-3.5 h-3.5" />
</button>
</span>
);
})}
</div>
<div className="h-[70px] flex items-end justify-end text-emerald-500">
<button type="button" onClick={() => setSkillDialogVisible(true)}>
<Sparkles className="w-4 h-4" />
</button>
</div>
</div>
</div>
<div className="flex justify-end pt-2">
<button className={`text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>取消</button>
</div>
</div>
)}
</section>
</div>
<Dialog
visible={toolDialogVisible}
onClose={() => {
setPendingTools(selectedTools);
setToolDialogVisible(false);
}}
header="添加工具"
width={720}
confirmBtn={null}
cancelBtn={null}
>
<div className="-mt-1">
<p className="text-xs text-gray-500 mb-4">选择要为专家启用的工具</p>
<div className="grid grid-cols-2 gap-3">
{toolOptions.map((tool) => {
const checked = pendingTools.includes(tool.id);
return (
<button
key={tool.id}
type="button"
onClick={() => {
setPendingTools((prev) =>
prev.includes(tool.id) ? prev.filter((id) => id !== tool.id) : [...prev, tool.id],
);
}}
className={`rounded-lg border p-3 text-left transition ${checked ? 'border-sky-400 bg-sky-50' : 'border-gray-200 hover:border-gray-300'}`}
>
<div className="flex items-start justify-between gap-2">
<div>
<div className="text-sm font-semibold text-gray-900">{tool.name}</div>
<div className="text-xs text-gray-500 mt-1 line-clamp-2">{tool.desc}</div>
</div>
<div className="mt-0.5">
{checked ? (
<CheckCircle2 className="w-4 h-4 text-sky-500" />
) : (
<div className="w-4 h-4 rounded-full border border-gray-300" />
)}
</div>
</div>
</button>
);
})}
</div>
<div className="mt-5 flex items-center justify-between border-t pt-3">
<div className="text-xs text-gray-500">已选择 {pendingTools.length} 个工具</div>
<button
type="button"
onClick={() => {
void bindSelectedTools();
}}
disabled={isToolSubmitting}
className="h-8 px-4 rounded-lg bg-sky-500 text-white text-sm hover:bg-sky-600 disabled:opacity-60"
>
确认添加
</button>
</div>
</div>
</Dialog>
<Dialog
visible={skillDialogVisible}
onClose={() => setSkillDialogVisible(false)}
header="绑定技能"
width={560}
confirmBtn={null}
cancelBtn={null}
>
<div className="-mt-1">
<div className="flex items-center gap-2 mb-3">
<Input
value={skillKeyword}
onChange={(value) => setSkillKeyword(String(value))}
placeholder="搜索技能"
className="flex-1"
/>
<button
type="button"
onClick={async () => {
for (const item of skillOptions) {
if (!selectedSkills.includes(item.id)) {
await bindSkill(item.id);
}
}
}}
className="h-9 px-4 rounded-lg bg-emerald-500 text-white text-sm hover:bg-emerald-600"
>
一键绑定全部
</button>
</div>
<div className="rounded-lg border border-gray-200 overflow-hidden max-h-[260px] overflow-y-auto">
{isSkillLoading ? (
<div className="px-3 py-6 text-sm text-gray-500">加载技能中...</div>
) : skillOptions.length === 0 ? (
<div className="px-3 py-6 text-sm text-gray-500">暂无技能</div>
) : (
skillOptions.map((skill) => {
const checked = selectedSkills.includes(skill.id);
return (
<button
key={skill.id}
type="button"
onClick={async () => {
if (checked) {
await unbindSkill(skill.id);
return;
}
await bindSkill(skill.id);
}}
className={`w-full px-3 py-2.5 text-left border-b last:border-b-0 transition ${checked ? 'bg-emerald-50' : 'bg-white hover:bg-gray-50'}`}
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-gray-900">{skill.name}</div>
<div className="text-xs text-gray-500 mt-1 line-clamp-2">{skill.desc}</div>
</div>
<div className="mt-0.5">
{bindingSkillIds.includes(skill.id) || unbindingSkillIds.includes(skill.id) ? (
<div className="w-4 h-4 rounded-full border-2 border-emerald-400 border-t-transparent animate-spin" />
) : checked ? (
<CheckCircle2 className="w-4 h-4 text-emerald-500" />
) : (
<div className="w-4 h-4 rounded-full border border-gray-300" />
)}
</div>
</div>
</button>
);
})
)}
</div>
</div>
</Dialog>
</div>
</>
);
}