diff --git a/app/api/admin/manage.py b/app/api/admin/manage.py index a6876c2..335878f 100644 --- a/app/api/admin/manage.py +++ b/app/api/admin/manage.py @@ -428,6 +428,13 @@ class UpdateSettingsRequest(BaseModel): grok_config: Optional[Dict[str, Any]] = None +class StreamTimeoutSettings(BaseModel): + """流式超时配置""" + stream_chunk_timeout: int = 120 + stream_first_response_timeout: int = 30 + stream_total_timeout: int = 600 + + @router.post("/api/settings") async def update_settings(request: UpdateSettingsRequest, _: bool = Depends(verify_admin_session)) -> Dict[str, Any]: """更新全局配置""" @@ -526,8 +533,8 @@ async def get_cache_size(_: bool = Depends(verify_admin_session)) -> Dict[str, A @router.post("/api/cache/clear") async def clear_cache(_: bool = Depends(verify_admin_session)) -> Dict[str, Any]: - """清理缓存 - + """清理缓存 + 删除所有临时文件""" try: logger.debug("[Admin] 开始清理缓存") @@ -560,7 +567,7 @@ async def clear_cache(_: bool = Depends(verify_admin_session)) -> Dict[str, Any] deleted_count = image_count + video_count logger.debug(f"[Admin] 缓存清理完成 - 图片: {image_count}, 视频: {video_count}, 总计: {deleted_count}") - + return { "success": True, "message": f"成功清理缓存,删除图片 {image_count} 个,视频 {video_count} 个,共 {deleted_count} 个文件", @@ -579,6 +586,86 @@ async def clear_cache(_: bool = Depends(verify_admin_session)) -> Dict[str, Any] ) +@router.post("/api/cache/clear/images") +async def clear_image_cache(_: bool = Depends(verify_admin_session)) -> Dict[str, Any]: + """清理图片缓存 + + 仅删除图片缓存文件""" + try: + logger.debug("[Admin] 开始清理图片缓存") + + deleted_count = 0 + + # 清理图片缓存 + if IMAGE_CACHE_DIR.exists(): + for file_path in IMAGE_CACHE_DIR.iterdir(): + if file_path.is_file(): + try: + file_path.unlink() + deleted_count += 1 + logger.debug(f"[Admin] 删除图片缓存: {file_path.name}") + except Exception as e: + logger.error(f"[Admin] 删除图片缓存失败: {file_path.name}, 错误: {str(e)}") + + logger.debug(f"[Admin] 图片缓存清理完成 - 删除 {deleted_count} 个文件") + + return { + "success": True, + "message": f"成功清理图片缓存,删除 {deleted_count} 个文件", + "data": { + "deleted_count": deleted_count, + "type": "images" + } + } + + except Exception as e: + logger.error(f"[Admin] 清理图片缓存异常 - 错误: {str(e)}") + raise HTTPException( + status_code=500, + detail={"error": f"清理图片缓存失败: {str(e)}", "code": "IMAGE_CACHE_CLEAR_ERROR"} + ) + + +@router.post("/api/cache/clear/videos") +async def clear_video_cache(_: bool = Depends(verify_admin_session)) -> Dict[str, Any]: + """清理视频缓存 + + 仅删除视频缓存文件""" + try: + logger.debug("[Admin] 开始清理视频缓存") + + deleted_count = 0 + + # 清理视频缓存 + if VIDEO_CACHE_DIR.exists(): + for file_path in VIDEO_CACHE_DIR.iterdir(): + if file_path.is_file(): + try: + file_path.unlink() + deleted_count += 1 + logger.debug(f"[Admin] 删除视频缓存: {file_path.name}") + except Exception as e: + logger.error(f"[Admin] 删除视频缓存失败: {file_path.name}, 错误: {str(e)}") + + logger.debug(f"[Admin] 视频缓存清理完成 - 删除 {deleted_count} 个文件") + + return { + "success": True, + "message": f"成功清理视频缓存,删除 {deleted_count} 个文件", + "data": { + "deleted_count": deleted_count, + "type": "videos" + } + } + + except Exception as e: + logger.error(f"[Admin] 清理视频缓存异常 - 错误: {str(e)}") + raise HTTPException( + status_code=500, + detail={"error": f"清理视频缓存失败: {str(e)}", "code": "VIDEO_CACHE_CLEAR_ERROR"} + ) + + @router.get("/api/stats") async def get_stats(_: bool = Depends(verify_admin_session)) -> Dict[str, Any]: """ diff --git a/app/services/grok/client.py b/app/services/grok/client.py index 9d4d935..b275308 100644 --- a/app/services/grok/client.py +++ b/app/services/grok/client.py @@ -180,9 +180,14 @@ class GrokClient: return await GrokClient._process_response(response, auth_token, model, stream) except curl_requests.RequestsError as e: + logger.error(f"[Client] 网络请求错误: {e}") raise GrokApiException(f"网络错误: {e}", "NETWORK_ERROR") from e except json.JSONDecodeError as e: + logger.error(f"[Client] JSON解析错误: {e}") raise GrokApiException(f"JSON解析错误: {e}", "JSON_ERROR") from e + except Exception as e: + logger.error(f"[Client] 未知请求错误: {type(e).__name__}: {e}") + raise GrokApiException(f"请求处理错误: {e}", "REQUEST_ERROR") from e @staticmethod def _build_headers(auth_token: str) -> Dict[str, str]: diff --git a/app/services/grok/processer.py b/app/services/grok/processer.py index 7d66084..171cc14 100644 --- a/app/services/grok/processer.py +++ b/app/services/grok/processer.py @@ -3,6 +3,7 @@ import json import uuid import time +import asyncio from typing import AsyncGenerator from app.core.config import setting @@ -19,12 +20,67 @@ from app.models.openai_schema import ( from app.services.grok.cache import image_cache_service, video_cache_service +class StreamTimeoutManager: + """流式响应超时管理器""" + + def __init__(self, chunk_timeout: int = 120, first_response_timeout: int = 30, total_timeout: int = 600): + """初始化超时管理器 + + Args: + chunk_timeout: 数据块间隔超时(秒) + first_response_timeout: 首次响应超时(秒) + total_timeout: 总超时限制(秒,0表示不限制) + """ + self.chunk_timeout = chunk_timeout + self.first_response_timeout = first_response_timeout + self.total_timeout = total_timeout + + self.start_time = asyncio.get_event_loop().time() + self.last_chunk_time = self.start_time + self.first_chunk_received = False + + def check_timeout(self) -> tuple[bool, str]: + """检查是否超时 + + Returns: + (is_timeout, timeout_message): 是否超时及超时信息 + """ + current_time = asyncio.get_event_loop().time() + + # 检查首次响应超时 + if not self.first_chunk_received: + if current_time - self.start_time > self.first_response_timeout: + return True, f"首次响应超时 ({self.first_response_timeout}秒未收到首个数据块)" + + # 检查总超时 + if self.total_timeout > 0: + if current_time - self.start_time > self.total_timeout: + return True, f"流式响应总超时 ({self.total_timeout}秒)" + + # 检查数据块间隔超时 + if self.first_chunk_received: + if current_time - self.last_chunk_time > self.chunk_timeout: + return True, f"数据块间隔超时 ({self.chunk_timeout}秒无新数据)" + + return False, "" + + def mark_chunk_received(self): + """标记收到数据块""" + self.last_chunk_time = asyncio.get_event_loop().time() + self.first_chunk_received = True + + def get_total_duration(self) -> float: + """获取总耗时(秒)""" + return asyncio.get_event_loop().time() - self.start_time + + class GrokResponseProcessor: """Grok API 响应处理器""" @staticmethod async def process_normal(response, auth_token: str, model: str = None) -> OpenAIChatCompletionResponse: """处理非流式响应""" + response_closed = False try: for chunk in response.iter_lines(): if not chunk: @@ -79,6 +135,7 @@ class GrokResponseProcessor: )], usage=None ) + response_closed = True response.close() return result @@ -130,17 +187,25 @@ class GrokResponseProcessor: )], usage=None ) + response_closed = True response.close() return result raise GrokApiException("无响应数据", "NO_RESPONSE") except json.JSONDecodeError as e: + logger.error(f"[Processor] JSON解析失败: {e}") raise GrokApiException(f"JSON解析失败: {e}", "JSON_ERROR") from e + except Exception as e: + logger.error(f"[Processor] 处理响应时发生未知错误: {type(e).__name__}: {e}") + raise GrokApiException(f"响应处理错误: {e}", "PROCESS_ERROR") from e finally: - # 确保响应对象被关闭 - if hasattr(response, 'close'): - response.close() + # 确保响应对象被关闭,避免双重释放 + if not response_closed and hasattr(response, 'close'): + try: + response.close() + except Exception as e: + logger.warning(f"[Processor] 关闭响应对象时出错: {e}") @staticmethod async def process_stream(response, auth_token: str) -> AsyncGenerator[str, None]: @@ -151,9 +216,17 @@ class GrokResponseProcessor: thinking_finished = False chunk_index = 0 model = None - filtered_tags = setting.grok_config.get("filtered_tags", "").split(",") + filtered_tags = setting.grok_config.get("filtered_tags", "").split(",") video_progress_started = False last_video_progress = -1 + response_closed = False + + # 初始化超时管理器 + timeout_manager = StreamTimeoutManager( + chunk_timeout=setting.grok_config.get("stream_chunk_timeout", 120), + first_response_timeout=setting.grok_config.get("stream_first_response_timeout", 30), + total_timeout=setting.grok_config.get("stream_total_timeout", 600) + ) def make_chunk(chunk_content: str, finish: str = None): """生成OpenAI格式的响应块""" @@ -175,6 +248,14 @@ class GrokResponseProcessor: try: for chunk in response.iter_lines(): + # 超时检查 + is_timeout, timeout_msg = timeout_manager.check_timeout() + if is_timeout: + logger.warning(f"[Processor] {timeout_msg}") + yield make_chunk("", "stop") + yield "data: [DONE]\n\n" + return + logger.debug(f"[Processor] 接收到数据块: {len(chunk)} bytes") if not chunk: continue @@ -235,8 +316,9 @@ class GrokResponseProcessor: except Exception as e: logger.warning(f"[Processor] 缓存视频失败: {e}") content += f'' - + yield make_chunk(content) + timeout_manager.mark_chunk_received() chunk_index += 1 continue @@ -266,9 +348,11 @@ class GrokResponseProcessor: logger.warning(f"[Processor] 缓存图片失败: {e}") content += f"![Generated Image](https://assets.grok.com/{img})\n" yield make_chunk(content.strip(), "stop") + timeout_manager.mark_chunk_received() return elif token: yield make_chunk(token) + timeout_manager.mark_chunk_received() chunk_index += 1 # 提取对话数据 @@ -323,6 +407,7 @@ class GrokResponseProcessor: thinking_finished = True yield make_chunk(content) + timeout_manager.mark_chunk_received() chunk_index += 1 is_thinking = current_is_thinking @@ -335,12 +420,23 @@ class GrokResponseProcessor: # 发送结束块 yield make_chunk("", "stop") - + # 发送流结束标记 yield "data: [DONE]\n\n" + + # 记录流式响应统计 + logger.info(f"[Processor] 流式响应完成,总耗时: {timeout_manager.get_total_duration():.2f}秒") except Exception as e: logger.error(f"[Processor] 流式处理严重错误: {e}") yield make_chunk(f"处理错误: {e}", "error") # 发送流结束标记 - yield "data: [DONE]\n\n" \ No newline at end of file + yield "data: [DONE]\n\n" + finally: + # 确保响应对象被关闭 + if not response_closed and hasattr(response, 'close'): + try: + response.close() + logger.debug("[Processor] 流式响应对象已关闭") + except Exception as e: + logger.warning(f"[Processor] 关闭流式响应对象时出错: {e}") \ No newline at end of file diff --git a/app/template/admin.html b/app/template/admin.html index f83ea1a..9bdd8d0 100644 --- a/app/template/admin.html +++ b/app/template/admin.html @@ -325,15 +325,28 @@
图片: +
视频: +
总计: -
+
+ + +
+
+ + +
+
+ + +
@@ -500,11 +534,13 @@ showToast=(m,t='info')=>{const d=document.createElement('div'),bc={success:'bg-green-600',error:'bg-destructive',info:'bg-primary'};d.className=`fixed bottom-4 right-4 ${bc[t]||bc.info} text-white px-4 py-2.5 rounded-lg shadow-lg text-sm font-medium z-50 animate-slide-up`;d.textContent=m;document.body.appendChild(d);setTimeout(()=>{d.style.opacity='0';d.style.transition='opacity 0.3s';setTimeout(()=>d.parentNode&&document.body.removeChild(d),300)},2000)}, logout=async()=>{if(!confirm('确定要退出登录吗?'))return;try{await apiRequest('/api/logout',{method:'POST'})}catch(e){console.error('登出失败:',e)}finally{localStorage.removeItem('adminToken');location.href='/login'}}, switchTab=t=>{['tokens','settings'].forEach(n=>{$(`panel${n.charAt(0).toUpperCase()+n.slice(1)}`).classList[n===t?'remove':'add']('hidden');$(`tab${n.charAt(0).toUpperCase()+n.slice(1)}`).classList[n===t?'add':'remove']('border-primary','text-primary');$(`tab${n.charAt(0).toUpperCase()+n.slice(1)}`).classList[n===t?'remove':'add']('border-transparent','text-muted-foreground')});t==='settings'&&loadSettings()}, - loadSettings=async()=>{try{const r=await apiRequest('/api/settings');if(!r)return;const d=await r.json();if(d.success){const g=d.data.global,k=d.data.grok;$('cfgAdminUser').value=g.admin_username||'';$('cfgAdminPass').value='';$('cfgLogLevel').value=g.log_level||'DEBUG';$('cfgImageCacheMaxSize').value=g.image_cache_max_size_mb||500;$('cfgVideoCacheMaxSize').value=g.video_cache_max_size_mb||1000;$('cfgBaseUrl').value=g.base_url||'';$('cfgApiKey').value=k.api_key||'';$('cfgProxyUrl').value=k.proxy_url||'';$('cfgCfClearance').value=k.cf_clearance||'';$('cfgStatsigId').value=k.x_statsig_id||'';$('cfgFilteredTags').value=k.filtered_tags||'';$('cfgTemporary').value=k.temporary!==false?'true':'false';await loadCacheSize()}}catch(e){console.error('加载配置失败:',e);showToast('加载配置失败','error')}}, + loadSettings=async()=>{try{const r=await apiRequest('/api/settings');if(!r)return;const d=await r.json();if(d.success){const g=d.data.global,k=d.data.grok;$('cfgAdminUser').value=g.admin_username||'';$('cfgAdminPass').value='';$('cfgLogLevel').value=g.log_level||'DEBUG';$('cfgImageCacheMaxSize').value=g.image_cache_max_size_mb||500;$('cfgVideoCacheMaxSize').value=g.video_cache_max_size_mb||1000;$('cfgBaseUrl').value=g.base_url||'';$('cfgApiKey').value=k.api_key||'';$('cfgProxyUrl').value=k.proxy_url||'';$('cfgCfClearance').value=k.cf_clearance||'';$('cfgStatsigId').value=k.x_statsig_id||'';$('cfgFilteredTags').value=k.filtered_tags||'';$('cfgTemporary').value=k.temporary!==false?'true':'false';$('cfgStreamChunkTimeout').value=k.stream_chunk_timeout||120;$('cfgStreamFirstResponseTimeout').value=k.stream_first_response_timeout||30;$('cfgStreamTotalTimeout').value=k.stream_total_timeout||600;await loadCacheSize()}}catch(e){console.error('加载配置失败:',e);showToast('加载配置失败','error')}}, loadCacheSize=async()=>{try{const r=await apiRequest('/api/cache/size');if(!r)return;const d=await r.json();if(d.success){$('imageCacheSize').value=d.data.image_size||'0 MB';$('videoCacheSize').value=d.data.video_size||'0 MB';$('totalCacheSize').value=d.data.total_size||'0 MB'}}catch(e){console.error('加载缓存大小失败:',e);$('imageCacheSize').value='0 MB';$('videoCacheSize').value='0 MB';$('totalCacheSize').value='0 MB'}}, + clearImageCache=async()=>{if(!confirm('确定要清理图片缓存吗?此操作将删除所有图片缓存文件!'))return;try{const r=await apiRequest('/api/cache/clear/images',{method:'POST'});if(!r)return;const d=await r.json();if(d.success){showToast(`图片缓存清理完成,已删除 ${d.data.deleted_count||0} 个文件`,'success');await loadCacheSize()}else{showToast('清理失败: '+(d.error||'未知错误'),'error')}}catch(e){showToast('清理失败: '+e.message,'error')}}, + clearVideoCache=async()=>{if(!confirm('确定要清理视频缓存吗?此操作将删除所有视频缓存文件!'))return;try{const r=await apiRequest('/api/cache/clear/videos',{method:'POST'});if(!r)return;const d=await r.json();if(d.success){showToast(`视频缓存清理完成,已删除 ${d.data.deleted_count||0} 个文件`,'success');await loadCacheSize()}else{showToast('清理失败: '+(d.error||'未知错误'),'error')}}catch(e){showToast('清理失败: '+e.message,'error')}}, clearCache=async()=>{if(!confirm('确定要清理缓存吗?此操作将删除 /data/temp 目录中的所有文件!'))return;try{const r=await apiRequest('/api/cache/clear',{method:'POST'});if(!r)return;const d=await r.json();if(d.success){showToast(`缓存清理完成,已删除 ${d.data.deleted_count||0} 个文件`,'success');await loadCacheSize()}else{showToast('清理失败: '+(d.error||'未知错误'),'error')}}catch(e){showToast('清理失败: '+e.message,'error')}}, saveGlobalSettings=async()=>{const gc={admin_username:$('cfgAdminUser').value,log_level:$('cfgLogLevel').value,image_cache_max_size_mb:parseInt($('cfgImageCacheMaxSize').value)||500,video_cache_max_size_mb:parseInt($('cfgVideoCacheMaxSize').value)||1000,base_url:$('cfgBaseUrl').value};if($('cfgAdminPass').value)gc.admin_password=$('cfgAdminPass').value;try{const r=await apiRequest('/api/settings');if(!r)return;const d=await r.json();if(!d.success)return showToast('加载配置失败','error');const s=await apiRequest('/api/settings',{method:'POST',body:JSON.stringify({global_config:gc,grok_config:d.data.grok})});if(!s)return;const sd=await s.json();sd.success?(showToast('全局配置保存成功','success'),$('cfgAdminPass').value=''):showToast('保存失败: '+(sd.error||'未知错误'),'error')}catch(e){showToast('保存失败: '+e.message,'error')}}, - saveGrokSettings=async()=>{const kc={api_key:$('cfgApiKey').value,proxy_url:$('cfgProxyUrl').value,cf_clearance:$('cfgCfClearance').value,x_statsig_id:$('cfgStatsigId').value,filtered_tags:$('cfgFilteredTags').value,temporary:$('cfgTemporary').value==='true'};try{const r=await apiRequest('/api/settings');if(!r)return;const d=await r.json();if(!d.success)return showToast('加载配置失败','error');const s=await apiRequest('/api/settings',{method:'POST',body:JSON.stringify({global_config:d.data.global,grok_config:kc})});if(!s)return;const sd=await s.json();sd.success?showToast('Grok配置保存成功','success'):showToast('保存失败: '+(sd.error||'未知错误'),'error')}catch(e){showToast('保存失败: '+e.message,'error')}}; + saveGrokSettings=async()=>{const kc={api_key:$('cfgApiKey').value,proxy_url:$('cfgProxyUrl').value,cf_clearance:$('cfgCfClearance').value,x_statsig_id:$('cfgStatsigId').value,filtered_tags:$('cfgFilteredTags').value,temporary:$('cfgTemporary').value==='true',stream_chunk_timeout:parseInt($('cfgStreamChunkTimeout').value)||120,stream_first_response_timeout:parseInt($('cfgStreamFirstResponseTimeout').value)||30,stream_total_timeout:parseInt($('cfgStreamTotalTimeout').value)||600};try{const r=await apiRequest('/api/settings');if(!r)return;const d=await r.json();if(!d.success)return showToast('加载配置失败','error');const s=await apiRequest('/api/settings',{method:'POST',body:JSON.stringify({global_config:d.data.global,grok_config:kc})});if(!s)return;const sd=await s.json();sd.success?showToast('Grok配置保存成功','success'):showToast('保存失败: '+(sd.error||'未知错误'),'error')}catch(e){showToast('保存失败: '+e.message,'error')}}; window.addEventListener('DOMContentLoaded',()=>{checkAuth();refreshTokens();setInterval(()=>{loadStats();updateRemaining()},30000)}); diff --git a/data/setting.toml b/data/setting.toml index 379d11e..a384137 100644 --- a/data/setting.toml +++ b/data/setting.toml @@ -2,15 +2,18 @@ admin_username = "admin" admin_password = "admin" log_level = "INFO" -# 图片缓存最大大小(MB) image_cache_max_size_mb = 500 -# 视频缓存最大大小(MB) video_cache_max_size_mb = 1000 base_url = "" + + [grok] api_key = "" proxy_url = "" +stream_chunk_timeout = 120 +stream_first_response_timeout = 30 +stream_total_timeout = 600 cf_clearance = "" temporary = true filtered_tags = "xaiartifact,xai:tool_usage_card,grok:render"