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 (

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 ? '(收拢中... 点击收起)' : '(点击展开)'}
);
};
// ===== 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}
))}
)}
>
) : (
选择或创建对话
从左侧列表选择一个对话,或点击上方按钮创建新对话
)}
{/* Knowledge Graph Conversion Progress Modal */}
{kgModalOpen && (
!kgError && kgStep !== 2 && setKgModalOpen(false)}>
e.stopPropagation()}>
转换知识图谱
{kgError ? (
{kgError}
) : kgStep === 3 && kgResult ? (
知识图谱生成完成
{kgResult.nodes}
节点
{kgResult.edges}
条边
已存入知识库:{kgResult.kbName}
) : (
= 1 ? 'active' : ''} ${kgStep > 1 ? 'done' : ''}`}>
{kgStep > 1 ? (
) : kgStep === 1 ? (
) : (
1
)}
提取对话内容
1 ? 'done' : ''}`}>
{kgStep === 2 &&
}
= 2 ? 'active' : ''} ${kgStep > 2 ? 'done' : ''}`}>
{kgStep > 2 ? (
) : kgStep === 2 ? (
) : (
2
)}
构建知识图谱
2 ? 'done' : ''}`} />
= 3 ? 'active' : ''} ${kgStep > 3 ? 'done' : ''}`}>
3
完成
{kgStep === 1 ? '正在提取对话内容...' : kgStep === 2 ? '正在构建知识图谱...' : '处理完成'}
)}
)}
);
};
export default ChatPage;