diff --git a/backend/python/app/api/assets.py b/backend/python/app/api/assets.py
index 562201c..8c49f07 100644
--- a/backend/python/app/api/assets.py
+++ b/backend/python/app/api/assets.py
@@ -368,6 +368,19 @@ def delete_asset(
asset.status = "deleted"
asset.updated_at = datetime.now()
+ # 若资产由知识库文档同步而来,双向删除对应的知识库文档
+ if asset.knowledge_doc_id:
+ try:
+ kb_doc = db.query(KnowledgeDocument).filter(KnowledgeDocument.id == asset.knowledge_doc_id).first()
+ if kb_doc:
+ kb = db.query(KnowledgeBase).filter(KnowledgeBase.id == kb_doc.knowledge_base_id).first()
+ if kb:
+ kb.document_count = max(0, (kb.document_count or 0) - 1)
+ kb.updated_at = datetime.now()
+ db.delete(kb_doc)
+ except Exception:
+ pass
+
LogService.log_operation(
user_id=current_user.id, username=current_user.username, action="delete",
target="asset", target_id=asset.id,
diff --git a/backend/python/app/api/chat.py b/backend/python/app/api/chat.py
index 0018b65..cd0e157 100644
--- a/backend/python/app/api/chat.py
+++ b/backend/python/app/api/chat.py
@@ -4094,7 +4094,28 @@ def _resolve_active_mcp_servers(db: Session, mcp_ids: list) -> list:
-def _discover_installed_skills(db: Session, user_id: str, limit: int = 15) -> list:
+def _list_enabled_mcp_servers(db: Session) -> list:
+ """返回平台所有已启用(is_enabled=True)的 MCP 服务清单,供 AI 准确回答"平台有哪些 MCP"。"""
+ servers = db.query(MCPServer).filter(MCPServer.is_enabled == True).all()
+ out = []
+ for s in servers:
+ try:
+ headers = json.loads(s.headers) if isinstance(s.headers, str) else (s.headers or {})
+ except Exception:
+ headers = {}
+ out.append({
+ "id": s.id,
+ "name": s.name,
+ "description": s.description or "",
+ "transport": s.transport_type or "streamable_http",
+ "endpoint": s.url or "",
+ "url": s.url or "",
+ "headers": headers,
+ })
+ return out
+
+
+def _discover_installed_skills(db: Session, user_id: str, limit: int = 300) -> list:
@@ -5311,6 +5332,7 @@ def _build_system_prompt(
mcp_functions: list = None,
auto_discovered: bool = False,
+ all_mcp: list = None,
) -> tuple:
@@ -5745,6 +5767,15 @@ def _build_system_prompt(
+ # 平台全量 MCP 清单(Issue:让用户询问"平台有哪些 MCP"时 AI 能准确列举,而非凭空编造)
+ if all_mcp:
+ _mcp_inventory = "\n".join([f"- {m.get('name','')}: {m.get('description','') or '(未填写描述)'}" for m in all_mcp])
+ system_parts.append(
+ f"【平台已安装的 MCP 应用清单(共 {len(all_mcp)} 个,均为平台真实接入并启用)】\n{_mcp_inventory}\n"
+ "当用户询问“平台有哪些 MCP / 接入了哪些 MCP / 有哪些工具服务”时,请严格依据以上清单准确列举名称与功能,"
+ "不要编造或遗漏,也不要把未列出的服务说成已接入。"
+ )
+
# MCP 应用注入(Issue 3:让用户询问 MCP 能力时,AI 能“看到”该 MCP 的资料与地址)
@@ -7074,6 +7105,7 @@ def get_messages(
"used_skills": msg.used_skills or [],
+ "used_mcp": msg.used_mcp or [],
@@ -8090,6 +8122,7 @@ async def send_message(
mcp_functions=mcp_function_definitions,
auto_discovered=used_auto_skills,
+ all_mcp=_list_enabled_mcp_servers(db),
)
@@ -8103,6 +8136,10 @@ async def send_message(
+ # 实际调用追踪(Issue:反馈真实使用的技能/MCP,而非注入的全部)
+ called_skill_names = []
+ called_mcp_names = []
+
messages = [{"role": "system", "content": system_prompt}]
@@ -8433,6 +8470,8 @@ async def send_message(
)
+ called_mcp_names.append(_srv.get("name", ""))
+
else:
tool_result = _execute_skill_tool(
@@ -8441,6 +8480,12 @@ async def send_message(
)
+ _sk = next((s for s in skills if s.get("func_name") == func_name), None)
+
+ if _sk:
+
+ called_skill_names.append(_sk.get("name", ""))
+
messages.append({
@@ -8671,7 +8716,7 @@ async def send_message(
- # 更新used_skills
+ # 更新used_skills / used_mcp:优先反馈用户显式指定的;否则反馈本轮实际调用的
@@ -8679,15 +8724,91 @@ async def send_message(
- if skills:
+ used_mcp = []
- used_skills = [s["name"] for s in skills]
+ if effective_skill_ids:
- ai_msg.used_skills = used_skills
+ # 用户显式选择了技能 -> 反馈配对的技能名
+
+
+
+ try:
+
+
+
+ _sel = _resolve_market_skills(db, effective_skill_ids, user_id)
+
+
+
+ used_skills = [s["name"] for s in _sel]
+
+
+
+ except Exception:
+
+
+
+ used_skills = []
+
+
+
+ elif called_skill_names:
+
+
+
+ # 未显式选技能 -> 反馈 AI 本轮实际调用的技能(可能为空)
+
+
+
+ used_skills = list(dict.fromkeys(called_skill_names))
+
+
+
+ if effective_mcp_ids:
+
+
+
+ # 用户显式选择了 MCP -> 反馈配对的 MCP 名
+
+
+
+ try:
+
+
+
+ _sel_mcp = _resolve_active_mcp_servers(db, effective_mcp_ids)
+
+
+
+ used_mcp = [m["name"] for m in _sel_mcp]
+
+
+
+ except Exception:
+
+
+
+ used_mcp = []
+
+
+
+ elif called_mcp_names:
+
+
+
+ used_mcp = list(dict.fromkeys(called_mcp_names))
+
+
+
+ ai_msg.used_skills = used_skills
+
+
+
+ ai_msg.used_mcp = used_mcp
@@ -8980,6 +9101,7 @@ async def send_message(
"used_skills": used_skills,
+ "used_mcp": used_mcp,
diff --git a/backend/python/app/api/im_bot_platform.py b/backend/python/app/api/im_bot_platform.py
index 6fb6ccd..e8a0d4a 100644
--- a/backend/python/app/api/im_bot_platform.py
+++ b/backend/python/app/api/im_bot_platform.py
@@ -188,7 +188,7 @@ def _qihua_reply(text: str, user_id: str = None, platform: str = "im") -> str:
try:
from app.api.im_llm import _call_real_llm
db = SessionLocal()
- return _call_real_llm(db, None, prompt, platform)
+ return _call_real_llm(db, None, prompt, platform, user_id=user_id)
except Exception as e:
return f"(AI 回复生成失败:{e})"
finally:
diff --git a/backend/python/app/api/knowledge.py b/backend/python/app/api/knowledge.py
index 72edbad..bcec7ac 100644
--- a/backend/python/app/api/knowledge.py
+++ b/backend/python/app/api/knowledge.py
@@ -1149,15 +1149,21 @@ def _split_content_into_chunks(content: str, chunk_size: int = 500) -> list:
def _sync_asset_for_knowledge_doc(db: Session, user_id: str, doc_id: str, filename: str, file_type: str, file_size: int):
"""将知识库文档同步为资产记录(问题9:资产关联知识库文档用量)"""
try:
+ # 文件类型归一化为前端子分类标签(docx/doc->word 等),确保出现在「文件 > Word」子栏
+ _ft_label = {
+ "pdf": "pdf", "docx": "word", "doc": "word", "xlsx": "excel", "xls": "excel",
+ "pptx": "ppt", "ppt": "ppt", "csv": "txt", "md": "md", "txt": "txt",
+ }.get((file_type or "").lower(), "other")
asset = Asset(
id=gen_uuid(),
user_id=user_id,
name=filename,
- category="doc",
+ category="file",
description=f"知识库文档 (ID: {doc_id})",
- file_type=file_type,
+ file_type=_ft_label,
size=file_size,
status="active",
+ knowledge_doc_id=doc_id,
created_at=datetime.now(),
updated_at=datetime.now(),
)
@@ -1191,6 +1197,15 @@ def delete_document(
pass # total_size removed
kb.updated_at = datetime.now()
+ # 同步删除知识库文档对应的资产记录(双向同步)
+ try:
+ linked_asset = db.query(Asset).filter(Asset.knowledge_doc_id == doc_id).first()
+ if linked_asset:
+ linked_asset.status = "deleted"
+ linked_asset.updated_at = datetime.now()
+ except Exception as _e:
+ logger.warning(f"同步删除关联资产失败: {_e}")
+
db.delete(doc)
db.commit()
diff --git a/backend/python/app/models.py b/backend/python/app/models.py
index 6cf665d..60067d9 100644
--- a/backend/python/app/models.py
+++ b/backend/python/app/models.py
@@ -106,6 +106,7 @@ class Message(Base):
mcp_ids = Column(JSON, default=None, comment="该轮对话选择的MCP应用ID列表")
attachments = Column(JSON, default=None, comment="该轮对话附带的文件附件列表")
used_skills = Column(JSON, default=None, comment="该轮对话调用的进化技能名列表")
+ used_mcp = Column(JSON, default=None, comment="该轮对话调用的MCP应用名列表")
created_at = Column(DateTime, default=datetime.now)
@@ -747,6 +748,7 @@ class Asset(Base):
content = Column(Text, default=None)
novel_meta = Column(JSON, default=None)
status = Column(String(10), nullable=False, default="active") # active / deleted
+ knowledge_doc_id = Column(String(36), default=None, comment="关联的知识库文档ID(由知识库同步资产时写入)")
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
diff --git a/frontend/src/pages/front/AssetsPage.tsx b/frontend/src/pages/front/AssetsPage.tsx
index 942d86a..0ad69b6 100644
--- a/frontend/src/pages/front/AssetsPage.tsx
+++ b/frontend/src/pages/front/AssetsPage.tsx
@@ -54,6 +54,7 @@ function mapFileTypeToLabel(ft: string): string {
const mapping: Record}
-
setOpen(true)}
- onLoad={(e) => {
- const im = e.currentTarget;
- if (im.naturalWidth && im.naturalHeight) setNatural({ w: im.naturalWidth, h: im.naturalHeight });
- }}
- />
-
- {dlOpen && (
-
');
- return '\x00CODEBLOCK' + idx + '\x00';
- });
-
- // 2. Extract inline code
- const inlineCodes: string[] = [];
- text = text.replace(/`([^`]+)`/g, (_m: string, code: string) => {
- const idx = inlineCodes.length;
- inlineCodes.push('' + escapeHtml(code.trim()) + '' + escapeHtml(code) + '');
- return '\x00INLINE' + idx + '\x00';
- });
-
- // 2.5 Extract media blocks (/
');
- text = text.replace(/\n/g, '
');
-
- // 10. Restore code blocks and inline codes
- text = text.replace(/\x00CODEBLOCK(\d+)\x00/g, (_m: string, idx: string) => codeBlocks[parseInt(idx)] || '');
- text = text.replace(/\x00INLINE(\d+)\x00/g, (_m: string, idx: string) => inlineCodes[parseInt(idx)] || '');
- text = text.replace(/\x00TABLE(\d+)\x00/g, (_m: string, idx: string) => tableBlocks[parseInt(idx)] || '');
- text = text.replace(/\x00MATHBLOCK(\d+)\x00/g, (_m: string, idx: string) => mathBlocks[parseInt(idx)] || '');
-
- // 10.5 Apply syntax highlighting to code blocks + add copy button
- text = text.replace(
- /
([\s\S]*?)<\/code><\/pre>/g,
- (_m: string, code: string) => {
- const decoded = code.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
- let highlighted: string;
- try { highlighted = hljs.highlightAuto(decoded).value; } catch { highlighted = code; }
- return `
-
- ${highlighted}
- `;
- }
- );
-
- // 11. Wrap in paragraph and clean up
- text = '' + text + '
';
- text = text.replace(/<\/p>/g, '');
- text = text.replace(/
()<\/p>/g, '$1');
- text = text.replace(/(
)<\/p>/g, '$1');
- text = text.replace(/(
)<\/p>/g, '$1');
- text = text.replace(/(
/g, '$1');
- text = text.replace(/(
)<\/p>/g, '$1');
- text = text.replace(/(
)<\/p>/g, '$1');
-
- // 12. 拆分为文本片段与媒体组件
- const parts = text.split(/(\x00MEDIA\d+\x00)/g);
- return parts.map((part: string, i: number) => {
- const m = part.match(/^\x00MEDIA(\d+)\x00$/);
- if (m) {
- const info = mediaBlocks[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
;
- });
- }, [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) => (
-
- ))}
-
-
- );
-};
-
-
-function escapeHtml(text: string): string {
- return text
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"');
-}
-
-// ===== Time Formatting =====
-function formatTime(iso: string): string {
- const date = new Date(iso);
- const now = new Date();
- const diff = now.getTime() - date.getTime();
- const oneDay = 24 * 60 * 60 * 1000;
-
- if (diff < oneDay) {
- return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
- } else if (diff < 7 * oneDay) {
- const days = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
- return days[date.getDay()];
- } else if (date.getFullYear() === now.getFullYear()) {
- return `${date.getMonth() + 1}/${date.getDate()}`;
- } else {
- return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`;
- }
-}
-
-// ===== 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}
-
- )}
-
- );
-};
-
-// ===== 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 [conversations, setConversations] = useState([]);
- const [messages, setMessages] = useState([]);
- const [loadingConversations, setLoadingConversations] = useState(true);
- const [loadingMessages, setLoadingMessages] = useState(false);
- 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,
- } : 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 || '转换失败,请稍后重试');
- }
- };
-
- // Handle send message - CALLS REAL API
- // 抽出公共发送逻辑,供输入框发送与「模糊创作意图」方向选择复用
- const sendText = async (content: string) => {
- if (!content.trim() || sending || !selectedConversation) return;
- setSending(true);
-
- // 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]);
-
- try {
- const res = await chatAPI.sendMessage(selectedConversation!.id, {
- 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,
- });
-
- 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 || [],
- });
- }
- return [...filtered, ...newMsgs];
- });
-
- // 媒体异步生成:启动轮询,待后端回写后自动刷新
- 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(() => {});
- }
- } catch (err: any) {
- // Remove optimistic message on error
- setMessages(prev => prev.filter(m => m.id !== tempUserMsg.id));
- alert('发送失败: ' + (err.message || err.detail || '未知错误'));
- } finally {
- setSending(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 clarify = msg.role === 'assistant' ? parseClarify(msg.content) : null;
- return (
-
-
- {clarify ? (
- sendText(`${opt.medium}:${clarify.prompt}`)}
- />
- ) : msg.role === 'assistant' ? (
-
- ) : (
- {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}
- )}
-
- )}
-
- {formatTime(msg.createdAt)}
- {msg.tokenUsed} tokens
-
-
- );
- })
- )}
- {sending && (
-
-
-
- 思考中...
-
-
- )}
-
-
-
- {/* 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;
+import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
+import { useParams, useNavigate } from 'react-router-dom';
+import { chatAPI, knowledgeAPI, marketAPI, evolutionAPI } from '@/api';
+import type { Conversation, Message } from '@/types';
+import './ChatPage.css';
+
+// ===== 增强渲染依赖 =====
+import hljs from 'highlight.js';
+import 'highlight.js/styles/github.css';
+import katex from 'katex';
+import 'katex/dist/katex.min.css';
+
+// ===== Enhanced Markdown Renderer =====
+// 媒体(图片/视频)以受限缩略图呈现,并提供下载(格式+尺寸)选项
+interface MediaInfo { type: 'img' | 'video'; src: string; alt?: string; }
+
+// 全屏灯箱预览
+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 }) => {
+ const nodes = useMemo(() => {
+ let text = content;
+
+ // 1. Extract code blocks first (protect from other replacements)
+ const codeBlocks: string[] = [];
+ text = text.replace(/```(\w*)\n?([\s\S]*?)```/g, (_m: string, _lang: string, code: string) => {
+ const idx = codeBlocks.length;
+ codeBlocks.push('' + escapeHtml(code.trim()) + '
');
+ return '\x00CODEBLOCK' + idx + '\x00';
+ });
+
+ // 2. Extract inline code
+ const inlineCodes: string[] = [];
+ text = text.replace(/`([^`]+)`/g, (_m: string, code: string) => {
+ const idx = inlineCodes.length;
+ inlineCodes.push('' + escapeHtml(code) + '');
+ return '\x00INLINE' + idx + '\x00';
+ });
+
+ // 2.5 Extract media blocks (
/