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:
1 parent
b7f76c3e92
commit
5d1370156e
3 files changed
+121
-21
No files matched your search
@@ -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": "发送成功",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- 初始化会员套餐
|
-- 初始化会员套餐
|
||||||
|
|||||||
Reference in new issue
Block a user