From 5d1370156e439a408d69c94423ebd9a3efcc6c17 Mon Sep 17 00:00:00 2001 From: QihuAI Design Date: Tue, 14 Jul 2026 21:48:39 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=81=9C=E6=AD=A2=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E5=A4=8D=E6=B4=BB=E9=BB=98=E8=AE=A4=E6=A8=A1=E5=9E=8B=E4=BE=9B?= =?UTF-8?q?=E5=BA=94=E5=95=86/=E6=A8=A1=E5=9E=8B=EF=BC=9BAI=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E5=AA=92=E4=BD=93=E7=94=9F=E6=88=90=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E5=BC=82=E6=AD=A5(=E4=BF=AE=E5=A4=8D=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E5=A4=B1=E8=B4=A5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - migrations/002_init_data.sql: 移除 model_providers/models 的 INSERT IGNORE 种子,重启容器不再复活 deepseek/openai/通义千问等默认供应商与模型,改由用户在后台大模型库自行维护 - chat.py: 图像/视频生成分支改为立即返回占位(generating=true),后台 asyncio 任务轮询 AGNES 并回写消息内容,避免最长 5 分钟同步轮询阻塞 HTTP 请求导致前端「发送失败:请求失败」 - ChatPage.tsx: 发送后若 assistant_message.generating,每 3s 轮询 getMessages 直至内容更新;页面刷新时若仍有「⏳」占位继续轮询;卸载清理定时器 --- backend/python/app/api/chat.py | 55 +++++++++++++++++++++-- frontend/src/pages/front/ChatPage.tsx | 64 +++++++++++++++++++++++++++ migrations/002_init_data.sql | 23 +++------- 3 files changed, 121 insertions(+), 21 deletions(-) diff --git a/backend/python/app/api/chat.py b/backend/python/app/api/chat.py index 982054c..a255fb0 100644 --- a/backend/python/app/api/chat.py +++ b/backend/python/app/api/chat.py @@ -184,6 +184,39 @@ async def _generate_media(db, ai_model, prompt: str, user_id: str = None) -> str return "⚠️ 未知的媒体模型类型。" +async def _generate_media_task(ai_msg_id: str, ai_model_id: str, prompt: str, user_id: str): + """后台异步执行图像/视频生成,完成后回写消息内容。 + + 目的是把最长 5 分钟的 AGNES 视频轮询从 HTTP 请求中剥离,避免请求被客户端/ + 代理超时断开(表现为前端「发送失败:请求失败」)。send_message 立即返回占位 + 内容(generating 标记),本任务独立在事件循环中轮询并在完成后 merge 回写消息。 + 使用独立的 SessionLocal 会话,因为请求级 db 在响应返回后即被 FastAPI 关闭。 + """ + db = SessionLocal() + try: + ai_model = db.query(AIModel).filter(AIModel.id == ai_model_id).first() + if not ai_model: + logger.warning(f"[_generate_media_task] 模型不存在: {ai_model_id}") + return + try: + result = await _generate_media(db, ai_model, prompt, user_id) + except Exception as e: + result = f"⚠️ 媒体生成失败:{str(e)[:200]}" + # 回写消息内容(占位 → 最终富文本/错误) + msg = db.query(Message).filter(Message.id == ai_msg_id).first() + if msg: + msg.content = result + msg.token_used = len(result) // 2 + db.commit() + logger.info(f"[_generate_media_task] 消息 {ai_msg_id} 已回写内容") + except Exception as e: + logger.warning(f"[_generate_media_task] 后台生成异常: {e}") + finally: + try: + db.close() + except Exception: + pass + # ============================================================ # Request Models @@ -1073,11 +1106,15 @@ async def send_message( if ai_model: # ====== 图像/视频生成模型分支 ====== caps = [c.lower() for c in (ai_model.capabilities or [])] if ai_model else [] + media_generating = False if ai_model and ("image" in caps or "video" in caps): - try: - ai_content = await _generate_media(db, ai_model, req.content, current_user.id) - except Exception as e: - ai_content = f"⚠️ 媒体生成失败:{str(e)[:200]}" + # 异步生成:立即返回占位内容,后台任务轮询 AGNES 并回写, + # 避免最长 5 分钟的视频轮询阻塞 HTTP 请求导致客户端超时断开。 + media_generating = True + if "video" in caps: + ai_content = "⏳ 视频生成中…(通常 1~3 分钟,完成后自动刷新)" + else: + ai_content = "⏳ 图像生成中…(完成后自动刷新)" elif ai_model and ai_model.endpoint: provider = db.query(ModelProvider).filter(ModelProvider.id == ai_model.provider_id).first() api_key = ai_model.api_key or (provider.api_key if provider else None) @@ -1312,6 +1349,15 @@ async def send_message( db.refresh(user_msg) db.refresh(ai_msg) + # 媒体生成改为后台异步:立即返回占位,后台任务完成后回写消息内容 + if media_generating: + try: + asyncio.create_task( + _generate_media_task(ai_msg.id, ai_model.id, req.content, current_user.id) + ) + except Exception as e: + logger.warning(f"[_generate_media_task] 启动后台任务失败: {e}") + return { "code": 0, "data": { @@ -1323,6 +1369,7 @@ async def send_message( "id": ai_msg.id, "role": ai_msg.role, "content": ai_msg.content, "token_used": ai_msg.token_used, "created_at": str(ai_msg.created_at), "used_skills": used_skills, + "generating": media_generating, }, }, "message": "发送成功", diff --git a/frontend/src/pages/front/ChatPage.tsx b/frontend/src/pages/front/ChatPage.tsx index 329d077..41f9f49 100644 --- a/frontend/src/pages/front/ChatPage.tsx +++ b/frontend/src/pages/front/ChatPage.tsx @@ -565,8 +565,67 @@ const ChatPage: React.FC = () => { } }; loadMessages(); + + // 页面刷新时若仍有「⏳」占位的媒体消息,继续轮询后端回写结果 + mapped.forEach((m) => { + if (m.role === 'assistant' && typeof m.content === 'string' && m.content.startsWith('⏳')) { + startMediaPolling(id, m.id); + } + }); }, [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) => { @@ -821,6 +880,11 @@ const ChatPage: React.FC = () => { return [...filtered, ...newMsgs]; }); + // 媒体异步生成:启动轮询,待后端回写后自动刷新 + if (data.assistant_message?.generating) { + startMediaPolling(selectedConversation!.id, data.assistant_message.id); + } + // Refresh conversations to update lastMessage loadConversations(); diff --git a/migrations/002_init_data.sql b/migrations/002_init_data.sql index 51fb7d8..b63b358 100644 --- a/migrations/002_init_data.sql +++ b/migrations/002_init_data.sql @@ -48,24 +48,13 @@ INSERT IGNORE INTO risk_rules (id, name, type, threshold, duration, is_enabled) ('risk-alert', '异常调用告警', 'alert', 1000, 3600, 1); -- ============================================================ --- 初始化模型供应商 +-- 模型供应商 / 模型 初始化(已移除自动 seeding) +-- ------------------------------------------------------------ +-- 说明:默认供应商(DeepSeek/OpenAI/通义千问/SiliconFlow)与默认模型由用户在 +-- 后台「大模型库」中自行维护,不再通过迁移脚本强制写入。此前每次容器重启都会 +-- 因 INSERT IGNORE 把已删除的默认供应商/模型重新复活,导致用户需反复手动清理。 +-- 现仅保留数据表结构(由其他迁移负责),种子数据交由用户管理,重启不再复活。 -- ============================================================ -INSERT IGNORE INTO model_providers (id, name, protocol, api_key, api_base_url) VALUES -('prov-001', 'DeepSeek', 'openai_compatible', '', 'https://api.deepseek.com'), -('prov-002', 'OpenAI', 'openai_compatible', '', 'https://api.openai.com'), -('prov-003', '通义千问', 'openai_compatible', '', 'https://dashscope.aliyuncs.com'), -('prov-004', 'SiliconFlow', 'openai_compatible', 'sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz', 'https://api.siliconflow.cn'); - --- ============================================================ --- 初始化模型 --- ============================================================ -INSERT IGNORE INTO models (id, provider_id, name, api_key, endpoint, capabilities, timeout, status, price_per_million_tokens, is_enabled, total_calls, total_tokens) VALUES -('model-001', 'prov-001', 'DeepSeek-V3', '', 'https://api.deepseek.com/v1/chat/completions', '["text", "thinking"]', 60, 'free', 0.0000, 1, 0, 0), -('model-002', 'prov-002', 'GPT-4o', '', 'https://api.openai.com/v1/chat/completions', '["text", "vision", "tool"]', 60, 'paid', 10.0000, 1, 0, 0), -('model-003', 'prov-003', 'Qwen-Max', '', 'https://dashscope.aliyuncs.com/compatible-mode/v1', '["text", "tool"]', 60, 'free', 0.0000, 1, 0, 0), -('model-004', 'prov-001', 'DeepSeek-R1', '', 'https://api.deepseek.com/v1/chat/completions', '["text", "thinking"]', 120, 'paid', 4.0000, 1, 0, 0), -('model-005', 'prov-003', 'Qwen-Plus', '', 'https://dashscope.aliyuncs.com/compatible-mode/v1', '["text"]', 60, 'free', 0.0000, 0, 0, 0), -('model-006', 'prov-004', 'DeepSeek-V4-Flash', '', 'https://api.siliconflow.cn/v1/chat/completions', '["text", "thinking", "tool"]', 60, 'free', 0.0000, 1, 0, 0); -- ============================================================ -- 初始化会员套餐