diff --git a/bot.py b/bot.py index ba210462..38894b29 100644 --- a/bot.py +++ b/bot.py @@ -212,6 +212,10 @@ if __name__ == "__main__": # 创建事件循环 loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) + + # 初始化 WebSocket 日志推送 + from src.common.logger import initialize_ws_handler + initialize_ws_handler(loop) try: # 执行初始化和任务调度 diff --git a/src/common/logger.py b/src/common/logger.py index 55833c34..9e7e08d4 100644 --- a/src/common/logger.py +++ b/src/common/logger.py @@ -19,6 +19,7 @@ PROJECT_ROOT = logger_file.parent.parent.parent.resolve() # 全局handler实例,避免重复创建 _file_handler = None _console_handler = None +_ws_handler = None def get_file_handler(): @@ -59,6 +60,35 @@ def get_console_handler(): return _console_handler +def get_ws_handler(): + """获取 WebSocket handler 单例""" + global _ws_handler + if _ws_handler is None: + _ws_handler = WebSocketLogHandler() + # WebSocket handler 推送所有级别的日志 + _ws_handler.setLevel(logging.DEBUG) + return _ws_handler + + +def initialize_ws_handler(loop): + """初始化 WebSocket handler 的事件循环 + + Args: + loop: asyncio 事件循环 + """ + handler = get_ws_handler() + handler.set_loop(loop) + + # 为 WebSocket handler 设置 JSON 格式化器(与文件格式相同) + handler.setFormatter(file_formatter) + + # 添加到根日志记录器 + root_logger = logging.getLogger() + if handler not in root_logger.handlers: + root_logger.addHandler(handler) + print("[日志系统] ✅ WebSocket 日志推送已启用") + + class TimestampedFileHandler(logging.Handler): """基于时间戳的文件处理器,简单的轮转份数限制""" @@ -145,12 +175,78 @@ class TimestampedFileHandler(logging.Handler): super().close() +class WebSocketLogHandler(logging.Handler): + """WebSocket 日志处理器 - 将日志实时推送到前端""" + + _log_counter = 0 # 类级别计数器,确保 ID 唯一性 + + def __init__(self, loop=None): + super().__init__() + self.loop = loop + self._initialized = False + + def set_loop(self, loop): + """设置事件循环""" + self.loop = loop + self._initialized = True + + def emit(self, record): + """发送日志到 WebSocket 客户端""" + if not self._initialized or self.loop is None: + return + + try: + # 获取格式化后的消息 + # 对于 structlog,formatted message 包含完整的日志信息 + formatted_msg = self.format(record) if self.formatter else record.getMessage() + + # 如果是 JSON 格式(文件格式化器),解析它 + message = formatted_msg + try: + import json + log_dict = json.loads(formatted_msg) + message = log_dict.get('event', formatted_msg) + except (json.JSONDecodeError, ValueError): + # 不是 JSON,直接使用消息 + message = formatted_msg + + # 生成唯一 ID: 时间戳毫秒 + 自增计数器 + WebSocketLogHandler._log_counter += 1 + log_id = f"{int(record.created * 1000)}_{WebSocketLogHandler._log_counter}" + + # 格式化日志数据 + log_data = { + "id": log_id, + "timestamp": datetime.fromtimestamp(record.created).strftime("%Y-%m-%d %H:%M:%S"), + "level": record.levelname, + "module": record.name, + "message": message, + } + + # 异步广播日志(不阻塞日志记录) + try: + import asyncio + from src.webui.logs_ws import broadcast_log + + asyncio.run_coroutine_threadsafe( + broadcast_log(log_data), + self.loop + ) + except Exception: + # WebSocket 推送失败不影响日志记录 + pass + + except Exception: + # 不要让 WebSocket 错误影响日志系统 + self.handleError(record) + + # 旧的轮转文件处理器已移除,现在使用基于时间戳的处理器 def close_handlers(): """安全关闭所有handler""" - global _file_handler, _console_handler + global _file_handler, _console_handler, _ws_handler if _file_handler: _file_handler.close() @@ -159,6 +255,10 @@ def close_handlers(): if _console_handler: _console_handler.close() _console_handler = None + + if _ws_handler: + _ws_handler.close() + _ws_handler = None def remove_duplicate_handlers(): # sourcery skip: for-append-to-extend, list-comprehension diff --git a/src/webui/emoji_routes.py b/src/webui/emoji_routes.py new file mode 100644 index 00000000..526bc3a9 --- /dev/null +++ b/src/webui/emoji_routes.py @@ -0,0 +1,483 @@ +"""表情包管理 API 路由""" +from fastapi import APIRouter, HTTPException, Header, Query +from pydantic import BaseModel +from typing import Optional, List +from src.common.logger import get_logger +from src.common.database.database_model import Emoji +from .token_manager import get_token_manager +import json +import time + +logger = get_logger("webui.emoji") + +# 创建路由器 +router = APIRouter(prefix="/emoji", tags=["Emoji"]) + + +class EmojiResponse(BaseModel): + """表情包响应""" + id: int + full_path: str + format: str + emoji_hash: str + description: str + query_count: int + is_registered: bool + is_banned: bool + emotion: Optional[List[str]] # 解析后的 JSON + record_time: float + register_time: Optional[float] + usage_count: int + last_used_time: Optional[float] + + +class EmojiListResponse(BaseModel): + """表情包列表响应""" + success: bool + total: int + page: int + page_size: int + data: List[EmojiResponse] + + +class EmojiDetailResponse(BaseModel): + """表情包详情响应""" + success: bool + data: EmojiResponse + + +class EmojiUpdateRequest(BaseModel): + """表情包更新请求""" + description: Optional[str] = None + is_registered: Optional[bool] = None + is_banned: Optional[bool] = None + emotion: Optional[List[str]] = None + + +class EmojiUpdateResponse(BaseModel): + """表情包更新响应""" + success: bool + message: str + data: Optional[EmojiResponse] = None + + +class EmojiDeleteResponse(BaseModel): + """表情包删除响应""" + success: bool + message: str + + +def verify_auth_token(authorization: Optional[str]) -> bool: + """验证认证 Token""" + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException(status_code=401, detail="未提供有效的认证信息") + + token = authorization.replace("Bearer ", "") + token_manager = get_token_manager() + + if not token_manager.verify_token(token): + raise HTTPException(status_code=401, detail="Token 无效或已过期") + + return True + + +def parse_emotion(emotion_str: Optional[str]) -> Optional[List[str]]: + """解析情感标签 JSON 字符串""" + if not emotion_str: + return None + try: + return json.loads(emotion_str) + except (json.JSONDecodeError, TypeError): + return None + + +def emoji_to_response(emoji: Emoji) -> EmojiResponse: + """将 Emoji 模型转换为响应对象""" + return EmojiResponse( + id=emoji.id, + full_path=emoji.full_path, + format=emoji.format, + emoji_hash=emoji.emoji_hash, + description=emoji.description, + query_count=emoji.query_count, + is_registered=emoji.is_registered, + is_banned=emoji.is_banned, + emotion=parse_emotion(emoji.emotion), + record_time=emoji.record_time, + register_time=emoji.register_time, + usage_count=emoji.usage_count, + last_used_time=emoji.last_used_time, + ) + + +@router.get("/list", response_model=EmojiListResponse) +async def get_emoji_list( + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(20, ge=1, le=100, description="每页数量"), + search: Optional[str] = Query(None, description="搜索关键词"), + is_registered: Optional[bool] = Query(None, description="是否已注册筛选"), + is_banned: Optional[bool] = Query(None, description="是否被禁用筛选"), + format: Optional[str] = Query(None, description="格式筛选"), + authorization: Optional[str] = Header(None) +): + """ + 获取表情包列表 + + Args: + page: 页码 (从 1 开始) + page_size: 每页数量 (1-100) + search: 搜索关键词 (匹配 description, emoji_hash) + is_registered: 是否已注册筛选 + is_banned: 是否被禁用筛选 + format: 格式筛选 + authorization: Authorization header + + Returns: + 表情包列表 + """ + try: + verify_auth_token(authorization) + + # 构建查询 + query = Emoji.select() + + # 搜索过滤 + if search: + query = query.where( + (Emoji.description.contains(search)) | + (Emoji.emoji_hash.contains(search)) + ) + + # 注册状态过滤 + if is_registered is not None: + query = query.where(Emoji.is_registered == is_registered) + + # 禁用状态过滤 + if is_banned is not None: + query = query.where(Emoji.is_banned == is_banned) + + # 格式过滤 + if format: + query = query.where(Emoji.format == format) + + # 排序:使用次数倒序,然后按记录时间倒序 + from peewee import Case + query = query.order_by( + Emoji.usage_count.desc(), + Case(None, [(Emoji.record_time.is_null(), 1)], 0), + Emoji.record_time.desc() + ) + + # 获取总数 + total = query.count() + + # 分页 + offset = (page - 1) * page_size + emojis = query.offset(offset).limit(page_size) + + # 转换为响应对象 + data = [emoji_to_response(emoji) for emoji in emojis] + + return EmojiListResponse( + success=True, + total=total, + page=page, + page_size=page_size, + data=data + ) + + except HTTPException: + raise + except Exception as e: + logger.exception(f"获取表情包列表失败: {e}") + raise HTTPException(status_code=500, detail=f"获取表情包列表失败: {str(e)}") from e + + +@router.get("/{emoji_id}", response_model=EmojiDetailResponse) +async def get_emoji_detail( + emoji_id: int, + authorization: Optional[str] = Header(None) +): + """ + 获取表情包详细信息 + + Args: + emoji_id: 表情包ID + authorization: Authorization header + + Returns: + 表情包详细信息 + """ + try: + verify_auth_token(authorization) + + emoji = Emoji.get_or_none(Emoji.id == emoji_id) + + if not emoji: + raise HTTPException(status_code=404, detail=f"未找到 ID 为 {emoji_id} 的表情包") + + return EmojiDetailResponse( + success=True, + data=emoji_to_response(emoji) + ) + + except HTTPException: + raise + except Exception as e: + logger.exception(f"获取表情包详情失败: {e}") + raise HTTPException(status_code=500, detail=f"获取表情包详情失败: {str(e)}") from e + + +@router.patch("/{emoji_id}", response_model=EmojiUpdateResponse) +async def update_emoji( + emoji_id: int, + request: EmojiUpdateRequest, + authorization: Optional[str] = Header(None) +): + """ + 增量更新表情包(只更新提供的字段) + + Args: + emoji_id: 表情包ID + request: 更新请求(只包含需要更新的字段) + authorization: Authorization header + + Returns: + 更新结果 + """ + try: + verify_auth_token(authorization) + + emoji = Emoji.get_or_none(Emoji.id == emoji_id) + + if not emoji: + raise HTTPException(status_code=404, detail=f"未找到 ID 为 {emoji_id} 的表情包") + + # 只更新提供的字段 + update_data = request.model_dump(exclude_unset=True) + + if not update_data: + raise HTTPException(status_code=400, detail="未提供任何需要更新的字段") + + # 处理情感标签(转换为 JSON) + if 'emotion' in update_data: + if update_data['emotion'] is None: + update_data['emotion'] = None + else: + update_data['emotion'] = json.dumps(update_data['emotion'], ensure_ascii=False) + + # 如果注册状态从 False 变为 True,记录注册时间 + if 'is_registered' in update_data and update_data['is_registered'] and not emoji.is_registered: + update_data['register_time'] = time.time() + + # 执行更新 + for field, value in update_data.items(): + setattr(emoji, field, value) + + emoji.save() + + logger.info(f"表情包已更新: ID={emoji_id}, 字段: {list(update_data.keys())}") + + return EmojiUpdateResponse( + success=True, + message=f"成功更新 {len(update_data)} 个字段", + data=emoji_to_response(emoji) + ) + + except HTTPException: + raise + except Exception as e: + logger.exception(f"更新表情包失败: {e}") + raise HTTPException(status_code=500, detail=f"更新表情包失败: {str(e)}") from e + + +@router.delete("/{emoji_id}", response_model=EmojiDeleteResponse) +async def delete_emoji( + emoji_id: int, + authorization: Optional[str] = Header(None) +): + """ + 删除表情包 + + Args: + emoji_id: 表情包ID + authorization: Authorization header + + Returns: + 删除结果 + """ + try: + verify_auth_token(authorization) + + emoji = Emoji.get_or_none(Emoji.id == emoji_id) + + if not emoji: + raise HTTPException(status_code=404, detail=f"未找到 ID 为 {emoji_id} 的表情包") + + # 记录删除信息 + emoji_hash = emoji.emoji_hash + + # 执行删除 + emoji.delete_instance() + + logger.info(f"表情包已删除: ID={emoji_id}, hash={emoji_hash}") + + return EmojiDeleteResponse( + success=True, + message=f"成功删除表情包: {emoji_hash}" + ) + + except HTTPException: + raise + except Exception as e: + logger.exception(f"删除表情包失败: {e}") + raise HTTPException(status_code=500, detail=f"删除表情包失败: {str(e)}") from e + + +@router.get("/stats/summary") +async def get_emoji_stats( + authorization: Optional[str] = Header(None) +): + """ + 获取表情包统计数据 + + Args: + authorization: Authorization header + + Returns: + 统计数据 + """ + try: + verify_auth_token(authorization) + + total = Emoji.select().count() + registered = Emoji.select().where(Emoji.is_registered).count() + banned = Emoji.select().where(Emoji.is_banned).count() + + # 按格式统计 + formats = {} + for emoji in Emoji.select(Emoji.format): + fmt = emoji.format + formats[fmt] = formats.get(fmt, 0) + 1 + + # 获取最常用的表情包(前10) + top_used = Emoji.select().order_by(Emoji.usage_count.desc()).limit(10) + top_used_list = [ + { + "id": emoji.id, + "emoji_hash": emoji.emoji_hash, + "description": emoji.description, + "usage_count": emoji.usage_count + } + for emoji in top_used + ] + + return { + "success": True, + "data": { + "total": total, + "registered": registered, + "banned": banned, + "unregistered": total - registered, + "formats": formats, + "top_used": top_used_list + } + } + + except HTTPException: + raise + except Exception as e: + logger.exception(f"获取统计数据失败: {e}") + raise HTTPException(status_code=500, detail=f"获取统计数据失败: {str(e)}") from e + + +@router.post("/{emoji_id}/register", response_model=EmojiUpdateResponse) +async def register_emoji( + emoji_id: int, + authorization: Optional[str] = Header(None) +): + """ + 注册表情包(快捷操作) + + Args: + emoji_id: 表情包ID + authorization: Authorization header + + Returns: + 更新结果 + """ + try: + verify_auth_token(authorization) + + emoji = Emoji.get_or_none(Emoji.id == emoji_id) + + if not emoji: + raise HTTPException(status_code=404, detail=f"未找到 ID 为 {emoji_id} 的表情包") + + if emoji.is_registered: + raise HTTPException(status_code=400, detail="该表情包已经注册") + + if emoji.is_banned: + raise HTTPException(status_code=400, detail="该表情包已被禁用,无法注册") + + # 注册表情包 + emoji.is_registered = True + emoji.register_time = time.time() + emoji.save() + + logger.info(f"表情包已注册: ID={emoji_id}") + + return EmojiUpdateResponse( + success=True, + message="表情包注册成功", + data=emoji_to_response(emoji) + ) + + except HTTPException: + raise + except Exception as e: + logger.exception(f"注册表情包失败: {e}") + raise HTTPException(status_code=500, detail=f"注册表情包失败: {str(e)}") from e + + +@router.post("/{emoji_id}/ban", response_model=EmojiUpdateResponse) +async def ban_emoji( + emoji_id: int, + authorization: Optional[str] = Header(None) +): + """ + 禁用表情包(快捷操作) + + Args: + emoji_id: 表情包ID + authorization: Authorization header + + Returns: + 更新结果 + """ + try: + verify_auth_token(authorization) + + emoji = Emoji.get_or_none(Emoji.id == emoji_id) + + if not emoji: + raise HTTPException(status_code=404, detail=f"未找到 ID 为 {emoji_id} 的表情包") + + # 禁用表情包(同时取消注册) + emoji.is_banned = True + emoji.is_registered = False + emoji.save() + + logger.info(f"表情包已禁用: ID={emoji_id}") + + return EmojiUpdateResponse( + success=True, + message="表情包禁用成功", + data=emoji_to_response(emoji) + ) + + except HTTPException: + raise + except Exception as e: + logger.exception(f"禁用表情包失败: {e}") + raise HTTPException(status_code=500, detail=f"禁用表情包失败: {str(e)}") from e diff --git a/src/webui/expression_routes.py b/src/webui/expression_routes.py new file mode 100644 index 00000000..de2594ee --- /dev/null +++ b/src/webui/expression_routes.py @@ -0,0 +1,404 @@ +"""表达方式管理 API 路由""" +from fastapi import APIRouter, HTTPException, Header, Query +from pydantic import BaseModel +from typing import Optional, List +from src.common.logger import get_logger +from src.common.database.database_model import Expression +from .token_manager import get_token_manager +import time + +logger = get_logger("webui.expression") + +# 创建路由器 +router = APIRouter(prefix="/expression", tags=["Expression"]) + + +class ExpressionResponse(BaseModel): + """表达方式响应""" + id: int + situation: str + style: str + context: Optional[str] + up_content: Optional[str] + last_active_time: float + chat_id: str + create_date: Optional[float] + + +class ExpressionListResponse(BaseModel): + """表达方式列表响应""" + success: bool + total: int + page: int + page_size: int + data: List[ExpressionResponse] + + +class ExpressionDetailResponse(BaseModel): + """表达方式详情响应""" + success: bool + data: ExpressionResponse + + +class ExpressionCreateRequest(BaseModel): + """表达方式创建请求""" + situation: str + style: str + context: Optional[str] = None + up_content: Optional[str] = None + chat_id: str + + +class ExpressionUpdateRequest(BaseModel): + """表达方式更新请求""" + situation: Optional[str] = None + style: Optional[str] = None + context: Optional[str] = None + up_content: Optional[str] = None + chat_id: Optional[str] = None + + +class ExpressionUpdateResponse(BaseModel): + """表达方式更新响应""" + success: bool + message: str + data: Optional[ExpressionResponse] = None + + +class ExpressionDeleteResponse(BaseModel): + """表达方式删除响应""" + success: bool + message: str + + +class ExpressionCreateResponse(BaseModel): + """表达方式创建响应""" + success: bool + message: str + data: ExpressionResponse + + +def verify_auth_token(authorization: Optional[str]) -> bool: + """验证认证 Token""" + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException(status_code=401, detail="未提供有效的认证信息") + + token = authorization.replace("Bearer ", "") + token_manager = get_token_manager() + + if not token_manager.verify_token(token): + raise HTTPException(status_code=401, detail="Token 无效或已过期") + + return True + + +def expression_to_response(expression: Expression) -> ExpressionResponse: + """将 Expression 模型转换为响应对象""" + return ExpressionResponse( + id=expression.id, + situation=expression.situation, + style=expression.style, + context=expression.context, + up_content=expression.up_content, + last_active_time=expression.last_active_time, + chat_id=expression.chat_id, + create_date=expression.create_date, + ) + + +@router.get("/list", response_model=ExpressionListResponse) +async def get_expression_list( + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(20, ge=1, le=100, description="每页数量"), + search: Optional[str] = Query(None, description="搜索关键词"), + chat_id: Optional[str] = Query(None, description="聊天ID筛选"), + authorization: Optional[str] = Header(None) +): + """ + 获取表达方式列表 + + Args: + page: 页码 (从 1 开始) + page_size: 每页数量 (1-100) + search: 搜索关键词 (匹配 situation, style, context) + chat_id: 聊天ID筛选 + authorization: Authorization header + + Returns: + 表达方式列表 + """ + try: + verify_auth_token(authorization) + + # 构建查询 + query = Expression.select() + + # 搜索过滤 + if search: + query = query.where( + (Expression.situation.contains(search)) | + (Expression.style.contains(search)) | + (Expression.context.contains(search)) + ) + + # 聊天ID过滤 + if chat_id: + query = query.where(Expression.chat_id == chat_id) + + # 排序:最后活跃时间倒序(NULL 值放在最后) + from peewee import Case + query = query.order_by( + Case(None, [(Expression.last_active_time.is_null(), 1)], 0), + Expression.last_active_time.desc() + ) + + # 获取总数 + total = query.count() + + # 分页 + offset = (page - 1) * page_size + expressions = query.offset(offset).limit(page_size) + + # 转换为响应对象 + data = [expression_to_response(expr) for expr in expressions] + + return ExpressionListResponse( + success=True, + total=total, + page=page, + page_size=page_size, + data=data + ) + + except HTTPException: + raise + except Exception as e: + logger.exception(f"获取表达方式列表失败: {e}") + raise HTTPException(status_code=500, detail=f"获取表达方式列表失败: {str(e)}") from e + + +@router.get("/{expression_id}", response_model=ExpressionDetailResponse) +async def get_expression_detail( + expression_id: int, + authorization: Optional[str] = Header(None) +): + """ + 获取表达方式详细信息 + + Args: + expression_id: 表达方式ID + authorization: Authorization header + + Returns: + 表达方式详细信息 + """ + try: + verify_auth_token(authorization) + + expression = Expression.get_or_none(Expression.id == expression_id) + + if not expression: + raise HTTPException(status_code=404, detail=f"未找到 ID 为 {expression_id} 的表达方式") + + return ExpressionDetailResponse( + success=True, + data=expression_to_response(expression) + ) + + except HTTPException: + raise + except Exception as e: + logger.exception(f"获取表达方式详情失败: {e}") + raise HTTPException(status_code=500, detail=f"获取表达方式详情失败: {str(e)}") from e + + +@router.post("/", response_model=ExpressionCreateResponse) +async def create_expression( + request: ExpressionCreateRequest, + authorization: Optional[str] = Header(None) +): + """ + 创建新的表达方式 + + Args: + request: 创建请求 + authorization: Authorization header + + Returns: + 创建结果 + """ + try: + verify_auth_token(authorization) + + current_time = time.time() + + # 创建表达方式 + expression = Expression.create( + situation=request.situation, + style=request.style, + context=request.context, + up_content=request.up_content, + chat_id=request.chat_id, + last_active_time=current_time, + create_date=current_time, + ) + + logger.info(f"表达方式已创建: ID={expression.id}, situation={request.situation}") + + return ExpressionCreateResponse( + success=True, + message="表达方式创建成功", + data=expression_to_response(expression) + ) + + except HTTPException: + raise + except Exception as e: + logger.exception(f"创建表达方式失败: {e}") + raise HTTPException(status_code=500, detail=f"创建表达方式失败: {str(e)}") from e + + +@router.patch("/{expression_id}", response_model=ExpressionUpdateResponse) +async def update_expression( + expression_id: int, + request: ExpressionUpdateRequest, + authorization: Optional[str] = Header(None) +): + """ + 增量更新表达方式(只更新提供的字段) + + Args: + expression_id: 表达方式ID + request: 更新请求(只包含需要更新的字段) + authorization: Authorization header + + Returns: + 更新结果 + """ + try: + verify_auth_token(authorization) + + expression = Expression.get_or_none(Expression.id == expression_id) + + if not expression: + raise HTTPException(status_code=404, detail=f"未找到 ID 为 {expression_id} 的表达方式") + + # 只更新提供的字段 + update_data = request.model_dump(exclude_unset=True) + + if not update_data: + raise HTTPException(status_code=400, detail="未提供任何需要更新的字段") + + # 更新最后活跃时间 + update_data['last_active_time'] = time.time() + + # 执行更新 + for field, value in update_data.items(): + setattr(expression, field, value) + + expression.save() + + logger.info(f"表达方式已更新: ID={expression_id}, 字段: {list(update_data.keys())}") + + return ExpressionUpdateResponse( + success=True, + message=f"成功更新 {len(update_data)} 个字段", + data=expression_to_response(expression) + ) + + except HTTPException: + raise + except Exception as e: + logger.exception(f"更新表达方式失败: {e}") + raise HTTPException(status_code=500, detail=f"更新表达方式失败: {str(e)}") from e + + +@router.delete("/{expression_id}", response_model=ExpressionDeleteResponse) +async def delete_expression( + expression_id: int, + authorization: Optional[str] = Header(None) +): + """ + 删除表达方式 + + Args: + expression_id: 表达方式ID + authorization: Authorization header + + Returns: + 删除结果 + """ + try: + verify_auth_token(authorization) + + expression = Expression.get_or_none(Expression.id == expression_id) + + if not expression: + raise HTTPException(status_code=404, detail=f"未找到 ID 为 {expression_id} 的表达方式") + + # 记录删除信息 + situation = expression.situation + + # 执行删除 + expression.delete_instance() + + logger.info(f"表达方式已删除: ID={expression_id}, situation={situation}") + + return ExpressionDeleteResponse( + success=True, + message=f"成功删除表达方式: {situation}" + ) + + except HTTPException: + raise + except Exception as e: + logger.exception(f"删除表达方式失败: {e}") + raise HTTPException(status_code=500, detail=f"删除表达方式失败: {str(e)}") from e + + +@router.get("/stats/summary") +async def get_expression_stats( + authorization: Optional[str] = Header(None) +): + """ + 获取表达方式统计数据 + + Args: + authorization: Authorization header + + Returns: + 统计数据 + """ + try: + verify_auth_token(authorization) + + total = Expression.select().count() + + # 按 chat_id 统计 + chat_stats = {} + for expr in Expression.select(Expression.chat_id): + chat_id = expr.chat_id + chat_stats[chat_id] = chat_stats.get(chat_id, 0) + 1 + + # 获取最近创建的记录数(7天内) + seven_days_ago = time.time() - (7 * 24 * 60 * 60) + recent = Expression.select().where( + (Expression.create_date.is_null(False)) & + (Expression.create_date >= seven_days_ago) + ).count() + + return { + "success": True, + "data": { + "total": total, + "recent_7days": recent, + "chat_count": len(chat_stats), + "top_chats": dict(sorted(chat_stats.items(), key=lambda x: x[1], reverse=True)[:10]) + } + } + + except HTTPException: + raise + except Exception as e: + logger.exception(f"获取统计数据失败: {e}") + raise HTTPException(status_code=500, detail=f"获取统计数据失败: {str(e)}") from e diff --git a/src/webui/log_broadcaster.py b/src/webui/log_broadcaster.py new file mode 100644 index 00000000..e69de29b diff --git a/src/webui/logs_routes.py b/src/webui/logs_routes.py new file mode 100644 index 00000000..e69de29b diff --git a/src/webui/logs_ws.py b/src/webui/logs_ws.py new file mode 100644 index 00000000..d8ef65aa --- /dev/null +++ b/src/webui/logs_ws.py @@ -0,0 +1,138 @@ +"""WebSocket 日志推送模块""" +from fastapi import APIRouter, WebSocket, WebSocketDisconnect +from typing import Set +import json +from pathlib import Path +from src.common.logger import get_logger + +logger = get_logger("webui.logs_ws") +router = APIRouter() + +# 全局 WebSocket 连接池 +active_connections: Set[WebSocket] = set() + + +def load_recent_logs(limit: int = 100) -> list[dict]: + """从日志文件中加载最近的日志 + + Args: + limit: 返回的最大日志条数 + + Returns: + 日志列表 + """ + logs = [] + log_dir = Path("logs") + + if not log_dir.exists(): + return logs + + # 获取所有日志文件,按修改时间排序 + log_files = sorted(log_dir.glob("app_*.log.jsonl"), key=lambda f: f.stat().st_mtime, reverse=True) + + # 用于生成唯一 ID 的计数器 + log_counter = 0 + + # 从最新的文件开始读取 + for log_file in log_files: + if len(logs) >= limit: + break + + try: + with open(log_file, "r", encoding="utf-8") as f: + lines = f.readlines() + # 从文件末尾开始读取 + for line in reversed(lines): + if len(logs) >= limit: + break + try: + log_entry = json.loads(line.strip()) + # 转换为前端期望的格式 + # 使用时间戳 + 计数器生成唯一 ID + timestamp_id = log_entry.get("timestamp", "0").replace("-", "").replace(" ", "").replace(":", "") + formatted_log = { + "id": f"{timestamp_id}_{log_counter}", + "timestamp": log_entry.get("timestamp", ""), + "level": log_entry.get("level", "INFO").upper(), + "module": log_entry.get("logger_name", ""), + "message": log_entry.get("event", ""), + } + logs.append(formatted_log) + log_counter += 1 + except (json.JSONDecodeError, KeyError): + continue + except Exception as e: + logger.error(f"读取日志文件失败 {log_file}: {e}") + continue + + # 反转列表,使其按时间顺序排列(旧到新) + return list(reversed(logs)) + + +@router.websocket("/ws/logs") +async def websocket_logs(websocket: WebSocket): + """WebSocket 日志推送端点 + + 客户端连接后会持续接收服务器端的日志消息 + """ + await websocket.accept() + active_connections.add(websocket) + logger.info(f"📡 WebSocket 客户端已连接,当前连接数: {len(active_connections)}") + + # 连接建立后,立即发送历史日志 + try: + recent_logs = load_recent_logs(limit=100) + logger.info(f"发送 {len(recent_logs)} 条历史日志到客户端") + + for log_entry in recent_logs: + await websocket.send_text(json.dumps(log_entry, ensure_ascii=False)) + except Exception as e: + logger.error(f"发送历史日志失败: {e}") + + try: + # 保持连接,等待客户端消息或断开 + while True: + # 接收客户端消息(用于心跳或控制指令) + data = await websocket.receive_text() + + # 可以处理客户端的控制消息,例如: + # - "ping" -> 心跳检测 + # - {"filter": "ERROR"} -> 设置日志级别过滤 + if data == "ping": + await websocket.send_text("pong") + + except WebSocketDisconnect: + active_connections.discard(websocket) + logger.info(f"📡 WebSocket 客户端已断开,当前连接数: {len(active_connections)}") + except Exception as e: + logger.error(f"❌ WebSocket 错误: {e}") + active_connections.discard(websocket) + + +async def broadcast_log(log_data: dict): + """广播日志到所有连接的 WebSocket 客户端 + + Args: + log_data: 日志数据字典 + """ + if not active_connections: + return + + # 格式化为 JSON + message = json.dumps(log_data, ensure_ascii=False) + + # 记录需要断开的连接 + disconnected = set() + + # 广播到所有客户端 + for connection in active_connections: + try: + await connection.send_text(message) + except Exception: + # 发送失败,标记为断开 + disconnected.add(connection) + + # 清理断开的连接 + if disconnected: + active_connections.difference_update(disconnected) + logger.debug(f"清理了 {len(disconnected)} 个断开的 WebSocket 连接") diff --git a/src/webui/manager.py b/src/webui/manager.py index 9a4999ff..3919df17 100644 --- a/src/webui/manager.py +++ b/src/webui/manager.py @@ -31,6 +31,14 @@ def setup_webui(mode: str = "production") -> bool: def setup_dev_mode() -> bool: """设置开发模式 - 仅启用 CORS,前端自行启动""" + from src.common.server import get_global_server + from .logs_ws import router as logs_router + + # 注册 WebSocket 日志路由(开发模式也需要) + server = get_global_server() + server.register_router(logs_router) + logger.info("✅ WebSocket 日志推送路由已注册") + logger.info("📝 WebUI 开发模式已启用") logger.info("🌐 请手动启动前端开发服务器: cd webui && npm run dev") logger.info("💡 前端将运行在 http://localhost:7999") @@ -42,6 +50,7 @@ def setup_production_mode() -> bool: try: from src.common.server import get_global_server from starlette.responses import FileResponse + from .logs_ws import router as logs_router import mimetypes # 确保正确的 MIME 类型映射 @@ -52,6 +61,11 @@ def setup_production_mode() -> bool: mimetypes.add_type('application/json', '.json') server = get_global_server() + + # 注册 WebSocket 日志路由 + server.register_router(logs_router) + logger.info("✅ WebSocket 日志推送路由已注册") + base_dir = Path(__file__).parent.parent.parent static_path = base_dir / "webui" / "dist" diff --git a/src/webui/person_routes.py b/src/webui/person_routes.py new file mode 100644 index 00000000..a5488d49 --- /dev/null +++ b/src/webui/person_routes.py @@ -0,0 +1,365 @@ +"""人物信息管理 API 路由""" +from fastapi import APIRouter, HTTPException, Header, Query +from pydantic import BaseModel +from typing import Optional, List, Dict +from src.common.logger import get_logger +from src.common.database.database_model import PersonInfo +from .token_manager import get_token_manager +import json +import time + +logger = get_logger("webui.person") + +# 创建路由器 +router = APIRouter(prefix="/person", tags=["Person"]) + + +class PersonInfoResponse(BaseModel): + """人物信息响应""" + id: int + is_known: bool + person_id: str + person_name: Optional[str] + name_reason: Optional[str] + platform: str + user_id: str + nickname: Optional[str] + group_nick_name: Optional[List[Dict[str, str]]] # 解析后的 JSON + memory_points: Optional[str] + know_times: Optional[float] + know_since: Optional[float] + last_know: Optional[float] + + +class PersonListResponse(BaseModel): + """人物列表响应""" + success: bool + total: int + page: int + page_size: int + data: List[PersonInfoResponse] + + +class PersonDetailResponse(BaseModel): + """人物详情响应""" + success: bool + data: PersonInfoResponse + + +class PersonUpdateRequest(BaseModel): + """人物信息更新请求""" + person_name: Optional[str] = None + name_reason: Optional[str] = None + nickname: Optional[str] = None + memory_points: Optional[str] = None + is_known: Optional[bool] = None + + +class PersonUpdateResponse(BaseModel): + """人物信息更新响应""" + success: bool + message: str + data: Optional[PersonInfoResponse] = None + + +class PersonDeleteResponse(BaseModel): + """人物删除响应""" + success: bool + message: str + + +def verify_auth_token(authorization: Optional[str]) -> bool: + """验证认证 Token""" + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException(status_code=401, detail="未提供有效的认证信息") + + token = authorization.replace("Bearer ", "") + token_manager = get_token_manager() + + if not token_manager.verify_token(token): + raise HTTPException(status_code=401, detail="Token 无效或已过期") + + return True + + +def parse_group_nick_name(group_nick_name_str: Optional[str]) -> Optional[List[Dict[str, str]]]: + """解析群昵称 JSON 字符串""" + if not group_nick_name_str: + return None + try: + return json.loads(group_nick_name_str) + except (json.JSONDecodeError, TypeError): + return None + + +def person_to_response(person: PersonInfo) -> PersonInfoResponse: + """将 PersonInfo 模型转换为响应对象""" + return PersonInfoResponse( + id=person.id, + is_known=person.is_known, + person_id=person.person_id, + person_name=person.person_name, + name_reason=person.name_reason, + platform=person.platform, + user_id=person.user_id, + nickname=person.nickname, + group_nick_name=parse_group_nick_name(person.group_nick_name), + memory_points=person.memory_points, + know_times=person.know_times, + know_since=person.know_since, + last_know=person.last_know, + ) + + +@router.get("/list", response_model=PersonListResponse) +async def get_person_list( + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(20, ge=1, le=100, description="每页数量"), + search: Optional[str] = Query(None, description="搜索关键词"), + is_known: Optional[bool] = Query(None, description="是否已认识筛选"), + platform: Optional[str] = Query(None, description="平台筛选"), + authorization: Optional[str] = Header(None) +): + """ + 获取人物信息列表 + + Args: + page: 页码 (从 1 开始) + page_size: 每页数量 (1-100) + search: 搜索关键词 (匹配 person_name, nickname, user_id) + is_known: 是否已认识筛选 + platform: 平台筛选 + authorization: Authorization header + + Returns: + 人物信息列表 + """ + try: + verify_auth_token(authorization) + + # 构建查询 + query = PersonInfo.select() + + # 搜索过滤 + if search: + query = query.where( + (PersonInfo.person_name.contains(search)) | + (PersonInfo.nickname.contains(search)) | + (PersonInfo.user_id.contains(search)) + ) + + # 已认识状态过滤 + if is_known is not None: + query = query.where(PersonInfo.is_known == is_known) + + # 平台过滤 + if platform: + query = query.where(PersonInfo.platform == platform) + + # 排序:最后更新时间倒序(NULL 值放在最后) + # Peewee 不支持 nulls_last,使用 CASE WHEN 来实现 + from peewee import Case + query = query.order_by( + Case(None, [(PersonInfo.last_know.is_null(), 1)], 0), + PersonInfo.last_know.desc() + ) + + # 获取总数 + total = query.count() + + # 分页 + offset = (page - 1) * page_size + persons = query.offset(offset).limit(page_size) + + # 转换为响应对象 + data = [person_to_response(person) for person in persons] + + return PersonListResponse( + success=True, + total=total, + page=page, + page_size=page_size, + data=data + ) + + except HTTPException: + raise + except Exception as e: + logger.exception(f"获取人物列表失败: {e}") + raise HTTPException(status_code=500, detail=f"获取人物列表失败: {str(e)}") from e + + +@router.get("/{person_id}", response_model=PersonDetailResponse) +async def get_person_detail( + person_id: str, + authorization: Optional[str] = Header(None) +): + """ + 获取人物详细信息 + + Args: + person_id: 人物唯一 ID + authorization: Authorization header + + Returns: + 人物详细信息 + """ + try: + verify_auth_token(authorization) + + person = PersonInfo.get_or_none(PersonInfo.person_id == person_id) + + if not person: + raise HTTPException(status_code=404, detail=f"未找到 ID 为 {person_id} 的人物信息") + + return PersonDetailResponse( + success=True, + data=person_to_response(person) + ) + + except HTTPException: + raise + except Exception as e: + logger.exception(f"获取人物详情失败: {e}") + raise HTTPException(status_code=500, detail=f"获取人物详情失败: {str(e)}") from e + + +@router.patch("/{person_id}", response_model=PersonUpdateResponse) +async def update_person( + person_id: str, + request: PersonUpdateRequest, + authorization: Optional[str] = Header(None) +): + """ + 增量更新人物信息(只更新提供的字段) + + Args: + person_id: 人物唯一 ID + request: 更新请求(只包含需要更新的字段) + authorization: Authorization header + + Returns: + 更新结果 + """ + try: + verify_auth_token(authorization) + + person = PersonInfo.get_or_none(PersonInfo.person_id == person_id) + + if not person: + raise HTTPException(status_code=404, detail=f"未找到 ID 为 {person_id} 的人物信息") + + # 只更新提供的字段 + update_data = request.model_dump(exclude_unset=True) + + if not update_data: + raise HTTPException(status_code=400, detail="未提供任何需要更新的字段") + + # 更新最后修改时间 + update_data['last_know'] = time.time() + + # 执行更新 + for field, value in update_data.items(): + setattr(person, field, value) + + person.save() + + logger.info(f"人物信息已更新: {person_id}, 字段: {list(update_data.keys())}") + + return PersonUpdateResponse( + success=True, + message=f"成功更新 {len(update_data)} 个字段", + data=person_to_response(person) + ) + + except HTTPException: + raise + except Exception as e: + logger.exception(f"更新人物信息失败: {e}") + raise HTTPException(status_code=500, detail=f"更新人物信息失败: {str(e)}") from e + + +@router.delete("/{person_id}", response_model=PersonDeleteResponse) +async def delete_person( + person_id: str, + authorization: Optional[str] = Header(None) +): + """ + 删除人物信息 + + Args: + person_id: 人物唯一 ID + authorization: Authorization header + + Returns: + 删除结果 + """ + try: + verify_auth_token(authorization) + + person = PersonInfo.get_or_none(PersonInfo.person_id == person_id) + + if not person: + raise HTTPException(status_code=404, detail=f"未找到 ID 为 {person_id} 的人物信息") + + # 记录删除信息 + person_name = person.person_name or person.nickname or person.user_id + + # 执行删除 + person.delete_instance() + + logger.info(f"人物信息已删除: {person_id} ({person_name})") + + return PersonDeleteResponse( + success=True, + message=f"成功删除人物信息: {person_name}" + ) + + except HTTPException: + raise + except Exception as e: + logger.exception(f"删除人物信息失败: {e}") + raise HTTPException(status_code=500, detail=f"删除人物信息失败: {str(e)}") from e + + +@router.get("/stats/summary") +async def get_person_stats( + authorization: Optional[str] = Header(None) +): + """ + 获取人物信息统计数据 + + Args: + authorization: Authorization header + + Returns: + 统计数据 + """ + try: + verify_auth_token(authorization) + + total = PersonInfo.select().count() + known = PersonInfo.select().where(PersonInfo.is_known).count() + unknown = total - known + + # 按平台统计 + platforms = {} + for person in PersonInfo.select(PersonInfo.platform): + platform = person.platform + platforms[platform] = platforms.get(platform, 0) + 1 + + return { + "success": True, + "data": { + "total": total, + "known": known, + "unknown": unknown, + "platforms": platforms + } + } + + except HTTPException: + raise + except Exception as e: + logger.exception(f"获取统计数据失败: {e}") + raise HTTPException(status_code=500, detail=f"获取统计数据失败: {str(e)}") from e diff --git a/src/webui/routes.py b/src/webui/routes.py index 517b77ed..3b49262d 100644 --- a/src/webui/routes.py +++ b/src/webui/routes.py @@ -6,6 +6,9 @@ from src.common.logger import get_logger from .token_manager import get_token_manager from .config_routes import router as config_router from .statistics_routes import router as statistics_router +from .person_routes import router as person_router +from .expression_routes import router as expression_router +from .emoji_routes import router as emoji_router logger = get_logger("webui.api") @@ -16,6 +19,12 @@ router = APIRouter(prefix="/api/webui", tags=["WebUI"]) router.include_router(config_router) # 注册统计数据路由 router.include_router(statistics_router) +# 注册人物信息管理路由 +router.include_router(person_router) +# 注册表达方式管理路由 +router.include_router(expression_router) +# 注册表情包管理路由 +router.include_router(emoji_router) class TokenVerifyRequest(BaseModel): diff --git a/webui/dist/assets/FloatingTanStackRouterDevtools-CXkXeTp3-DxlgenHe.js b/webui/dist/assets/FloatingTanStackRouterDevtools-CXkXeTp3-DxlgenHe.js deleted file mode 100644 index a875760a..00000000 --- a/webui/dist/assets/FloatingTanStackRouterDevtools-CXkXeTp3-DxlgenHe.js +++ /dev/null @@ -1,486 +0,0 @@ -import{c as ne,a as Ue,b as w,u as bt,d as _t,i as Ft,e as zt,f as K,t as O,s as tt,m as je,g as u,h as Mt,j as d,k as F,l as Z,M as Qe,r as Re,n as H,o as s,F as Ut,S as Bt,p as pt,q as Et,v as Ot,w as Dt,x as yt,y as rt,D as Tt,z as It,A as Gt}from"./index-Dq16ignL.js";let At={data:""},Pt=e=>{if(typeof window=="object"){let t=(e?e.querySelector("#_goober"):window._goober)||Object.assign(document.createElement("style"),{innerHTML:" ",id:"_goober"});return t.nonce=window.__nonce__,t.parentNode||(e||document.head).appendChild(t),t.firstChild}return e||At},Rt=/(?:([\u0080-\uFFFF\w-%@]+) *:? *([^{;]+?);|([^;}{]*?) *{)|(}\s*)/g,Lt=/\/\*[^]*?\*\/| +/g,ht=/\n+/g,we=(e,t)=>{let n="",i="",p="";for(let l in e){let a=e[l];l[0]=="@"?l[1]=="i"?n=l+" "+a+";":i+=l[1]=="f"?we(a,l):l+"{"+we(a,l[1]=="k"?"":t)+"}":typeof a=="object"?i+=we(a,t?t.replace(/([^,])+/g,f=>l.replace(/([^,]*:\S+\([^)]*\))|([^,])+/g,g=>/&/.test(g)?g.replace(/&/g,f):f?f+" "+g:g)):l):a!=null&&(l=/^--/.test(l)?l:l.replace(/[A-Z]/g,"-$&").toLowerCase(),p+=we.p?we.p(l,a):l+":"+a+";")}return n+(t&&p?t+"{"+p+"}":p)+i},$e={},kt=e=>{if(typeof e=="object"){let t="";for(let n in e)t+=n+kt(e[n]);return t}return e},jt=(e,t,n,i,p)=>{let l=kt(e),a=$e[l]||($e[l]=(g=>{let o=0,r=11;for(;o>>0;return"go"+r})(l));if(!$e[a]){let g=l!==e?e:(o=>{let r,v,h=[{}];for(;r=Rt.exec(o.replace(Lt,""));)r[4]?h.shift():r[3]?(v=r[3].replace(ht," ").trim(),h.unshift(h[0][v]=h[0][v]||{})):h[0][r[1]]=r[2].replace(ht," ").trim();return h[0]})(e);$e[a]=we(p?{["@keyframes "+a]:g}:g,n?"":"."+a)}let f=n&&$e.g?$e.g:null;return n&&($e.g=$e[a]),((g,o,r,v)=>{v?o.data=o.data.replace(v,g):o.data.indexOf(g)===-1&&(o.data=r?g+o.data:o.data+g)})($e[a],t,i,f),a},Ht=(e,t,n)=>e.reduce((i,p,l)=>{let a=t[l];if(a&&a.call){let f=a(n),g=f&&f.props&&f.props.className||/^go/.test(f)&&f;a=g?"."+g:f&&typeof f=="object"?f.props?"":we(f,""):f===!1?"":f}return i+p+(a??"")},"");function Be(e){let t=this||{},n=e.call?e(t.p):e;return jt(n.unshift?n.raw?Ht(n,[].slice.call(arguments,1),t.p):n.reduce((i,p)=>Object.assign(i,p&&p.call?p(t.p):p),{}):n,Pt(t.target),t.g,t.o,t.k)}Be.bind({g:1});Be.bind({k:1});const Nt=typeof window>"u";function et(e){const t={pending:"yellow",success:"green",error:"red",notFound:"purple",redirected:"gray"};return e.isFetching&&e.status==="success"?e.isFetching==="beforeLoad"?"purple":"blue":t[e.status]}function Vt(e,t){const n=e.find(i=>i.routeId===t.id);return n?et(n):"gray"}function Jt(){const[e,t]=ne(!1);return(Nt?Ue:w)(()=>{t(!0)}),e}const Yt=e=>{const t=Object.getOwnPropertyNames(Object(e)),n=typeof e=="bigint"?`${e.toString()}n`:e;try{return JSON.stringify(n,t)}catch{return"unable to stringify"}};function qt(e,t=[n=>n]){return e.map((n,i)=>[n,i]).sort(([n,i],[p,l])=>{for(const a of t){const f=a(n),g=a(p);if(typeof f>"u"){if(typeof g>"u")continue;return 1}if(f!==g)return f>g?1:-1}return i-l}).map(([n])=>n)}const I={colors:{inherit:"inherit",current:"currentColor",transparent:"transparent",black:"#000000",white:"#ffffff",neutral:{50:"#f9fafb",100:"#f2f4f7",200:"#eaecf0",300:"#d0d5dd",400:"#98a2b3",500:"#667085",600:"#475467",700:"#344054",800:"#1d2939",900:"#101828"},darkGray:{50:"#525c7a",100:"#49536e",200:"#414962",300:"#394056",400:"#313749",500:"#292e3d",600:"#212530",700:"#191c24",800:"#111318",900:"#0b0d10"},gray:{50:"#f9fafb",100:"#f2f4f7",200:"#eaecf0",300:"#d0d5dd",400:"#98a2b3",500:"#667085",600:"#475467",700:"#344054",800:"#1d2939",900:"#101828"},blue:{25:"#F5FAFF",50:"#EFF8FF",100:"#D1E9FF",200:"#B2DDFF",300:"#84CAFF",400:"#53B1FD",500:"#2E90FA",600:"#1570EF",700:"#175CD3",800:"#1849A9",900:"#194185"},green:{25:"#F6FEF9",50:"#ECFDF3",100:"#D1FADF",200:"#A6F4C5",300:"#6CE9A6",400:"#32D583",500:"#12B76A",600:"#039855",700:"#027A48",800:"#05603A",900:"#054F31"},red:{50:"#fef2f2",100:"#fee2e2",200:"#fecaca",300:"#fca5a5",400:"#f87171",500:"#ef4444",600:"#dc2626",700:"#b91c1c",800:"#991b1b",900:"#7f1d1d",950:"#450a0a"},yellow:{25:"#FFFCF5",50:"#FFFAEB",100:"#FEF0C7",200:"#FEDF89",300:"#FEC84B",400:"#FDB022",500:"#F79009",600:"#DC6803",700:"#B54708",800:"#93370D",900:"#7A2E0E"},purple:{25:"#FAFAFF",50:"#F4F3FF",100:"#EBE9FE",200:"#D9D6FE",300:"#BDB4FE",400:"#9B8AFB",500:"#7A5AF8",600:"#6938EF",700:"#5925DC",800:"#4A1FB8",900:"#3E1C96"},teal:{25:"#F6FEFC",50:"#F0FDF9",100:"#CCFBEF",200:"#99F6E0",300:"#5FE9D0",400:"#2ED3B7",500:"#15B79E",600:"#0E9384",700:"#107569",800:"#125D56",900:"#134E48"},pink:{25:"#fdf2f8",50:"#fce7f3",100:"#fbcfe8",200:"#f9a8d4",300:"#f472b6",400:"#ec4899",500:"#db2777",600:"#be185d",700:"#9d174d",800:"#831843",900:"#500724"},cyan:{25:"#ecfeff",50:"#cffafe",100:"#a5f3fc",200:"#67e8f9",300:"#22d3ee",400:"#06b6d4",500:"#0891b2",600:"#0e7490",700:"#155e75",800:"#164e63",900:"#083344"}},alpha:{90:"e5",70:"b3",20:"33"},font:{size:{"2xs":"calc(var(--tsrd-font-size) * 0.625)",xs:"calc(var(--tsrd-font-size) * 0.75)",sm:"calc(var(--tsrd-font-size) * 0.875)",md:"var(--tsrd-font-size)"},lineHeight:{xs:"calc(var(--tsrd-font-size) * 1)",sm:"calc(var(--tsrd-font-size) * 1.25)"},weight:{normal:"400",medium:"500",semibold:"600",bold:"700"},fontFamily:{sans:"ui-sans-serif, Inter, system-ui, sans-serif, sans-serif",mono:"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace"}},border:{radius:{xs:"calc(var(--tsrd-font-size) * 0.125)",sm:"calc(var(--tsrd-font-size) * 0.25)",md:"calc(var(--tsrd-font-size) * 0.375)",full:"9999px"}},size:{0:"0px",.5:"calc(var(--tsrd-font-size) * 0.125)",1:"calc(var(--tsrd-font-size) * 0.25)",1.5:"calc(var(--tsrd-font-size) * 0.375)",2:"calc(var(--tsrd-font-size) * 0.5)",2.5:"calc(var(--tsrd-font-size) * 0.625)",3:"calc(var(--tsrd-font-size) * 0.75)",3.5:"calc(var(--tsrd-font-size) * 0.875)",4:"calc(var(--tsrd-font-size) * 1)",5:"calc(var(--tsrd-font-size) * 1.25)",8:"calc(var(--tsrd-font-size) * 2)"}},Kt=e=>{const{colors:t,font:n,size:i,alpha:p,border:l}=I,{fontFamily:a,lineHeight:f,size:g}=n,o=e?Be.bind({target:e}):Be;return{devtoolsPanelContainer:o` - direction: ltr; - position: fixed; - bottom: 0; - right: 0; - z-index: 99999; - width: 100%; - max-height: 90%; - border-top: 1px solid ${t.gray[700]}; - transform-origin: top; - `,devtoolsPanelContainerVisibility:r=>o` - visibility: ${r?"visible":"hidden"}; - `,devtoolsPanelContainerResizing:r=>r()?o` - transition: none; - `:o` - transition: all 0.4s ease; - `,devtoolsPanelContainerAnimation:(r,v)=>r?o` - pointer-events: auto; - transform: translateY(0); - `:o` - pointer-events: none; - transform: translateY(${v}px); - `,logo:o` - cursor: pointer; - display: flex; - flex-direction: column; - background-color: transparent; - border: none; - font-family: ${a.sans}; - gap: ${I.size[.5]}; - padding: 0px; - &:hover { - opacity: 0.7; - } - &:focus-visible { - outline-offset: 4px; - border-radius: ${l.radius.xs}; - outline: 2px solid ${t.blue[800]}; - } - `,tanstackLogo:o` - font-size: ${n.size.md}; - font-weight: ${n.weight.bold}; - line-height: ${n.lineHeight.xs}; - white-space: nowrap; - color: ${t.gray[300]}; - `,routerLogo:o` - font-weight: ${n.weight.semibold}; - font-size: ${n.size.xs}; - background: linear-gradient(to right, #84cc16, #10b981); - background-clip: text; - -webkit-background-clip: text; - line-height: 1; - -webkit-text-fill-color: transparent; - white-space: nowrap; - `,devtoolsPanel:o` - display: flex; - font-size: ${g.sm}; - font-family: ${a.sans}; - background-color: ${t.darkGray[700]}; - color: ${t.gray[300]}; - - @media (max-width: 700px) { - flex-direction: column; - } - @media (max-width: 600px) { - font-size: ${g.xs}; - } - `,dragHandle:o` - position: absolute; - left: 0; - top: 0; - width: 100%; - height: 4px; - cursor: row-resize; - z-index: 100000; - &:hover { - background-color: ${t.purple[400]}${p[90]}; - } - `,firstContainer:o` - flex: 1 1 500px; - min-height: 40%; - max-height: 100%; - overflow: auto; - border-right: 1px solid ${t.gray[700]}; - display: flex; - flex-direction: column; - `,routerExplorerContainer:o` - overflow-y: auto; - flex: 1; - `,routerExplorer:o` - padding: ${I.size[2]}; - `,row:o` - display: flex; - align-items: center; - padding: ${I.size[2]} ${I.size[2.5]}; - gap: ${I.size[2.5]}; - border-bottom: ${t.darkGray[500]} 1px solid; - align-items: center; - `,detailsHeader:o` - font-family: ui-sans-serif, Inter, system-ui, sans-serif, sans-serif; - position: sticky; - top: 0; - z-index: 2; - background-color: ${t.darkGray[600]}; - padding: 0px ${I.size[2]}; - font-weight: ${n.weight.medium}; - font-size: ${n.size.xs}; - min-height: ${I.size[8]}; - line-height: ${n.lineHeight.xs}; - text-align: left; - display: flex; - align-items: center; - `,maskedBadge:o` - background: ${t.yellow[900]}${p[70]}; - color: ${t.yellow[300]}; - display: inline-block; - padding: ${I.size[0]} ${I.size[2.5]}; - border-radius: ${l.radius.full}; - font-size: ${n.size.xs}; - font-weight: ${n.weight.normal}; - border: 1px solid ${t.yellow[300]}; - `,maskedLocation:o` - color: ${t.yellow[300]}; - `,detailsContent:o` - padding: ${I.size[1.5]} ${I.size[2]}; - display: flex; - align-items: center; - justify-content: space-between; - font-size: ${n.size.xs}; - `,routeMatchesToggle:o` - display: flex; - align-items: center; - border: 1px solid ${t.gray[500]}; - border-radius: ${l.radius.sm}; - overflow: hidden; - `,routeMatchesToggleBtn:(r,v)=>{const C=[o` - appearance: none; - border: none; - font-size: 12px; - padding: 4px 8px; - background: transparent; - cursor: pointer; - font-family: ${a.sans}; - font-weight: ${n.weight.medium}; - `];if(r){const m=o` - background: ${t.darkGray[400]}; - color: ${t.gray[300]}; - `;C.push(m)}else{const m=o` - color: ${t.gray[500]}; - background: ${t.darkGray[800]}${p[20]}; - `;C.push(m)}return v&&C.push(o` - border-right: 1px solid ${I.colors.gray[500]}; - `),C},detailsHeaderInfo:o` - flex: 1; - justify-content: flex-end; - display: flex; - align-items: center; - font-weight: ${n.weight.normal}; - color: ${t.gray[400]}; - `,matchRow:r=>{const h=[o` - display: flex; - border-bottom: 1px solid ${t.darkGray[400]}; - cursor: pointer; - align-items: center; - padding: ${i[1]} ${i[2]}; - gap: ${i[2]}; - font-size: ${g.xs}; - color: ${t.gray[300]}; - `];if(r){const C=o` - background: ${t.darkGray[500]}; - `;h.push(C)}return h},matchIndicator:r=>{const h=[o` - flex: 0 0 auto; - width: ${i[3]}; - height: ${i[3]}; - background: ${t[r][900]}; - border: 1px solid ${t[r][500]}; - border-radius: ${l.radius.full}; - transition: all 0.25s ease-out; - box-sizing: border-box; - `];if(r==="gray"){const C=o` - background: ${t.gray[700]}; - border-color: ${t.gray[400]}; - `;h.push(C)}return h},matchID:o` - flex: 1; - line-height: ${f.xs}; - `,ageTicker:r=>{const h=[o` - display: flex; - gap: ${i[1]}; - font-size: ${g.xs}; - color: ${t.gray[400]}; - font-variant-numeric: tabular-nums; - line-height: ${f.xs}; - `];if(r){const C=o` - color: ${t.yellow[400]}; - `;h.push(C)}return h},secondContainer:o` - flex: 1 1 500px; - min-height: 40%; - max-height: 100%; - overflow: auto; - border-right: 1px solid ${t.gray[700]}; - display: flex; - flex-direction: column; - `,thirdContainer:o` - flex: 1 1 500px; - overflow: auto; - display: flex; - flex-direction: column; - height: 100%; - border-right: 1px solid ${t.gray[700]}; - - @media (max-width: 700px) { - border-top: 2px solid ${t.gray[700]}; - } - `,fourthContainer:o` - flex: 1 1 500px; - min-height: 40%; - max-height: 100%; - overflow: auto; - display: flex; - flex-direction: column; - `,routesContainer:o` - overflow-x: auto; - overflow-y: visible; - `,routesRowContainer:(r,v)=>{const C=[o` - display: flex; - border-bottom: 1px solid ${t.darkGray[400]}; - align-items: center; - padding: ${i[1]} ${i[2]}; - gap: ${i[2]}; - font-size: ${g.xs}; - color: ${t.gray[300]}; - cursor: ${v?"pointer":"default"}; - line-height: ${f.xs}; - `];if(r){const m=o` - background: ${t.darkGray[500]}; - `;C.push(m)}return C},routesRow:r=>{const h=[o` - flex: 1 0 auto; - display: flex; - justify-content: space-between; - align-items: center; - font-size: ${g.xs}; - line-height: ${f.xs}; - `];if(!r){const C=o` - color: ${t.gray[400]}; - `;h.push(C)}return h},routesRowInner:o` - display: 'flex'; - align-items: 'center'; - flex-grow: 1; - min-width: 0; - `,routeParamInfo:o` - color: ${t.gray[400]}; - font-size: ${g.xs}; - line-height: ${f.xs}; - `,nestedRouteRow:r=>o` - margin-left: ${r?0:i[3.5]}; - border-left: ${r?"":`solid 1px ${t.gray[700]}`}; - `,code:o` - font-size: ${g.xs}; - line-height: ${f.xs}; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - `,matchesContainer:o` - flex: 1 1 auto; - overflow-y: auto; - `,cachedMatchesContainer:o` - flex: 1 1 auto; - overflow-y: auto; - max-height: 50%; - `,historyContainer:o` - display: flex; - flex: 1 1 auto; - overflow-y: auto; - max-height: 50%; - `,historyOverflowContainer:o` - padding: ${i[1]} ${i[2]}; - font-size: ${I.font.size.xs}; - `,maskedBadgeContainer:o` - flex: 1; - justify-content: flex-end; - display: flex; - `,matchDetails:o` - display: flex; - flex-direction: column; - padding: ${I.size[2]}; - font-size: ${I.font.size.xs}; - color: ${I.colors.gray[300]}; - line-height: ${I.font.lineHeight.sm}; - `,matchStatus:(r,v)=>{const C=v&&r==="success"?v==="beforeLoad"?"purple":"blue":{pending:"yellow",success:"green",error:"red",notFound:"purple",redirected:"gray"}[r];return o` - display: flex; - justify-content: center; - align-items: center; - height: 40px; - border-radius: ${I.border.radius.sm}; - font-weight: ${I.font.weight.normal}; - background-color: ${I.colors[C][900]}${I.alpha[90]}; - color: ${I.colors[C][300]}; - border: 1px solid ${I.colors[C][600]}; - margin-bottom: ${I.size[2]}; - transition: all 0.25s ease-out; - `},matchDetailsInfo:o` - display: flex; - justify-content: flex-end; - flex: 1; - `,matchDetailsInfoLabel:o` - display: flex; - `,mainCloseBtn:o` - background: ${t.darkGray[700]}; - padding: ${i[1]} ${i[2]} ${i[1]} ${i[1.5]}; - border-radius: ${l.radius.md}; - position: fixed; - z-index: 99999; - display: inline-flex; - width: fit-content; - cursor: pointer; - appearance: none; - border: 0; - gap: 8px; - align-items: center; - border: 1px solid ${t.gray[500]}; - font-size: ${n.size.xs}; - cursor: pointer; - transition: all 0.25s ease-out; - - &:hover { - background: ${t.darkGray[500]}; - } - `,mainCloseBtnPosition:r=>o` - ${r==="top-left"?`top: ${i[2]}; left: ${i[2]};`:""} - ${r==="top-right"?`top: ${i[2]}; right: ${i[2]};`:""} - ${r==="bottom-left"?`bottom: ${i[2]}; left: ${i[2]};`:""} - ${r==="bottom-right"?`bottom: ${i[2]}; right: ${i[2]};`:""} - `,mainCloseBtnAnimation:r=>r?o` - opacity: 0; - pointer-events: none; - visibility: hidden; - `:o` - opacity: 1; - pointer-events: auto; - visibility: visible; - `,routerLogoCloseButton:o` - font-weight: ${n.weight.semibold}; - font-size: ${n.size.xs}; - background: linear-gradient(to right, #98f30c, #00f4a3); - background-clip: text; - -webkit-background-clip: text; - line-height: 1; - -webkit-text-fill-color: transparent; - white-space: nowrap; - `,mainCloseBtnDivider:o` - width: 1px; - background: ${I.colors.gray[600]}; - height: 100%; - border-radius: 999999px; - color: transparent; - `,mainCloseBtnIconContainer:o` - position: relative; - width: ${i[5]}; - height: ${i[5]}; - background: pink; - border-radius: 999999px; - overflow: hidden; - `,mainCloseBtnIconOuter:o` - width: ${i[5]}; - height: ${i[5]}; - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - filter: blur(3px) saturate(1.8) contrast(2); - `,mainCloseBtnIconInner:o` - width: ${i[4]}; - height: ${i[4]}; - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - `,panelCloseBtn:o` - position: absolute; - cursor: pointer; - z-index: 100001; - display: flex; - align-items: center; - justify-content: center; - outline: none; - background-color: ${t.darkGray[700]}; - &:hover { - background-color: ${t.darkGray[500]}; - } - - top: 0; - right: ${i[2]}; - transform: translate(0, -100%); - border-right: ${t.darkGray[300]} 1px solid; - border-left: ${t.darkGray[300]} 1px solid; - border-top: ${t.darkGray[300]} 1px solid; - border-bottom: none; - border-radius: ${l.radius.sm} ${l.radius.sm} 0px 0px; - padding: ${i[1]} ${i[1.5]} ${i[.5]} ${i[1.5]}; - - &::after { - content: ' '; - position: absolute; - top: 100%; - left: -${i[2.5]}; - height: ${i[1.5]}; - width: calc(100% + ${i[5]}); - } - `,panelCloseBtnIcon:o` - color: ${t.gray[400]}; - width: ${i[2]}; - height: ${i[2]}; - `,navigateButton:o` - background: none; - border: none; - padding: 0 0 0 4px; - margin: 0; - color: ${t.gray[400]}; - font-size: ${g.md}; - cursor: pointer; - line-height: 1; - vertical-align: middle; - margin-right: 0.5ch; - flex-shrink: 0; - &:hover { - color: ${t.blue[300]}; - } - `}};function Ee(){const e=bt(yt),[t]=ne(Kt(e));return t}const Wt=e=>{try{const t=localStorage.getItem(e);return typeof t=="string"?JSON.parse(t):void 0}catch{return}};function Ze(e,t){const[n,i]=ne();return Ue(()=>{const l=Wt(e);i(typeof l>"u"||l===null?typeof t=="function"?t():t:l)}),[n,l=>{i(a=>{let f=l;typeof l=="function"&&(f=l(a));try{localStorage.setItem(e,JSON.stringify(f))}catch{}return f})}]}var Zt=O(''),Je=O("
"),Qt=O("
Pathname
age / staleTime / gcTime
'),pr=O("
masked"),$t=O("
"),hr=O("
  • "),vr=O("
  • This panel displays the most recent 15 navigations."),$r=O("
    Cached Matches
    age / staleTime / gcTime
    "),mr=O("
    Match Details
    ID:
    State:
    Last Updated:
    Explorer
    "),xr=O("
    Loader Data"),br=O("
    Search Params
    "),yr=O(""),kr=O('