diff --git a/app/api/v1/admin_api/config.py b/app/api/v1/admin_api/config.py index eff5140..90f4d8a 100644 --- a/app/api/v1/admin_api/config.py +++ b/app/api/v1/admin_api/config.py @@ -1,10 +1,12 @@ import os +import shutil +from pathlib import Path from fastapi import APIRouter, Depends, HTTPException from app.core.auth import verify_app_key from app.core.config import config -from app.core.logger import logger +from app.core.logger import logger, LOG_DIR from app.services.grok.services.model import ModelService from app.services.token import get_token_manager from app.core.storage import ( @@ -18,6 +20,44 @@ from app.services.cf_refresh.scheduler import refresh_once router = APIRouter() +def _clear_log_dir() -> dict: + """清空日志目录内容,保留目录本身。""" + log_dir = Path(LOG_DIR) + log_dir.mkdir(parents=True, exist_ok=True) + deleted_files = 0 + deleted_dirs = 0 + released_bytes = 0 + + for child in log_dir.iterdir(): + try: + if child.is_file() or child.is_symlink(): + try: + released_bytes += child.stat().st_size + except OSError: + pass + child.unlink(missing_ok=True) + deleted_files += 1 + continue + if child.is_dir(): + for nested in child.rglob("*"): + if nested.is_file(): + try: + released_bytes += nested.stat().st_size + except OSError: + pass + shutil.rmtree(child, ignore_errors=False) + deleted_dirs += 1 + except FileNotFoundError: + continue + + return { + "log_dir": str(log_dir), + "deleted_files": deleted_files, + "deleted_dirs": deleted_dirs, + "released_bytes": released_bytes, + } + + @router.get("/verify", dependencies=[Depends(verify_app_key)]) async def admin_verify(): """验证后台访问密钥(app_key)""" @@ -65,6 +105,21 @@ async def refresh_cf_clearance(): raise HTTPException(status_code=500, detail=str(e)) +@router.post("/config/clear-logs", dependencies=[Depends(verify_app_key)]) +async def clear_logs(): + """清空日志目录。""" + try: + result = _clear_log_dir() + return { + "status": "success", + "message": "日志文件夹已清空", + "data": result, + } + except Exception as e: + logger.error(f"Clear log directory failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + @router.get("/model-routing/meta", dependencies=[Depends(verify_app_key)]) async def get_model_routing_meta(): """获取模型池路由界面所需的模型与池元数据。""" diff --git a/app/api/v1/chat.py b/app/api/v1/chat.py index 81f9487..ddc319c 100644 --- a/app/api/v1/chat.py +++ b/app/api/v1/chat.py @@ -2,11 +2,11 @@ Chat Completions API 路由 """ -import asyncio -from typing import Any, Dict, List, Optional, Union import base64 import binascii +import asyncio import time +from typing import Any, Dict, List, Optional, Union import orjson from fastapi import APIRouter, Request @@ -28,14 +28,6 @@ from app.services.grok.utils.response import ( wrap_image_content, ) - -try: - with open("app_traffic.log", "w", encoding="utf-8") as f: - import datetime - f.write(f"=== app_traffic.log Cleared on Uvicorn Loaded [{datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] ===\n\n") -except Exception: - pass - class MessageItem(BaseModel): """消息项""" @@ -1041,20 +1033,6 @@ async def chat_completions(request: ChatCompletionRequest, raw_request: Request) except Exception as e: return _chat_error_as_success_response(request.model, _video_error_message(e)) else: - try: - with open("app_traffic.log", "a", encoding="utf-8") as f: - import datetime - f.write(f"--- Chat Request [{datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] ---\n") - dump = { - "model": request.model, - "messages": [m.model_dump() for m in request.messages], - "stream": request.stream, - "tools": request.tools if request.tools else None, - } - f.write(orjson.dumps(dump, option=orjson.OPT_INDENT_2).decode('utf-8') + "\n\n") - except Exception: - pass - result = await ChatService.completions( model=request.model, messages=[msg.model_dump() for msg in request.messages], diff --git a/app/core/config.py b/app/core/config.py index d761bd8..fed1070 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -50,6 +50,8 @@ def _migrate_deprecated_config( "grok.thinking": "app.thinking", "grok.dynamic_statsig": "app.dynamic_statsig", "grok.filter_tags": "app.filter_tags", + "chat.capture_enabled": "app.chat_capture_enabled", + "chat.capture_file": "app.chat_capture_file", "grok.timeout": "voice.timeout", "grok.base_proxy_url": "proxy.base_proxy_url", "grok.asset_proxy_url": "proxy.asset_proxy_url", @@ -154,13 +156,16 @@ def _migrate_deprecated_config( "thinking": "thinking", "dynamic_statsig": "dynamic_statsig", "filter_tags": "filter_tags", + "capture_enabled": "chat_capture_enabled", + "capture_file": "chat_capture_file", } chat_section = config.get("chat") if isinstance(chat_section, dict): app_section = result.setdefault("app", {}) for old_key, new_key in legacy_chat_map.items(): - if old_key in chat_section and new_key not in app_section: - app_section[new_key] = chat_section[old_key] + if old_key in chat_section: + if new_key not in app_section: + app_section[new_key] = chat_section[old_key] if isinstance(result.get("chat"), dict): result["chat"].pop(old_key, None) migrated_count += 1 @@ -292,10 +297,38 @@ class Config: storage = get_storage() async with storage.acquire_lock("config_save", timeout=10): self._ensure_defaults() + previous = deepcopy(self._config or {}) base = _deep_merge(self._defaults, self._config or {}) merged = _deep_merge(base, new_config or {}) await storage.save_config(merged) self._config = merged + self._apply_runtime_updates(previous, self._config) + + def _apply_runtime_updates(self, previous: Dict[str, Any], current: Dict[str, Any]): + """配置变更后立即应用可热更新的运行时设置。""" + prev_app = previous.get("app") if isinstance(previous, dict) else {} + curr_app = current.get("app") if isinstance(current, dict) else {} + prev_app = prev_app if isinstance(prev_app, dict) else {} + curr_app = curr_app if isinstance(curr_app, dict) else {} + + log_related_keys = { + "app_log_enabled", + "chat_capture_enabled", + "chat_capture_file", + } + if any(prev_app.get(key) != curr_app.get(key) for key in log_related_keys): + try: + from app.core.logger import setup_logging + import os + + setup_logging( + level=os.getenv("LOG_LEVEL", "INFO"), + json_console=False, + file_logging=bool(curr_app.get("app_log_enabled", True)), + ) + logger.info("Applied runtime log configuration update.") + except Exception as e: + logger.warning(f"Failed to hot-apply log configuration: {e}") # 全局配置实例 diff --git a/app/core/logger.py b/app/core/logger.py index f12207c..abc3f49 100644 --- a/app/core/logger.py +++ b/app/core/logger.py @@ -16,9 +16,19 @@ if not hasattr(logger, "isEnabledFor"): # 日志目录 DEFAULT_LOG_DIR = Path(__file__).parent.parent.parent / "logs" LOG_DIR = Path(os.getenv("LOG_DIR", str(DEFAULT_LOG_DIR))) +LEGACY_TRAFFIC_LOG = DEFAULT_LOG_DIR.parent / "app_traffic.log" _LOG_DIR_READY = False +def _cleanup_legacy_logs() -> None: + """清理历史遗留的根目录日志文件。""" + try: + if LEGACY_TRAFFIC_LOG.exists() and LEGACY_TRAFFIC_LOG.is_file(): + LEGACY_TRAFFIC_LOG.unlink() + except Exception: + pass + + def _prepare_log_dir() -> bool: """确保日志目录可用""" global LOG_DIR, _LOG_DIR_READY @@ -26,6 +36,7 @@ def _prepare_log_dir() -> bool: return True try: LOG_DIR.mkdir(parents=True, exist_ok=True) + _cleanup_legacy_logs() _LOG_DIR_READY = True return True except Exception: @@ -108,10 +119,13 @@ def _file_json_sink(message): def setup_logging( level: str = "DEBUG", json_console: bool = True, - file_logging: bool = True, + file_logging: bool | None = None, ): """设置日志配置""" logger.remove() + if file_logging is None: + from app.core.config import get_config + file_logging = bool(get_config("app.app_log_enabled", True)) file_logging = _env_flag("LOG_FILE_ENABLED", file_logging) enqueue_logs = _should_enqueue_logs() diff --git a/app/services/grok/services/chat.py b/app/services/grok/services/chat.py index f37ef5b..153c9a1 100644 --- a/app/services/grok/services/chat.py +++ b/app/services/grok/services/chat.py @@ -343,10 +343,13 @@ def _build_capture_event(resp: Dict[str, Any], model: str, phase: str, seq: int) def _capture_app_chat_event(resp: Dict[str, Any], model: str, phase: str, seq: int) -> None: - if not get_config("chat.capture_enabled"): + if not get_config("app.chat_capture_enabled", get_config("chat.capture_enabled", False)): return - capture_file = get_config("chat.capture_file") or str(LOG_DIR / "app_chat_capture.jsonl") + capture_file = get_config( + "app.chat_capture_file", + get_config("chat.capture_file", str(LOG_DIR / "app_chat_capture.jsonl")), + ) or str(LOG_DIR / "app_chat_capture.jsonl") try: event = _build_capture_event(resp, model, phase, seq) with open(capture_file, "ab") as f: diff --git a/app/static/admin/css/config.css b/app/static/admin/css/config.css index 85b61ab..daef99e 100644 --- a/app/static/admin/css/config.css +++ b/app/static/admin/css/config.css @@ -21,6 +21,22 @@ color: var(--accents-7); } +.config-section-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-top: 12px; + margin-bottom: 4px; +} + +.config-footer-actions { + display: flex; + justify-content: flex-start; + margin-top: 8px; + padding-top: 4px; +} + .config-grid { display: grid; gap: 14px; diff --git a/app/static/admin/js/config.js b/app/static/admin/js/config.js index bed9fd9..4777e4f 100644 --- a/app/static/admin/js/config.js +++ b/app/static/admin/js/config.js @@ -50,7 +50,10 @@ const LOCALE_MAP = { "stream": { title: "流式响应", desc: "是否默认启用流式输出。" }, "thinking": { title: "思维链", desc: "是否默认启用思维链输出。" }, "dynamic_statsig": { title: "动态指纹", desc: "是否默认启用动态生成 Statsig 指纹。" }, - "filter_tags": { title: "过滤标签", desc: "设置自动过滤 Grok 响应中的特殊标签。" } + "filter_tags": { title: "过滤标签", desc: "设置自动过滤 Grok 响应中的特殊标签。" }, + "app_log_enabled": { title: "应用运行日志", desc: "控制是否将服务运行日志写入 logs/app_YYYY-MM-DD.log,用于排查启动、接口错误和系统异常。" }, + "chat_capture_enabled": { title: "对话抓包日志", desc: "控制是否记录对话上游原始响应到抓包文件,用于排查图片来源、卡片附件和搜索结果关联问题。" }, + "chat_capture_file": { title: "对话抓包文件", desc: "对话抓包日志的写入路径,建议保留在 logs 目录下统一管理。" } }, @@ -182,6 +185,9 @@ const CF_MANAGED_PROXY_KEYS = ['cf_clearance', 'browser', 'user_agent']; const CF_REFRESH_SUB_KEYS = ['flaresolverr_url', 'refresh_interval', 'timeout']; const SECTION_ORDER = new Map(Object.keys(LOCALE_MAP).map((key, index) => [key, index])); +const HIDDEN_CONFIG_KEYS = new Map([ + ['chat', new Set(['capture_enabled', 'capture_file'])], +]); function getText(section, key) { if (LOCALE_MAP[section] && LOCALE_MAP[section][key]) { @@ -374,7 +380,8 @@ function renderConfig(data) { const keyOrder = localeSection ? new Map(Object.keys(localeSection).map((k, i) => [k, i])) : null; const allKeys = sortByOrder(Object.keys(items), keyOrder); - const visibleKeys = allKeys.filter(key => !(section === 'proxy' && key === 'cf_cookies')); + const hiddenKeys = HIDDEN_CONFIG_KEYS.get(section) || new Set(); + const visibleKeys = allKeys.filter(key => !(section === 'proxy' && key === 'cf_cookies') && !hiddenKeys.has(key)); if (visibleKeys.length > 0) { const card = document.createElement('div'); @@ -385,7 +392,7 @@ function renderConfig(data) { if (section === 'proxy') { const actionRow = document.createElement('div'); - actionRow.className = 'flex items-center gap-2 mt-3 mb-4'; + actionRow.className = 'config-section-actions'; const refreshBtn = document.createElement('button'); refreshBtn.type = 'button'; @@ -404,7 +411,7 @@ function renderConfig(data) { actionRow.appendChild(refreshBtn); header.appendChild(actionRow); } - + // 添加部分说明(如果有) if (SECTION_DESCRIPTIONS[section]) { const descP = document.createElement('p'); @@ -424,6 +431,27 @@ function renderConfig(data) { }); card.appendChild(grid); + if (section === 'app') { + const footerActions = document.createElement('div'); + footerActions.className = 'config-footer-actions'; + const clearLogsBtn = document.createElement('button'); + clearLogsBtn.type = 'button'; + clearLogsBtn.id = 'clear-logs-btn'; + clearLogsBtn.className = 'geist-button-danger gap-2'; + clearLogsBtn.innerHTML = ` + + + + + + + + 清空日志文件夹 + `; + clearLogsBtn.addEventListener('click', clearLogsDirectory); + card.appendChild(footerActions); + footerActions.appendChild(clearLogsBtn); + } if (grid.children.length > 0) { fragment.appendChild(card); } @@ -467,6 +495,56 @@ async function manualRefreshCfClearance() { } } +function formatBytes(bytes) { + const size = Number(bytes || 0); + if (!size) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let value = size; + let unitIndex = 0; + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex += 1; + } + return `${value >= 10 || unitIndex === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}`; +} + +async function clearLogsDirectory() { + const btn = byId('clear-logs-btn'); + if (!btn) return; + const confirmed = window.confirm('将清空 logs 文件夹中的全部日志文件,是否继续?'); + if (!confirmed) return; + + const originalHtml = btn.innerHTML; + btn.disabled = true; + btn.innerHTML = ` + + + + 清理中... + `; + + try { + const res = await fetch('/v1/admin/config/clear-logs', { + method: 'POST', + headers: buildAuthHeaders(apiKey) + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(data.detail || data.message || `HTTP ${res.status}`); + } + const info = data.data || {}; + const filesText = Number(info.deleted_files || 0); + const dirsText = Number(info.deleted_dirs || 0); + const bytesText = formatBytes(info.released_bytes || 0); + showToast(`已清空日志:${filesText} 个文件,${dirsText} 个目录,释放 ${bytesText}`, 'success'); + } catch (e) { + showToast(`清空失败: ${e.message || e}`, 'error'); + } finally { + btn.disabled = false; + btn.innerHTML = originalHtml; + } +} + function normalizeModelRoutingAssignments(raw) { const next = {}; const source = raw && typeof raw === 'object' ? raw : {}; diff --git a/config.defaults.toml b/config.defaults.toml index ec77492..871eab8 100644 --- a/config.defaults.toml +++ b/config.defaults.toml @@ -26,6 +26,12 @@ thinking = true dynamic_statsig = true # 过滤的特殊标签列表 filter_tags = ["xaiartifact","xai:tool_usage_card"] +# 是否将应用运行日志写入 logs/app_YYYY-MM-DD.log +app_log_enabled = true +# 是否记录对话详细抓包日志(用于排查图片来源、卡片原始返回等) +chat_capture_enabled = true +# 对话详细抓包日志文件路径(相对项目根目录) +chat_capture_file = "logs/app_chat_capture.jsonl" # ==================== 代理配置 ==================== @@ -99,10 +105,6 @@ limit_mb = 512 [chat] # Reverse 接口并发上限 concurrent = 50 -# 是否将 app-chat 上游关键响应写入抓包文件 -capture_enabled = true -# app-chat 详细抓包文件路径(JSONL) -capture_file = "logs/app_chat_capture.jsonl" # 连接超时时间(秒),用于弱网快速失败 connect_timeout = 12 # Reverse 接口超时时间(秒) diff --git a/main.py b/main.py index c400443..939eecb 100644 --- a/main.py +++ b/main.py @@ -49,9 +49,9 @@ from fastapi.staticfiles import StaticFiles @asynccontextmanager async def lifespan(app: FastAPI): """应用生命周期管理""" - # 在 worker 启动阶段初始化日志,避免 Granian 下模块导入时绑定的标准流失效。 + # 启动早期先仅初始化控制台日志,配置加载后再按配置决定是否写文件。 setup_logging( - level=os.getenv("LOG_LEVEL", "INFO"), json_console=False, file_logging=True + level=os.getenv("LOG_LEVEL", "INFO"), json_console=False, file_logging=False ) # 1. 注册服务默认配置 @@ -62,6 +62,11 @@ async def lifespan(app: FastAPI): # 2. 加载配置 await config.load() + setup_logging( + level=os.getenv("LOG_LEVEL", "INFO"), + json_console=False, + file_logging=get_config("app.app_log_enabled", True), + ) # 3. 启动服务显示 logger.info("Starting Grok2API...")