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

@@ -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>