import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { chatAPI, knowledgeAPI, marketAPI, evolutionAPI } from '@/api'; import { formatChatTime } from '@/utils/datetime'; import type { Conversation, Message } from '@/types'; import './ChatPage.css'; // ===== 增强渲染依赖 ===== import 'highlight.js/styles/github.css'; import 'katex/dist/katex.min.css'; import mermaid from 'mermaid'; mermaid.initialize({ startOnLoad: false, theme: 'default' }); // ===== AI 回复异常检测 ===== const AI_ERROR_PATTERNS = [ 'AI模型调用失败', 'AI服务暂时不可用', 'AI模型返回了空响应', 'AI模型已处理工具调用结果', '该模型缺少API密钥或端点配置', '请选择一个已配置的AI模型', '请先在后台管理-大模型库配置默认对话模型', '⚠️ 多 Agent 编排执行失败', '⚠️ 外部 Agent 调用失败', '编排未产生内容', '外部 Agent 未返回内容', ]; function isAiError(content: string): boolean { if (!content) return false; return AI_ERROR_PATTERNS.some(p => content.includes(p)); } // ===== Enhanced Markdown Renderer ===== // 媒体(图片/视频)以受限缩略图呈现,并提供下载(格式+尺寸)选项 import { renderMarkdownToHtml, MediaInfo } from '@/utils/markdown'; // 全屏灯箱预览 const ChatLightbox: React.FC<{ src: string; type: 'img' | 'video'; onClose: () => void }> = ({ src, type, onClose }) => { useEffect(() => { const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; document.addEventListener('keydown', h); return () => document.removeEventListener('keydown', h); }, [onClose]); return (
e.stopPropagation()}> {type === 'video' ?
); }; // 根据原始尺寸推算 3 个建议下载尺寸(保持宽高比) function computeSizes(natural: { w: number; h: number }) { if (natural.w && natural.h) { const ratio = natural.w / natural.h; const fit = (maxDim: number) => ratio >= 1 ? { w: maxDim, h: Math.round(maxDim / ratio) } : { w: Math.round(maxDim * ratio), h: maxDim }; return [ { label: '小图', ...fit(512) }, { label: '中图', ...fit(1024) }, { label: '原始', w: natural.w, h: natural.h }, ]; } // 未知原始尺寸时按生成默认 4:3 给出三档 return [ { label: '小图', w: 512, h: 384 }, { label: '中图', w: 1024, h: 768 }, { label: '大图', w: 2048, h: 1536 }, ]; } // 客户端画布转换格式+尺寸后下载;跨域受限时回退到新标签页打开原图 function downloadImage(src: string, format: 'png' | 'jpg' | 'webp', w: number, h: number) { const fallback = () => window.open(src, '_blank'); const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => { try { const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d'); if (!ctx) { fallback(); return; } if (format === 'jpg') { ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, w, h); } ctx.drawImage(img, 0, 0, w, h); const mime = format === 'jpg' ? 'image/jpeg' : format === 'webp' ? 'image/webp' : 'image/png'; canvas.toBlob((blob) => { if (!blob) { fallback(); return; } const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `aiflow-image-${w}x${h}.${format}`; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); }, mime, 0.92); } catch (e) { fallback(); } }; img.onerror = fallback; img.src = src; } // 媒体渲染组件:固定尺寸缩略图 + 点击放大 + 下载(格式/尺寸) const ChatMedia: React.FC<{ info: MediaInfo }> = ({ info }) => { const [open, setOpen] = useState(false); const [dlOpen, setDlOpen] = useState(false); const [fmt, setFmt] = useState<'png' | 'jpg' | 'webp'>('png'); const [natural, setNatural] = useState<{ w: number; h: number }>({ w: 0, h: 0 }); if (info.type === 'video') { return (
); } const sizes = computeSizes(natural); return (
{info.alt setOpen(true)} onLoad={(e) => { const im = e.currentTarget; if (im.naturalWidth && im.naturalHeight) setNatural({ w: im.naturalWidth, h: im.naturalHeight }); }} /> {dlOpen && (
e.stopPropagation()}>
下载图片
格式
{(['png', 'jpg', 'webp'] as const).map((f) => ( ))}
尺寸
{sizes.map((s, i) => ( ))}
)} {open && setOpen(false)} />}
); }; const MarkdownContent: React.FC<{ content: string }> = ({ content }) => { // 渲染管线(代码块/加粗/表格/换行/媒体占位符等)已抽到 src/utils/markdown.ts, // 组件只负责把返回的 html 切分为「文本片段 + 媒体组件」。 const { html, media } = useMemo(() => renderMarkdownToHtml(content), [content]); const nodes = useMemo(() => { // 12. 拆分为文本片段与媒体组件(媒体以 \x00MEDIA\x00 占位符保留在 html 中) const parts = html.split(/(\x00MEDIA\d+\x00)/g); return parts.map((part: string, i: number) => { const m = part.match(/^\x00MEDIA(\d+)\x00$/); if (m) { const info = media[parseInt(m[1])]; if (info && info.src) return ; return null; } if (!part) return null; // 仅清除被媒体切分产生的悬空

标签,保留段落内部的成对

let s = part.replace(/^<\/p>/, '').replace(/

$/, ''); if (!s.trim()) return null; return

; }); }, [html, media]); const containerRef = useRef(null); useEffect(() => { if (!containerRef.current) return; const diagrams = containerRef.current.querySelectorAll('.md-mermaid:not(.md-mermaid-rendered)'); if (diagrams.length === 0) return; const timer = setTimeout(async () => { for (const el of diagrams) { try { const code = el.getAttribute('data-code') || ''; if (!code) continue; const id = 'mermaid-' + Math.random().toString(36).slice(2, 10); const { svg } = await mermaid.render(id, code); el.innerHTML = svg; el.classList.add('md-mermaid-rendered'); } catch (e) { el.innerHTML = '
Mermaid: ' + (e as Error).message + '
'; } } }, 50); return () => clearTimeout(timer); }, [content]); return
{nodes}
; }; // 解析后端返回的「模糊创作意图」确认指令:<>{json}<> function parseClarify(content: string): { prompt: string; options: any[] } | null { if (!content || typeof content !== 'string') return null; const m = content.match(/^<>([\s\S]+)<>$/); if (!m) return null; try { const obj = JSON.parse(m[1]); if (obj && Array.isArray(obj.options)) return obj; } catch { /* ignore */ } return null; } // 模糊创作意图确认卡片:让用户选择产出方向(文案/图片/视频/其他) const ClarifyCard: React.FC<{ data: { prompt: string; options: any[] }; onChoose: (opt: any) => void }> = ({ data, onChoose }) => { return (
🤔 这个请求的方向还不太明确,您希望以哪种形式呈现?
「{data.prompt}」
{data.options.map((opt: any) => ( ))}
); }; // ===== Time Formatting ===== function formatTime(iso: string): string { return formatChatTime(iso); } // ===== Helpers: normalize API responses ===== function unwrapList(res: any): any[] { const data = res?.data !== undefined ? res.data : res; if (Array.isArray(data)) return data; if (data?.items) return data.items; return []; } function unwrapData(res: any): any { return res?.data !== undefined ? res.data : res; } // ===== Icons ===== const IconSearch = () => ( ); const IconPlus = () => ( ); const IconMore = () => ( ); const IconSend = () => ( ); const IconChevronDown = () => ( ); const IconEdit = () => ( ); const IconArchive = () => ( ); const IconTrash = () => ( ); const IconPin = () => ( ); const IconGraph = () => ( ); const IconKnowledge = () => ( ); const IconAgent = () => ( ); const IconSkill = () => ( ); const IconMcp = () => ( ); const IconPaperclip = () => ( ); const IconMic = () => ( ); const IconStop = () => ( ); const IconVolume = () => ( ); const IconModel = () => ( ); const IconEmpty = () => ( ); const IconSpinner = () => ( ); // ===== Dropdown Component ===== interface DropdownProps { trigger: React.ReactNode; children: React.ReactNode; className?: string; align?: 'left' | 'right'; dropup?: boolean; closeOnClick?: boolean; } const Dropdown: React.FC = ({ trigger, children, className = '', align = 'left', dropup = false, closeOnClick = true }) => { const [open, setOpen] = useState(false); const ref = useRef(null); const menuRef = useRef(null); const openRef = useRef(open); openRef.current = open; useEffect(() => { const handleClickOutside = (e: MouseEvent) => { const target = e.target as Node; // 如果点击在wrapper内部,不关闭 if (ref.current?.contains(target)) return; setOpen(false); }; document.addEventListener('mousedown', handleClickOutside, true); return () => document.removeEventListener('mousedown', handleClickOutside, true); }, []); // 打开时处理z-index useEffect(() => { if (open && dropup) { const menu = menuRef.current; if (menu) { menu.style.zIndex = '9999'; } } }, [open, dropup]); const alignClass = align === 'right' ? 'align-right' : 'align-left'; const dropupClass = dropup ? 'dropup' : ''; const handleTriggerMouseDown = (e: React.MouseEvent) => { // 阻止冒泡到document,防止被handleClickOutside误关 e.stopPropagation(); // 基于当前状态切换(在mousedown阶段决定,避免与click冲突) setOpen(prev => !prev); }; const handleTriggerClick = (e: React.MouseEvent) => { // 阻止click冒泡到父级的导航onClick e.stopPropagation(); e.preventDefault(); }; return (
{trigger}
{open && (
e.stopPropagation()} onClick={(e) => { e.stopPropagation(); if (closeOnClick) setOpen(false); }} > {children}
)}
); }; // ===== AI 思考 / 执行过程面板(顶部展开 + 自动收拢) ===== // autoExpand=true 时(本轮刚产生的助手消息)首屏展开呈现思考链路, // 6s 后若用户未手动干预则自动收拢为一行摘要,避免与回复内容冲突。 // 展开时自动滚动到视图内,带脉冲动画提示用户注意。 const AiProcessBlock: React.FC<{ steps: any[]; autoExpand: boolean }> = ({ steps, autoExpand }) => { const [open, setOpen] = useState(autoExpand); const [fresh, setFresh] = useState(autoExpand); const timerRef = useRef(null); const touchedRef = useRef(false); const blockRef = useRef(null); useEffect(() => { if (!autoExpand) { setOpen(false); setFresh(false); return; } setOpen(true); setFresh(true); touchedRef.current = false; // 自动滚动到视图内(上方留 60px 避免被顶部遮挡) if (blockRef.current) { blockRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' }); } timerRef.current = window.setTimeout(() => { if (!touchedRef.current) { setOpen(false); // 收拢后短暂保留 fresh 样式再移除 setTimeout(() => setFresh(false), 500); } }, 6000); return () => { if (timerRef.current) window.clearTimeout(timerRef.current); }; }, [autoExpand]); if (!steps || steps.length === 0) return null; const toggle = () => { touchedRef.current = true; setFresh(false); if (timerRef.current) { window.clearTimeout(timerRef.current); timerRef.current = null; } setOpen(o => !o); }; return (
{ e.preventDefault(); toggle(); }}> 🧠 AI 思考 / 执行过程({steps.length} 步 — {open ? '自动收拢中... 点击手动收起' : '点击展开查看'})
{steps.map((s: any, i: number) => (
{s.type === 'thinking' ? '💡' : s.type === 'skill' ? '🛠' : '🔌'}
{s.type === 'thinking' && (<>思考

{s.text}

)} {s.type === 'skill' && (<>调用技能:{s.name}

{(s.result || '').slice(0, 400)}

)} {s.type === 'mcp' && (<>MCP · {s.server}:{s.name}

{(s.result || '').slice(0, 400)}

)}
))}
); }; // 思考过程收拢式展示(缺陷5:默认折叠;fresh 流式消息自动展开,数秒后自动收拢) const ReasoningBlock: React.FC<{ text: string; fresh: boolean }> = ({ text, fresh }) => { const [open, setOpen] = useState(fresh); const [isFresh, setFreshState] = useState(fresh); const touchedRef = useRef(false); const timerRef = useRef(null); useEffect(() => { if (!fresh) { setOpen(false); setFreshState(false); return; } setOpen(true); setFreshState(true); touchedRef.current = false; timerRef.current = window.setTimeout(() => { if (!touchedRef.current) { setOpen(false); setTimeout(() => setFreshState(false), 500); } }, 5000); return () => { if (timerRef.current) window.clearTimeout(timerRef.current); }; }, [fresh]); if (!text) return null; const toggle = () => { touchedRef.current = true; setFreshState(false); if (timerRef.current) window.clearTimeout(timerRef.current); setOpen(o => !o); }; return (
{ e.preventDefault(); toggle(); }}> 🤔 AI 思考过程{open ? '(收拢中... 点击收起)' : '(点击展开)'}
💡

{text}

); }; // ===== Main Component ===== const ChatPage: React.FC = () => { const { id } = useParams<{ id?: string }>(); const navigate = useNavigate(); const [searchQuery, setSearchQuery] = useState(''); const [selectedModelId, setSelectedModelId] = useState('auto'); const [chatModels, setChatModels] = useState([]); const [inputValue, setInputValue] = useState(''); const [sending, setSending] = useState(false); const [sendingTimeout, setSendingTimeout] = useState(false); const sendingTimerRef = useRef | null>(null); // 正在 SSE 流式渲染的助手消息 id;非空时隐藏"思考中"占位气泡 const [streamingId, setStreamingId] = useState(null); // 流式中断控制器(缺陷4:支持「停止生成」) const abortRef = useRef(null); const stopStreaming = () => { try { abortRef.current?.abort(); } catch { /* noop */ } }; const [conversations, setConversations] = useState([]); const [messages, setMessages] = useState([]); const [loadingConversations, setLoadingConversations] = useState(true); const [loadingMessages, setLoadingMessages] = useState(false); // 本轮对话新产生的助手消息 id 集合,用于触发"思考过程"首屏展开 const [freshMsgIds, setFreshMsgIds] = useState>(new Set()); const [knowledgeBases, setKnowledgeBases] = useState([]); const [agents, setAgents] = useState([]); const [skills, setSkills] = useState([]); const [mcps, setMcps] = useState([]); // 多选组合模式 const [selectedAgentIds, setSelectedAgentIds] = useState([]); const [selectedSkillIds, setSelectedSkillIds] = useState([]); const [selectedKbIds, setSelectedKbIds] = useState([]); const [selectedMcpIds, setSelectedMcpIds] = useState([]); // 文件附件 const [attachments, setAttachments] = useState<{ name: string; file_type: string; content?: string }[]>([]); const fileInputRef = useRef(null); const recognitionRef = useRef(null); const [recording, setRecording] = useState(false); const messagesEndRef = useRef(null); const inputRef = useRef(null); // Knowledge graph conversion state const [kgModalOpen, setKgModalOpen] = useState(false); const [kgStep, setKgStep] = useState<1 | 2 | 3>(1); const [kgResult, setKgResult] = useState<{ nodes: number; edges: number; kbName: string; kbId: string } | null>(null); const [kgError, setKgError] = useState(null); const [kgConvId, setKgConvId] = useState(null); // Load models from backend API useEffect(() => { chatAPI.getModels().then((res: any) => { const data = res.data || res; const models = Array.isArray(data) ? data : (data?.items || []); setChatModels(models); if (models.length > 0 && !selectedModelId) { setSelectedModelId('auto'); } }).catch(() => { setChatModels([]); }); }, []); // Load conversations from API const loadConversations = useCallback(async () => { try { setLoadingConversations(true); const res = await chatAPI.getConversations(); const convs = unwrapList(res); // Sort: pinned first, then by updatedAt desc const sorted = [...convs].sort((a: any, b: any) => { if (a.pinned && !b.pinned) return -1; if (!a.pinned && b.pinned) return 1; return new Date(b.updatedAt || b.updated_at || 0).getTime() - new Date(a.updatedAt || a.updated_at || 0).getTime(); }); // Normalize field names const mapped: Conversation[] = sorted.map((c: any) => ({ id: c.id, userId: c.userId || c.user_id || '', title: c.title || '未命名对话', modelId: c.modelId || c.model_id || '', status: c.status || 'active', pinned: c.pinned || false, knowledgeGraphCount: c.knowledgeGraphCount ?? c.knowledge_graph_count ?? 0, lastMessage: c.lastMessage || c.last_message || '', createdAt: c.createdAt || c.created_at || new Date().toISOString(), updatedAt: c.updatedAt || c.updated_at || new Date().toISOString(), })); setConversations(mapped); } catch (err) { console.error('加载对话列表失败', err); } finally { setLoadingConversations(false); } }, []); // Load conversations on mount useEffect(() => { loadConversations(); }, [loadConversations]); // Load messages for selected conversation useEffect(() => { if (!id) { setMessages([]); return; } const loadMessages = async () => { try { setLoadingMessages(true); const res = await chatAPI.getMessages(id); const msgs = unwrapList(res); const mapped: Message[] = msgs.map((m: any) => ({ id: m.id, conversationId: m.conversationId || m.conversation_id || id, role: m.role, content: m.content, tokenUsed: m.tokenUsed ?? m.token_used ?? 0, createdAt: m.createdAt || m.created_at || new Date().toISOString(), })); setMessages(mapped); // 页面刷新时若仍有「⏳」占位的媒体消息,继续轮询后端回写结果 mapped.forEach((m) => { if (m.role === 'assistant' && typeof m.content === 'string' && m.content.startsWith('⏳')) { startMediaPolling(id, m.id); } }); } catch (err) { console.error('加载消息失败', err); setMessages([]); } finally { setLoadingMessages(false); } }; loadMessages(); }, [id]); // 媒体(图像/视频)异步生成轮询:后端先返回「⏳」占位,前端定时拉取该消息最新内容并回写 const pollMediaRef = useRef | null>>({}); const stopMediaPolling = (msgId: string) => { const t = pollMediaRef.current[msgId]; if (t) { clearInterval(t); pollMediaRef.current[msgId] = null; } }; const startMediaPolling = (convId: string, msgId: string) => { if (pollMediaRef.current[msgId]) return; // 已在轮询 let ticks = 0; const timer = setInterval(async () => { ticks += 1; try { const res = await chatAPI.getMessages(convId); const msgs: any[] = unwrapList(res); const updated = msgs.find((m: any) => m.id === msgId); if (!updated) { stopMediaPolling(msgId); return; } const content: string = updated.content || ''; if (content.startsWith('⏳')) { if (ticks > 120) stopMediaPolling(msgId); // ~6 分钟兜底 return; } // 生成完成:回写消息内容 setMessages(prev => prev.map(m => m.id === msgId ? { ...m, content, tokenUsed: updated.tokenUsed ?? updated.token_used ?? m.tokenUsed, createdAt: updated.createdAt || updated.created_at || m.createdAt, used_skills: updated.usedSkills ?? updated.used_skills ?? m.used_skills, used_mcp: updated.usedMcp ?? updated.used_mcp ?? m.used_mcp, } : m)); stopMediaPolling(msgId); } catch (e) { if (ticks > 120) stopMediaPolling(msgId); // 网络异常兜底 } }, 3000); pollMediaRef.current[msgId] = timer; }; // 卸载时清理所有轮询定时器 useEffect(() => { return () => { Object.values(pollMediaRef.current).forEach(t => t && clearInterval(t)); }; }, []); // Load knowledge bases, agents, skills for attachment dropdowns useEffect(() => { knowledgeAPI.getBases().then((res: any) => { const data = res?.data !== undefined ? res.data : res; if (data?.bases && Array.isArray(data.bases)) { setKnowledgeBases(data.bases); } else if (Array.isArray(data)) { setKnowledgeBases(data); } else if (data?.items) { setKnowledgeBases(data.items); } else { setKnowledgeBases([]); } }).catch(() => {}); marketAPI.getApps({ type: 'agent' }).then((res: any) => { const data = res?.data !== undefined ? res.data : res; let apps: any[] = []; if (Array.isArray(data)) apps = data; else if (data?.items) apps = data.items; // 显示所有已发布的Agent(不需要安装即可在聊天中使用) setAgents(apps.filter((a: any) => a.status === 'published' || a.status === 'active' || !a.status)); }).catch(() => {}); marketAPI.getApps({ type: 'skill' }).then((res: any) => { const data = res?.data !== undefined ? res.data : res; let apps: any[] = []; if (Array.isArray(data)) apps = data; else if (data?.items) apps = data.items; // 显示所有已发布的技能(不需要安装即可在聊天中使用) setSkills(apps.filter((a: any) => a.status === 'published' || a.status === 'active' || !a.status)); }).catch(() => {}); marketAPI.getApps({ type: 'mcp' }).then((res: any) => { const data = res?.data !== undefined ? res.data : res; let apps: any[] = []; if (Array.isArray(data)) apps = data; else if (data?.items) apps = data.items; setMcps(apps.filter((a: any) => a.status === 'published' || a.status === 'active' || !a.status)); }).catch(() => {}); }, []); // Determine selected conversation const selectedConversation = useMemo(() => { if (id) { return conversations.find(c => c.id === id) || null; } return conversations[0] || null; }, [id, conversations]); // Filter conversations by search const filteredConversations = useMemo(() => { const pinned = conversations.filter(c => c.pinned); const unpinned = conversations.filter(c => !c.pinned); if (!searchQuery.trim()) return [...pinned, ...unpinned]; const q = searchQuery.toLowerCase(); return [...pinned.filter(c => c.title.toLowerCase().includes(q)), ...unpinned.filter(c => c.title.toLowerCase().includes(q))]; }, [conversations, searchQuery]); // Selected model info(Auto 自动:按对话意图自动匹配后台各能力默认模型) const isAutoModel = selectedModelId === 'auto'; const AUTO_MODEL = { id: 'auto', name: 'Auto 自动', status: 'free', provider_name: '按能力智能匹配', capabilities: [], price_per_million_tokens: 0 }; const selectedModel = isAutoModel ? AUTO_MODEL : (chatModels.find(m => m.id === selectedModelId) || chatModels[0] || AUTO_MODEL); // Scroll to bottom when messages change useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); // Select first conversation if no id param useEffect(() => { if (!id && conversations.length > 0) { navigate(`/chat/${conversations[0].id}`, { replace: true }); } }, [id, conversations, navigate]); // Handle new conversation const handleNewConversation = async () => { try { const res = await chatAPI.createConversation({ title: '新对话', model_id: selectedModelId === 'auto' ? (chatModels[0]?.id || '') : selectedModelId }); const newConv = unwrapData(res); const conv: Conversation = { id: newConv.id, userId: newConv.userId || newConv.user_id || '', title: newConv.title || '新对话', modelId: newConv.modelId || newConv.model_id || selectedModelId, status: 'active', pinned: false, knowledgeGraphCount: 0, createdAt: newConv.createdAt || newConv.created_at || new Date().toISOString(), updatedAt: newConv.updatedAt || newConv.updated_at || new Date().toISOString(), }; setConversations(prev => [conv, ...prev]); navigate(`/chat/${conv.id}`); } catch (err: any) { alert('创建对话失败: ' + (err.message || '未知错误')); } }; // Handle rename const handleRename = async (convId: string, e: React.MouseEvent) => { e.stopPropagation(); const newTitle = prompt('请输入新名称:'); if (newTitle && newTitle.trim()) { try { await chatAPI.updateConversation(convId, { title: newTitle.trim() }); setConversations(prev => prev.map(c => c.id === convId ? { ...c, title: newTitle.trim() } : c)); } catch (err: any) { alert('重命名失败: ' + (err.message || '未知错误')); } } }; // Handle archive const handleArchive = async (convId: string, e: React.MouseEvent) => { e.stopPropagation(); const conv = conversations.find(c => c.id === convId); if (!conv) return; const newStatus = conv.status === 'archived' ? 'active' : 'archived'; try { await chatAPI.updateConversation(convId, { status: newStatus }); setConversations(prev => prev.map(c => c.id === convId ? { ...c, status: newStatus } as Conversation : c)); } catch (err: any) { alert('操作失败: ' + (err.message || '未知错误')); } }; // Handle delete const handleDelete = async (convId: string, e: React.MouseEvent) => { e.stopPropagation(); if (!confirm('确定要删除这个对话吗?')) return; try { await chatAPI.deleteConversation(convId); const next = conversations.filter(c => c.id !== convId); setConversations(next); if (selectedConversation?.id === convId) { if (next.length > 0) { navigate(`/chat/${next[0].id}`); } else { navigate('/chat'); } } } catch (err: any) { alert('删除失败: ' + (err.message || '未知错误')); } }; // Handle pin / unpin const handlePin = async (convId: string, e: React.MouseEvent) => { e.stopPropagation(); const conv = conversations.find(c => c.id === convId); if (!conv) return; const newPinned = !conv.pinned; try { await chatAPI.updateConversation(convId, { pinned: newPinned }); setConversations(prev => { const updated = prev.map(c => c.id === convId ? { ...c, pinned: newPinned } : c); const pinned = updated.filter(c => c.pinned); const unpinned = updated.filter(c => !c.pinned).sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); return [...pinned, ...unpinned]; }); } catch (err: any) { alert('操作失败: ' + (err.message || '未知错误')); } }; // Handle convert to knowledge graph const handleKnowledgeGraph = async (convId: string, e: React.MouseEvent) => { e.stopPropagation(); setKgModalOpen(true); setKgStep(1); setKgResult(null); setKgError(null); setKgConvId(convId); try { await new Promise(r => setTimeout(r, 600)); setKgStep(2); const res = await chatAPI.convertToKnowledgeGraph(convId); await new Promise(r => setTimeout(r, 400)); setKgStep(3); if (res.code === 0) { const graph = res.data.graph; setKgResult({ nodes: graph.stats.total_nodes, edges: graph.stats.total_edges, kbName: res.data.knowledge_base_name, kbId: res.data.knowledge_base_id, }); // 使用后端返回的真实计数(幂等,始终为 1),避免前端重复累加导致计数虚高 const newCount = res.data.knowledge_graph_count ?? 1; setConversations(prev => prev.map(c => c.id === convId ? { ...c, knowledgeGraphCount: newCount } : c) ); } else { setKgError(res.message || '转换失败'); } } catch (err) { setKgError((err as Error).message || '转换失败,请稍后重试'); } }; // 非流式兜底发送(与历史行为一致)——供 SSE 连接/流式失败时回退 const sendLegacy = async ( payload: any, tempUserMsg: Message, ) => { const res = await chatAPI.sendMessage(selectedConversation!.id, payload); const data = unwrapData(res); // Replace temporary user message and add assistant response setMessages(prev => { const filtered = prev.filter(m => m.id !== tempUserMsg.id); const newMsgs: Message[] = []; if (data.user_message) { newMsgs.push({ id: data.user_message.id, conversationId: selectedConversation?.id || '', role: 'user', content: data.user_message.content, tokenUsed: data.user_message.token_used || 0, createdAt: data.user_message.created_at || new Date().toISOString(), }); } if (data.assistant_message) { newMsgs.push({ id: data.assistant_message.id, conversationId: selectedConversation?.id || '', role: 'assistant', content: data.assistant_message.content, tokenUsed: data.assistant_message.token_used || 0, createdAt: data.assistant_message.created_at || new Date().toISOString(), used_skills: data.assistant_message.used_skills || [], used_mcp: data.assistant_message.used_mcp || [], process_steps: data.assistant_message.process_steps || [], }); } return [...filtered, ...newMsgs]; }); // 标记本轮新产生的助手消息,触发"思考过程"首屏展开 if (data.assistant_message) { setFreshMsgIds(prev => new Set(prev).add(data.assistant_message.id)); } // 媒体异步生成:启动轮询,待后端回写后自动刷新 if (data.assistant_message?.generating) { startMediaPolling(selectedConversation!.id, data.assistant_message.id); } // Refresh conversations to update lastMessage loadConversations(); // 发送后清空本次附件(选择状态仍保留) setAttachments([]); // GAP-07: 对话结束后异步提交轨迹到进化引擎(后台静默,不阻塞UI) if (selectedConversation?.id && data.assistant_message) { const recentMsgs = [ ...(data.user_message ? [{ role: 'user', content: data.user_message.content, tokens: data.user_message.token_used || 0 }] : []), { role: 'assistant', content: data.assistant_message.content, tokens: data.assistant_message.token_used || 0 }, ]; evolutionAPI.submitTrajectory({ conversation_id: selectedConversation.id, messages: recentMsgs, }).catch(() => {}); } }; // SSE 流式发送(Phase 0)——实时增量渲染 AI 回复文本与思考链(reasoning) const streamSend = async ( convId: string, payload: any, tempUserMsg: Message, assistantId: string, ): Promise => { const token = localStorage.getItem('aiflow_token'); const controller = new AbortController(); abortRef.current = controller; const resp = await fetch(`/app/api/chat/conversations/${convId}/messages/stream`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), }, body: JSON.stringify(payload), signal: controller.signal, }); if (!resp.ok || !resp.body) { throw new Error(`SSE 连接失败 (${resp.status})`); } const reader = resp.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; let accReasoning = ''; let gotDone = false; let pendingDelta = ''; let reasoningDirty = false; let rafId: number | null = null; // 缺陷4:用 requestAnimationFrame 合并同一帧内的多个 delta/reasoning,避免逐 token 重渲染抖动 const flushBuffered = () => { rafId = null; if (!pendingDelta && !reasoningDirty) return; const piece = pendingDelta; pendingDelta = ''; const r = reasoningDirty ? accReasoning : null; reasoningDirty = false; setMessages(prev => prev.map(m => { if (m.id !== assistantId) return m; const patch: any = {}; if (piece) patch.content = (m.content || '') + piece; if (r !== null) patch.reasoning = r; return { ...m, ...patch }; })); }; const scheduleFlush = () => { if (rafId == null) rafId = requestAnimationFrame(flushBuffered); }; const finalizeFlush = () => { if (rafId != null) { cancelAnimationFrame(rafId); rafId = null; } flushBuffered(); }; while (true) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); let sep: number; while ((sep = buffer.indexOf('\n\n')) >= 0) { const raw = buffer.slice(0, sep); buffer = buffer.slice(sep + 2); const dataLine = raw.split('\n').find(l => l.startsWith('data: ')); if (!dataLine) continue; let evt: any; try { evt = JSON.parse(dataLine.slice(6)); } catch { continue; } if (evt.type === 'delta') { pendingDelta += evt.content || ''; scheduleFlush(); } else if (evt.type === 'reasoning') { accReasoning += evt.content || ''; reasoningDirty = true; scheduleFlush(); } else if (evt.type === 'step') { // 缺陷2:实时增量过程步骤,前端随事件收拢式渲染 const step = evt.step; const idx = typeof evt.index === 'number' ? evt.index : -1; setMessages(prev => prev.map(m => { if (m.id !== assistantId) return m; const steps: any[] = (m as any).process_steps || []; const next = steps.slice(); if (idx >= 0 && idx < next.length) next[idx] = step; else next.push(step); return { ...m, process_steps: next }; })); } else if (evt.type === 'done') { gotDone = true; finalizeFlush(); const finalId = evt.message_id || assistantId; setMessages(prev => prev.map(m => { if (m.id !== assistantId) return m; return { ...m, id: finalId, content: evt.content ?? m.content, process_steps: evt.process_steps || [], used_skills: evt.used_skills || [], used_mcp: evt.used_mcp || [], generating: !!evt.generating, }; })); setStreamingId(null); abortRef.current = null; if (evt.generating) startMediaPolling(convId, finalId); setFreshMsgIds(prev => new Set(prev).add(finalId)); loadConversations(); setAttachments([]); if (convId && evt.content) { evolutionAPI.submitTrajectory({ conversation_id: convId, messages: [ { role: 'user', content: tempUserMsg.content, tokens: 0 }, { role: 'assistant', content: evt.content, tokens: 0 }, ], }).catch(() => {}); } } else if (evt.type === 'error') { // 缺陷4:容错——保留已生成内容,不强删,标记中断后优雅收尾 finalizeFlush(); setMessages(prev => prev.map(m => { if (m.id !== assistantId) return m; const note = '\n\n_(生成中断:' + (evt.message || '未知错误') + ')_'; return { ...m, content: (m.content || '') + note }; })); setStreamingId(null); abortRef.current = null; setFreshMsgIds(prev => new Set(prev).add(assistantId)); loadConversations(); gotDone = true; return; } } } if (!gotDone) { // 流结束但未收到 done:已累积的 delta 仍可展示,但视为异常兜底 finalizeFlush(); abortRef.current = null; setStreamingId(null); throw new Error('流式响应未正常结束'); } }; // Handle send message - CALLS REAL API(Phase 0:默认 SSE 流式,失败回退非流式) // 抽出公共发送逻辑,供输入框发送与「模糊创作意图」方向选择复用 const sendText = async (content: string) => { if (!content.trim() || sending || !selectedConversation) return; setSending(true); setSendingTimeout(false); // 60s 超时警告 if (sendingTimerRef.current) clearTimeout(sendingTimerRef.current); sendingTimerRef.current = setTimeout(() => { setSendingTimeout(true); }, 60000); // Add user message optimistically const tempUserMsg: Message = { id: `temp_${Date.now()}`, conversationId: selectedConversation?.id || '', role: 'user', content, tokenUsed: 0, createdAt: new Date().toISOString(), }; setMessages(prev => [...prev, tempUserMsg]); const payload = { content, model_id: selectedModelId || undefined, agent_ids: selectedAgentIds.length > 0 ? selectedAgentIds : undefined, skill_ids: selectedSkillIds.length > 0 ? selectedSkillIds : undefined, knowledge_base_ids: selectedKbIds.length > 0 ? selectedKbIds : undefined, mcp_ids: selectedMcpIds.length > 0 ? selectedMcpIds : undefined, attachments: attachments.length > 0 ? attachments.map(a => ({ name: a.name, file_type: a.file_type, content: a.content || '', })) : undefined, use_tools: selectedSkillIds.length > 0 || selectedAgentIds.length > 0 || selectedMcpIds.length > 0, }; // 先放一个空的助手消息占位,用于 SSE 流式增量渲染 const assistantId = `temp_assistant_${Date.now()}`; setMessages(prev => [...prev, { id: assistantId, conversationId: selectedConversation!.id, role: 'assistant', content: '', tokenUsed: 0, createdAt: new Date().toISOString(), reasoning: '', } as Message]); setStreamingId(assistantId); try { await streamSend(selectedConversation!.id, payload, tempUserMsg, assistantId); } catch (err: any) { // 移除流式占位,回退非流式重新发送 setMessages(prev => prev.filter(m => m.id !== assistantId && m.id !== tempUserMsg.id)); setStreamingId(null); try { await sendLegacy(payload, tempUserMsg); } catch (e2: any) { setMessages(prev => prev.filter(m => m.id !== tempUserMsg.id)); alert('发送失败: ' + (e2?.message || e2?.detail || '未知错误')); } } finally { if (sendingTimerRef.current) { clearTimeout(sendingTimerRef.current); sendingTimerRef.current = null; } setSending(false); setSendingTimeout(false); inputRef.current?.focus(); } }; // 文件上传:读取文本类文件内容作为附件上下文 const handleFileChange = (e: React.ChangeEvent) => { const files = Array.from(e.target.files || []); if (!files.length) return; const textExts = ['txt', 'md', 'markdown', 'csv', 'json', 'log', 'py', 'js', 'ts', 'tsx', 'jsx', 'html', 'css', 'xml', 'yaml', 'yml', 'sql', 'text']; files.forEach(file => { const ext = (file.name.split('.').pop() || '').toLowerCase(); const isText = textExts.includes(ext) || file.type.startsWith('text/'); if (isText && file.size <= 200 * 1024) { const reader = new FileReader(); reader.onload = () => { const content = String(reader.result || ''); setAttachments(prev => [...prev, { name: file.name, file_type: ext || 'txt', content }]); }; reader.readAsText(file); } else { setAttachments(prev => [...prev, { name: file.name, file_type: ext || file.type || 'bin' }]); } }); e.target.value = ''; }; // 语音输入(浏览器 Web Speech API,支持中文) const handleMicClick = () => { const SR: any = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition; if (!SR) { alert('当前浏览器不支持语音输入,请使用 Chrome / Edge 等浏览器。'); return; } if (recording) { recognitionRef.current?.stop(); return; } const rec = new SR(); recognitionRef.current = rec; rec.lang = 'zh-CN'; rec.interimResults = true; rec.continuous = false; rec.onresult = (ev: any) => { let text = ''; for (let i = 0; i < ev.results.length; i++) text += ev.results[i][0].transcript; setInputValue(prev => (prev ? prev + text : text)); }; rec.onend = () => { setRecording(false); recognitionRef.current = null; }; rec.onerror = () => { setRecording(false); recognitionRef.current = null; }; setRecording(true); rec.start(); }; // 语音合成(朗读回复,浏览器 TTS) const speakText = (text: string) => { if (typeof window === 'undefined' || !('speechSynthesis' in window)) { alert('当前浏览器不支持朗读。'); return; } const synth = window.speechSynthesis; synth.cancel(); const plain = text .replace(/```[\s\S]*?```/g, ' ') .replace(/[#>*`_~\-]+/g, ' ') .replace(/\!\[[^\]]*\]\([^)]*\)/g, ' ') .replace(/\[[^\]]*\]\([^)]*\)/g, ' ') .replace(/\s+/g, ' ') .trim(); const u = new SpeechSynthesisUtterance(plain); u.lang = 'zh-CN'; u.rate = 1; synth.speak(u); }; const handleSend = () => { const content = inputValue.trim(); if (!content || sending) return; setInputValue(''); sendText(content); }; // Handle key down const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } }; return (
{/* Left Sidebar - Conversation List */} {/* Right Main Chat Area */}
{selectedConversation ? ( <> {/* Chat Header */}
{selectedModel.name} {(selectedModel.status === 'free' || selectedModel.price_per_million_tokens === 0) ? '免费' : '付费'}
} > {chatModels.filter(m => m.is_enabled !== false).map(model => ( ))} {selectedConversation.title}
{/* Messages Area */}
{loadingMessages ? (

加载消息中...

) : messages.length === 0 ? (

开始一段新对话

向 {selectedModel.name} 发送消息,开始对话

) : ( messages.map(msg => { // Real used_skills from API response const usedSkills: string[] = (msg.role === 'assistant' && msg.used_skills) ? msg.used_skills : []; const usedMcp: string[] = (msg.role === 'assistant' && msg.used_mcp) ? msg.used_mcp : []; const steps: any[] = (msg as any).process_steps || []; // 模糊创作意图确认卡片 const clarify = msg.role === 'assistant' ? parseClarify(msg.content) : null; return (
{msg.role === 'assistant' && steps.length > 0 && ( )} {msg.role === 'assistant' && (msg as any).reasoning && ( )}
{clarify ? ( sendText(`${opt.medium}:${clarify.prompt}`)} /> ) : msg.role === 'assistant' && isAiError(msg.content) ? (
⚠️
) : msg.role === 'assistant' ? ( msg.content ? : (streamingId === msg.id ? 生成中… : null) ) : ( {msg.content} )}
{!clarify && msg.role === 'assistant' && msg.content && ( )} {/* Skill usage tags */} {msg.role === 'assistant' && usedSkills.length > 0 && (
{usedSkills.slice(0, 3).map(skillName => ( { window.location.href = '/evolution?skill=' + encodeURIComponent(skillName); }} title={`使用了专属技能:${skillName}`} > {skillName} ))} {usedSkills.length > 3 && ( +{usedSkills.length - 3} )}
)} {msg.role === 'assistant' && usedMcp.length > 0 && (
{usedMcp.slice(0, 3).map(mcpName => ( {mcpName} ))} {usedMcp.length > 3 && ( +{usedMcp.length - 3} )}
)}
{formatTime(msg.createdAt)} {msg.tokenUsed} tokens
); }) )} {sending && !streamingId && (
{sendingTimeout ? '⏳ 响应时间较长,AI 仍在处理中...' : '思考中...'} {sendingTimeout && ( )}
)}
{/* Input Area */}
{selectedKbIds.length > 0 ? `知识库 (${selectedKbIds.length})` : '知识库'} } >
选择知识库(多选)
{knowledgeBases.map((kb: any) => ( ))} {knowledgeBases.length > 0 && selectedKbIds.length > 0 && (
)} {selectedKbIds.length > 0 && ( )} {knowledgeBases.length === 0 && (
暂无可用知识库
)} {selectedAgentIds.length > 0 ? `Agent (${selectedAgentIds.length})` : 'Agent'} } >
选择 Agent(多选)
{agents.map((agent: any) => ( ))} {agents.length > 0 && selectedAgentIds.length > 0 && (
)} {selectedAgentIds.length > 0 && ( )} {agents.length === 0 && (
暂无可用 Agent
)} {selectedSkillIds.length > 0 ? `技能 (${selectedSkillIds.length})` : '技能'} } >
选择技能(多选)
{skills.map((skill: any) => ( ))} {skills.length > 0 && selectedSkillIds.length > 0 && (
)} {selectedSkillIds.length > 0 && ( )} {skills.length === 0 && (
暂无可用技能
)} {selectedMcpIds.length > 0 ? `MCP (${selectedMcpIds.length})` : 'MCP'} } >
选择 MCP 应用(多选)
{mcps.map((mcp: any) => ( ))} {mcps.length > 0 && selectedMcpIds.length > 0 && (
)} {selectedMcpIds.length > 0 && ( )} {mcps.length === 0 && (
暂无可用 MCP 应用
)}
{attachments.length > 0 && (
{attachments.map((att, idx) => ( {att.name} ))}
)}