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 = { pdf: 'pdf', docx: 'word', doc: 'word', xlsx: 'excel', xls: 'excel', pptx: 'ppt', ppt: 'ppt', csv: 'txt', md: 'md', txt: 'txt', + word: 'word', excel: 'excel', }; return mapping[ft] || 'other'; } @@ -399,7 +400,7 @@ const AssetsPage: React.FC = () => { const VALID_CATEGORIES = ['file', 'novel', 'image', 'video', 'audio', 'custom', 'other']; const normalizeAsset = (item: any): Asset => { const rawCat = item.category; - const ft = item.fileType || item.file_type || 'other'; + const ft = mapFileTypeToLabel(item.fileType || item.file_type || 'other'); let category = rawCat; if (!VALID_CATEGORIES.includes(category)) { category = (ft && ft !== 'other') ? 'file' : 'other'; diff --git a/frontend/src/pages/front/ChatPage.tsx b/frontend/src/pages/front/ChatPage.tsx index bf08806..6857996 100644 --- a/frontend/src/pages/front/ChatPage.tsx +++ b/frontend/src/pages/front/ChatPage.tsx @@ -1,1695 +1,1719 @@ -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 ( -
- {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 }) => { - 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 (/