import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ArrowDown, ArrowLeft, Bot, CheckCircle2, History as HistoryIcon, Plus, Save, Search, Sparkles, X, } from 'lucide-react'; import { Input, Textarea } from 'tdesign-react'; import { useTheme } from '../../contexts/ThemeContext'; import ChatInputComposer from '../agent/ChatInputComposer'; import AgentChatMessageList, { type AgentChatMsg } from './AgentChatMessageList'; import SixStepChatHistory, { type SixStepConversationMeta } from './SixStepChatHistory'; interface AgentDetail { key: string; type?: string; deletable?: boolean; name: string; description: string; structured_prompt: string; chat_prompt: string; default_prompt?: string; default_model: string; schema_hint: string; bound_skills: string[]; order: number; } interface SkillOption { key: string; name: string; description: string; type: string; } interface ModelOption { id: string; name: string; provider: string; description?: string; } interface ModelsResponse { default_model_id: string; models: ModelOption[]; } interface AgentEditPageProps { agentKey: string; onBack?: () => void; } interface ConversationMessage extends AgentChatMsg { created_at?: number; } interface ConversationApiRecord { id: string; agent_key: string; title: string; model?: string | null; created_at: number; updated_at: number; messages: Array<{ id: string; role: 'user' | 'assistant'; content: string; created_at?: number; }>; } interface Conversation extends SixStepConversationMeta { createdAt: number; model?: string; messages: ConversationMessage[]; } interface AgentFormState { name: string; description: string; structured_prompt: string; chat_prompt: string; default_model: string; } const normalizeConversation = (item: ConversationApiRecord): Conversation => ({ id: item.id, title: item.title || '未命名对话', model: item.model || '', createdAt: Number(item.created_at || Date.now()), updatedAt: Number(item.updated_at || Date.now()), messages: (item.messages || []).map((message) => ({ id: message.id, role: message.role, content: message.content, created_at: message.created_at, })), }); const sortConversations = (items: Conversation[]) => [...items].sort((a, b) => Number(b.updatedAt || 0) - Number(a.updatedAt || 0)); const backUrl = 'https://ch1.b.uat.4.cn/zncmback'; export default function AgentEditPage({ agentKey, onBack }: AgentEditPageProps) { const { theme } = useTheme(); const isDark = theme === 'dark'; const [detail, setDetail] = useState(null); const [skills, setSkills] = useState([]); const [models, setModels] = useState([]); const [globalDefaultModel, setGlobalDefaultModel] = useState(''); const [form, setForm] = useState({ name: '', description: '', structured_prompt: '', chat_prompt: '', default_model: '', }); const [boundSkills, setBoundSkills] = useState([]); const [chatModel, setChatModel] = useState(''); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [saving, setSaving] = useState(false); const [saveMsg, setSaveMsg] = useState(null); const [pickerOpen, setPickerOpen] = useState(false); const [historyOpen, setHistoryOpen] = useState(false); const [historyVisible, setHistoryVisible] = useState(false); const [conversations, setConversations] = useState([]); const [activeId, setActiveId] = useState(null); const [chatInput, setChatInput] = useState(''); const [isStreaming, setIsStreaming] = useState(false); const [streamingMsgId, setStreamingMsgId] = useState(null); const [autoScrollEnabled, setAutoScrollEnabled] = useState(true); const abortRef = useRef(null); const messageListRef = useRef(null); const programmaticScrollRef = useRef(false); const historyTimerRef = useRef(null); const openHistoryPanel = useCallback(() => { if (historyTimerRef.current) { window.clearTimeout(historyTimerRef.current); historyTimerRef.current = null; } setHistoryVisible(true); window.requestAnimationFrame(() => { window.requestAnimationFrame(() => { setHistoryOpen(true); }); }); }, []); const closeHistoryPanel = useCallback(() => { if (historyTimerRef.current) { window.clearTimeout(historyTimerRef.current); } setHistoryOpen(false); historyTimerRef.current = window.setTimeout(() => { setHistoryVisible(false); historyTimerRef.current = null; }, 300); }, []); useEffect(() => { return () => { if (historyTimerRef.current) { window.clearTimeout(historyTimerRef.current); } }; }, []); const showTransientMessage = (message: string) => { setSaveMsg(message); window.setTimeout(() => { setSaveMsg((current) => (current === message ? null : current)); }, 2400); }; const upsertConversationState = (conversation: Conversation) => { setConversations((prev) => sortConversations([conversation, ...prev.filter((item) => item.id !== conversation.id)])); }; const scrollToBottom = useCallback((behavior: ScrollBehavior = 'auto') => { const container = messageListRef.current; if (!container) { return; } programmaticScrollRef.current = true; container.scrollTo({ top: container.scrollHeight, behavior, }); window.setTimeout(() => { programmaticScrollRef.current = false; }, behavior === 'smooth' ? 400 : 120); }, []); const fetchConversations = useCallback(async (): Promise => { const res = await fetch(`${backUrl}/api/agents/${agentKey}/conversations`); if (!res.ok) { throw new Error(`加载聊天记录失败:${res.status}`); } const raw = (await res.json()) as ConversationApiRecord[]; return sortConversations(raw.map(normalizeConversation)); }, [agentKey]); const createConversationOnServer = async ( title: string, model?: string, messages: ConversationMessage[] = [], ): Promise => { const res = await fetch(`${backUrl}/api/agents/${agentKey}/conversations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title, model: model || undefined, messages, }), }); if (!res.ok) { throw new Error(`创建对话失败:${res.status}`); } return normalizeConversation((await res.json()) as ConversationApiRecord); }; const updateConversationOnServer = async ( conversationId: string, payload: { title?: string; model?: string; messages?: ConversationMessage[]; }, ): Promise => { const res = await fetch(`${backUrl}/api/agents/${agentKey}/conversations/${conversationId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!res.ok) { throw new Error(`更新对话失败:${res.status}`); } return normalizeConversation((await res.json()) as ConversationApiRecord); }; useEffect(() => { let cancelled = false; (async () => { setLoading(true); setError(null); try { const [agentRes, skillRes, modelRes, conversationList] = await Promise.all([ fetch(`${backUrl}/api/agents/${agentKey}`), fetch(`${backUrl}/api/skills`), fetch(`${backUrl}/api/models`), fetchConversations(), ]); if (!agentRes.ok) { throw new Error(`加载智能体失败:${agentRes.status}`); } const agentData = (await agentRes.json()) as AgentDetail; const skillData = skillRes.ok ? ((await skillRes.json()) as SkillOption[]) : []; const modelData = modelRes.ok ? ((await modelRes.json()) as ModelsResponse) : { default_model_id: '', models: [] }; if (cancelled) { return; } const effectiveModel = agentData.default_model || modelData.default_model_id || ''; setDetail(agentData); setSkills(skillData); setModels(modelData.models || []); setGlobalDefaultModel(modelData.default_model_id || ''); setForm({ name: agentData.name || '', description: agentData.description || '', structured_prompt: agentData.structured_prompt || agentData.default_prompt || '', chat_prompt: agentData.chat_prompt || agentData.default_prompt || '', default_model: effectiveModel, }); setBoundSkills(agentData.bound_skills || []); setConversations(conversationList); setActiveId((current) => { if (current && conversationList.some((item) => item.id === current)) { return current; } return conversationList[0]?.id || null; }); setChatModel((current) => current || effectiveModel); } catch (e) { if (!cancelled) { setError(e instanceof Error ? e.message : '加载失败'); } } finally { if (!cancelled) { setLoading(false); } } })(); return () => { cancelled = true; }; }, [agentKey, fetchConversations]); useEffect(() => { if (autoScrollEnabled) { scrollToBottom(isStreaming ? 'auto' : 'smooth'); } }, [activeId, autoScrollEnabled, conversations, isStreaming, scrollToBottom]); const skillNameMap = useMemo(() => { const map: Record = {}; skills.forEach((skill) => { map[skill.key] = skill.name; }); return map; }, [skills]); const activeConversation = useMemo( () => conversations.find((item) => item.id === activeId) || null, [activeId, conversations], ); useEffect(() => { if (activeConversation?.model) { setChatModel(activeConversation.model); return; } if (form.default_model) { setChatModel(form.default_model); return; } if (globalDefaultModel) { setChatModel(globalDefaultModel); } }, [activeConversation?.id, activeConversation?.model, form.default_model, globalDefaultModel]); useEffect(() => { setAutoScrollEnabled(true); window.setTimeout(() => { scrollToBottom('auto'); }, 0); }, [activeId, scrollToBottom]); const activeModelLabel = useMemo(() => { const current = models.find((item) => item.id === chatModel); return current?.name || chatModel || '跟随默认模型'; }, [chatModel, models]); const removeBoundSkill = (key: string) => { setBoundSkills((prev) => prev.filter((item) => item !== key)); }; const handleSave = async () => { if (!detail) { return; } setSaving(true); setSaveMsg(null); try { const res = await fetch(`${backUrl}/api/agents/${detail.key}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: form.name, description: form.description, structured_prompt: form.structured_prompt, chat_prompt: form.chat_prompt, default_model: form.default_model, bound_skills: boundSkills, }), }); if (!res.ok) { throw new Error(`保存失败:${res.status}`); } const updated = (await res.json()) as AgentDetail; setDetail(updated); setForm((prev) => ({ ...prev, name: updated.name || '', description: updated.description || '', structured_prompt: updated.structured_prompt || '', chat_prompt: updated.chat_prompt || '', default_model: updated.default_model || prev.default_model, })); setBoundSkills(updated.bound_skills || []); setChatModel((current) => current || updated.default_model || ''); showTransientMessage('保存成功'); } catch (e) { setSaveMsg(e instanceof Error ? e.message : '保存失败'); } finally { setSaving(false); } }; const handleNewConversation = async () => { try { const created = await createConversationOnServer('新对话', chatModel || form.default_model); upsertConversationState(created); setActiveId(created.id); openHistoryPanel(); } catch (e) { setSaveMsg(e instanceof Error ? e.message : '创建对话失败'); } }; const handleDeleteConversation = async (id: string) => { try { const res = await fetch(`${backUrl}/api/agents/${agentKey}/conversations/${id}`, { method: 'DELETE', }); if (!res.ok) { throw new Error(`删除对话失败:${res.status}`); } setConversations((prev) => { const next = prev.filter((item) => item.id !== id); setActiveId((current) => (current === id ? next[0]?.id || null : current)); return next; }); } catch (e) { setSaveMsg(e instanceof Error ? e.message : '删除对话失败'); } }; const handleRenameConversation = async (id: string, title: string) => { try { const updated = await updateConversationOnServer(id, { title }); upsertConversationState(updated); if (activeId === id) { setActiveId(updated.id); } } catch (e) { setSaveMsg(e instanceof Error ? e.message : '重命名失败'); } }; const handleClearAllConversations = async () => { try { const res = await fetch(`${backUrl}/api/agents/${agentKey}/conversations`, { method: 'DELETE', }); if (!res.ok) { throw new Error(`清空聊天记录失败:${res.status}`); } setConversations([]); setActiveId(null); showTransientMessage('聊天记录已清空'); } catch (e) { setSaveMsg(e instanceof Error ? e.message : '清空聊天记录失败'); } }; const handleStop = () => { abortRef.current?.abort(); abortRef.current = null; setIsStreaming(false); setStreamingMsgId(null); }; const handleMessageListScroll = () => { const container = messageListRef.current; if (!container || programmaticScrollRef.current) { return; } const distanceToBottom = container.scrollHeight - container.scrollTop - container.clientHeight; const nearBottom = distanceToBottom <= 80; setAutoScrollEnabled(nearBottom); }; const handleResumeAutoScroll = () => { setAutoScrollEnabled(true); scrollToBottom('smooth'); }; const handleSend = async () => { const text = chatInput.trim(); if (!text || !detail) { return; } let conversation = activeConversation; try { if (!conversation) { conversation = await createConversationOnServer( text.slice(0, 16) || '新对话', chatModel || form.default_model, ); upsertConversationState(conversation); setActiveId(conversation.id); } } catch (e) { setSaveMsg(e instanceof Error ? e.message : '创建对话失败'); return; } const conversationId = conversation.id; const previousMessages = conversation.messages.slice(); const title = previousMessages.length === 0 ? text.slice(0, 16) || conversation.title || '新对话' : conversation.title; const userMsg: ConversationMessage = { id: `m-${Date.now()}-u`, role: 'user', content: text, created_at: Date.now() / 1000, }; const assistantMsg: ConversationMessage = { id: `m-${Date.now()}-a`, role: 'assistant', content: '', created_at: Date.now() / 1000 + 0.001, }; let assistantContent = ''; const effectiveModel = chatModel || conversation.model || form.default_model || globalDefaultModel; const optimisticMessages = [...previousMessages, userMsg, assistantMsg]; setAutoScrollEnabled(true); upsertConversationState({ ...conversation, title, model: effectiveModel, updatedAt: Date.now() / 1000, messages: optimisticMessages, }); setActiveId(conversationId); setChatInput(''); setIsStreaming(true); setStreamingMsgId(assistantMsg.id); const controller = new AbortController(); abortRef.current = controller; const appendDelta = (delta: string) => { assistantContent += delta; setConversations((prev) => prev.map((item) => { if (item.id !== conversationId) { return item; } const messages = item.messages.slice(); const idx = messages.findIndex((message) => message.id === assistantMsg.id); if (idx !== -1) { messages[idx] = { ...messages[idx], content: messages[idx].content + delta, }; } return { ...item, messages, updatedAt: Date.now() / 1000 }; }), ); }; try { const res = await fetch(`${backUrl}/api/agents/${detail.key}/chat/stream`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: text, history: previousMessages.map((item) => ({ role: item.role, content: item.content })), model: effectiveModel || undefined, output_format: 'markdown', }), signal: controller.signal, }); if (!res.ok || !res.body) { throw new Error(`流式请求失败:${res.status}`); } const reader = res.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 }); let sepIdx = buffer.indexOf('\n\n'); while (sepIdx !== -1) { const raw = buffer.slice(0, sepIdx).replace(/\r/g, ''); buffer = buffer.slice(sepIdx + 2); let eventName = 'message'; let dataStr = ''; raw.split('\n').forEach((line) => { if (line.startsWith('event:')) { eventName = line.slice(6).trim(); } else if (line.startsWith('data:')) { dataStr += line.slice(5).trim(); } }); if (dataStr) { try { const data = JSON.parse(dataStr) as { text?: string; message?: string }; if (eventName === 'delta' && typeof data.text === 'string') { appendDelta(data.text); } else if (eventName === 'error' && data.message) { appendDelta(`\n[错误] ${data.message}`); } } catch { // ignore malformed SSE payloads } } sepIdx = buffer.indexOf('\n\n'); } } } catch (err) { if ((err as Error)?.name !== 'AbortError') { const message = err instanceof Error ? err.message : '请求失败'; if (!assistantContent) { assistantContent = `[错误] ${message}`; } setConversations((prev) => prev.map((item) => { if (item.id !== conversationId) { return item; } const messages = item.messages.slice(); const idx = messages.findIndex((messageItem) => messageItem.id === assistantMsg.id); if (idx !== -1) { messages[idx] = { ...messages[idx], content: messages[idx].content || `[错误] ${message}`, }; } return { ...item, messages, updatedAt: Date.now() / 1000 }; }), ); } } finally { setIsStreaming(false); setStreamingMsgId(null); abortRef.current = null; try { const persisted = await updateConversationOnServer(conversationId, { title, model: effectiveModel || undefined, messages: [ ...previousMessages, userMsg, { ...assistantMsg, content: assistantContent, }, ], }); upsertConversationState(persisted); } catch (e) { setSaveMsg(e instanceof Error ? e.message : '保存聊天记录失败'); } } }; if (loading) { return
加载中...
; } if (error || !detail) { return
{error || '智能体不存在'}
; } return (
{pickerOpen && ( setPickerOpen(false)} onConfirm={async (keys) => { setBoundSkills(keys); setPickerOpen(false); if (!detail) { return; } try { const res = await fetch(`${backUrl}/api/agents/${detail.key}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ bound_skills: keys }), }); if (!res.ok) { throw new Error(`更新技能绑定失败:${res.status}`); } const updated = (await res.json()) as AgentDetail; setBoundSkills(updated.bound_skills || []); showTransientMessage('技能绑定已更新'); } catch (e) { setSaveMsg(e instanceof Error ? e.message : '技能绑定失败'); } }} /> )} {historyVisible ? (
({ id: item.id, title: item.title, updatedAt: item.updatedAt, }))} activeId={activeId} onClose={closeHistoryPanel} onOpen={openHistoryPanel} onSelect={(id) => setActiveId(id)} onNew={() => void handleNewConversation()} onDelete={(id) => void handleDeleteConversation(id)} onRename={(id, title) => void handleRenameConversation(id, title)} onClearAll={() => void handleClearAllConversations()} />
) : null}
{!historyOpen && ( )}

{form.name || detail.key}

{detail.key}

聊天模型
{saveMsg && ( {saveMsg} )}
{!autoScrollEnabled && ( )}
void handleSend()} onStop={handleStop} isSending={isStreaming} isDarkMode={isDark} showUpload={false} placeholder={`给 ${form.name || detail.key} 发送消息...`} showBorderTop />