feat: 对话历史页面租户分组展示功能

- 新增 ConversationHistoryManager.get_tenant_summary() 按租户聚合会话统计
- get_sessions_paginated() 和 get_conversation_analytics() 增加 tenant_id 过滤
- 新增 GET /api/conversations/tenants 租户汇总端点
- sessions 和 analytics API 端点支持 tenant_id 查询参数
- 前端实现租户卡片列表视图和租户详情会话表格视图
- 实现面包屑导航、搜索范围限定、统计面板上下文切换
- 会话删除后自动检测空租户并返回列表视图
- dashboard.html 添加租户视图 DOM 容器
- 交互模式与知识库租户分组视图保持一致
This commit is contained in:
2026-04-01 16:11:02 +08:00
parent e14e3ee7a5
commit 7013e9db70
27 changed files with 2753 additions and 276 deletions

View File

@@ -49,6 +49,7 @@ class ServerConfig:
websocket_port: int = 8765
debug: bool = False
log_level: str = "INFO"
tenant_id: str = "default" # 当前实例的租户标识
@dataclass
class FeishuConfig:
@@ -145,7 +146,8 @@ class UnifiedConfig:
port=int(os.getenv("SERVER_PORT", 5000)),
websocket_port=int(os.getenv("WEBSOCKET_PORT", 8765)),
debug=os.getenv("DEBUG_MODE", "False").lower() in ('true', '1', 't'),
log_level=os.getenv("LOG_LEVEL", "INFO").upper()
log_level=os.getenv("LOG_LEVEL", "INFO").upper(),
tenant_id=os.getenv("TENANT_ID", "default"),
)
logger.info("Server config loaded.")
return config

View File

@@ -1,35 +1,50 @@
# -*- coding: utf-8 -*-
"""
统一 LLM 客户端
兼容所有 OpenAI 格式 API千问、Gemini、DeepSeek、本地 Ollama 等)
通过 .env 中 LLM_PROVIDER / LLM_BASE_URL / LLM_MODEL 切换模型
"""
import requests
import json
import logging
from typing import Dict, List, Optional, Any
from typing import Dict, List, Optional, Any, Generator
from datetime import datetime
from src.config.unified_config import get_config
logger = logging.getLogger(__name__)
class QwenClient:
"""阿里云千问API客户端"""
def __init__(self):
class LLMClient:
"""
统一大模型客户端
所有 OpenAI 兼容 API 都走这一个类,不再区分 provider。
"""
def __init__(self, base_url: str = None, api_key: str = None,
model: str = None, timeout: int = None):
config = get_config()
self.base_url = config.llm.base_url or "https://dashscope.aliyuncs.com/compatible-mode/v1"
self.api_key = config.llm.api_key
self.model_name = config.llm.model
self.timeout = config.llm.timeout
self.base_url = (base_url or config.llm.base_url or
"https://dashscope.aliyuncs.com/compatible-mode/v1")
self.api_key = api_key or config.llm.api_key
self.model_name = model or config.llm.model
self.timeout = timeout or config.llm.timeout
self.headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json"
"Content-Type": "application/json",
}
# ── 普通请求 ──────────────────────────────────────────
def chat_completion(
self,
messages: List[Dict[str, str]],
temperature: float = 0.7,
max_tokens: int = 1000,
stream: bool = False
**kwargs,
) -> Dict[str, Any]:
"""发送聊天请求"""
"""标准聊天补全(非流式)"""
try:
url = f"{self.base_url}/chat/completions"
payload = {
@@ -37,114 +52,146 @@ class QwenClient:
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
"stream": stream
"stream": False,
}
response = requests.post(
url,
headers=self.headers,
json=payload,
timeout=self.timeout
url, headers=self.headers, json=payload, timeout=self.timeout
)
if response.status_code == 200:
result = response.json()
logger.info("API请求成功")
return result
return response.json()
else:
logger.error(f"API请求失败: {response.status_code} - {response.text}")
logger.error(f"LLM API 失败: {response.status_code} - {response.text}")
return {"error": f"API请求失败: {response.status_code}"}
except requests.exceptions.Timeout:
logger.error("API请求超时")
logger.error("LLM API 超时")
return {"error": "请求超时"}
except requests.exceptions.RequestException as e:
logger.error(f"API请求异常: {e}")
logger.error(f"LLM API 异常: {e}")
return {"error": f"请求异常: {str(e)}"}
except Exception as e:
logger.error(f"未知错误: {e}")
logger.error(f"LLM 未知错误: {e}")
return {"error": f"未知错误: {str(e)}"}
# ── 流式请求 ──────────────────────────────────────────
def chat_completion_stream(
self,
messages: List[Dict[str, str]],
temperature: float = 0.7,
max_tokens: int = 1000,
) -> Generator[str, None, None]:
"""流式聊天补全,逐 token yield 文本片段"""
try:
url = f"{self.base_url}/chat/completions"
payload = {
"model": self.model_name,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
"stream": True,
}
response = requests.post(
url, headers=self.headers, json=payload,
timeout=self.timeout, stream=True,
)
if response.status_code != 200:
logger.error(f"流式 API 失败: {response.status_code}")
return
for line in response.iter_lines(decode_unicode=True):
if not line or not line.startswith("data: "):
continue
data_str = line[6:]
if data_str.strip() == "[DONE]":
break
try:
chunk = json.loads(data_str)
delta = chunk.get("choices", [{}])[0].get("delta", {})
content = delta.get("content", "")
if content:
yield content
except (json.JSONDecodeError, IndexError, KeyError):
continue
except requests.exceptions.Timeout:
logger.error("流式 API 超时")
except Exception as e:
logger.error(f"流式 API 异常: {e}")
# ── 便捷方法 ──────────────────────────────────────────
def generate_response(
self,
user_message: str,
context: Optional[str] = None,
knowledge_base: Optional[List[str]] = None
knowledge_base: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""生成回复"""
messages = []
# 系统提示词
system_prompt = "你是一个专业的客服助手,请根据用户问题提供准确、 helpful的回复。"
"""快捷生成回复"""
system_prompt = "你是一个专业的客服助手,请根据用户问题提供准确、有帮助的回复。"
if context:
system_prompt += f"\n\n上下文信息: {context}"
if knowledge_base:
system_prompt += f"\n\n相关知识库: {' '.join(knowledge_base)}"
messages.append({"role": "system", "content": system_prompt})
messages.append({"role": "user", "content": user_message})
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_message},
]
result = self.chat_completion(messages)
if "error" in result:
return result
try:
response_content = result["choices"][0]["message"]["content"]
return {
"response": response_content,
"response": result["choices"][0]["message"]["content"],
"usage": result.get("usage", {}),
"model": result.get("model", ""),
"timestamp": datetime.now().isoformat()
"timestamp": datetime.now().isoformat(),
}
except (KeyError, IndexError) as e:
logger.error(f"解析API响应失败: {e}")
logger.error(f"解析响应失败: {e}")
return {"error": f"解析响应失败: {str(e)}"}
def extract_entities(self, text: str) -> Dict[str, Any]:
"""提取文本中的实体信息"""
prompt = f"""
请从以下文本中提取关键信息,包括:
1. 问题类型/类别
2. 优先级(高/中/低)
3. 关键词
4. 情感倾向(正面/负面/中性)
文本: {text}
请以JSON格式返回结果。
"""
import re
prompt = (
f"请从以下文本中提取关键信息,包括:\n"
f"1. 问题类型/类别\n2. 优先级(高/中/低)\n"
f"3. 关键词\n4. 情感倾向(正面/负面/中性)\n\n"
f"文本: {text}\n\n请以JSON格式返回结果。"
)
messages = [
{"role": "system", "content": "你是一个信息提取专家,请准确提取文本中的关键信息。"},
{"role": "user", "content": prompt}
{"role": "user", "content": prompt},
]
result = self.chat_completion(messages, temperature=0.3)
if "error" in result:
return result
try:
response_content = result["choices"][0]["message"]["content"]
# 尝试解析JSON
import re
json_match = re.search(r'\{.*\}', response_content, re.DOTALL)
if json_match:
return json.loads(json_match.group())
else:
return {"raw_response": response_content}
content = result["choices"][0]["message"]["content"]
json_match = re.search(r'\{.*\}', content, re.DOTALL)
return json.loads(json_match.group()) if json_match else {"raw_response": content}
except Exception as e:
logger.error(f"解析实体提取结果失败: {e}")
return {"error": f"解析失败: {str(e)}"}
def test_connection(self) -> bool:
"""测试API连接"""
"""测试连接"""
try:
result = self.chat_completion([
{"role": "user", "content": "你好"}
], max_tokens=10)
result = self.chat_completion(
[{"role": "user", "content": "你好"}], max_tokens=10
)
return "error" not in result
except Exception as e:
logger.error(f"API连接测试失败: {e}")
except Exception:
return False
# ── 向后兼容别名 ──────────────────────────────────────────
# 旧代码中 `from src.core.llm_client import QwenClient` 仍然能用
QwenClient = LLMClient

View File

@@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, Float, Boolean, ForeignKey
from sqlalchemy import Column, Integer, String, Text, DateTime, Float, Boolean, ForeignKey, Index
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship
from datetime import datetime
@@ -6,11 +6,15 @@ import hashlib
Base = declarative_base()
# 默认租户ID单租户部署时使用
DEFAULT_TENANT = "default"
class WorkOrder(Base):
"""工单模型"""
__tablename__ = "work_orders"
id = Column(Integer, primary_key=True)
tenant_id = Column(String(50), nullable=False, default=DEFAULT_TENANT, index=True)
order_id = Column(String(50), unique=True, nullable=False)
title = Column(String(200), nullable=False)
description = Column(Text, nullable=False)
@@ -63,6 +67,7 @@ class ChatSession(Base):
__tablename__ = "chat_sessions"
id = Column(Integer, primary_key=True)
tenant_id = Column(String(50), nullable=False, default=DEFAULT_TENANT, index=True)
session_id = Column(String(100), unique=True, nullable=False) # 唯一会话标识
user_id = Column(String(100), nullable=True) # 用户标识
work_order_id = Column(Integer, ForeignKey("work_orders.id"), nullable=True)
@@ -100,6 +105,7 @@ class Conversation(Base):
__tablename__ = "conversations"
id = Column(Integer, primary_key=True)
tenant_id = Column(String(50), nullable=False, default=DEFAULT_TENANT, index=True)
session_id = Column(String(100), ForeignKey("chat_sessions.session_id"), nullable=True) # 关联会话
work_order_id = Column(Integer, ForeignKey("work_orders.id"))
user_message = Column(Text, nullable=False)
@@ -124,6 +130,7 @@ class KnowledgeEntry(Base):
__tablename__ = "knowledge_entries"
id = Column(Integer, primary_key=True)
tenant_id = Column(String(50), nullable=False, default=DEFAULT_TENANT, index=True)
question = Column(Text, nullable=False)
answer = Column(Text, nullable=False)
category = Column(String(100), nullable=False)
@@ -164,6 +171,7 @@ class Analytics(Base):
__tablename__ = "analytics"
id = Column(Integer, primary_key=True)
tenant_id = Column(String(50), nullable=False, default=DEFAULT_TENANT, index=True)
date = Column(DateTime, nullable=False)
total_orders = Column(Integer, default=0)
resolved_orders = Column(Integer, default=0)
@@ -184,6 +192,7 @@ class Alert(Base):
__tablename__ = "alerts"
id = Column(Integer, primary_key=True)
tenant_id = Column(String(50), nullable=False, default=DEFAULT_TENANT, index=True)
rule_name = Column(String(100), nullable=False)
alert_type = Column(String(50), nullable=False)
level = Column(String(20), nullable=False) # info, warning, error, critical
@@ -242,6 +251,7 @@ class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
tenant_id = Column(String(50), nullable=False, default=DEFAULT_TENANT, index=True)
username = Column(String(50), unique=True, nullable=False)
password_hash = Column(String(128), nullable=False)
email = Column(String(120), unique=True, nullable=True)

View File

@@ -14,7 +14,7 @@ from ..core.database import db_manager
from ..core.models import Conversation, WorkOrder, WorkOrderSuggestion, KnowledgeEntry, ChatSession
from ..core.redis_manager import redis_manager
from src.config.unified_config import get_config
from sqlalchemy import and_, or_, desc
from sqlalchemy import and_, or_, desc, func, case
logger = logging.getLogger(__name__)
@@ -634,7 +634,8 @@ class ConversationHistoryManager:
def get_conversation_analytics(
self,
work_order_id: Optional[int] = None,
days: int = 7
days: int = 7,
tenant_id: Optional[str] = None
) -> Dict[str, Any]:
"""获取对话分析数据包含AI建议统计"""
try:
@@ -652,6 +653,8 @@ class ConversationHistoryManager:
conv_query = session.query(Conversation)
if work_order_id:
conv_query = conv_query.filter(Conversation.work_order_id == work_order_id)
if tenant_id is not None:
conv_query = conv_query.filter(Conversation.tenant_id == tenant_id)
conversations = conv_query.filter(
Conversation.timestamp >= cutoff_date
@@ -718,6 +721,49 @@ class ConversationHistoryManager:
logger.error(f"获取对话分析数据失败: {e}")
return {}
# ==================== 租户汇总方法 ====================
def get_tenant_summary(self) -> List[Dict[str, Any]]:
"""
按 tenant_id 聚合 ChatSession返回租户汇总列表。
按 last_active_time 降序排列。
数据库异常或无记录时返回空列表。
"""
try:
with db_manager.get_session() as session:
results = session.query(
ChatSession.tenant_id,
func.count(ChatSession.id).label('session_count'),
func.coalesce(func.sum(ChatSession.message_count), 0).label('message_count'),
func.sum(
case(
(ChatSession.status == 'active', 1),
else_=0
)
).label('active_session_count'),
func.max(ChatSession.updated_at).label('last_active_time')
).group_by(
ChatSession.tenant_id
).order_by(
desc('last_active_time')
).all()
summary = []
for row in results:
summary.append({
'tenant_id': row.tenant_id,
'session_count': row.session_count,
'message_count': int(row.message_count),
'active_session_count': int(row.active_session_count),
'last_active_time': row.last_active_time.isoformat() if row.last_active_time else None
})
return summary
except Exception as e:
logger.error(f"获取租户汇总失败: {e}")
return []
# ==================== 会话管理方法 ====================
def get_sessions_paginated(
@@ -726,13 +772,17 @@ class ConversationHistoryManager:
per_page: int = 20,
status: Optional[str] = None,
search: str = '',
date_filter: str = ''
date_filter: str = '',
tenant_id: Optional[str] = None
) -> Dict[str, Any]:
"""分页获取会话列表"""
try:
with db_manager.get_session() as session:
query = session.query(ChatSession)
if tenant_id is not None:
query = query.filter(ChatSession.tenant_id == tenant_id)
if status:
query = query.filter(ChatSession.status == status)

View File

@@ -232,6 +232,98 @@ class RealtimeChatManager:
"confidence": 0.1,
"ai_suggestions": []
}
def _generate_response_stream(self, user_message: str, knowledge_results: List[Dict], context: List[Dict], work_order_id: Optional[int] = None):
"""流式生成回复yield 每个 token 片段"""
try:
ai_suggestions = self._get_workorder_ai_suggestions(work_order_id)
prompt = self._build_chat_prompt(user_message, knowledge_results, context, ai_suggestions)
for chunk in self.llm_client.chat_completion_stream(
messages=[{"role": "user", "content": prompt}],
temperature=0.7,
max_tokens=1000,
):
yield chunk
except Exception as e:
logger.error(f"流式生成回复失败: {e}")
yield "抱歉,系统出现错误,请稍后再试。"
def process_message_stream(self, session_id: str, user_message: str, ip_address: str = None, invocation_method: str = "http_stream"):
"""流式处理用户消息yield SSE 事件"""
import time as _time
if session_id not in self.active_sessions:
yield f"data: {json.dumps({'error': '会话不存在'}, ensure_ascii=False)}\n\n"
return
session = self.active_sessions[session_id]
session["last_activity"] = datetime.now()
session["message_count"] += 1
session["ip_address"] = ip_address
session["invocation_method"] = invocation_method
user_msg = ChatMessage(
role="user",
content=user_message,
timestamp=datetime.now(),
message_id=f"msg_{int(_time.time())}_{session['message_count']}"
)
self.message_history[session_id].append(user_msg)
# 搜索知识 + VIN
knowledge_results = self._search_knowledge(user_message)
vin = self._extract_vin(user_message)
if vin:
latest = self.vehicle_manager.get_latest_vehicle_data_by_vin(vin)
if latest:
knowledge_results = [{
"question": f"VIN {vin} 的最新实时数据",
"answer": json.dumps(latest, ensure_ascii=False),
"similarity_score": 1.0,
"source": "vehicle_realtime"
}] + knowledge_results
knowledge_results = knowledge_results[:5]
# 流式生成
full_content = []
for chunk in self._generate_response_stream(
user_message, knowledge_results, session["context"], session["work_order_id"]
):
full_content.append(chunk)
yield f"data: {json.dumps({'chunk': chunk}, ensure_ascii=False)}\n\n"
# 拼接完整回复
content = "".join(full_content)
confidence = self._calculate_confidence(knowledge_results, content)
# 创建助手消息并保存
assistant_msg = ChatMessage(
role="assistant",
content=content,
timestamp=datetime.now(),
message_id=f"msg_{int(_time.time())}_{session['message_count'] + 1}",
work_order_id=session["work_order_id"],
knowledge_used=knowledge_results,
confidence_score=confidence,
)
self.message_history[session_id].append(assistant_msg)
session["context"].append({"role": "user", "content": user_message})
session["context"].append({"role": "assistant", "content": content})
if len(session["context"]) > 20:
session["context"] = session["context"][-20:]
self._save_conversation(session_id, user_msg, assistant_msg, ip_address, invocation_method)
if knowledge_results:
used_ids = [r["id"] for r in knowledge_results if r.get("id")]
if used_ids:
self.knowledge_manager.update_usage_count(used_ids)
# 发送完成事件
yield f"data: {json.dumps({'done': True, 'confidence_score': confidence, 'message_id': assistant_msg.message_id}, ensure_ascii=False)}\n\n"
def _build_chat_prompt(self, user_message: str, knowledge_results: List[Dict], context: List[Dict], ai_suggestions: List[str] = None) -> str:
"""构建聊天提示词"""

View File

@@ -5,7 +5,7 @@ from datetime import datetime
import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
from sqlalchemy import func
from sqlalchemy import func, Integer
from ..core.database import db_manager
from ..core.models import KnowledgeEntry, WorkOrder, Conversation
@@ -162,24 +162,24 @@ class KnowledgeManager:
logger.error(f"查找相似条目失败: {e}")
return None
def search_knowledge(self, query: str, top_k: int = 3, verified_only: bool = True) -> List[Dict[str, Any]]:
def search_knowledge(self, query: str, top_k: int = 3, verified_only: bool = True, tenant_id: Optional[str] = None) -> List[Dict[str, Any]]:
"""搜索知识库 — 优先使用 embedding 语义检索,降级为关键词匹配"""
try:
# 尝试 embedding 语义检索
if self.embedding_enabled:
results = self._search_by_embedding(query, top_k, verified_only)
results = self._search_by_embedding(query, top_k, verified_only, tenant_id=tenant_id)
if results:
return results
logger.debug("Embedding 检索无结果,降级为关键词匹配")
# 降级:关键词匹配
return self._search_by_keyword(query, top_k, verified_only)
return self._search_by_keyword(query, top_k, verified_only, tenant_id=tenant_id)
except Exception as e:
logger.error(f"搜索知识库失败: {e}")
return []
def _search_by_embedding(self, query: str, top_k: int = 3, verified_only: bool = True) -> List[Dict[str, Any]]:
def _search_by_embedding(self, query: str, top_k: int = 3, verified_only: bool = True, tenant_id: Optional[str] = None) -> List[Dict[str, Any]]:
"""基于 embedding 向量的语义检索"""
try:
query_vec = self.embedding_client.embed_text(query)
@@ -205,6 +205,8 @@ class KnowledgeManager:
KnowledgeEntry.id.in_(candidate_ids),
KnowledgeEntry.is_active == True
)
if tenant_id is not None:
query_filter = query_filter.filter(KnowledgeEntry.tenant_id == tenant_id)
if verified_only:
query_filter = query_filter.filter(KnowledgeEntry.is_verified == True)
@@ -212,10 +214,13 @@ class KnowledgeManager:
# 如果 verified_only 没结果,回退到全部
if not entries and verified_only:
entries = session.query(KnowledgeEntry).filter(
fallback_filter = session.query(KnowledgeEntry).filter(
KnowledgeEntry.id.in_(candidate_ids),
KnowledgeEntry.is_active == True
).all()
)
if tenant_id is not None:
fallback_filter = fallback_filter.filter(KnowledgeEntry.tenant_id == tenant_id)
entries = fallback_filter.all()
results = []
for entry in entries:
@@ -240,7 +245,7 @@ class KnowledgeManager:
logger.error(f"Embedding 搜索失败: {e}")
return []
def _search_by_keyword(self, query: str, top_k: int = 3, verified_only: bool = True) -> List[Dict[str, Any]]:
def _search_by_keyword(self, query: str, top_k: int = 3, verified_only: bool = True, tenant_id: Optional[str] = None) -> List[Dict[str, Any]]:
"""基于关键词的搜索(降级方案)"""
try:
with db_manager.get_session() as session:
@@ -249,6 +254,9 @@ class KnowledgeManager:
KnowledgeEntry.is_active == True
)
if tenant_id is not None:
query_filter = query_filter.filter(KnowledgeEntry.tenant_id == tenant_id)
# 如果只搜索已验证的知识库
if verified_only:
query_filter = query_filter.filter(KnowledgeEntry.is_verified == True)
@@ -256,7 +264,10 @@ class KnowledgeManager:
entries = query_filter.all()
# 若已验证为空,则回退到全部活跃条目
if not entries and verified_only:
entries = session.query(KnowledgeEntry).filter(KnowledgeEntry.is_active == True).all()
fallback_filter = session.query(KnowledgeEntry).filter(KnowledgeEntry.is_active == True)
if tenant_id is not None:
fallback_filter = fallback_filter.filter(KnowledgeEntry.tenant_id == tenant_id)
entries = fallback_filter.all()
if not entries:
logger.warning("知识库中没有活跃条目")
@@ -334,10 +345,14 @@ class KnowledgeManager:
answer: str,
category: str,
confidence_score: float = 0.5,
is_verified: bool = False
is_verified: bool = False,
tenant_id: Optional[str] = None
) -> bool:
"""添加知识库条目"""
try:
# 确定 tenant_id优先使用传入值否则取配置默认值
effective_tenant_id = tenant_id if tenant_id is not None else get_config().server.tenant_id
# 生成 embedding
embedding_json = None
text_for_embedding = question + " " + answer
@@ -354,6 +369,7 @@ class KnowledgeManager:
confidence_score=confidence_score,
usage_count=0,
is_verified=is_verified,
tenant_id=effective_tenant_id,
vector_embedding=embedding_json
)
session.add(entry)
@@ -541,18 +557,23 @@ class KnowledgeManager:
logger.error(f"删除知识库条目失败: {e}")
return False
def get_knowledge_stats(self) -> Dict[str, Any]:
def get_knowledge_stats(self, tenant_id: Optional[str] = None) -> Dict[str, Any]:
"""获取知识库统计信息"""
try:
with db_manager.get_session() as session:
# 基础过滤条件
base_filter = [KnowledgeEntry.is_active == True]
if tenant_id is not None:
base_filter.append(KnowledgeEntry.tenant_id == tenant_id)
# 只统计活跃(未删除)的条目
total_entries = session.query(KnowledgeEntry).filter(
KnowledgeEntry.is_active == True
*base_filter
).count()
# 统计已验证的条目
verified_entries = session.query(KnowledgeEntry).filter(
KnowledgeEntry.is_active == True,
*base_filter,
KnowledgeEntry.is_verified == True
).count()
@@ -561,27 +582,100 @@ class KnowledgeManager:
KnowledgeEntry.category,
func.count(KnowledgeEntry.id)
).filter(
KnowledgeEntry.is_active == True
*base_filter
).group_by(KnowledgeEntry.category).all()
# 平均置信度(仅限活跃条目)
avg_confidence = session.query(
func.avg(KnowledgeEntry.confidence_score)
).filter(
KnowledgeEntry.is_active == True
*base_filter
).scalar() or 0.0
return {
result = {
"total_entries": total_entries,
"active_entries": verified_entries, # 将 active_entries 复用为已验证数量,或前端相应修改
"category_distribution": dict(category_stats),
"average_confidence": float(avg_confidence)
}
if tenant_id is not None:
result["tenant_id"] = tenant_id
return result
except Exception as e:
logger.error(f"获取知识库统计失败: {e}")
return {}
def get_tenant_summary(self) -> List[Dict[str, Any]]:
"""按 tenant_id 聚合活跃知识条目,返回租户汇总列表。
返回格式: [
{
"tenant_id": "market_a",
"entry_count": 42,
"verified_count": 30,
"category_distribution": {"FAQ": 20, "故障排查": 22}
}, ...
]
按 entry_count 降序排列。
"""
try:
with db_manager.get_session() as session:
# 主聚合查询:按 tenant_id 统计 entry_count 和 verified_count
summary_rows = session.query(
KnowledgeEntry.tenant_id,
func.count(KnowledgeEntry.id).label('entry_count'),
func.sum(
func.cast(KnowledgeEntry.is_verified, Integer)
).label('verified_count')
).filter(
KnowledgeEntry.is_active == True
).group_by(
KnowledgeEntry.tenant_id
).order_by(
func.count(KnowledgeEntry.id).desc()
).all()
if not summary_rows:
return []
# 类别分布查询:按 tenant_id + category 统计
category_rows = session.query(
KnowledgeEntry.tenant_id,
KnowledgeEntry.category,
func.count(KnowledgeEntry.id).label('cat_count')
).filter(
KnowledgeEntry.is_active == True
).group_by(
KnowledgeEntry.tenant_id,
KnowledgeEntry.category
).all()
# 构建 tenant_id -> {category: count} 映射
category_map: Dict[str, Dict[str, int]] = {}
for row in category_rows:
if row.tenant_id not in category_map:
category_map[row.tenant_id] = {}
category_map[row.tenant_id][row.category] = row.cat_count
# 组装结果
result = []
for row in summary_rows:
result.append({
"tenant_id": row.tenant_id,
"entry_count": row.entry_count,
"verified_count": int(row.verified_count or 0),
"category_distribution": category_map.get(row.tenant_id, {})
})
return result
except Exception as e:
logger.error(f"获取租户汇总失败: {e}")
return []
def update_usage_count(self, entry_ids: List[int]) -> bool:
"""更新知识库条目的使用次数"""
try:
@@ -602,12 +696,15 @@ class KnowledgeManager:
logger.error(f"更新知识库使用次数失败: {e}")
return False
def get_knowledge_paginated(self, page: int = 1, per_page: int = 10, category_filter: str = '', verified_filter: str = '') -> Dict[str, Any]:
def get_knowledge_paginated(self, page: int = 1, per_page: int = 10, category_filter: str = '', verified_filter: str = '', tenant_id: Optional[str] = None) -> Dict[str, Any]:
"""获取知识库条目(分页和过滤)"""
try:
with db_manager.get_session() as session:
query = session.query(KnowledgeEntry).filter(KnowledgeEntry.is_active == True)
if tenant_id is not None:
query = query.filter(KnowledgeEntry.tenant_id == tenant_id)
if category_filter:
query = query.filter(KnowledgeEntry.category == category_filter)
if verified_filter:

View File

@@ -11,7 +11,7 @@ import logging
from datetime import datetime, timedelta
from typing import Dict, Any
from flask import Flask, render_template, request, jsonify, send_from_directory, make_response, session, redirect, url_for
from flask import Flask, render_template, request, jsonify, send_from_directory, make_response, session, redirect, url_for, Response
from flask_cors import CORS
from src.config.unified_config import get_config
@@ -207,6 +207,33 @@ def send_chat_message():
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route('/api/chat/message/stream', methods=['POST'])
def send_chat_message_stream():
"""流式聊天消息 — SSE 逐 token 推送"""
try:
data = request.get_json()
session_id = data.get('session_id')
message = data.get('message')
if not session_id or not message:
return jsonify({"error": "缺少必要参数"}), 400
chat_mgr = service_manager.get_chat_manager()
def generate():
try:
for event in chat_mgr.process_message_stream(session_id, message):
yield event
except Exception as e:
import json as _json
yield f"data: {_json.dumps({'error': str(e)}, ensure_ascii=False)}\n\n"
return Response(generate(), mimetype='text/event-stream',
headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route('/api/chat/history/<session_id>')
def get_chat_history(session_id):
"""获取对话历史"""

View File

@@ -335,10 +335,12 @@ def get_conversation_analytics():
try:
work_order_id = request.args.get('work_order_id', type=int)
days = request.args.get('days', 7, type=int)
tenant_id = request.args.get('tenant_id')
analytics = history_manager.get_conversation_analytics(
work_order_id=work_order_id,
days=days
days=days,
tenant_id=tenant_id
)
return jsonify({
@@ -351,6 +353,17 @@ def get_conversation_analytics():
return jsonify({"error": str(e)}), 500
@conversations_bp.route('/tenants', methods=['GET'])
def get_tenants():
"""获取租户汇总列表"""
try:
tenants = history_manager.get_tenant_summary()
return jsonify(tenants)
except Exception as e:
logger.error(f"获取租户汇总失败: {e}")
return jsonify({"error": str(e)}), 500
# ==================== 会话管理 API ====================
@conversations_bp.route('/sessions')
@@ -362,13 +375,15 @@ def get_sessions():
status = request.args.get('status', '') # active, ended, 空=全部
search = request.args.get('search', '')
date_filter = request.args.get('date_filter', '')
tenant_id = request.args.get('tenant_id')
result = history_manager.get_sessions_paginated(
page=page,
per_page=per_page,
status=status or None,
search=search,
date_filter=date_filter
date_filter=date_filter,
tenant_id=tenant_id
)
return jsonify(result)

View File

@@ -25,6 +25,18 @@ def get_agent_assistant():
_agent_assistant = TSPAgentAssistant()
return _agent_assistant
@knowledge_bp.route('/tenants')
@handle_api_errors
def get_tenants():
"""获取租户汇总列表"""
try:
result = service_manager.get_assistant().knowledge_manager.get_tenant_summary()
return jsonify(result)
except Exception as e:
logger = logging.getLogger(__name__)
logger.error(f"获取租户汇总失败: {e}")
return create_error_response("获取租户汇总失败", 500)
@knowledge_bp.route('')
@handle_api_errors
def get_knowledge():
@@ -33,12 +45,14 @@ def get_knowledge():
per_page = request.args.get('per_page', 10, type=int)
category_filter = request.args.get('category', '')
verified_filter = request.args.get('verified', '')
tenant_id = request.args.get('tenant_id')
result = service_manager.get_assistant().knowledge_manager.get_knowledge_paginated(
page=page,
per_page=per_page,
category_filter=category_filter,
verified_filter=verified_filter
verified_filter=verified_filter,
tenant_id=tenant_id
)
return jsonify(result)
@@ -47,6 +61,7 @@ def get_knowledge():
def search_knowledge():
"""搜索知识库"""
query = request.args.get('q', '')
tenant_id = request.args.get('tenant_id')
logger = logging.getLogger(__name__)
logger.info(f"搜索查询: '{query}'")
@@ -55,7 +70,7 @@ def search_knowledge():
return jsonify([])
assistant = service_manager.get_assistant()
results = assistant.knowledge_manager.search_knowledge(query, top_k=5)
results = assistant.knowledge_manager.search_knowledge(query, top_k=5, tenant_id=tenant_id)
logger.info(f"搜索结果数量: {len(results)}")
return jsonify(results)
@@ -64,11 +79,13 @@ def search_knowledge():
def add_knowledge():
"""添加知识库条目"""
data = request.get_json()
tenant_id = data.get('tenant_id')
success = service_manager.get_assistant().knowledge_manager.add_knowledge_entry(
question=data['question'],
answer=data['answer'],
category=data['category'],
confidence_score=data.get('confidence_score', 0.8)
confidence_score=data.get('confidence_score', 0.8),
tenant_id=tenant_id
)
if success:
return create_success_response("知识添加成功")
@@ -79,7 +96,8 @@ def add_knowledge():
@handle_api_errors
def get_knowledge_stats():
"""获取知识库统计"""
stats = service_manager.get_assistant().knowledge_manager.get_knowledge_stats()
tenant_id = request.args.get('tenant_id')
stats = service_manager.get_assistant().knowledge_manager.get_knowledge_stats(tenant_id=tenant_id)
return jsonify(stats)
@knowledge_bp.route('/upload', methods=['POST'])

View File

@@ -63,6 +63,42 @@ def get_settings():
except Exception as e:
return jsonify({"error": str(e)}), 500
@system_bp.route('/runtime-config')
def get_runtime_config():
"""获取运行时配置信息(不含敏感信息)"""
try:
from src.config.unified_config import get_config
cfg = get_config()
return jsonify({
"success": True,
"tenant_id": cfg.server.tenant_id,
"llm": {
"provider": cfg.llm.provider,
"model": cfg.llm.model,
"base_url": cfg.llm.base_url or "",
"temperature": cfg.llm.temperature,
"max_tokens": cfg.llm.max_tokens,
"timeout": cfg.llm.timeout,
},
"embedding": {
"enabled": cfg.embedding.enabled,
"model": cfg.embedding.model,
},
"redis": {
"enabled": cfg.redis.enabled,
"host": cfg.redis.host,
},
"server": {
"port": cfg.server.port,
"websocket_port": cfg.server.websocket_port,
"debug": cfg.server.debug,
"log_level": cfg.server.log_level,
},
})
except Exception as e:
return jsonify({"error": str(e)}), 500
@system_bp.route('/settings', methods=['POST'])
def save_settings():
"""保存系统设置"""

View File

@@ -104,28 +104,69 @@ class ChatHttpClient {
this.showTypingIndicator();
try {
const response = await this.sendRequest('POST', '/message', {
session_id: this.sessionId,
message: message
// 使用流式接口
const response = await fetch('/api/chat/message/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: this.sessionId, message: message })
});
this.hideTypingIndicator();
if (response.success) {
// 添加助手回复
this.addMessage('assistant', response.content, {
knowledge_used: response.knowledge_used,
confidence_score: response.confidence_score,
work_order_id: response.work_order_id
});
// 更新工单ID
if (response.work_order_id) {
document.getElementById('work-order-id').value = response.work_order_id;
if (!response.ok) {
this.addMessage('assistant', '请求失败,请稍后再试。');
return;
}
// 创建一个空的助手消息容器用于流式填充
const msgEl = this.addMessage('assistant', '', {}, true);
const contentEl = msgEl.querySelector('.message-content') || msgEl;
let fullContent = '';
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop(); // 保留不完整的行
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const dataStr = line.slice(6).trim();
if (dataStr === '[DONE]') continue;
try {
const data = JSON.parse(dataStr);
if (data.chunk) {
fullContent += data.chunk;
contentEl.textContent = fullContent;
// 自动滚动
const chatMessages = document.getElementById('chat-messages');
if (chatMessages) chatMessages.scrollTop = chatMessages.scrollHeight;
}
if (data.done) {
// 流结束,可以拿到 confidence_score 等元数据
if (data.confidence_score != null) {
msgEl.dataset.confidence = data.confidence_score;
}
}
if (data.error) {
fullContent += `\n[错误: ${data.error}]`;
contentEl.textContent = fullContent;
}
} catch (e) {
// 忽略解析错误
}
}
} else {
this.addMessage('assistant', '抱歉,我暂时无法处理您的问题。请稍后再试。');
}
if (!fullContent) {
contentEl.textContent = '抱歉,我暂时无法处理您的问题。请稍后再试。';
}
} catch (error) {
@@ -199,7 +240,7 @@ class ChatHttpClient {
return await response.json();
}
addMessage(role, content, metadata = {}) {
addMessage(role, content, metadata = {}, streaming = false) {
const messagesContainer = document.getElementById('chat-messages');
// 如果是第一条消息,清空欢迎信息
@@ -216,13 +257,19 @@ class ChatHttpClient {
const contentDiv = document.createElement('div');
contentDiv.className = 'message-content';
contentDiv.innerHTML = content;
if (!streaming) {
contentDiv.innerHTML = content;
} else {
contentDiv.textContent = content;
}
// 添加时间戳
const timeDiv = document.createElement('div');
timeDiv.className = 'message-time';
timeDiv.textContent = new Date().toLocaleTimeString();
contentDiv.appendChild(timeDiv);
if (!streaming) {
contentDiv.appendChild(timeDiv);
}
// 添加元数据
if (metadata.knowledge_used && metadata.knowledge_used.length > 0) {
@@ -258,6 +305,7 @@ class ChatHttpClient {
messagesContainer.scrollTop = messagesContainer.scrollHeight;
this.messageCount++;
return messageDiv;
}
addSystemMessage(content) {

File diff suppressed because it is too large Load Diff

View File

@@ -662,19 +662,22 @@
</div>
</div>
<div class="mb-2">
<small class="text-white-50">当前状态: <span id="agent-current-state">空闲</span></small>
</div>
<div class="mb-2">
<small class="text-white-50">活跃目标: <span id="agent-active-goals">0</span></small>
<small class="text-white-50">运行状态: <span id="agent-current-state" class="badge bg-success">active</span></small>
</div>
<div class="mb-2">
<small class="text-white-50">可用工具: <span id="agent-available-tools">0</span></small>
</div>
<div class="mb-2">
<small class="text-white-50">最大工具轮次: <span id="agent-max-rounds">5</span></small>
</div>
<div class="mb-2">
<small class="text-white-50">执行历史: <span id="agent-history-count">0</span></small>
</div>
</div>
<div class="card mb-3">
<div class="card-header">
<h5><i class="fas fa-tools me-2"></i>工具管理</h5>
<h5><i class="fas fa-tools me-2"></i>ReAct 工具列表</h5>
</div>
<div class="card-body">
<div id="tools-list">
@@ -684,23 +687,6 @@
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h5><i class="fas fa-plus me-2"></i>添加自定义工具</h5>
</div>
<div class="card-body">
<div class="mb-3">
<input type="text" class="form-control" id="tool-name" placeholder="工具名称">
</div>
<div class="mb-3">
<textarea class="form-control" id="tool-description" rows="3" placeholder="工具描述"></textarea>
</div>
<button class="btn btn-primary w-100" id="register-tool">
<i class="fas fa-plus me-1"></i>注册工具
</button>
</div>
</div>
</div>
<div class="col-md-8">
@@ -892,22 +878,27 @@
<!-- 知识库标签页 -->
<div id="knowledge-tab" class="tab-content" style="display: none;">
<!-- 面包屑导航 -->
<div id="knowledge-breadcrumb" class="mb-3"></div>
<div class="row mb-4">
<div class="col-md-8">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5><i class="fas fa-database me-2"></i>知识库管理</h5>
<div class="btn-group">
<button class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#addKnowledgeModal">
<div class="btn-group" id="knowledge-action-buttons">
<button class="btn btn-outline-secondary btn-sm" id="knowledge-refresh-btn" onclick="dashboard.refreshKnowledge()">
<i class="fas fa-sync-alt me-1"></i>刷新
</button>
<button class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#addKnowledgeModal" style="display:none" id="knowledge-add-btn">
<i class="fas fa-plus me-1"></i>添加知识
</button>
<button class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#uploadFileModal">
<button class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#uploadFileModal" style="display:none" id="knowledge-upload-btn">
<i class="fas fa-upload me-1"></i>上传文件
</button>
</div>
</div>
<div class="card-body">
<div class="mb-3">
<div class="mb-3" id="knowledge-search-bar" style="display:none">
<div class="input-group">
<input type="text" class="form-control" id="knowledge-search" placeholder="搜索知识库...">
<button class="btn btn-outline-secondary" id="search-knowledge">
@@ -915,13 +906,26 @@
</button>
</div>
</div>
<div id="knowledge-list">
<!-- 租户卡片列表容器 -->
<div id="knowledge-tenant-list" class="row">
<div class="loading-spinner">
<i class="fas fa-spinner fa-spin"></i>
</div>
</div>
<div id="knowledge-pagination" class="mt-3">
<!-- 分页控件将在这里显示 -->
<!-- 租户详情容器 -->
<div id="knowledge-tenant-detail" style="display:none">
<div class="d-flex gap-2 mb-3" id="knowledge-filter-bar">
<select class="form-select form-select-sm" id="knowledge-category-filter" style="width:auto" onchange="dashboard.applyKnowledgeFilters()">
<option value="">全部分类</option>
</select>
<select class="form-select form-select-sm" id="knowledge-verified-filter" style="width:auto" onchange="dashboard.applyKnowledgeFilters()">
<option value="">全部状态</option>
<option value="true">已验证</option>
<option value="false">未验证</option>
</select>
</div>
<div id="knowledge-list"></div>
<div id="knowledge-pagination" class="mt-3"></div>
</div>
</div>
</div>
@@ -943,7 +947,7 @@
<div class="mb-3">
<small class="text-muted">平均置信度</small>
<div class="progress">
<div class="progress-bar" id="knowledge-confidence" role="progressbar" style="width: 0%"></div>
<div class="progress-bar" id="knowledge-confidence-bar" role="progressbar" style="width: 0%"></div>
</div>
</div>
</div>
@@ -1252,6 +1256,8 @@
<!-- 对话历史标签页 -->
<div id="conversation-history-tab" class="tab-content" style="display: none;">
<!-- 面包屑导航 -->
<div id="conversation-breadcrumb" class="mb-3"></div>
<div class="row mb-4">
<div class="col-md-8">
<div class="card">
@@ -1267,38 +1273,40 @@
</div>
</div>
<div class="card-body">
<div class="mb-3">
<div class="row">
<div class="col-md-4">
<input type="text" class="form-control" id="conversation-search" placeholder="搜索对话内容...">
</div>
<div class="col-md-3">
<select class="form-select" id="conversation-user-filter">
<option value="">全部用户</option>
</select>
</div>
<div class="col-md-3">
<select class="form-select" id="conversation-date-filter">
<option value="">全部时间</option>
<option value="today">今天</option>
<option value="week">本周</option>
<option value="month">本月</option>
</select>
</div>
<div class="col-md-2">
<button class="btn btn-outline-secondary w-100" onclick="dashboard.filterConversations()">
<i class="fas fa-search"></i>
</button>
</div>
</div>
</div>
<div id="conversation-list">
<!-- 租户卡片列表容器 -->
<div id="conversation-tenant-list" class="row">
<div class="loading-spinner">
<i class="fas fa-spinner fa-spin"></i>
</div>
</div>
<div id="conversations-pagination" class="mt-3">
<!-- 分页控件将在这里显示 -->
<!-- 租户详情容器 -->
<div id="conversation-tenant-detail" style="display:none">
<div class="d-flex gap-2 mb-3">
<select class="form-select form-select-sm" id="conversation-status-filter" style="width:auto" onchange="dashboard.loadConversationTenantDetail(dashboard.conversationCurrentTenantId)">
<option value="">全部</option>
<option value="active">活跃</option>
<option value="ended">已结束</option>
</select>
<select class="form-select form-select-sm" id="conversation-detail-date-filter" style="width:auto" onchange="dashboard.loadConversationTenantDetail(dashboard.conversationCurrentTenantId)">
<option value="">全部时间</option>
<option value="today">今天</option>
<option value="week">本周</option>
<option value="month">本月</option>
</select>
<div class="input-group input-group-sm" style="width:auto">
<input type="text" class="form-control" id="conversation-search" placeholder="搜索会话...">
<button class="btn btn-outline-secondary" onclick="dashboard.filterConversations()">
<i class="fas fa-search"></i>
</button>
</div>
</div>
<div id="conversation-session-list"></div>
<div id="conversation-session-pagination" class="mt-3"></div>
</div>
<!-- 保留原有容器用于向后兼容 -->
<div id="conversation-list" style="display:none">
</div>
<div id="conversations-pagination" class="mt-3" style="display:none">
</div>
</div>
</div>
@@ -2085,6 +2093,21 @@
<!-- 系统信息显示 -->
<div class="col-md-6">
<div class="card mb-3">
<div class="card-header">
<h5><i class="fas fa-building me-2"></i>租户与模型信息</h5>
</div>
<div class="card-body">
<table class="table table-sm mb-0">
<tr><td class="text-muted" style="width:40%">租户ID</td><td id="setting-tenant-id">-</td></tr>
<tr><td class="text-muted">LLM Provider</td><td id="setting-llm-provider">-</td></tr>
<tr><td class="text-muted">LLM Model</td><td id="setting-llm-model">-</td></tr>
<tr><td class="text-muted">LLM Base URL</td><td id="setting-llm-base-url" style="word-break:break-all">-</td></tr>
<tr><td class="text-muted">Embedding</td><td id="setting-embedding-status">-</td></tr>
<tr><td class="text-muted">Redis</td><td id="setting-redis-status">-</td></tr>
</table>
</div>
</div>
<div class="card">
<div class="card-header">
<h5><i class="fas fa-info-circle me-2"></i><span data-i18n="settings-system-info">系统信息</span></h5>