1102 lines
42 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|