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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
"""构建聊天提示词"""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
"""获取对话历史"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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():
|
||||
"""保存系统设置"""
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user