diff --git a/data/tsp_assistant.db b/data/tsp_assistant.db index 8b7c620..659d6b8 100644 Binary files a/data/tsp_assistant.db and b/data/tsp_assistant.db differ diff --git a/src/__pycache__/agent_assistant.cpython-310.pyc b/src/__pycache__/agent_assistant.cpython-310.pyc index e7f5b08..060a70c 100644 Binary files a/src/__pycache__/agent_assistant.cpython-310.pyc and b/src/__pycache__/agent_assistant.cpython-310.pyc differ diff --git a/src/agent/__pycache__/agent_core.cpython-310.pyc b/src/agent/__pycache__/agent_core.cpython-310.pyc index a84da53..3a255d9 100644 Binary files a/src/agent/__pycache__/agent_core.cpython-310.pyc and b/src/agent/__pycache__/agent_core.cpython-310.pyc differ diff --git a/src/agent/agent_core.py b/src/agent/agent_core.py index 6951032..3d5a1b8 100644 --- a/src/agent/agent_core.py +++ b/src/agent/agent_core.py @@ -29,6 +29,7 @@ class AgentState(Enum): PLANNING = "planning" EXECUTING = "executing" LEARNING = "learning" + PROCESSING = "processing" ERROR = "error" class AgentCore: diff --git a/src/agent_assistant.py b/src/agent_assistant.py index ff9338a..5449a67 100644 --- a/src/agent_assistant.py +++ b/src/agent_assistant.py @@ -6,10 +6,12 @@ TSP Agent助手 - 简化版本 import logging import asyncio +import json from typing import Dict, Any, List, Optional from datetime import datetime from src.config.unified_config import get_config from src.agent.llm_client import LLMManager +from src.web.service_manager import service_manager logger = logging.getLogger(__name__) @@ -304,22 +306,65 @@ class TSPAgentAssistant: logger.error(f"获取LLM使用统计失败: {e}") return {} + def process_message_agent_sync(self, message: str, user_id: str = "admin", + work_order_id: Optional[int] = None, + enable_proactive: bool = True) -> Dict[str, Any]: + """处理消息(同步桥接)""" + try: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + return loop.run_until_complete(self.process_message_agent(message, user_id, work_order_id, enable_proactive)) + except Exception as e: + logger.error(f"同步处理消息失败: {e}") + return {"error": str(e)} + async def process_message_agent(self, message: str, user_id: str = "admin", work_order_id: Optional[int] = None, enable_proactive: bool = True) -> Dict[str, Any]: - """处理消息""" + """处理消息 (实战化)""" try: - # 简化的消息处理 + logger.info(f"Agent收到消息: {message}") + + # 1. 识别意图和推荐工具 + prompt = f"用户消息: {message}\n请分析用户意图,并从工具列表中选择最合适的工具。工具列表: {json.dumps(self.get_available_tools())}\n请直接返回你的分析和建议响应。" + + response_text = await self.llm_manager.generate(prompt) + + # 2. 模拟动作生成 + actions = [] + if "工单" in message or "查询" in message: + actions.append({"type": "tool_call", "tool": "search_work_order", "status": "suggested"}) + return { "success": True, - "message": f"Agent收到消息: {message}", + "response": response_text, + "actions": actions, "user_id": user_id, "work_order_id": work_order_id, + "status": "completed", "timestamp": datetime.now().isoformat() } except Exception as e: logger.error(f"处理消息失败: {e}") return {"error": str(e)} + + def execute_tool_sync(self, tool_name: str, parameters: Dict[str, Any] = None) -> Dict[str, Any]: + """执行工具(同步桥接)""" + try: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + return loop.run_until_complete(self.execute_tool(tool_name, parameters)) + except Exception as e: + return {"error": str(e)} + + def trigger_sample_actions_sync(self) -> Dict[str, Any]: + """触发示例动作(同步桥接)""" + try: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + return loop.run_until_complete(self.trigger_sample_actions()) + except Exception as e: + return {"success": False, "error": str(e)} async def trigger_sample_actions(self) -> Dict[str, Any]: """触发示例动作""" @@ -336,7 +381,7 @@ class TSPAgentAssistant: logger.error(f"触发示例动作失败: {e}") return {"success": False, "error": str(e)} - def process_file_to_knowledge(self, file_path: str, filename: str) -> Dict[str, Any]: + async def process_file_to_knowledge(self, file_path: str, filename: str) -> Dict[str, Any]: """处理文件并生成知识库""" try: import os @@ -356,20 +401,39 @@ class TSPAgentAssistant: logger.info(f"文件读取成功: {filename}, 字符数={len(content)}") - # 使用简化的知识提取 + # 使用LLM进行知识提取 (异步调用) logger.info(f"正在对文件内容进行 AI 知识提取...") - knowledge_entries = self._extract_knowledge_from_content(content, filename) + knowledge_entries = await self._extract_knowledge_from_content(content, filename) logger.info(f"知识提取完成: 共提取出 {len(knowledge_entries)} 个潜在条目") # 保存到知识库 saved_count = 0 + + # 获取知识库管理器 + try: + knowledge_manager = service_manager.get_assistant().knowledge_manager + except Exception as e: + logger.error(f"无法获取知识库管理器: {e}") + knowledge_manager = None + for i, entry in enumerate(knowledge_entries): try: logger.info(f"正在保存知识条目 [{i+1}/{len(knowledge_entries)}]: {entry.get('question', '')[:30]}...") - # 这里在实际项目中应当注入知识库管理器的保存逻辑 - # 但在当前简化版本中仅记录日志 - saved_count += 1 + + if knowledge_manager: + # 实际保存到数据库 + knowledge_manager.add_knowledge_entry( + question=entry.get('question'), + answer=entry.get('answer'), + category=entry.get('category', '文档导入'), + confidence_score=entry.get('confidence_score', 0.8) + ) + saved_count += 1 + else: + # 如果无法获取管理器,仅记录日志(降级处理) + logger.warning("知识库管理器不可用,跳过保存") + except Exception as save_error: logger.error(f"保存知识条目 {i+1} 时出错: {save_error}") @@ -402,26 +466,71 @@ class TSPAgentAssistant: logger.error(f"读取文件失败: {e}") return "" - def _extract_knowledge_from_content(self, content: str, filename: str) -> List[Dict[str, Any]]: - """从内容中提取知识""" + async def _extract_knowledge_from_content(self, content: str, filename: str) -> List[Dict[str, Any]]: + """从内容中提取知识 - 使用LLM""" try: - # 简化的知识提取逻辑 - entries = [] - - # 按段落分割内容 - paragraphs = content.split('\n\n') - - for i, paragraph in enumerate(paragraphs[:5]): # 最多提取5个 - if len(paragraph.strip()) > 20: # 过滤太短的段落 - entries.append({ - "question": f"关于{filename}的问题{i+1}", - "answer": paragraph.strip(), - "category": "文档知识", - "confidence_score": 0.7 + # 限制内容长度,避免超出token限制 + # 假设每个汉字2个token,保留前8000个字符作为上下文 + truncated_content = content[:8000] + if len(content) > 8000: + truncated_content += "\n...(后续内容已省略)" + + prompt = f""" +你是一个专业的知识库构建助手。请分析以下文档内容,提取出关键的"问题"和"答案"对,用于构建知识库。 + +文档文件名:{filename} +文档内容: +{truncated_content} + +要求: +1. 提取文档中的核心知识点,转化为"问题(question)"和"答案(answer)"的形式。 +2. "问题"应该清晰明确,方便用户搜索。 +3. "答案"应该准确、完整,直接回答问题。 +4. "分类(category)"请根据内容自动归类(如:故障排查、操作指南、系统配置、业务流程等)。 +5. 输出格式必须是合法的 JSON 数组,不要包含Markdown标记。 + +JSON格式示例: +[ + {{"question": "如何重置密码?", "answer": "请访问设置页面,点击重置密码按钮...", "category": "操作指南"}}, + {{"question": "系统支持哪些浏览器?", "answer": "支持Chrome, Edge, Firefox...", "category": "系统配置"}} +] +""" + # 调用LLM生成 + logger.info("正在调用LLM进行知识提取...") + response_text = await self.llm_manager.generate(prompt, temperature=0.3) + + # 清理响应中的Markdown标记(如果存在) + cleaned_text = response_text.strip() + if cleaned_text.startswith("```json"): + cleaned_text = cleaned_text[7:] + if cleaned_text.startswith("```"): + cleaned_text = cleaned_text[3:] + if cleaned_text.endswith("```"): + cleaned_text = cleaned_text[:-3] + cleaned_text = cleaned_text.strip() + + # 解析JSON + try: + entries = json.loads(cleaned_text) + except json.JSONDecodeError: + # 尝试修复常见的JSON错误 + logger.warning(f"JSON解析失败,尝试简单修复: {cleaned_text[:100]}...") + # 这里可以添加更复杂的修复逻辑,或者直接记录错误 + return [] + + # 验证和标准化 + valid_entries = [] + for entry in entries: + if isinstance(entry, dict) and "question" in entry and "answer" in entry: + valid_entries.append({ + "question": entry["question"], + "answer": entry["answer"], + "category": entry.get("category", "文档导入"), + "confidence_score": 0.9 # LLM生成的置信度较高 }) - - return entries - + + return valid_entries + except Exception as e: logger.error(f"提取知识失败: {e}") return [] diff --git a/src/core/__pycache__/cache_manager.cpython-310.pyc b/src/core/__pycache__/cache_manager.cpython-310.pyc index e849ca6..c4a808f 100644 Binary files a/src/core/__pycache__/cache_manager.cpython-310.pyc and b/src/core/__pycache__/cache_manager.cpython-310.pyc differ diff --git a/src/core/__pycache__/query_optimizer.cpython-310.pyc b/src/core/__pycache__/query_optimizer.cpython-310.pyc index 2c5bf3c..154a704 100644 Binary files a/src/core/__pycache__/query_optimizer.cpython-310.pyc and b/src/core/__pycache__/query_optimizer.cpython-310.pyc differ diff --git a/src/core/cache_manager.py b/src/core/cache_manager.py index 9197cb4..915d506 100644 --- a/src/core/cache_manager.py +++ b/src/core/cache_manager.py @@ -161,13 +161,36 @@ class CacheManager: """淘汰最旧的缓存""" if not self.memory_cache: return - + oldest_key = min( self.memory_cache.keys(), key=lambda k: self.memory_cache[k]['created_at'] ) del self.memory_cache[oldest_key] - + + def check_and_set_message_processed(self, message_id: str, ttl: int = 300) -> bool: + """ + 检查消息是否已处理,如果未处理则标记为已处理 + + Args: + message_id: 消息ID + ttl: 过期时间(秒),默认5分钟 + + Returns: + bool: True 表示已处理(重复消息),False 表示未处理(新消息) + """ + key = f"msg_processed:{message_id}" + + # 使用锁确保原子性(针对内存缓存) + with self.cache_lock: + # 1. 检查是否存在 + if self.get(key): + return True + + # 2. 如果不存在,则标记为已处理 + self.set(key, 1, ttl) + return False + def get_stats(self) -> Dict[str, Any]: """获取缓存统计信息""" with self.cache_lock: @@ -218,11 +241,10 @@ class DatabaseCache: logger.debug(f"缓存未命中: {cache_key}") result = func(*args, **kwargs) self.cache_manager.set(cache_key, result, self.ttl) - + return result return wrapper - # 全局缓存管理器实例 cache_manager = CacheManager() diff --git a/src/core/query_optimizer.py b/src/core/query_optimizer.py index 478d02f..ed5afb1 100644 --- a/src/core/query_optimizer.py +++ b/src/core/query_optimizer.py @@ -92,7 +92,9 @@ class QueryOptimizer: 'confidence_score': conv.confidence_score, 'work_order_id': conv.work_order_id, 'ip_address': conv.ip_address, - 'invocation_method': conv.invocation_method + 'invocation_method': conv.invocation_method, + # 构造用户显示名称:如果有IP则显示IP,否则显示匿名 + 'user_id': f"{conv.ip_address} ({conv.invocation_method})" if conv.ip_address else "匿名" }) # 记录查询时间 diff --git a/src/integrations/__pycache__/ai_suggestion_service.cpython-310.pyc b/src/integrations/__pycache__/ai_suggestion_service.cpython-310.pyc index bdf00eb..535f2d0 100644 Binary files a/src/integrations/__pycache__/ai_suggestion_service.cpython-310.pyc and b/src/integrations/__pycache__/ai_suggestion_service.cpython-310.pyc differ diff --git a/src/integrations/ai_suggestion_service.py b/src/integrations/ai_suggestion_service.py index 2d923d5..6c97194 100644 --- a/src/integrations/ai_suggestion_service.py +++ b/src/integrations/ai_suggestion_service.py @@ -293,27 +293,27 @@ class AISuggestionService: if is_first_suggestion: prompt += """ 要求: -1. 详细分析问题描述,识别可能的根本原因 -2. 基于当前处理进度,判断问题处于哪个阶段 -3. 提供具体的排查步骤和技术指导 -4. 建议需要收集哪些技术信息(如日志、配置、版本等) -5. 如果需要,可以建议进站处理的具体项目 -6. 语言专业,包含技术细节,方便技术人员理解 -7. 建议格式要清晰,便于执行和跟踪 +1. 输出格式必须严格遵守:"时间:当前问题现状,下一步做法" + 例如:"0210:无TBOX登录记录,需要用户进站抓取TBOX日志分析。" +2. "时间"使用当前的MMDD格式(如0210) +3. "当前问题现状"要基于问题描述和处理记录总结,简明扼要 +4. "下一步做法"如果是首次建议,只提供远程排查步骤(如检查网络、重启等),不要提进站 +5. 总字数控制在100字以内,不要分行,不要列点 +6. 绝对不要使用"建议您"、"您可以"等客套话,直接陈述做法 -请提供完整的分析和建议:""" +请按照上述格式生成建议:""" else: prompt += """ 要求: -1. 基于已有处理记录和当前进度,分析问题进展情况 -2. 判断之前的处理步骤是否有效,找出可能的遗漏点 -3. 根据问题发展阶段提供更深入的技术解决方案 -4. 如果远程处理无效,明确说明需要哪些线下技术支持 -5. 详细说明进站后需要执行的具体诊断和修复步骤 -6. 包含技术参数、工具要求和注意事项 -7. 便于技术人员快速理解问题状态和下一步行动 +1. 输出格式必须严格遵守:"时间:当前问题现状,下一步做法" + 例如:"0210:无TBOX登录记录,需要用户进站抓取TBOX日志分析。" +2. "时间"使用当前的MMDD格式(如0210) +3. "当前问题现状"要简述之前的处理结果(如远程无效) +4. "下一步做法"可以包含进站抓取日志、联系技术支持等深入方案 +5. 总字数控制在100字以内,不要分行,不要列点 +6. 绝对不要使用"建议您"、"您可以"等客套话,直接陈述做法 -请提供针对性的深入分析和处理建议:""" +请按照上述格式生成建议:""" return prompt diff --git a/src/integrations/feishu_longconn_service.py b/src/integrations/feishu_longconn_service.py index a520e27..a9f18dc 100644 --- a/src/integrations/feishu_longconn_service.py +++ b/src/integrations/feishu_longconn_service.py @@ -12,6 +12,7 @@ from lark_oapi.api.im.v1 import P2ImMessageReceiveV1, ReplyMessageRequest, Reply from src.config.unified_config import get_config from src.web.service_manager import service_manager +from src.core.cache_manager import cache_manager logger = logging.getLogger(__name__) @@ -66,15 +67,30 @@ class FeishuLongConnService: message_id = message.message_id chat_id = message.chat_id message_type = message.message_type + chat_type = getattr(message, 'chat_type', 'unknown') # 获取会话类型 content = message.content sender = event.sender - logger.info(f"📋 消息详情:") + # 获取发送者ID和群信息 + sender_id = sender.sender_id.user_id + try: + tenant_key = sender.sender_id.tenant_key + except: + tenant_key = "unknown" + + # 详细日志记录 + logger.info(f"📋 消息详情 [长连接]:") logger.info(f" - 消息ID: {message_id}") - logger.info(f" - 群聊ID: {chat_id}") - logger.info(f" - 发送者ID: {sender.sender_id.user_id}") + logger.info(f" - 会话类型: {'群聊(group)' if chat_type == 'group' else '私聊(p2p)' if chat_type == 'p2p' else chat_type}") + logger.info(f" - 会话ID: {chat_id}") + logger.info(f" - 发送者ID: {sender_id}") + logger.info(f" - 租户Key: {tenant_key}") logger.info(f" - 消息类型: {message_type}") - logger.info(f" - 原始内容: {content}") + + # 消息去重检查 + if cache_manager.check_and_set_message_processed(message_id): + logger.warning(f"🔁 消息 {message_id} 已被处理过(可能是Webhook已处理),跳过") + return # 只处理文本消息 if message_type != "text": @@ -145,8 +161,8 @@ class FeishuLongConnService: response_data = chat_manager.process_message( session_id=session_id, user_message=text_content, - ip_address=None, - invocation_method="feishu_longconn" + ip_address=f"Feishu:{sender_id}", + invocation_method=f"Feishu({chat_type})" ) logger.info(f"📊 处理结果: {response_data.get('success')}") diff --git a/src/knowledge_base/__pycache__/knowledge_manager.cpython-310.pyc b/src/knowledge_base/__pycache__/knowledge_manager.cpython-310.pyc index dfc5062..2a5e403 100644 Binary files a/src/knowledge_base/__pycache__/knowledge_manager.cpython-310.pyc and b/src/knowledge_base/__pycache__/knowledge_manager.cpython-310.pyc differ diff --git a/src/knowledge_base/knowledge_manager.py b/src/knowledge_base/knowledge_manager.py index 16f4da2..1b923c3 100644 --- a/src/knowledge_base/knowledge_manager.py +++ b/src/knowledge_base/knowledge_manager.py @@ -401,27 +401,35 @@ class KnowledgeManager: """获取知识库统计信息""" try: with db_manager.get_session() as session: - total_entries = session.query(KnowledgeEntry).count() - active_entries = session.query(KnowledgeEntry).filter( + # 只统计活跃(未删除)的条目 + total_entries = session.query(KnowledgeEntry).filter( KnowledgeEntry.is_active == True ).count() - # 按类别统计 + # 统计已验证的条目 + verified_entries = session.query(KnowledgeEntry).filter( + KnowledgeEntry.is_active == True, + KnowledgeEntry.is_verified == True + ).count() + + # 按类别统计(仅限活跃条目) category_stats = session.query( KnowledgeEntry.category, - session.query(KnowledgeEntry).filter( - KnowledgeEntry.category == KnowledgeEntry.category - ).count() + func.count(KnowledgeEntry.id) + ).filter( + KnowledgeEntry.is_active == True ).group_by(KnowledgeEntry.category).all() - # 平均置信度 + # 平均置信度(仅限活跃条目) avg_confidence = session.query( func.avg(KnowledgeEntry.confidence_score) + ).filter( + KnowledgeEntry.is_active == True ).scalar() or 0.0 return { "total_entries": total_entries, - "active_entries": active_entries, + "active_entries": verified_entries, # 将 active_entries 复用为已验证数量,或前端相应修改 "category_distribution": dict(category_stats), "average_confidence": float(avg_confidence) } diff --git a/src/utils/__pycache__/helpers.cpython-310.pyc b/src/utils/__pycache__/helpers.cpython-310.pyc index cefe89b..6df3827 100644 Binary files a/src/utils/__pycache__/helpers.cpython-310.pyc and b/src/utils/__pycache__/helpers.cpython-310.pyc differ diff --git a/src/web/blueprints/__pycache__/agent.cpython-310.pyc b/src/web/blueprints/__pycache__/agent.cpython-310.pyc index 9ec7ac6..7a31cbc 100644 Binary files a/src/web/blueprints/__pycache__/agent.cpython-310.pyc and b/src/web/blueprints/__pycache__/agent.cpython-310.pyc differ diff --git a/src/web/blueprints/__pycache__/feishu_bot.cpython-310.pyc b/src/web/blueprints/__pycache__/feishu_bot.cpython-310.pyc index 5f308d9..891c37b 100644 Binary files a/src/web/blueprints/__pycache__/feishu_bot.cpython-310.pyc and b/src/web/blueprints/__pycache__/feishu_bot.cpython-310.pyc differ diff --git a/src/web/blueprints/__pycache__/knowledge.cpython-310.pyc b/src/web/blueprints/__pycache__/knowledge.cpython-310.pyc index c4d3d03..3b9b40c 100644 Binary files a/src/web/blueprints/__pycache__/knowledge.cpython-310.pyc and b/src/web/blueprints/__pycache__/knowledge.cpython-310.pyc differ diff --git a/src/web/blueprints/__pycache__/workorders.cpython-310.pyc b/src/web/blueprints/__pycache__/workorders.cpython-310.pyc index 6b6015e..7d673ca 100644 Binary files a/src/web/blueprints/__pycache__/workorders.cpython-310.pyc and b/src/web/blueprints/__pycache__/workorders.cpython-310.pyc differ diff --git a/src/web/blueprints/agent.py b/src/web/blueprints/agent.py index e96e77d..8a1bf32 100644 --- a/src/web/blueprints/agent.py +++ b/src/web/blueprints/agent.py @@ -49,8 +49,7 @@ def trigger_sample_action(): """触发示例动作""" try: from src.web.service_manager import service_manager - import asyncio - result = asyncio.run(service_manager.get_agent_assistant().trigger_sample_actions()) + result = service_manager.get_agent_assistant().trigger_sample_actions_sync() return jsonify(result) except Exception as e: return jsonify({"error": str(e)}), 500 @@ -163,13 +162,12 @@ def agent_chat(): agent_assistant = service_manager.get_agent_assistant() # 模拟Agent处理(实际应该调用真正的Agent处理逻辑) - import asyncio - result = asyncio.run(agent_assistant.process_message_agent( + result = agent_assistant.process_message_agent_sync( message=message, user_id=context.get('user_id', 'admin'), work_order_id=None, enable_proactive=True - )) + ) return jsonify({ "success": True, @@ -215,8 +213,7 @@ def execute_agent_tool(): if not tool_name: return jsonify({"error": "缺少工具名称tool"}), 400 - import asyncio - result = asyncio.run(service_manager.get_agent_assistant().agent_core.tool_manager.execute_tool(tool_name, parameters)) + result = service_manager.get_agent_assistant().agent_core.tool_manager.execute_tool_sync(tool_name, parameters) return jsonify(result) except Exception as e: return jsonify({"error": str(e)}), 500 diff --git a/src/web/blueprints/feishu_bot.py b/src/web/blueprints/feishu_bot.py index a3a6d15..1dbc2e6 100644 --- a/src/web/blueprints/feishu_bot.py +++ b/src/web/blueprints/feishu_bot.py @@ -9,6 +9,7 @@ import threading from flask import Blueprint, request, jsonify, current_app from src.integrations.feishu_service import FeishuService from src.web.service_manager import service_manager +from src.core.cache_manager import cache_manager # 初始化日志 logger = logging.getLogger(__name__) @@ -35,11 +36,21 @@ def _process_message_in_background(app, event_data: dict): message_id = message.get('message_id') chat_id = message.get('chat_id') + chat_type = message.get('chat_type', 'unknown') if not message_id or not chat_id: logger.error(f"[Feishu Bot] 事件数据缺少必要字段: {event_data}") return + # 记录会话类型 + chat_type_desc = '群聊(group)' if chat_type == 'group' else '私聊(p2p)' if chat_type == 'p2p' else chat_type + logger.info(f"[Feishu Bot] 收到 {chat_type_desc} 消息, ChatID: {chat_id}") + + # 消息去重检查 + if cache_manager.check_and_set_message_processed(message_id): + logger.warning(f"[Feishu Bot] 🔁 消息 {message_id} 已被处理过(可能是长连接已处理),跳过") + return + # 内容是一个JSON字符串,需要再次解析 try: content_json = json.loads(message.get('content', '{}')) diff --git a/src/web/blueprints/knowledge.py b/src/web/blueprints/knowledge.py index a828797..001f67f 100644 --- a/src/web/blueprints/knowledge.py +++ b/src/web/blueprints/knowledge.py @@ -11,6 +11,7 @@ import logging from flask import Blueprint, request, jsonify from src.agent_assistant import TSPAgentAssistant from src.web.service_manager import service_manager +from src.core.cache_manager import cache_manager from src.web.error_handlers import handle_api_errors, create_error_response, create_success_response knowledge_bp = Blueprint('knowledge', __name__, url_prefix='/api/knowledge') @@ -98,7 +99,16 @@ def upload_knowledge_file(): try: file.save(temp_path) assistant = get_agent_assistant() - result = assistant.process_file_to_knowledge(temp_path, file.filename) + # 由于process_file_to_knowledge现在是异步的,我们需要同步调用它 + # 或者将整个视图函数改为异步(Flask 2.0+支持) + import asyncio + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + result = loop.run_until_complete(assistant.process_file_to_knowledge(temp_path, file.filename)) + loop.close() + + cache_manager.clear() + return jsonify(result) finally: try: @@ -108,6 +118,78 @@ def upload_knowledge_file(): logger = logging.getLogger(__name__) logger.warning(f"清理临时文件失败: {cleanup_error}") +@knowledge_bp.route('/batch_delete', methods=['POST']) +@handle_api_errors +def batch_delete_knowledge(): + """批量删除知识库条目""" + data = request.get_json() + if not data or 'ids' not in data: + return create_error_response("缺少 ids 参数", 400) + + ids = data['ids'] + if not isinstance(ids, list): + return create_error_response("ids 必须是列表", 400) + + knowledge_manager = service_manager.get_assistant().knowledge_manager + success_count = 0 + fail_count = 0 + + for knowledge_id in ids: + if knowledge_manager.delete_knowledge_entry(knowledge_id): + success_count += 1 + else: + fail_count += 1 + + return create_success_response(f"批量删除完成: 成功 {success_count} 条,失败 {fail_count} 条") + +@knowledge_bp.route('/batch_verify', methods=['POST']) +@handle_api_errors +def batch_verify_knowledge(): + """批量验证知识库条目""" + data = request.get_json() + if not data or 'ids' not in data: + return create_error_response("缺少 ids 参数", 400) + + ids = data['ids'] + if not isinstance(ids, list): + return create_error_response("ids 必须是列表", 400) + + knowledge_manager = service_manager.get_assistant().knowledge_manager + success_count = 0 + fail_count = 0 + + for knowledge_id in ids: + if knowledge_manager.verify_knowledge_entry(knowledge_id, verified_by="admin"): + success_count += 1 + else: + fail_count += 1 + + return create_success_response(f"批量验证完成: 成功 {success_count} 条,失败 {fail_count} 条") + +@knowledge_bp.route('/batch_unverify', methods=['POST']) +@handle_api_errors +def batch_unverify_knowledge(): + """批量取消验证知识库条目""" + data = request.get_json() + if not data or 'ids' not in data: + return create_error_response("缺少 ids 参数", 400) + + ids = data['ids'] + if not isinstance(ids, list): + return create_error_response("ids 必须是列表", 400) + + knowledge_manager = service_manager.get_assistant().knowledge_manager + success_count = 0 + fail_count = 0 + + for knowledge_id in ids: + if knowledge_manager.unverify_knowledge_entry(knowledge_id): + success_count += 1 + else: + fail_count += 1 + + return create_success_response(f"批量取消验证完成: 成功 {success_count} 条,失败 {fail_count} 条") + @knowledge_bp.route('/delete/', methods=['DELETE']) @handle_api_errors def delete_knowledge(knowledge_id): diff --git a/src/web/blueprints/workorders.py b/src/web/blueprints/workorders.py index 6f52bda..0914304 100644 --- a/src/web/blueprints/workorders.py +++ b/src/web/blueprints/workorders.py @@ -478,19 +478,42 @@ def import_workorders(): # 解析Excel文件 try: df = pd.read_excel(upload_path) + if df is None or df.empty: + return jsonify({"error": "Excel文件内容为空或无法识别表格结构"}), 400 + imported_workorders = [] + skipped_rows = 0 # 处理每一行数据 for index, row in df.iterrows(): - # 根据Excel列名映射到工单字段 - title = str(row.get('标题', row.get('title', f'导入工单 {index + 1}'))) - description = str(row.get('描述', row.get('description', ''))) - category = str(row.get('分类', row.get('category', '技术问题'))) - priority = str(row.get('优先级', row.get('priority', 'medium'))) - status = str(row.get('状态', row.get('status', 'open'))) + # 定义候选列名映射 + col_mappings = { + 'title': ['标题', 'title', '工单标题', '问题主体', 'Subject'], + 'description': ['描述', 'description', '问题描述', '详细内容', 'Detail'], + 'category': ['分类', 'category', '业务分类', '模块', 'Type'], + 'priority': ['优先级', 'priority', '紧急程度', 'Level', 'Priority'], + 'status': ['状态', 'status', '工单状态', 'State'], + 'resolution': ['解决方案', 'resolution', '处理结果', 'Solution'], + 'satisfaction': ['满意度', 'satisfaction_score', 'rating', 'Score'] + } + + def get_val(row_data, keys, default=''): + for k in keys: + if k in row_data: + val = row_data[k] + if pd.notna(val): + return str(val) + return default + + title = get_val(row, col_mappings['title'], f'导入工单 {index + 1}') + description = get_val(row, col_mappings['description'], '') + category = get_val(row, col_mappings['category'], '技术问题') + priority = get_val(row, col_mappings['priority'], 'medium') + status = get_val(row, col_mappings['status'], 'open') # 验证必填字段 if not title or title.strip() == '': + skipped_rows += 1 continue # 生成唯一的工单ID @@ -513,12 +536,14 @@ def import_workorders(): ) # 处理可选字段 - if pd.notna(row.get('解决方案', row.get('resolution'))): - workorder.resolution = str(row.get('解决方案', row.get('resolution'))) + res_val = get_val(row, col_mappings['resolution'], None) + if res_val: + workorder.resolution = res_val - if pd.notna(row.get('满意度', row.get('satisfaction_score'))): + score_val = get_val(row, col_mappings['satisfaction'], None) + if score_val: try: - workorder.satisfaction_score = int(row.get('满意度', row.get('satisfaction_score'))) + workorder.satisfaction_score = int(float(score_val)) except (ValueError, TypeError): workorder.satisfaction_score = None @@ -551,8 +576,9 @@ def import_workorders(): return jsonify({ "success": True, - "message": f"成功导入 {len(imported_workorders)} 个工单", + "message": f"成功导入 {len(imported_workorders)} 个工单" + (f",跳过 {skipped_rows} 行无效数据" if skipped_rows > 0 else ""), "imported_count": len(imported_workorders), + "skipped_count": skipped_rows, "workorders": imported_workorders }) diff --git a/src/web/static/css/style.css b/src/web/static/css/style.css index 5c8f11b..6107d42 100644 --- a/src/web/static/css/style.css +++ b/src/web/static/css/style.css @@ -20,7 +20,7 @@ body { .btn-group .btn:hover { transform: translateY(-1px); - box-shadow: 0 2px 8px rgba(0,0,0,0.15); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); } /* 小尺寸按钮优化 */ @@ -97,7 +97,7 @@ body { bottom: 100%; left: 50%; transform: translateX(-50%); - background-color: rgba(0,0,0,0.8); + background-color: rgba(0, 0, 0, 0.8); color: white; padding: 0.25rem 0.5rem; border-radius: 0.25rem; @@ -153,9 +153,17 @@ body { } @keyframes pulse { - 0% { opacity: 1; } - 50% { opacity: 0.5; } - 100% { opacity: 1; } + 0% { + opacity: 1; + } + + 50% { + opacity: 0.5; + } + + 100% { + opacity: 1; + } } .health-dot.normal { @@ -176,8 +184,13 @@ body { } @keyframes spin { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } } /* 可点击统计数字样式 */ @@ -239,23 +252,23 @@ body { height: 1.75rem; padding: 0.25rem 0.375rem; } - + .btn-sm i { font-size: 0.75rem; } - + .btn-group .btn:not(:last-child) { margin-right: 0.125rem; } - + .table .btn-group .btn { margin: 0 0.0625rem; } - + .clickable-stat { font-size: 1.5rem; } - + .modal-xl { max-width: 95vw; } @@ -321,7 +334,7 @@ body { .alert-card:hover { transform: translateY(-2px); - box-shadow: 0 4px 8px rgba(0,0,0,0.1); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); } .alert-card.critical { @@ -401,7 +414,7 @@ body { .card:hover { transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(0,0,0,0.15); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); } /* 按钮样式 */ @@ -427,8 +440,13 @@ body { } @keyframes spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } } /* 响应式设计 */ @@ -436,13 +454,13 @@ body { .container-fluid { padding: 0 15px; } - + .score-circle { width: 80px; height: 80px; font-size: 1.2rem; } - + .card-body { padding: 1rem; } @@ -493,9 +511,11 @@ body { 0% { box-shadow: 0 0 0 0 rgba(40, 167, 69, 0.7); } + 70% { box-shadow: 0 0 0 10px rgba(40, 167, 69, 0); } + 100% { box-shadow: 0 0 0 0 rgba(40, 167, 69, 0); } @@ -505,7 +525,7 @@ body { .modal-content { border-radius: 0.5rem; border: none; - box-shadow: 0 10px 30px rgba(0,0,0,0.3); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3); } .modal-header { @@ -520,7 +540,7 @@ body { /* 表格样式 */ .table-hover tbody tr:hover { - background-color: rgba(0,123,255,0.1); + background-color: rgba(0, 123, 255, 0.1); } /* 空状态样式 */ @@ -649,20 +669,20 @@ body { flex-direction: column; align-items: stretch; } - + .alert-controls .form-select { min-width: auto; } - + .alert-card .d-flex { flex-direction: column; } - + .alert-card .ms-3 { margin-left: 0 !important; margin-top: 10px; } - + .alert-data { font-size: 10px; max-height: 80px; @@ -700,7 +720,7 @@ body { .preset-card:hover { transform: translateY(-5px); - box-shadow: 0 8px 25px rgba(0,0,0,0.15); + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15); border-color: #007bff; } @@ -796,15 +816,15 @@ body { .preset-card .card-body { padding: 1rem; } - + .preset-card h6 { font-size: 0.9rem; } - + .preset-card p { font-size: 0.8rem; } - + .preset-params .badge { font-size: 0.65rem; padding: 0.2rem 0.4rem; @@ -863,8 +883,27 @@ body { .preset-preview .preview-param span { color: #6c757d; font-size: 0.8rem; + font-style: italic; } +/* 历史对话字体深度统一补丁 */ +.conversation-item, +.conversation-item h6, +.conversation-item p, +.conversation-item span, +.conversation-item small, +.conversation-item strong, +#conversation-list * { + font-family: var(--font-family-primary) !important; +} + +.conversation-preview p { + font-size: var(--font-size-sm); + line-height: var(--line-height-relaxed); + color: var(--text-secondary); +} + + /* AI建议与人工描述优化样式 */ .ai-suggestion-section { background: linear-gradient(135deg, #f8f9ff, #e8f2ff); @@ -1087,13 +1126,18 @@ body { left: -100%; width: 100%; height: 100%; - background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent); + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent); animation: shimmer 1.5s infinite; } @keyframes shimmer { - 0% { left: -100%; } - 100% { left: 100%; } + 0% { + left: -100%; + } + + 100% { + left: 100%; + } } /* 按钮加载状态 */ @@ -1122,9 +1166,17 @@ body { } @keyframes successPulse { - 0% { transform: scale(1); } - 50% { transform: scale(1.05); } - 100% { transform: scale(1); } + 0% { + transform: scale(1); + } + + 50% { + transform: scale(1.05); + } + + 100% { + transform: scale(1); + } } /* 工具提示优化 */ @@ -1176,94 +1228,49 @@ body { padding: 15px; margin: 10px 0; } - + .ai-suggestion-header { flex-direction: column; align-items: flex-start; gap: 10px; } - + .similarity-indicator { flex-direction: column; align-items: flex-start; gap: 8px; } - + .action-buttons { flex-direction: column; align-items: stretch; } - + .save-human-btn, .approve-btn { width: 100%; text-align: center; } - + .tooltip-custom::before { font-size: 0.7rem; padding: 6px 10px; } } - -/* f���pencaSGr7h_ */ -.vehicle-data-card { - background: linear-gradient(135deg, #e8f5e8, #f0f8f0); - border: 1px solid #4caf50; - border-radius: 10px; - margin: 10px 0; - padding: 15px; - box-shadow: 0 2px 10px rgba(76, 175, 80, 0.1); -} - -.vehicle-data-header { - border-bottom: 1px solid #4caf50; - padding-bottom: 10px; - margin-bottom: 15px; -} - -.vehicle-data-header h5 { - color: #2e7d32; - margin: 0; - font-size: 1.1rem; -} - -.vehicle-data-content { - padding: 0; -} - -.vehicle-info { - background: white; - border-radius: 8px; - padding: 12px; - margin-bottom: 10px; - border-left: 4px solid #4caf50; -} - -.vehicle-info h6 { - color: #1976d2; - margin-bottom: 8px; - font-size: 1rem; -} - -.vehicle-details p { - margin: 5px 0; - font-size: 0.9rem; - color: #333; -} - -.vehicle-details strong { - color: #2e7d32; -} - -.vehicle-error { - background: #ffebee; - color: #c62828; - padding: 10px; - border-radius: 5px; - border-left: 4px solid #f44336; - font-style: italic; -} - - \ No newline at end of file +/* 历史对话字体深度统一补丁 - V2 (修复编码) */ +.conversation-item, +.conversation-item h6, +.conversation-item p, +.conversation-item span, +.conversation-item small, +.conversation-item strong, +#conversation-list * { + font-family: var(--font-family-primary) !important; +} + +.conversation-preview p { + font-size: var(--font-size-sm); + line-height: var(--line-height-relaxed); + color: var(--text-secondary); +} \ No newline at end of file diff --git a/src/web/static/js/dashboard.js b/src/web/static/js/dashboard.js index 49a7b57..58ed840 100644 --- a/src/web/static/js/dashboard.js +++ b/src/web/static/js/dashboard.js @@ -196,7 +196,10 @@ class TSPDashboard { this.paginationConfig = { defaultPageSize: 10, pageSizeOptions: [5, 10, 20, 50], - maxVisiblePages: 5 + maxVisiblePages: 5, + currentKnowledgePage: 1, // 追踪知识库当前页 + currentWorkOrderPage: 1, // 追踪工单当前页 + currentConversationPage: 1 // 追踪对话历史当前页 }; this.init(); @@ -2076,6 +2079,7 @@ class TSPDashboard { // 知识库管理 async loadKnowledge(page = 1) { + this.paginationConfig.currentKnowledgePage = page; try { const pageSize = this.getPageSize('knowledge-pagination'); const response = await fetch(`/api/knowledge?page=${page}&per_page=${pageSize}`); @@ -2317,7 +2321,7 @@ class TSPDashboard { const data = await response.json(); if (data.success) { this.showNotification('知识库验证成功', 'success'); - this.loadKnowledge(); + this.loadKnowledge(this.paginationConfig.currentKnowledgePage); } else { this.showNotification('知识库验证失败', 'error'); } @@ -2339,7 +2343,7 @@ class TSPDashboard { const data = await response.json(); if (data.success) { this.showNotification('取消验证成功', 'success'); - this.loadKnowledge(); + this.loadKnowledge(this.paginationConfig.currentKnowledgePage); } else { this.showNotification('取消验证失败', 'error'); } @@ -2361,8 +2365,8 @@ class TSPDashboard { const data = await response.json(); if (data.success) { - this.showNotification('知识库删除成功', 'success'); - this.loadKnowledge(); + this.showNotification('知识库条目已删除', 'success'); + this.loadKnowledge(this.paginationConfig.currentKnowledgePage); } else { this.showNotification('知识库删除失败', 'error'); } @@ -2440,6 +2444,7 @@ class TSPDashboard { // 工单管理 async loadWorkOrders(page = 1, forceRefresh = false) { + this.paginationConfig.currentWorkOrderPage = page; const cacheKey = `workorders_page_${page}`; if (!forceRefresh && this.cache.has(cacheKey)) { @@ -2936,8 +2941,8 @@ class TSPDashboard { if (data.success) { this.showNotification('工单删除成功', 'success'); - // 立即刷新工单列表和统计 - await this.loadWorkOrders(); + // 立即刷新工单列表和统计 (使用当前页码并强制刷新) + await this.loadWorkOrders(this.paginationConfig.currentWorkOrderPage, true); await this.loadAnalytics(); } else { this.showNotification('删除工单失败: ' + (data.error || '未知错误'), 'error'); @@ -3233,12 +3238,37 @@ class TSPDashboard { return; } - const html = conversations.map(conv => ` + const html = conversations.map(conv => { + // 识别来源和类型 + let sourceBadge = ''; + const method = conv.invocation_method || ''; + const userId = conv.user_id || ''; + + // 判断是否来自飞书 (根据调用方式或ID格式) + if (method.includes('feishu') || userId.startsWith('ou_')) { + sourceBadge = '飞书'; + } else { + sourceBadge = 'Web'; + } + + // 判断群聊/私聊 + let typeBadge = ''; + if (method.includes('group')) { + typeBadge = '群聊'; + } else if (method.includes('p2p')) { + typeBadge = '私聊'; + } + + return `
-
用户: ${conv.user_id || '匿名'}
+
+ ${sourceBadge} + ${typeBadge} + 用户: ${userId || '匿名'} +
${new Date(conv.timestamp).toLocaleString()}
@@ -3260,7 +3290,8 @@ class TSPDashboard {
- `).join(''); + `; + }).join(''); container.innerHTML = html; } diff --git a/src/web/static/js/main.js b/src/web/static/js/main.js index b30eb92..649b19f 100644 --- a/src/web/static/js/main.js +++ b/src/web/static/js/main.js @@ -155,8 +155,9 @@ class App { // 映射路由到页面文件 const pageFile = this.getPageFile(route.name); - // 动态导入页面组件 - const pageModule = await import(`./pages/${pageFile}.js`); + // 动态导入页面组件 (添加版本号防止缓存) + const version = '1.0.2'; + const pageModule = await import(`./pages/${pageFile}.js?v=${version}`); const PageComponent = pageModule.default; // 实例化页面组件 diff --git a/src/web/static/js/pages/knowledge.js b/src/web/static/js/pages/knowledge.js index c272c63..54d15f1 100644 --- a/src/web/static/js/pages/knowledge.js +++ b/src/web/static/js/pages/knowledge.js @@ -6,28 +6,668 @@ export default class Knowledge { constructor(container, route) { this.container = container; this.route = route; + this.currentPage = 1; // 初始化当前页码 this.init(); } async init() { this.render(); + this.bindEvents(); + await Promise.all([ + this.loadKnowledgeList(), + this.loadStats() + ]); + } + + async loadStats() { + try { + const response = await fetch('/api/knowledge/stats'); + if (response.ok) { + const stats = await response.json(); + + // 更新统计数据显示 + const totalEl = this.container.querySelector('#stat-total'); + const activeEl = this.container.querySelector('#stat-active'); + const catsEl = this.container.querySelector('#stat-categories'); + const confEl = this.container.querySelector('#stat-confidence'); + + if (totalEl) totalEl.textContent = stats.total_entries || 0; + if (activeEl) activeEl.textContent = stats.active_entries || 0; // 后端现在返回的是已验证数量 + if (catsEl) catsEl.textContent = Object.keys(stats.category_distribution || {}).length; + if (confEl) confEl.textContent = ((stats.average_confidence || 0) * 100).toFixed(0) + '%'; + } + } catch (error) { + console.error('加载统计信息失败:', error); + } } render() { this.container.innerHTML = ` - + + + `; } @@ -302,9 +384,112 @@ export default class WorkOrders { } // 全局函数供表格操作使用 -window.viewWorkOrder = function(id) { - if (window.showToast) { - window.showToast(`查看工单 ${id} 功能开发中`, 'info'); +window.viewWorkOrder = async function(id) { + try { + // 显示模态框(先显示加载状态) + const modalEl = document.getElementById('workorder-detail-modal'); + const modal = new bootstrap.Modal(modalEl); + modal.show(); + + // 重置内容 + document.getElementById('modal-status-badge').innerHTML = ''; + document.getElementById('modal-ai-analysis').innerHTML = '
正在分析...'; + document.getElementById('modal-chat-history').innerHTML = '
'; + + // 获取详情 + const response = await fetch(`/api/workorders/${id}`); + const result = await response.json(); + + if (!result.success) { + alert('获取工单详情失败'); + return; + } + + const wo = result.workorder; + + // 填充基本信息 + document.getElementById('modal-title').textContent = wo.title; + document.getElementById('modal-id').textContent = wo.order_id || wo.id; + document.getElementById('modal-category').textContent = wo.category || '-'; + document.getElementById('modal-created-at').textContent = new Date(wo.created_at).toLocaleString(); + document.getElementById('modal-user').textContent = wo.user_id || '-'; + document.getElementById('modal-description').textContent = wo.description || '无描述'; + + // 状态徽章 + const statusMap = { + 'open': '待处理', + 'in_progress': '处理中', + 'resolved': '已解决', + 'closed': '已关闭' + }; + document.getElementById('modal-status-badge').innerHTML = statusMap[wo.status] || wo.status; + + // 优先级 + const priorityMap = { + 'low': '', + 'medium': '', + 'high': '', + 'urgent': '紧急' + }; + document.getElementById('modal-priority').innerHTML = priorityMap[wo.priority] || wo.priority; + + // AI 分析/建议 + if (wo.resolution) { + document.getElementById('modal-ai-analysis').innerHTML = ` +
${wo.resolution}
+ `; + } else { + document.getElementById('modal-ai-analysis').textContent = '暂无 AI 分析建议'; + } + + // 渲染对话/处理历史 (模拟数据或真实数据) + const historyContainer = document.getElementById('modal-chat-history'); + // 这里假设后端返回的详情中包含 history 或 timeline + // 如果没有,暂时显示描述作为第一条记录 + + let historyHtml = ''; + + // 模拟一条初始记录 + historyHtml += ` +
+
+ 用户 + ${new Date(wo.created_at).toLocaleString()} +
+
+ ${wo.description} +
+
+ `; + + if (wo.timeline && wo.timeline.length > 0) { + // 如果有真实的时间轴数据 + historyHtml = wo.timeline.map(item => ` +
+
+ + ${item.author || (item.type === 'ai' ? 'AI 助手' : '系统')} + + ${new Date(item.timestamp).toLocaleString()} +
+
+ ${item.content} +
+
+ `).join(''); + } + + historyContainer.innerHTML = historyHtml; + + // 绑定编辑按钮 + document.getElementById('modal-edit-btn').onclick = () => { + modal.hide(); + editWorkOrder(id); + }; + + } catch (e) { + console.error('查看工单详情失败', e); + alert('查看详情失败: ' + e.message); } }; @@ -315,15 +500,50 @@ window.editWorkOrder = function(id) { }; window.deleteWorkOrder = function(id) { - if (confirm(`确定要删除工单 ${id} 吗?`)) { - if (window.showToast) { - window.showToast('删除功能开发中', 'info'); - } + if (!confirm(`确定要删除工单 ${id} 吗?`)) { + return; } + + (async () => { + try { + const response = await fetch(`/api/workorders/${id}`, { + method: 'DELETE' + }); + const result = await response.json(); + + if (response.ok && result.success) { + if (window.showToast) { + window.showToast(result.message || `工单 ${id} 删除成功`, 'success'); + } + // 通知当前页面刷新工单列表(保持当前页) + const event = new CustomEvent('workorder-deleted', { detail: { id } }); + document.dispatchEvent(event); + } else { + if (window.showToast) { + window.showToast(result.message || '删除工单失败', 'error'); + } else { + alert(result.message || '删除工单失败'); + } + } + } catch (error) { + console.error('删除工单失败:', error); + if (window.showToast) { + window.showToast('删除失败,请检查网络或查看控制台日志', 'error'); + } else { + alert('删除失败,请检查网络或查看控制台日志'); + } + } + })(); }; window.changePage = function(page) { // 重新加载当前页面实例 const event = new CustomEvent('changePage', { detail: { page } }); document.dispatchEvent(event); -}; \ No newline at end of file +}; + +// 监听删除事件,触发当前 WorkOrders 列表刷新(保持当前页) +document.addEventListener('workorder-deleted', () => { + const event = new CustomEvent('reloadWorkOrders'); + document.dispatchEvent(event); +}); \ No newline at end of file diff --git a/src/web/templates/dashboard.html b/src/web/templates/dashboard.html index da4b558..9aa9963 100644 --- a/src/web/templates/dashboard.html +++ b/src/web/templates/dashboard.html @@ -10,8 +10,8 @@ - - + +