diff --git a/backend/python/app/api/chat.py b/backend/python/app/api/chat.py index c7e8014..cf29f07 100644 --- a/backend/python/app/api/chat.py +++ b/backend/python/app/api/chat.py @@ -66,6 +66,50 @@ def detect_media_intent(text: str) -> str | None: return "audio" return None + +def detect_ambiguous_creative_intent(text: str): + """检测「模糊创作意图」:用户使用了创作类动词(制作/创作/做/生成/画/写/设计…), + 但未明确产出形式(文案/图片/视频/其他)。此时应与用户确认方向,而不是直接调用大模型 + (某些模型会对这类开放式创作请求返回 "high risk" 拒答)。 + + 返回 None 表示无需确认;否则返回 {"options": [...]}。 + """ + if not text: + return None + t = text.strip() + tl = t.lower() + # 已有明确媒体意图(图像/视频/语音)则无需确认 + if detect_media_intent(t): + return None + # 疑问句不触发 + if tl.endswith(("?", "?")) or any(q in tl for q in ("吗", "呢", "怎么", "如何", "能否", "是否可以", "可以吗", "会不会", "why", "how", "什么", "哪些")): + return None + # 已明确给出产出形式(文字类/其他类)则无需确认 + explicit_medium = ( + "文案", "文章", "诗歌", "诗", "小说", "故事", "代码", "程序", "脚本", "总结", "翻译", + "邮件", "报告", "段落", "正文", "文字", "写作", "ppt", "演示", "思维导图", "表格", + "图表", "数据", + ) + if any(m in tl for m in explicit_medium): + return None + # 创作类动词 + creative_verbs = ( + "制作", "创作", "做个", "做一个", "搞一个", "产出", "构思", "设想", + "打造", "塑造", "生成", "帮我做", "帮我生成", "帮我画", "帮我写", "请制作", + "请创作", "请生成", "请画", "请写", "来一个", "来一张", "来一幅", "做一张", + "做张", "写一首", "写一段", "写个", + ) + if not any(v in t for v in creative_verbs): + return None + # 模糊:有创作动词但无明确产出形式 → 提供方向选择 + options = [ + {"key": "text", "label": "文案", "desc": "文章 / 故事 / 诗歌等文字内容", "medium": "文案"}, + {"key": "image", "label": "图片", "desc": "插画 / 海报 / 照片等图像", "medium": "图片"}, + {"key": "video", "label": "视频", "desc": "短片 / 动画等视频", "medium": "视频"}, + {"key": "other", "label": "其他", "desc": "语音 / 代码 / 其他形式", "medium": "其他"}, + ] + return {"options": options} + # CST timezone (UTC+8) for consistent time display CST_TZ = timezone(timedelta(hours=8)) @@ -243,6 +287,8 @@ class SendMessageRequest(BaseModel): skill_ids: Optional[list] = None agent_ids: Optional[list] = None knowledge_base_ids: Optional[list] = None + mcp_ids: Optional[list] = None + attachments: Optional[list] = None use_tools: Optional[bool] = False @@ -374,7 +420,36 @@ def _resolve_market_agents(db: Session, agent_ids: list, user_id: str) -> list: return results -def _parse_skill_content(content: str) -> dict: +def _resolve_market_mcp(db: Session, mcp_ids: list, user_id: str) -> list: + """ + 加载用户选择的 MCP 应用配置(MarketApp.type == 'mcp')。 + 返回: [{id, name, transport, endpoint, description}] + """ + if not mcp_ids: + return [] + + mcps = db.query(MarketApp).filter( + MarketApp.id.in_(mcp_ids), + MarketApp.type == "mcp" + ).all() + + results = [] + for m in mcps: + config = {} + try: + if m.skill_content: + config = json.loads(m.skill_content) + except Exception: + config = {} + results.append({ + "id": m.id, + "name": m.name, + "transport": m.mcp_transport or config.get("transport", "stdio"), + "endpoint": m.agent_endpoint or config.get("endpoint", "") or config.get("url", ""), + "description": m.description or config.get("description", ""), + }) + + return results """ 解析SKILL.md格式内容, 提取结构化信息。 SKILL.md格式: @@ -651,6 +726,7 @@ def _build_system_prompt( agents: list, kb_context: str, user_content: str, + mcp: list = None, ) -> tuple: """ 动态组装system prompt + function definitions。 @@ -736,6 +812,12 @@ def _build_system_prompt( if kb_context: system_parts.append(f"参考以下知识库内容回答用户问题:\n{kb_context}") + # MCP 应用注入 + if mcp: + mcp_descs = ", ".join([f"{s['name']}({s.get('transport', 'stdio')})" for s in mcp]) + system_parts.append(f"已连接的 MCP 应用(外部工具服务): {mcp_descs}") + system_parts.append("当用户请求需要调用外部系统/工具(如查询数据库、调用API、操作第三方服务)时,可参考上述 MCP 应用提供的能力;若平台已接入对应 MCP 工具,优先通过工具调用完成。") + system_prompt = "\n".join(system_parts) return system_prompt, function_definitions @@ -882,6 +964,8 @@ def _get_conversation_context(db: Session, conv_id: str) -> dict: context["agent_ids"] = first_tool_msg.agent_ids if first_tool_msg.knowledge_ids: context["knowledge_base_ids"] = first_tool_msg.knowledge_ids + if getattr(first_tool_msg, "mcp_ids", None): + context["mcp_ids"] = first_tool_msg.mcp_ids return context @@ -1068,33 +1152,88 @@ async def send_message( effective_skill_ids = req.skill_ids or [] effective_agent_ids = req.agent_ids or [] effective_kb_ids = req.knowledge_base_ids or [] + effective_mcp_ids = req.mcp_ids or [] effective_use_tools = req.use_tools or False - if not effective_skill_ids and not effective_agent_ids and not effective_kb_ids: + if not effective_skill_ids and not effective_agent_ids and not effective_kb_ids and not effective_mcp_ids: # 从对话上下文继承 prev_context = _get_conversation_context(db, conv_id) if prev_context: effective_skill_ids = prev_context.get("skill_ids", []) effective_agent_ids = prev_context.get("agent_ids", []) effective_kb_ids = prev_context.get("knowledge_base_ids", []) - if effective_skill_ids or effective_agent_ids: + effective_mcp_ids = prev_context.get("mcp_ids", []) + if effective_skill_ids or effective_agent_ids or effective_mcp_ids: effective_use_tools = True - # 保存用户消息(含skill/agent/kb选择用于上下文保持) + # 附件内容拼接到用户消息中(文本类文件附带内容, 供模型参考) + attachment_block = "" + if req.attachments: + parts = [] + for att in req.attachments: + name = att.get("name", "附件") if isinstance(att, dict) else str(att) + ftype = att.get("file_type", "") if isinstance(att, dict) else "" + content = att.get("content", "") if isinstance(att, dict) else "" + if content: + parts.append(f"【附件: {name}】\n{content}") + else: + parts.append(f"【附件(二进制, 名称: {name}, 类型: {ftype})】") + if parts: + attachment_block = "\n\n" + "\n\n".join(parts) + + # 保存用户消息(含skill/agent/kb/mcp选择用于上下文保持) user_msg = Message( id=gen_uuid(), conversation_id=conv_id, role="user", - content=req.content, + content=req.content + attachment_block, token_used=len(req.content) // 2, model_id=req.model_id or conv.model_id, skill_ids=req.skill_ids if req.skill_ids else None, agent_ids=req.agent_ids if req.agent_ids else None, knowledge_ids=req.knowledge_base_ids if req.knowledge_base_ids else None, + mcp_ids=req.mcp_ids if req.mcp_ids else None, + attachments=req.attachments if req.attachments else None, created_at=cst_now(), ) db.add(user_msg) + # ===== 模糊创作意图拦截 ===== + # 用户使用创作类动词(制作/创作/做/生成…)但未明确产出形式时,先与用户确认方向, + # 避免直接调用大模型被内容安全策略拒答(如返回 "high risk")。 + clarify = detect_ambiguous_creative_intent(req.content) + if clarify: + clarify_content = "<>" + json.dumps( + {"prompt": req.content, "options": clarify["options"]}, ensure_ascii=False + ) + "<>" + ai_msg = Message( + id=gen_uuid(), + conversation_id=conv_id, + role="assistant", + content=clarify_content, + token_used=0, + model_id=req.model_id or conv.model_id, + created_at=cst_now(), + ) + db.add(ai_msg) + db.commit() + db.refresh(user_msg) + db.refresh(ai_msg) + return { + "code": 0, + "data": { + "user_message": { + "id": user_msg.id, "role": user_msg.role, "content": user_msg.content, + "token_used": user_msg.token_used, "created_at": str(user_msg.created_at), + }, + "assistant_message": { + "id": ai_msg.id, "role": ai_msg.role, "content": clarify_content, + "created_at": str(ai_msg.created_at), "clarify": clarify, + }, + }, + "message": "请选择创作方向", + } + # ===== 核心聊天引擎 ===== user_id = current_user.id model_id = req.model_id or conv.model_id @@ -1140,6 +1279,7 @@ async def send_message( # Stage A+B: 解析Skills和Agents skills = _resolve_market_skills(db, effective_skill_ids, user_id) agents = _resolve_market_agents(db, effective_agent_ids, user_id) + mcps = _resolve_market_mcp(db, effective_mcp_ids, user_id) # Stage B.1: Agent可能自带skill_ids和kb_ids agent_extra_skill_ids = [] @@ -1171,6 +1311,7 @@ async def send_message( agents=agents, kb_context=kb_context, user_content=req.content, + mcp=mcps, ) # 构建消息列表 diff --git a/backend/python/app/api/market.py b/backend/python/app/api/market.py index fa6a0b1..20c9142 100644 --- a/backend/python/app/api/market.py +++ b/backend/python/app/api/market.py @@ -58,6 +58,7 @@ class UploadAppRequest(BaseModel): is_member_free: Optional[bool] = False join_promotion: Optional[bool] = False agent_endpoint: Optional[str] = None + api_key: Optional[str] = None class PurchaseAppRequest(BaseModel): @@ -119,6 +120,8 @@ def list_apps( result = [] for app in apps: + # 用户自己创建/导入的应用应始终视为已安装(避免安装记录缺失导致显示未安装) + is_user_owned = bool(user_id) and app.source == "user" and app.publisher_id == user_id item = { "id": app.id, "name": app.name, @@ -136,7 +139,7 @@ def list_apps( "rating_avg": float(app.rating_avg) if app.rating_avg else 0, "review_count": app.review_count or 0, "created_at": str(app.created_at) if app.created_at else None, - "installed": app.id in installed_app_ids if user_id else False, + "installed": (app.id in installed_app_ids or is_user_owned) if user_id else False, } result.append(item) @@ -222,6 +225,7 @@ def upload_app( is_member_free=req.is_member_free if req.is_member_free is not None else False, join_promotion=req.join_promotion if req.join_promotion is not None else False, agent_endpoint=req.agent_endpoint, + agent_api_key=req.api_key, status="published", # 用户创建的应用直接发布 created_at=datetime.now(), updated_at=datetime.now(), diff --git a/backend/python/app/models.py b/backend/python/app/models.py index bbaa3a3..6399e71 100644 --- a/backend/python/app/models.py +++ b/backend/python/app/models.py @@ -102,6 +102,8 @@ class Message(Base): knowledge_ids = Column(JSON, default=None) agent_ids = Column(JSON, default=None) skill_ids = Column(JSON, default=None) + mcp_ids = Column(JSON, default=None, comment="该轮对话选择的MCP应用ID列表") + attachments = Column(JSON, default=None, comment="该轮对话附带的文件附件列表") used_skills = Column(JSON, default=None, comment="该轮对话调用的进化技能名列表") created_at = Column(DateTime, default=datetime.now) @@ -293,6 +295,7 @@ class MarketApp(Base): skill_content = Column(Text, default=None) mcp_transport = Column(String(20), default=None) agent_endpoint = Column(String(500), default=None) + agent_api_key = Column(String(500), default=None, comment="单个Agent接入的API Key") created_at = Column(DateTime, default=datetime.now) updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 3e16e6d..882640a 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -467,9 +467,14 @@ export const notificationAPI = { export const assetsAPI = { - getAssets: (category?: string) => - - get(`/assets/${category ? `?category=${category}` : ''}`), + getAssets: (params?: { category?: string; page?: number; page_size?: number }) => { + const qs: string[] = []; + if (params?.category) qs.push(`category=${encodeURIComponent(params.category)}`); + if (params?.page) qs.push(`page=${params.page}`); + if (params?.page_size) qs.push(`page_size=${params.page_size}`); + const q = qs.length ? `?${qs.join('&')}` : ''; + return get(`/assets/${q}`); + }, createAsset: (data: any) => post('/assets/', data), diff --git a/frontend/src/pages/front/AssetsPage.tsx b/frontend/src/pages/front/AssetsPage.tsx index fd1afae..942d86a 100644 --- a/frontend/src/pages/front/AssetsPage.tsx +++ b/frontend/src/pages/front/AssetsPage.tsx @@ -392,24 +392,43 @@ const AssetsPage: React.FC = () => { const [showCreate, setShowCreate] = useState(false); const navigate = useNavigate(); - const fetchAssets = async (category?: string) => { + // 将后端返回的资产标准化: + // 1. 统一 fileType(兼容 file_type 字段) + // 2. 兼容遗留的文档类分类(doc/document 等)与带文件类型但分类异常的资产, + // 统一归入 file 分类,确保它们出现在「文件」页签及其对应的子类型(如 pdf)中。 + 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'; + let category = rawCat; + if (!VALID_CATEGORIES.includes(category)) { + category = (ft && ft !== 'other') ? 'file' : 'other'; + } + return { ...item, category, fileType: ft }; + }; + + // 一次性拉取当前用户全部资产(分页累加),分类与子类型筛选全部在前端完成, + // 避免后端按 category 过滤时漏掉历史遗留分类(如 doc)的资产。 + const fetchAllAssets = async () => { setLoading(true); setError(null); try { - const catParam = category && category !== 'all' ? category : undefined; - const res = await assetsAPI.getAssets(catParam); - // getAssets 返回分页格式 {total, page, page_size, items} - const rawItems = (res.data as any)?.items || res.data || []; - // 标准化资产数据:如果category为空但有fileType,则自动归类为file - const normalizedAssets: Asset[] = rawItems.map((item: any) => ({ - ...item, - category: item.category || (item.fileType || item.file_type ? 'file' : 'other'), - fileType: item.fileType || item.file_type || 'other', - })); - + let all: any[] = []; + let page = 1; + const pageSize = 100; + for (;;) { + const res = await assetsAPI.getAssets({ page, page_size: pageSize }); + const data = (res.data as any) || {}; + const items: any[] = data.items || []; + const total: number = data.total || 0; + all = all.concat(items); + if (items.length === 0 || all.length >= total) break; + page += 1; + } + const normalized = all.map(normalizeAsset); // 按id去重 const seen = new Set(); - const deduped = normalizedAssets.filter(a => { + const deduped = normalized.filter(a => { if (seen.has(a.id)) return false; seen.add(a.id); return true; @@ -451,13 +470,9 @@ const AssetsPage: React.FC = () => { useEffect(() => { fetchCategories(); - fetchAssets(); + fetchAllAssets(); }, []); - useEffect(() => { - fetchAssets(activeCategory === 'all' ? undefined : activeCategory); - }, [activeCategory]); - // Filter by sub-category for files const filtered = (() => { // 如果是文件分类,先确保只显示文件类资产 @@ -640,7 +655,7 @@ const AssetsPage: React.FC = () => { {showCreate && ( setShowCreate(false)} - onCreated={() => fetchAssets(activeCategory === 'all' ? undefined : activeCategory)} + onCreated={() => fetchAllAssets()} /> )} diff --git a/frontend/src/pages/front/ChatPage.css b/frontend/src/pages/front/ChatPage.css index c6301d0..0ac13ea 100644 --- a/frontend/src/pages/front/ChatPage.css +++ b/frontend/src/pages/front/ChatPage.css @@ -1257,3 +1257,175 @@ max-width: 92vw; max-height: 86vh; } + +/* ===== Clarify Card (模糊创作意图方向确认) ===== */ +.chat-clarify-card { + display: flex; + flex-direction: column; + gap: var(--space-md); + padding: var(--space-lg); + background: var(--color-bg-tertiary, #f5f7fb); + border: 1px solid var(--color-border-light, #e6e9f0); + border-radius: var(--radius-lg); +} + +.chat-clarify-title { + font-size: var(--text-sm); + font-weight: 600; + color: var(--color-text-primary); +} + +.chat-clarify-sub { + font-size: var(--text-xs); + color: var(--color-text-tertiary); + padding: 6px 10px; + background: var(--color-bg-secondary, #fff); + border-radius: var(--radius-sm); + border-left: 3px solid var(--color-accent, #5B5FED); + word-break: break-all; +} + +.chat-clarify-options { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: var(--space-sm); +} + +.chat-clarify-option { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + padding: 10px 14px; + border: 1.5px solid var(--color-border, #dfe3ec); + border-radius: var(--radius-md); + background: var(--color-bg-secondary, #fff); + cursor: pointer; + transition: all var(--transition-fast); + text-align: left; +} + +.chat-clarify-option:hover { + border-color: var(--color-accent, #5B5FED); + background: var(--color-accent-subtle, #eef0ff); + transform: translateY(-1px); +} + +.chat-clarify-opt-label { + font-size: var(--text-sm); + font-weight: 700; + color: var(--color-accent, #5B5FED); +} + +.chat-clarify-opt-desc { + font-size: var(--text-xs); + color: var(--color-text-tertiary); + line-height: 1.4; +} + +@media (max-width: 640px) { + .chat-clarify-options { + grid-template-columns: 1fr; + } +} + +/* ===== 附件 chips ===== */ +.attachment-chips { + display: flex; + flex-wrap: wrap; + gap: var(--space-xs); + padding: 8px 4px 0; +} + +.attachment-chip { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 220px; + padding: 5px 8px 5px 10px; + background: var(--color-bg-tertiary, #f5f7fb); + border: 1px solid var(--color-border-light, #e6e9f0); + border-radius: var(--radius-md); + font-size: var(--text-xs); + color: var(--color-text-secondary); +} + +.attachment-chip svg { + flex: none; + color: var(--color-accent, #5B5FED); +} + +.attachment-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.attachment-remove { + flex: none; + border: none; + background: transparent; + color: var(--color-text-tertiary); + font-size: 16px; + line-height: 1; + cursor: pointer; + padding: 0 2px; +} + +.attachment-remove:hover { + color: #e5484d; +} + +/* ===== 语音输入/朗读按钮 ===== */ +.mic-btn { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border: 1px solid var(--color-border, #dfe3ec); + border-radius: var(--radius-md); + background: var(--color-bg-secondary, #fff); + color: var(--color-text-secondary); + cursor: pointer; + transition: all var(--transition-fast); +} + +.mic-btn:hover { + border-color: var(--color-accent, #5B5FED); + color: var(--color-accent, #5B5FED); +} + +.mic-btn.recording { + background: #e5484d; + border-color: #e5484d; + color: #fff; + animation: mic-pulse 1.2s infinite; +} + +@keyframes mic-pulse { + 0% { box-shadow: 0 0 0 0 rgba(229, 72, 77, 0.5); } + 70% { box-shadow: 0 0 0 10px rgba(229, 72, 77, 0); } + 100% { box-shadow: 0 0 0 0 rgba(229, 72, 77, 0); } +} + +.msg-tts-btn { + margin-top: 6px; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + border: 1px solid var(--color-border-light, #e6e9f0); + border-radius: var(--radius-sm); + background: var(--color-bg-secondary, #fff); + color: var(--color-text-tertiary); + cursor: pointer; + font-size: var(--text-xs); + transition: all var(--transition-fast); +} + +.msg-tts-btn:hover { + border-color: var(--color-accent, #5B5FED); + color: var(--color-accent, #5B5FED); +} diff --git a/frontend/src/pages/front/ChatPage.tsx b/frontend/src/pages/front/ChatPage.tsx index b2a8da2..7bd0c20 100644 --- a/frontend/src/pages/front/ChatPage.tsx +++ b/frontend/src/pages/front/ChatPage.tsx @@ -236,6 +236,36 @@ const MarkdownContent: React.FC<{ content: string }> = ({ 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 @@ -358,6 +388,37 @@ const IconSkill = () => ( ); +const IconMcp = () => ( + + + + +); + +const IconPaperclip = () => ( + + + +); + +const IconMic = () => ( + + + +); + +const IconStop = () => ( + + + +); + +const IconVolume = () => ( + + + +); + const IconModel = () => ( @@ -471,10 +532,17 @@ const ChatPage: React.FC = () => { 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); @@ -655,6 +723,13 @@ const ChatPage: React.FC = () => { // 显示所有已发布的技能(不需要安装即可在聊天中使用) 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 @@ -822,10 +897,9 @@ const ChatPage: React.FC = () => { }; // Handle send message - CALLS REAL API - const handleSend = async () => { - if (!inputValue.trim() || sending) return; - const content = inputValue.trim(); - setInputValue(''); + // 抽出公共发送逻辑,供输入框发送与「模糊创作意图」方向选择复用 + const sendText = async (content: string) => { + if (!content.trim() || sending || !selectedConversation) return; setSending(true); // Add user message optimistically @@ -846,7 +920,11 @@ const ChatPage: React.FC = () => { agent_ids: selectedAgentIds.length > 0 ? selectedAgentIds : undefined, skill_ids: selectedSkillIds.length > 0 ? selectedSkillIds : undefined, knowledge_base_ids: selectedKbIds.length > 0 ? selectedKbIds : undefined, - use_tools: selectedSkillIds.length > 0 || selectedAgentIds.length > 0, + 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); @@ -886,6 +964,8 @@ const ChatPage: React.FC = () => { // Refresh conversations to update lastMessage loadConversations(); + // 发送后清空本次附件(选择状态仍保留) + setAttachments([]); // GAP-07: 对话结束后异步提交轨迹到进化引擎(后台静默,不阻塞UI) if (selectedConversation?.id && data.assistant_message) { @@ -908,6 +988,83 @@ const ChatPage: React.FC = () => { } }; + // 文件上传:读取文本类文件内容作为附件上下文 + 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) { @@ -1064,18 +1221,34 @@ const ChatPage: React.FC = () => {

) : ( - messages.map(msg => { - // Real used_skills from API response - const usedSkills: string[] = (msg.role === 'assistant' && msg.used_skills) ? msg.used_skills : []; - return ( -
-
- {msg.role === 'assistant' ? ( - - ) : ( - {msg.content} - )} -
+ 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 && (
@@ -1216,19 +1389,88 @@ const ChatPage: React.FC = () => {
暂无可用技能
)} + + + + {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} + + + ))} +
+ )} +