fix: 停止迁移复活默认模型供应商/模型;AI聊天媒体生成改为异步(修复视频请求失败)

- 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 直至内容更新;页面刷新时若仍有「⏳」占位继续轮询;卸载清理定时器
This commit is contained in:
QihuAI Design committed 2026-07-14 21:48:39 +08:00
1 parent b7f76c3e92
commit 5d1370156e
3 files changed
+121 -21

No files matched your search

+51 -4
View File
@@ -184,6 +184,39 @@ async def _generate_media(db, ai_model, prompt: str, user_id: str = None) -> str
return "⚠️ 未知的媒体模型类型。" 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 # Request Models
@@ -1073,11 +1106,15 @@ async def send_message(
if ai_model: if ai_model:
# ====== 图像/视频生成模型分支 ====== # ====== 图像/视频生成模型分支 ======
caps = [c.lower() for c in (ai_model.capabilities or [])] if ai_model else [] 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): if ai_model and ("image" in caps or "video" in caps):
try: # 异步生成:立即返回占位内容,后台任务轮询 AGNES 并回写,
ai_content = await _generate_media(db, ai_model, req.content, current_user.id) # 避免最长 5 分钟的视频轮询阻塞 HTTP 请求导致客户端超时断开。
except Exception as e: media_generating = True
ai_content = f"⚠️ 媒体生成失败:{str(e)[:200]}" if "video" in caps:
ai_content = "⏳ 视频生成中…(通常 1~3 分钟,完成后自动刷新)"
else:
ai_content = "⏳ 图像生成中…(完成后自动刷新)"
elif ai_model and ai_model.endpoint: elif ai_model and ai_model.endpoint:
provider = db.query(ModelProvider).filter(ModelProvider.id == ai_model.provider_id).first() 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) 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(user_msg)
db.refresh(ai_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 { return {
"code": 0, "code": 0,
"data": { "data": {
@@ -1323,6 +1369,7 @@ async def send_message(
"id": ai_msg.id, "role": ai_msg.role, "content": ai_msg.content, "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), "token_used": ai_msg.token_used, "created_at": str(ai_msg.created_at),
"used_skills": used_skills, "used_skills": used_skills,
"generating": media_generating,
}, },
}, },
"message": "发送成功", "message": "发送成功",
+64
View File
@@ -565,8 +565,67 @@ const ChatPage: React.FC = () => {
} }
}; };
loadMessages(); loadMessages();
// 页面刷新时若仍有「⏳」占位的媒体消息,继续轮询后端回写结果
mapped.forEach((m) => {
if (m.role === 'assistant' && typeof m.content === 'string' && m.content.startsWith('⏳')) {
startMediaPolling(id, m.id);
}
});
}, [id]); }, [id]);
// 媒体(图像/视频)异步生成轮询:后端先返回「⏳」占位,前端定时拉取该消息最新内容并回写
const pollMediaRef = useRef<Record<string, ReturnType<typeof setInterval> | 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 // Load knowledge bases, agents, skills for attachment dropdowns
useEffect(() => { useEffect(() => {
knowledgeAPI.getBases().then((res: any) => { knowledgeAPI.getBases().then((res: any) => {
@@ -821,6 +880,11 @@ const ChatPage: React.FC = () => {
return [...filtered, ...newMsgs]; return [...filtered, ...newMsgs];
}); });
// 媒体异步生成:启动轮询,待后端回写后自动刷新
if (data.assistant_message?.generating) {
startMediaPolling(selectedConversation!.id, data.assistant_message.id);
}
// Refresh conversations to update lastMessage // Refresh conversations to update lastMessage
loadConversations(); loadConversations();
+6 -17
View File
@@ -48,24 +48,13 @@ INSERT IGNORE INTO risk_rules (id, name, type, threshold, duration, is_enabled)
('risk-alert', '异常调用告警', 'alert', 1000, 3600, 1); ('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);
-- ============================================================ -- ============================================================
-- 初始化会员套餐 -- 初始化会员套餐