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...")