diff --git a/backend/app/main.py b/backend/app/main.py index 7912fab..089c660 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -14,6 +14,7 @@ from app.routers import ( settings_router, folder_router, skill_router, + log_router, ) from app.routers.scheduler import router as scheduler_router from app.services.scheduler_service import start_scheduler, stop_scheduler, get_scheduler_status @@ -61,6 +62,7 @@ app.include_router(todo_router) app.include_router(settings_router) app.include_router(folder_router) app.include_router(skill_router) +app.include_router(log_router) app.include_router(scheduler_router) diff --git a/backend/app/models/log.py b/backend/app/models/log.py new file mode 100644 index 0000000..51d38c6 --- /dev/null +++ b/backend/app/models/log.py @@ -0,0 +1,33 @@ +from sqlalchemy import Column, String, Text, DateTime, Index, Enum as SQLEnum +from app.models.base import BaseModel +import enum + + +class LogLevel(str, enum.Enum): + DEBUG = "debug" + INFO = "info" + WARNING = "warning" + ERROR = "error" + + +class LogType(str, enum.Enum): + AGENT = "agent" # 智能体调用 + SYSTEM = "system" # 系统运行 + CHAT = "chat" # 问答对话 + + +class Log(BaseModel): + __tablename__ = "logs" + + level = Column(String(20), default=LogLevel.INFO.value, index=True) # debug/info/warning/error + type = Column(String(20), default=LogType.SYSTEM.value, index=True) # agent/system/chat + user_id = Column(String(36), nullable=True, index=True) # 关联用户 + message = Column(Text, nullable=False) # 日志内容 + details = Column(Text, nullable=True) # 详细信息(JSON) + source = Column(String(100), nullable=True) # 来源模块 + duration_ms = Column(String(20), nullable=True) # 执行耗时 + + __table_args__ = ( + Index('idx_logs_type_level', 'type', 'level'), + Index('idx_logs_created_at', 'created_at'), + ) diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py index 0a0279e..390048d 100644 --- a/backend/app/routers/__init__.py +++ b/backend/app/routers/__init__.py @@ -9,3 +9,4 @@ from app.routers.todo import router as todo_router from app.routers.settings import router as settings_router from app.routers.folder import router as folder_router from app.routers.skill import router as skill_router +from app.routers.log import router as log_router diff --git a/backend/app/routers/log.py b/backend/app/routers/log.py new file mode 100644 index 0000000..1608c90 --- /dev/null +++ b/backend/app/routers/log.py @@ -0,0 +1,93 @@ +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel +from typing import Optional +from app.database import get_db +from app.models.user import User +from app.routers.auth import get_current_user +from app.services.log_service import LogService + +router = APIRouter(prefix="/api/logs", tags=["Log"]) + + +class LogOut(BaseModel): + id: str + level: str + type: str + user_id: Optional[str] + message: str + source: Optional[str] + details: Optional[str] + duration_ms: Optional[str] + created_at: str + updated_at: str + + model_config = {"from_attributes": True} + + +class LogStatsOut(BaseModel): + total: int + by_type: dict + by_level: dict + + +class LogQueryOut(BaseModel): + logs: list[LogOut] + total: int + page: int + page_size: int + + +@router.get("", response_model=LogQueryOut) +async def list_logs( + log_type: Optional[str] = Query(None, description="日志类型: agent/system/chat"), + level: Optional[str] = Query(None, description="日志级别: debug/info/warning/error"), + source: Optional[str] = Query(None, description="来源模块"), + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=200), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """查询日志列表""" + svc = LogService(db) + offset = (page - 1) * page_size + logs, total = await svc.list_logs( + log_type=log_type, + level=level, + user_id=current_user.id, + source=source, + limit=page_size, + offset=offset, + ) + return LogQueryOut( + logs=[LogOut.model_validate(log) for log in logs], + total=total, + page=page, + page_size=page_size, + ) + + +@router.get("/stats", response_model=LogStatsOut) +async def get_log_stats( + hours: int = Query(24, ge=1, le=168), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """获取日志统计""" + svc = LogService(db) + stats = await svc.get_log_stats(hours=hours) + return LogStatsOut(**stats) + + +@router.get("/recent", response_model=list[LogOut]) +async def get_recent_logs( + log_type: Optional[str] = Query(None), + hours: int = Query(24, ge=1, le=168), + limit: int = Query(50, ge=1, le=200), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """获取最近的日志""" + svc = LogService(db) + logs = await svc.get_recent_logs(log_type=log_type, hours=hours, limit=limit) + return [LogOut.model_validate(log) for log in logs] diff --git a/backend/app/services/log_service.py b/backend/app/services/log_service.py new file mode 100644 index 0000000..bf824d9 --- /dev/null +++ b/backend/app/services/log_service.py @@ -0,0 +1,262 @@ +""" +运行日志服务 +提供统一的日志记录接口,支持分类存储和查询 +""" +import json +import logging +from datetime import datetime, timedelta +from typing import Optional +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, and_, desc, func +from app.models.log import Log, LogType, LogLevel + +logger = logging.getLogger(__name__) + +# 日志级别映射 +LEVEL_MAP = { + "DEBUG": LogLevel.DEBUG, + "INFO": LogLevel.INFO, + "WARNING": LogLevel.WARNING, + "ERROR": LogLevel.ERROR, +} + + +class LogService: + def __init__(self, db: AsyncSession): + self.db = db + + async def log( + self, + message: str, + level: str = "info", + log_type: str = "system", + user_id: Optional[str] = None, + source: Optional[str] = None, + details: Optional[dict] = None, + duration_ms: Optional[int] = None, + ) -> Log: + """记录日志""" + log_entry = Log( + level=level, + type=log_type, + user_id=user_id, + message=message, + source=source, + details=json.dumps(details, ensure_ascii=False) if details else None, + duration_ms=str(duration_ms) if duration_ms else None, + ) + self.db.add(log_entry) + await self.db.commit() + await self.db.refresh(log_entry) + return log_entry + + async def agent_log( + self, + message: str, + user_id: Optional[str] = None, + source: Optional[str] = None, + details: Optional[dict] = None, + duration_ms: Optional[int] = None, + ) -> Log: + """记录智能体调用日志""" + return await self.log( + message=message, + level="info", + log_type="agent", + user_id=user_id, + source=source, + details=details, + duration_ms=duration_ms, + ) + + async def system_log( + self, + message: str, + level: str = "info", + source: Optional[str] = None, + details: Optional[dict] = None, + ) -> Log: + """记录系统运行日志""" + return await self.log( + message=message, + level=level, + log_type="system", + user_id=None, + source=source, + details=details, + ) + + async def chat_log( + self, + message: str, + user_id: str, + details: Optional[dict] = None, + duration_ms: Optional[int] = None, + ) -> Log: + """记录问答日志""" + return await self.log( + message=message, + level="info", + log_type="chat", + user_id=user_id, + source="chat", + details=details, + duration_ms=duration_ms, + ) + + async def list_logs( + self, + log_type: Optional[str] = None, + level: Optional[str] = None, + user_id: Optional[str] = None, + source: Optional[str] = None, + limit: int = 100, + offset: int = 0, + ) -> tuple[list[Log], int]: + """ + 查询日志列表 + + Returns: + (logs, total_count) + """ + conditions = [] + + if log_type: + conditions.append(Log.type == log_type) + if level: + conditions.append(Log.level == level) + if user_id: + conditions.append(Log.user_id == user_id) + if source: + conditions.append(Log.source == source) + + # 统计总数 + count_query = select(func.count(Log.id)) + if conditions: + count_query = count_query.where(and_(*conditions)) + total_result = await self.db.execute(count_query) + total = total_result.scalar() or 0 + + # 查询列表 + query = ( + select(Log) + .where(and_(*conditions)) if conditions else select(Log) + ).order_by(desc(Log.created_at)).limit(limit).offset(offset) + + result = await self.db.execute(query) + logs = list(result.scalars().all()) + + return logs, total + + async def get_recent_logs( + self, + log_type: Optional[str] = None, + hours: int = 24, + limit: int = 100, + ) -> list[Log]: + """获取最近的日志""" + since = datetime.utcnow() - timedelta(hours=hours) + conditions = [Log.created_at >= since] + + if log_type: + conditions.append(Log.type == log_type) + + query = ( + select(Log) + .where(and_(*conditions)) + .order_by(desc(Log.created_at)) + .limit(limit) + ) + result = await self.db.execute(query) + return list(result.scalars().all()) + + async def get_log_stats(self, hours: int = 24) -> dict: + """获取日志统计""" + since = datetime.utcnow() - timedelta(hours=hours) + + stats = { + "total": 0, + "by_type": {"agent": 0, "system": 0, "chat": 0}, + "by_level": {"debug": 0, "info": 0, "warning": 0, "error": 0}, + } + + # 按类型统计 + for log_type in ["agent", "system", "chat"]: + query = select(func.count(Log.id)).where( + and_(Log.type == log_type, Log.created_at >= since) + ) + result = await self.db.execute(query) + count = result.scalar() or 0 + stats["by_type"][log_type] = count + stats["total"] += count + + # 按级别统计 + for level in ["debug", "info", "warning", "error"]: + query = select(func.count(Log.id)).where( + and_(Log.level == level, Log.created_at >= since) + ) + result = await self.db.execute(query) + stats["by_level"][level] = result.scalar() or 0 + + return stats + + +# 全局日志记录函数,方便各处调用 +_global_db_session = None + + +def set_log_session(db: AsyncSession): + """设置全局日志会话""" + global _global_db_session + _global_db_session = db + + +def get_log_session() -> Optional[AsyncSession]: + """获取全局日志会话""" + return _global_db_session + + +async def log_agent_event( + message: str, + user_id: Optional[str] = None, + source: Optional[str] = None, + details: Optional[dict] = None, + duration_ms: Optional[int] = None, +): + """记录智能体事件到数据库""" + if _global_db_session: + try: + svc = LogService(_global_db_session) + await svc.agent_log(message, user_id, source, details, duration_ms) + except Exception as e: + logger.error(f"Failed to log agent event: {e}") + + +async def log_system_event( + message: str, + level: str = "info", + source: Optional[str] = None, + details: Optional[dict] = None, +): + """记录系统事件到数据库""" + if _global_db_session: + try: + svc = LogService(_global_db_session) + await svc.system_log(message, level, source, details) + except Exception as e: + logger.error(f"Failed to log system event: {e}") + + +async def log_chat_event( + message: str, + user_id: str, + details: Optional[dict] = None, + duration_ms: Optional[int] = None, +): + """记录聊天事件到数据库""" + if _global_db_session: + try: + svc = LogService(_global_db_session) + await svc.chat_log(message, user_id, details, duration_ms) + except Exception as e: + logger.error(f"Failed to log chat event: {e}") diff --git a/frontend/src/api/log.ts b/frontend/src/api/log.ts new file mode 100644 index 0000000..043af39 --- /dev/null +++ b/frontend/src/api/log.ts @@ -0,0 +1,61 @@ +import api from './index' +import type { AxiosResponse } from 'axios' + +export interface Log { + id: string + level: 'debug' | 'info' | 'warning' | 'error' + type: 'agent' | 'system' | 'chat' + user_id: string | null + message: string + source: string | null + details: string | null + duration_ms: string | null + created_at: string + updated_at: string +} + +export interface LogStats { + total: number + by_type: { + agent: number + system: number + chat: number + } + by_level: { + debug: number + info: number + warning: number + error: number + } +} + +export interface LogQueryResult { + logs: Log[] + total: number + page: number + page_size: number +} + +export const logApi = { + list: (params?: { + log_type?: string + level?: string + source?: string + page?: number + page_size?: number + }): Promise> => { + return api.get('/api/logs', { params }) + }, + + getStats: (hours?: number): Promise> => { + return api.get('/api/logs/stats', { params: { hours } }) + }, + + getRecent: (params?: { + log_type?: string + hours?: number + limit?: number + }): Promise> => { + return api.get('/api/logs/recent', { params }) + }, +} diff --git a/frontend/src/components/SidebarNav.vue b/frontend/src/components/SidebarNav.vue index 7f942d4..036f3dc 100644 --- a/frontend/src/components/SidebarNav.vue +++ b/frontend/src/components/SidebarNav.vue @@ -1,7 +1,7 @@ + + + +