From 7013e9db70dd7cfb9a843d114f5d6026eb86a21a Mon Sep 17 00:00:00 2001 From: Jeason <1710884619@qq.com> Date: Wed, 1 Apr 2026 16:11:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AF=B9=E8=AF=9D=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E7=A7=9F=E6=88=B7=E5=88=86=E7=BB=84=E5=B1=95?= =?UTF-8?q?=E7=A4=BA=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ConversationHistoryManager.get_tenant_summary() 按租户聚合会话统计 - get_sessions_paginated() 和 get_conversation_analytics() 增加 tenant_id 过滤 - 新增 GET /api/conversations/tenants 租户汇总端点 - sessions 和 analytics API 端点支持 tenant_id 查询参数 - 前端实现租户卡片列表视图和租户详情会话表格视图 - 实现面包屑导航、搜索范围限定、统计面板上下文切换 - 会话删除后自动检测空租户并返回列表视图 - dashboard.html 添加租户视图 DOM 容器 - 交互模式与知识库租户分组视图保持一致 --- .env | 3 + .env.example | 3 + .../conversation-tenant-view/.config.kiro | 1 + .../specs/conversation-tenant-view/design.md | 319 ++++++ .../conversation-tenant-view/requirements.md | 116 +++ .kiro/specs/conversation-tenant-view/tasks.md | 142 +++ .../specs/knowledge-tenant-view/.config.kiro | 1 + .kiro/specs/knowledge-tenant-view/design.md | 310 ++++++ .../knowledge-tenant-view/requirements.md | 102 ++ .kiro/specs/knowledge-tenant-view/tasks.md | 157 +++ data/tsp_assistant.db | Bin 196608 -> 196608 bytes init_database.py | 25 + nginx.conf | 13 + scripts/migrate_tenant.py | 58 ++ src/config/unified_config.py | 4 +- src/core/llm_client.py | 201 ++-- src/core/models.py | 12 +- src/dialogue/conversation_history.py | 56 +- src/dialogue/realtime_chat.py | 92 ++ src/knowledge_base/knowledge_manager.py | 131 ++- src/web/app.py | 29 +- src/web/blueprints/conversations.py | 19 +- src/web/blueprints/knowledge.py | 26 +- src/web/blueprints/system.py | 36 + src/web/static/js/chat_http.js | 92 +- src/web/static/js/dashboard.js | 942 ++++++++++++++++-- src/web/templates/dashboard.html | 139 +-- 27 files changed, 2753 insertions(+), 276 deletions(-) create mode 100644 .kiro/specs/conversation-tenant-view/.config.kiro create mode 100644 .kiro/specs/conversation-tenant-view/design.md create mode 100644 .kiro/specs/conversation-tenant-view/requirements.md create mode 100644 .kiro/specs/conversation-tenant-view/tasks.md create mode 100644 .kiro/specs/knowledge-tenant-view/.config.kiro create mode 100644 .kiro/specs/knowledge-tenant-view/design.md create mode 100644 .kiro/specs/knowledge-tenant-view/requirements.md create mode 100644 .kiro/specs/knowledge-tenant-view/tasks.md create mode 100644 scripts/migrate_tenant.py diff --git a/.env b/.env index be2b728..811f54c 100644 --- a/.env +++ b/.env @@ -21,6 +21,9 @@ DEBUG_MODE=False # Logging level for the application. Options: DEBUG, INFO, WARNING, ERROR, CRITICAL LOG_LEVEL=INFO +# 租户标识 — 多项目共用同一套代码时,用不同的 TENANT_ID 隔离数据 +TENANT_ID=default + # ============================================================================ # DATABASE CONFIGURATION diff --git a/.env.example b/.env.example index 72fcf0e..17dfceb 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,9 @@ DEBUG_MODE=False # Logging level for the application. Options: DEBUG, INFO, WARNING, ERROR, CRITICAL LOG_LEVEL=INFO +# 租户标识 — 多项目共用同一套代码时,用不同的 TENANT_ID 隔离数据 +TENANT_ID=default + # ============================================================================ # DATABASE CONFIGURATION diff --git a/.kiro/specs/conversation-tenant-view/.config.kiro b/.kiro/specs/conversation-tenant-view/.config.kiro new file mode 100644 index 0000000..3d29ef0 --- /dev/null +++ b/.kiro/specs/conversation-tenant-view/.config.kiro @@ -0,0 +1 @@ +{"specId": "b7e3c1a2-5f84-4d9e-a1b3-8c6d2e4f7a90", "workflowType": "requirements-first", "specType": "feature"} \ No newline at end of file diff --git a/.kiro/specs/conversation-tenant-view/design.md b/.kiro/specs/conversation-tenant-view/design.md new file mode 100644 index 0000000..56e1732 --- /dev/null +++ b/.kiro/specs/conversation-tenant-view/design.md @@ -0,0 +1,319 @@ +# Design Document: 对话历史租户分组展示 (conversation-tenant-view) + +## Overview + +本设计将对话历史页面从扁平会话列表改造为两层结构:第一层按 `tenant_id` 分组展示租户汇总卡片(会话总数、消息总数、活跃会话数、最近活跃时间),第二层展示某租户下的具体会话列表。点击会话仍可查看消息详情(保留现有第三层功能)。改造涉及三个层面: + +1. **后端 API 层** — 在 `conversations_bp` 中新增租户汇总端点 `GET /api/conversations/tenants`,并为现有 `/api/conversations/sessions` 和 `/api/conversations/analytics` 端点增加 `tenant_id` 查询参数支持。 +2. **业务逻辑层** — 在 `ConversationHistoryManager` 中新增 `get_tenant_summary()` 方法,并为 `get_sessions_paginated()`、`get_conversation_analytics()` 方法增加 `tenant_id` 过滤参数。 +3. **前端展示层** — 在 `dashboard.js` 中实现 `Tenant_List_View` 和 `Tenant_Detail_View` 两个视图状态的切换逻辑,包括面包屑导航、统计面板上下文切换、搜索范围限定。 + +数据模型 `ChatSession` 和 `Conversation` 已有 `tenant_id` 字段(`String(50)`, indexed),无需数据库迁移。 + +交互模式与知识库租户分组视图(knowledge-tenant-view)保持一致。 + +## Architecture + +```mermaid +graph TD + subgraph Frontend["前端 (dashboard.js)"] + TLV[Tenant_List_View
租户卡片列表] + TDV[Tenant_Detail_View
租户会话列表] + MDV[Message_Detail_View
会话消息详情] + Stats[统计面板
全局/租户统计切换] + Breadcrumb[面包屑导航] + end + + subgraph API["Flask Blueprint (conversations_bp)"] + EP1["GET /api/conversations/tenants"] + EP2["GET /api/conversations/sessions?tenant_id=X"] + EP3["GET /api/conversations/analytics?tenant_id=X"] + EP4["GET /api/conversations/sessions/<id>"] + EP5["DELETE /api/conversations/sessions/<id>"] + end + + subgraph Service["ConversationHistoryManager"] + M1[get_tenant_summary] + M2[get_sessions_paginated
+tenant_id filter] + M3[get_conversation_analytics
+tenant_id filter] + M4[get_session_messages] + M5[delete_session] + end + + subgraph DB["SQLAlchemy"] + CS[ChatSession
tenant_id indexed] + CV[Conversation
tenant_id indexed] + end + + TLV -->|点击租户卡片| TDV + TDV -->|点击会话行| MDV + TDV -->|面包屑返回| TLV + MDV -->|面包屑返回| TDV + + TLV --> EP1 + TDV --> EP2 + Stats --> EP3 + MDV --> EP4 + TDV --> EP5 + + EP1 --> M1 + EP2 --> M2 + EP3 --> M3 + EP4 --> M4 + EP5 --> M5 + + M1 --> CS + M2 --> CS + M3 --> CS & CV + M4 --> CV + M5 --> CS & CV +``` + +### 设计决策 + +- **不引入新模型/表**:`tenant_id` 已存在于 `ChatSession` 和 `Conversation`,聚合查询通过 `GROUP BY` 实现,无需额外的 Tenant 表。 +- **视图状态管理在前端**:使用 JS 变量 `conversationCurrentTenantId` 控制当前视图层级,避免引入前端路由框架。与 knowledge-tenant-view 的 `currentTenantId` 模式一致。 +- **统计面板复用**:同一个统计面板根据 `conversationCurrentTenantId` 是否为 `null` 决定请求全局或租户级统计。 +- **搜索范围自动限定**:当处于 `Tenant_Detail_View` 时,搜索请求自动附加 `tenant_id` 参数。 +- **复用现有删除逻辑**:`delete_session()` 已实现删除会话及关联消息,无需修改。 + +## Components and Interfaces + +### 1. ConversationHistoryManager 新增/修改方法 + +```python +# 新增方法 +def get_tenant_summary(self) -> List[Dict[str, Any]]: + """ + 按 tenant_id 聚合 ChatSession,返回租户汇总列表。 + 返回格式: [ + { + "tenant_id": "market_a", + "session_count": 15, + "message_count": 230, + "active_session_count": 5, + "last_active_time": "2026-03-20T10:30:00" + }, ... + ] + 按 last_active_time 降序排列。 + """ + +# 修改方法签名 +def get_sessions_paginated( + self, + page: int = 1, + per_page: int = 20, + status: Optional[str] = None, + search: str = '', + date_filter: str = '', + tenant_id: Optional[str] = None # 新增 +) -> Dict[str, Any] + +def get_conversation_analytics( + self, + work_order_id: Optional[int] = None, + days: int = 7, + tenant_id: Optional[str] = None # 新增 +) -> Dict[str, Any] +``` + +### 2. Conversations API 新增/修改端点 + +| 端点 | 方法 | 变更 | 说明 | +|------|------|------|------| +| `/api/conversations/tenants` | GET | 新增 | 返回租户汇总数组 | +| `/api/conversations/sessions` | GET | 修改 | 增加 `tenant_id` 查询参数 | +| `/api/conversations/analytics` | GET | 修改 | 增加 `tenant_id` 查询参数 | + +现有端点保持不变: +- `GET /api/conversations/sessions/` — 获取会话消息详情 +- `DELETE /api/conversations/sessions/` — 删除会话 + +### 3. 前端组件 + +| 组件/函数 | 职责 | +|-----------|------| +| `loadConversationTenantList()` | 请求 `/api/conversations/tenants`,渲染租户卡片 | +| `loadConversationTenantDetail(tenantId, page)` | 请求 `/api/conversations/sessions?tenant_id=X`,渲染会话列表 | +| `renderConversationBreadcrumb(tenantId, sessionTitle)` | 渲染面包屑 "对话历史 > {tenant_id}" 或 "对话历史 > {tenant_id} > {session_title}" | +| `loadConversationStats(tenantId)` | 根据 tenantId 是否为 null 请求全局/租户统计 | +| `searchConversationSessions()` | 搜索时自动附加 `conversationCurrentTenantId` | + +## Data Models + +### ChatSession(现有,无变更) + +```python +class ChatSession(Base): + __tablename__ = "chat_sessions" + id = Column(Integer, primary_key=True) + tenant_id = Column(String(50), nullable=False, default="default", 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) + title = Column(String(200), nullable=True) + status = Column(String(20), default="active") # active, ended + message_count = Column(Integer, default=0) + source = Column(String(50), nullable=True) + ip_address = Column(String(45), nullable=True) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + ended_at = Column(DateTime, nullable=True) +``` + +### Conversation(现有,无变更) + +```python +class Conversation(Base): + __tablename__ = "conversations" + id = Column(Integer, primary_key=True) + tenant_id = Column(String(50), nullable=False, default="default", 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) + assistant_response = Column(Text, nullable=False) + timestamp = Column(DateTime, default=datetime.now) + confidence_score = Column(Float) + response_time = Column(Float) + # ... 其他字段 +``` + +### Tenant Summary(API 响应结构,非持久化) + +```json +{ + "tenant_id": "market_a", + "session_count": 15, + "message_count": 230, + "active_session_count": 5, + "last_active_time": "2026-03-20T10:30:00" +} +``` + +### Analytics 响应结构(扩展) + +现有 analytics 响应增加 `tenant_id` 字段(仅当按租户筛选时返回),其余结构不变。 + + + +## Correctness Properties + +*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* + +### Property 1: Tenant summary aggregation correctness + +*For any* set of `ChatSession` records with mixed `tenant_id`, `status`, and `message_count` values, calling `get_tenant_summary()` should return a list where each element's `session_count` equals the number of `ChatSession` records for that `tenant_id`, each `message_count` equals the sum of `message_count` fields for that `tenant_id`, and each `active_session_count` equals the count of `ChatSession` records with `status == 'active'` for that `tenant_id`. + +**Validates: Requirements 1.1, 1.2, 1.3, 1.4** + +### Property 2: Tenant summary sorted by last_active_time descending + +*For any* result returned by `get_tenant_summary()`, the list should be sorted such that for every consecutive pair of elements `(a, b)`, `a.last_active_time >= b.last_active_time`. + +**Validates: Requirements 1.5** + +### Property 3: Session filtering by tenant, status, and search + +*For any* combination of `tenant_id`, `status`, and `search` parameters, all sessions returned by `get_sessions_paginated()` should satisfy all specified filter conditions simultaneously. Specifically: every returned session's `tenant_id` matches the requested `tenant_id`, every returned session's `status` matches the `status` filter (if provided), and every returned session's `title` or `session_id` contains the `search` string (if provided). + +**Validates: Requirements 2.1, 2.3** + +### Property 4: Pagination consistency with tenant filter + +*For any* `tenant_id` and valid `page`/`per_page` values, the sessions returned by `get_sessions_paginated(tenant_id=X, page=P, per_page=N)` should be a correct slice of the full filtered result set. The `total` field should equal the count of all matching sessions, `total_pages` should equal `ceil(total / per_page)`, and the number of returned sessions should equal `min(per_page, total - (page-1)*per_page)` when `page <= total_pages`. + +**Validates: Requirements 2.2** + +### Property 5: Session deletion removes session and all associated messages + +*For any* `ChatSession` and its associated `Conversation` records, after calling `delete_session(session_id)`, querying for the `ChatSession` by `session_id` should return no results, and querying for `Conversation` records with that `session_id` should also return no results. + +**Validates: Requirements 6.2** + +### Property 6: Search results scoped to tenant + +*For any* search query and `tenant_id`, all sessions returned by `get_sessions_paginated(search=Q, tenant_id=X)` should have `tenant_id == X`. The result set should be a subset of what `get_sessions_paginated(search=Q)` returns (without tenant filter). + +**Validates: Requirements 7.1, 7.2** + +### Property 7: Analytics scoped to tenant + +*For any* `tenant_id`, the analytics returned by `get_conversation_analytics(tenant_id=X)` should reflect only `ChatSession` and `Conversation` records with `tenant_id == X`. When `tenant_id` is omitted, the analytics should aggregate across all tenants. Specifically, the conversation total count with a tenant filter should be less than or equal to the global total count. + +**Validates: Requirements 8.3, 8.4** + +## Error Handling + +### API 层错误处理 + +所有 API 端点使用 try/except 包裹,捕获异常后返回统一错误格式: + +| 异常场景 | HTTP 状态码 | 响应格式 | +|----------|------------|---------| +| 参数校验失败(如 `page < 1`) | 400 | `{"error": "描述信息"}` | +| 数据库查询异常 | 500 | `{"error": "描述信息"}` | +| 正常但无数据 | 200 | 空数组 `[]` 或 `{"sessions": [], "total": 0}` | + +### 业务逻辑层错误处理 + +- `get_tenant_summary()` — 数据库异常时返回空列表 `[]`,记录 error 日志。 +- `get_sessions_paginated()` — 异常时返回空结构 `{"sessions": [], "total": 0, ...}`(现有行为保持不变)。 +- `get_conversation_analytics()` — 异常时返回空字典 `{}`(现有行为保持不变)。 +- `delete_session()` — 异常时返回 `False`,记录 error 日志(现有行为保持不变)。 + +### 前端错误处理 + +- API 请求失败时通过 `showNotification(message, 'error')` 展示错误提示。 +- 网络超时或断连时显示通用错误消息。 +- 删除操作失败时显示具体失败原因。 + +## Testing Strategy + +### 测试框架 + +- **单元测试**: `pytest` +- **属性测试**: `hypothesis`(Python property-based testing 库) +- **每个属性测试最少运行 100 次迭代** + +### 属性测试(Property-Based Tests) + +每个 Correctness Property 对应一个属性测试,使用 `hypothesis` 的 `@given` 装饰器生成随机输入。 + +测试标签格式: `Feature: conversation-tenant-view, Property {number}: {property_text}` + +| Property | 测试描述 | 生成策略 | +|----------|---------|---------| +| Property 1 | 生成随机 ChatSession 列表(混合 tenant_id、status、message_count),验证 `get_tenant_summary()` 聚合正确性 | `st.lists(st.builds(ChatSession, tenant_id=st.sampled_from([...]), status=st.sampled_from(['active','ended']), message_count=st.integers(min_value=0, max_value=100)))` | +| Property 2 | 验证 `get_tenant_summary()` 返回列表按 last_active_time 降序 | 复用 Property 1 的生成策略 | +| Property 3 | 生成随机 tenant_id + status + search 组合,验证过滤结果一致性 | `st.sampled_from(tenant_ids)`, `st.sampled_from(['active','ended',''])`, `st.text(min_size=0, max_size=20)` | +| Property 4 | 生成随机 page/per_page,验证分页切片正确性 | `st.integers(min_value=1, max_value=10)` for page/per_page | +| Property 5 | 创建随机会话及关联消息,删除后验证两者均不存在 | `st.text(min_size=1, max_size=50)` for session_id, `st.integers(min_value=1, max_value=10)` for message count | +| Property 6 | 生成随机搜索词和 tenant_id,验证搜索结果范围 | `st.text()` for query, `st.sampled_from(tenant_ids)` | +| Property 7 | 生成随机 tenant_id,验证 analytics 数据与手动聚合一致 | `st.sampled_from(tenant_ids)` + `st.none()` | + +### 单元测试(Unit Tests) + +单元测试聚焦于边界情况和具体示例: + +- **边界**: 无 ChatSession 记录时 `get_tenant_summary()` 返回空数组 +- **边界**: 不存在的 `tenant_id` 查询返回空列表 + `total=0` +- **示例**: 数据库异常时 API 返回 500 +- **示例**: 删除最后一个会话后租户从汇总中消失 +- **集成**: 前端 `loadConversationTenantList()` → API → Manager 完整链路 + +### 测试配置 + +```python +from hypothesis import settings + +@settings(max_examples=100) +``` + +每个属性测试函数头部添加注释引用设计文档中的 Property 编号,例如: + +```python +# Feature: conversation-tenant-view, Property 1: Tenant summary aggregation correctness +@given(sessions=st.lists(chat_session_strategy(), min_size=0, max_size=50)) +def test_tenant_summary_aggregation(sessions): + ... +``` diff --git a/.kiro/specs/conversation-tenant-view/requirements.md b/.kiro/specs/conversation-tenant-view/requirements.md new file mode 100644 index 0000000..68ee096 --- /dev/null +++ b/.kiro/specs/conversation-tenant-view/requirements.md @@ -0,0 +1,116 @@ +# Requirements Document + +## Introduction + +对话历史租户分组展示功能。当前对话历史页面以扁平的会话列表展示所有 `ChatSession` 记录,缺乏租户(市场)维度的组织结构。本功能将对话历史页面改造为两层结构:第一层按租户分组展示汇总信息(会话总数、消息总数、最近活跃时间等),第二层展示某个租户下的具体会话列表。点击具体会话仍可查看消息详情(保留现有功能)。交互模式与知识库租户分组视图保持一致,包括卡片视图、面包屑导航、搜索范围限定和统计面板上下文切换。 + +## Glossary + +- **Dashboard**: Flask + Jinja2 + Bootstrap 5 构建的 Web 管理后台主页面(`dashboard.html`) +- **Conversation_Tab**: Dashboard 中 `#conversation-history-tab` 区域,用于展示和管理对话历史 +- **Conversation_API**: Flask Blueprint `conversations_bp`,提供对话相关的 REST API(`/api/conversations/*`) +- **History_Manager**: `ConversationHistoryManager` 类,封装对话历史的数据库查询与业务逻辑 +- **Tenant**: 租户,即市场标识(如 `market_a`、`market_b`),通过 `ChatSession.tenant_id` 字段区分 +- **Tenant_Summary**: 租户汇总信息,包含租户 ID、会话总数、消息总数、活跃会话数、最近活跃时间等聚合数据 +- **Tenant_List_View**: 第一层视图,以卡片形式展示所有租户的对话汇总信息 +- **Tenant_Detail_View**: 第二层视图,展示某个租户下的具体会话列表(含分页、筛选) +- **ChatSession**: SQLAlchemy 数据模型,包含 `tenant_id`、`session_id`、`title`、`status`、`message_count`、`source`、`created_at`、`updated_at` 等字段 +- **Conversation**: SQLAlchemy 数据模型,表示单条对话消息,包含 `tenant_id`、`session_id`、`user_message`、`assistant_response` 等字段 + +## Requirements + +### Requirement 1: 租户汇总 API + +**User Story:** 作为管理员,我希望后端提供按租户分组的对话会话汇总接口,以便前端展示每个租户的对话统计。 + +#### Acceptance Criteria + +1. WHEN a GET request is sent to `/api/conversations/tenants`, THE Conversation_API SHALL return a JSON array of Tenant_Summary objects, each containing `tenant_id`, `session_count`, `message_count`, `active_session_count`, and `last_active_time` +2. THE Conversation_API SHALL compute `session_count` by counting all ChatSession records for each Tenant +3. THE Conversation_API SHALL compute `message_count` by summing the `message_count` field of all ChatSession records for each Tenant +4. THE Conversation_API SHALL compute `active_session_count` by counting ChatSession records with `status == 'active'` for each Tenant +5. THE Conversation_API SHALL sort the Tenant_Summary array by `last_active_time` in descending order +6. WHEN no ChatSession records exist, THE Conversation_API SHALL return an empty JSON array with HTTP status 200 +7. IF a database query error occurs, THEN THE Conversation_API SHALL return an error response with HTTP status 500 and a descriptive error message + +### Requirement 2: 租户会话列表 API + +**User Story:** 作为管理员,我希望后端提供按租户筛选的会话分页接口,以便在点击某个租户后查看该租户下的具体会话列表。 + +#### Acceptance Criteria + +1. WHEN a GET request with query parameter `tenant_id` is sent to `/api/conversations/sessions`, THE Conversation_API SHALL return only the ChatSession records belonging to the specified Tenant +2. THE Conversation_API SHALL support pagination via `page` and `per_page` query parameters when filtering by `tenant_id` +3. THE Conversation_API SHALL support `status` and `search` query parameters for further filtering within a Tenant +4. WHEN the `tenant_id` parameter value does not match any existing ChatSession records, THE Conversation_API SHALL return an empty session list with `total` equal to 0 and HTTP status 200 +5. THE History_Manager SHALL accept `tenant_id` as a filter parameter in the `get_sessions_paginated` method and return paginated results scoped to the specified Tenant + +### Requirement 3: 租户列表视图(第一层) + +**User Story:** 作为管理员,我希望对话历史页面首先展示按租户分组的汇总卡片,以便快速了解各市场的对话活跃度。 + +#### Acceptance Criteria + +1. WHEN the Conversation_Tab is activated, THE Dashboard SHALL display a Tenant_List_View showing one card per Tenant +2. THE Tenant_List_View SHALL display the following information for each Tenant: tenant_id(租户名称), session_count(会话总数), message_count(消息总数), active_session_count(活跃会话数), last_active_time(最近活跃时间) +3. WHEN the Tenant_List_View is loading data, THE Dashboard SHALL display a loading spinner in the Conversation_Tab area +4. WHEN no tenants exist, THE Dashboard SHALL display a placeholder message indicating that no conversation sessions are available +5. THE Tenant_List_View SHALL refresh its data when the user clicks a refresh button + +### Requirement 4: 租户详情视图(第二层) + +**User Story:** 作为管理员,我希望点击某个租户卡片后能查看该租户下的具体会话列表,以便管理和审查对话内容。 + +#### Acceptance Criteria + +1. WHEN a user clicks on a Tenant card in the Tenant_List_View, THE Dashboard SHALL transition to the Tenant_Detail_View showing ChatSession records for the selected Tenant +2. THE Tenant_Detail_View SHALL display each ChatSession with the following fields: title(会话标题), message_count(消息数), status(状态), source(来源), created_at(创建时间), updated_at(最近更新时间) +3. THE Tenant_Detail_View SHALL provide a breadcrumb navigation showing "对话历史 > {tenant_id}" to indicate the current context +4. WHEN the user clicks the breadcrumb "对话历史" link, THE Dashboard SHALL navigate back to the Tenant_List_View +5. THE Tenant_Detail_View SHALL support pagination with configurable page size +6. THE Tenant_Detail_View SHALL support filtering by session status and date range + +### Requirement 5: 会话详情查看(第三层保留) + +**User Story:** 作为管理员,我希望在租户详情视图中点击某个会话后能查看该会话的消息详情,以便审查具体对话内容。 + +#### Acceptance Criteria + +1. WHEN a user clicks on a ChatSession row in the Tenant_Detail_View, THE Dashboard SHALL display the message detail view showing all Conversation records for the selected ChatSession +2. THE Dashboard SHALL retain the existing message detail display logic and UI layout +3. THE Dashboard SHALL provide a breadcrumb navigation showing "对话历史 > {tenant_id} > {session_title}" in the message detail view +4. WHEN the user clicks the breadcrumb "{tenant_id}" link, THE Dashboard SHALL navigate back to the Tenant_Detail_View for the corresponding Tenant + +### Requirement 6: 会话管理操作 + +**User Story:** 作为管理员,我希望在租户详情视图中能对会话执行删除操作,以便维护对话历史数据。 + +#### Acceptance Criteria + +1. WHILE viewing the Tenant_Detail_View, THE Dashboard SHALL provide a delete button for each ChatSession row +2. WHEN a user deletes a ChatSession in the Tenant_Detail_View, THE Conversation_API SHALL delete the ChatSession and all associated Conversation records +3. WHEN a user deletes a ChatSession, THE Dashboard SHALL refresh the Tenant_Detail_View to reflect the updated data +4. WHEN a user deletes all ChatSession records for a Tenant, THE Dashboard SHALL navigate back to the Tenant_List_View and remove the empty Tenant card +5. IF a ChatSession deletion fails, THEN THE Dashboard SHALL display an error notification with the failure reason + +### Requirement 7: 搜索功能适配 + +**User Story:** 作为管理员,我希望在租户详情视图中搜索会话时,搜索范围限定在当前租户内,以便精确查找。 + +#### Acceptance Criteria + +1. WHILE viewing the Tenant_Detail_View, THE Dashboard SHALL scope the session search to the currently selected Tenant +2. WHEN a search query is submitted in the Tenant_Detail_View, THE Conversation_API SHALL filter search results by the specified `tenant_id` +3. WHEN the search query is cleared, THE Dashboard SHALL restore the full paginated session list for the current Tenant +4. THE History_Manager search method SHALL accept an optional `tenant_id` parameter to limit search scope + +### Requirement 8: 统计信息适配 + +**User Story:** 作为管理员,我希望对话历史统计面板在租户列表视图时展示全局统计,在租户详情视图时展示当前租户的统计,以便获取准确的上下文信息。 + +#### Acceptance Criteria + +1. WHILE the Tenant_List_View is displayed, THE Dashboard SHALL show global conversation statistics (total sessions across all tenants, total messages, total active sessions) +2. WHILE the Tenant_Detail_View is displayed, THE Dashboard SHALL show statistics scoped to the selected Tenant +3. WHEN a GET request with query parameter `tenant_id` is sent to `/api/conversations/analytics`, THE Conversation_API SHALL return analytics data filtered by the specified Tenant +4. WHEN the `tenant_id` parameter is omitted from the analytics request, THE Conversation_API SHALL return global analytics across all tenants diff --git a/.kiro/specs/conversation-tenant-view/tasks.md b/.kiro/specs/conversation-tenant-view/tasks.md new file mode 100644 index 0000000..6270e34 --- /dev/null +++ b/.kiro/specs/conversation-tenant-view/tasks.md @@ -0,0 +1,142 @@ +# Implementation Plan: 对话历史租户分组展示 (conversation-tenant-view) + +## Overview + +将对话历史页面从扁平会话列表改造为两层结构:第一层按 `tenant_id` 分组展示租户汇总卡片,第二层展示租户下的会话列表。改造涉及 ConversationHistoryManager 业务逻辑层、Flask API 层、前端 dashboard.js 三个层面。交互模式与知识库租户分组视图保持一致。 + +## Tasks + +- [x] 1. ConversationHistoryManager 新增 get_tenant_summary 方法 + - [x] 1.1 在 `src/dialogue/conversation_history.py` 中新增 `get_tenant_summary()` 方法 + - 使用 SQLAlchemy `GROUP BY ChatSession.tenant_id` 聚合所有 ChatSession 记录 + - 计算每个租户的 `session_count`(会话总数)、`message_count`(消息总数,sum of message_count)、`active_session_count`(status=='active' 的会话数)、`last_active_time`(max of updated_at) + - 按 `last_active_time` 降序排列 + - 数据库异常时返回空列表 `[]`,记录 error 日志 + - 无 ChatSession 记录时返回空列表 `[]` + - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6_ + + - [ ]* 1.2 为 get_tenant_summary 编写属性测试 + - **Property 1: Tenant summary aggregation correctness** + - **Property 2: Tenant summary sorted by last_active_time descending** + - 使用 `hypothesis` 生成随机 ChatSession 列表(混合 tenant_id、status、message_count),验证聚合正确性和排序 + - **Validates: Requirements 1.1, 1.2, 1.3, 1.4, 1.5** + +- [x] 2. ConversationHistoryManager 现有方法增加 tenant_id 过滤 + - [x] 2.1 为 `get_sessions_paginated()` 增加 `tenant_id` 可选参数 + - 在 `src/dialogue/conversation_history.py` 中修改方法签名,增加 `tenant_id: Optional[str] = None` + - 当 `tenant_id` 不为 None 时,在查询中增加 `ChatSession.tenant_id == tenant_id` 过滤条件 + - 返回结构不变,仅过滤范围缩小 + - _Requirements: 2.1, 2.2, 2.3, 2.5_ + + - [ ]* 2.2 为 get_sessions_paginated 的 tenant_id 过滤编写属性测试 + - **Property 3: Session filtering by tenant, status, and search** + - **Property 4: Pagination consistency with tenant filter** + - **Validates: Requirements 2.1, 2.2, 2.3** + + - [x] 2.3 为 `get_conversation_analytics()` 增加 `tenant_id` 可选参数 + - 当 `tenant_id` 不为 None 时,所有统计查询增加 `ChatSession.tenant_id == tenant_id` 和 `Conversation.tenant_id == tenant_id` 过滤 + - 返回结构不变 + - _Requirements: 8.3, 8.4_ + + - [ ]* 2.4 为 get_conversation_analytics 的 tenant_id 过滤编写属性测试 + - **Property 7: Analytics scoped to tenant** + - **Validates: Requirements 8.3, 8.4** + +- [x] 3. Checkpoint - 确保后端业务逻辑层完成 + - Ensure all tests pass, ask the user if questions arise. + +- [x] 4. Conversations API 层新增和修改端点 + - [x] 4.1 在 `src/web/blueprints/conversations.py` 中新增 `GET /api/conversations/tenants` 端点 + - 调用 `history_manager.get_tenant_summary()` 返回租户汇总 JSON 数组 + - 使用 try/except 包裹,异常时返回 HTTP 500 + - _Requirements: 1.1, 1.5, 1.6, 1.7_ + + - [x] 4.2 修改 `GET /api/conversations/sessions` 端点,增加 `tenant_id` 查询参数支持 + - 从 `request.args` 获取 `tenant_id` 参数,传递给 `history_manager.get_sessions_paginated()` + - _Requirements: 2.1, 2.2, 2.3, 2.4_ + + - [x] 4.3 修改 `GET /api/conversations/analytics` 端点,增加 `tenant_id` 查询参数支持 + - 从 `request.args` 获取 `tenant_id` 参数,传递给 `history_manager.get_conversation_analytics()` + - _Requirements: 8.3, 8.4_ + + - [ ]* 4.4 为新增和修改的 API 端点编写单元测试 + - 测试 `/api/conversations/tenants` 返回正确的汇总数据 + - 测试各端点的 `tenant_id` 参数过滤行为 + - 测试空数据和异常情况 + - _Requirements: 1.1, 1.6, 1.7, 2.4_ + +- [x] 5. Checkpoint - 确保后端 API 层完成 + - Ensure all tests pass, ask the user if questions arise. + +- [x] 6. 前端 Tenant_List_View(租户列表视图) + - [x] 6.1 在 `src/web/static/js/dashboard.js` 中实现 `loadConversationTenantList()` 函数 + - 请求 `GET /api/conversations/tenants` 获取租户汇总数据 + - 渲染租户卡片列表,每张卡片展示 `tenant_id`、`session_count`、`message_count`、`active_session_count`、`last_active_time` + - 添加加载中 spinner 状态 + - 无租户时展示空状态占位提示 + - 卡片点击事件绑定,调用 `loadConversationTenantDetail(tenantId)` + - _Requirements: 3.1, 3.2, 3.3, 3.4_ + + - [x] 6.2 实现刷新按钮功能 + - 在对话历史 tab 区域添加刷新按钮,点击时重新调用 `loadConversationTenantList()` + - _Requirements: 3.5_ + +- [x] 7. 前端 Tenant_Detail_View(租户详情视图) + - [x] 7.1 实现 `loadConversationTenantDetail(tenantId, page)` 函数 + - 请求 `GET /api/conversations/sessions?tenant_id=X&page=P&per_page=N` 获取会话列表 + - 渲染会话表格,展示 title、message_count、status、source、created_at、updated_at + - 实现分页控件 + - 支持 status 和 date_filter 筛选 + - _Requirements: 4.1, 4.2, 4.5, 4.6_ + + - [x] 7.2 实现面包屑导航 `renderConversationBreadcrumb(tenantId, sessionTitle)` + - 展示 "对话历史 > {tenant_id}" 面包屑(租户详情视图) + - 展示 "对话历史 > {tenant_id} > {session_title}" 面包屑(消息详情视图) + - 点击 "对话历史" 链接时调用 `loadConversationTenantList()` 返回租户列表视图 + - 点击 "{tenant_id}" 链接时调用 `loadConversationTenantDetail(tenantId)` 返回租户详情视图 + - 管理 `conversationCurrentTenantId` 状态变量控制视图层级 + - _Requirements: 4.3, 4.4, 5.3, 5.4_ + + - [x] 7.3 在 Tenant_Detail_View 中集成会话管理操作 + - 每行会话提供删除按钮,调用 `DELETE /api/conversations/sessions/` + - 删除成功后刷新当前租户详情视图 + - 删除所有会话后自动返回租户列表视图并移除空租户卡片 + - 操作失败时通过 `showNotification` 展示错误提示 + - _Requirements: 6.1, 6.2, 6.3, 6.4, 6.5_ + + - [ ]* 7.4 为删除操作编写属性测试 + - **Property 5: Session deletion removes session and all associated messages** + - **Validates: Requirements 6.2** + +- [x] 8. 前端搜索和统计面板适配 + - [x] 8.1 修改搜索功能 `searchConversationSessions()` + - 在 Tenant_Detail_View 中搜索时自动附加 `tenant_id` 参数 + - 清空搜索时恢复当前租户的完整分页列表 + - _Requirements: 7.1, 7.2, 7.3_ + + - [ ]* 8.2 为搜索范围限定编写属性测试 + - **Property 6: Search results scoped to tenant** + - **Validates: Requirements 7.1, 7.2** + + - [x] 8.3 修改 `loadConversationStats(tenantId)` 函数 + - 当 `conversationCurrentTenantId` 为 null 时请求全局统计 + - 当 `conversationCurrentTenantId` 有值时请求 `GET /api/conversations/analytics?tenant_id=X` + - _Requirements: 8.1, 8.2_ + +- [x] 9. 前端 HTML 模板更新 + - [x] 9.1 在 `src/web/templates/dashboard.html` 的 `#conversation-history-tab` 区域添加必要的 DOM 容器 + - 添加面包屑容器、租户卡片列表容器、租户详情容器 + - 确保与现有 Bootstrap 5 样式一致,与知识库租户视图风格统一 + - _Requirements: 3.1, 4.3_ + +- [x] 10. Final checkpoint - 确保所有功能集成完成 + - Ensure all tests pass, ask the user if questions arise. + +## Notes + +- Tasks marked with `*` are optional and can be skipped for faster MVP +- Each task references specific requirements for traceability +- Checkpoints ensure incremental validation +- Property tests validate universal correctness properties from the design document +- 数据模型 `ChatSession` 和 `Conversation` 已有 `tenant_id` 字段且已建索引,无需数据库迁移 +- 交互模式与知识库租户分组视图 (knowledge-tenant-view) 保持一致 diff --git a/.kiro/specs/knowledge-tenant-view/.config.kiro b/.kiro/specs/knowledge-tenant-view/.config.kiro new file mode 100644 index 0000000..811fee9 --- /dev/null +++ b/.kiro/specs/knowledge-tenant-view/.config.kiro @@ -0,0 +1 @@ +{"specId": "0d6981a4-ab44-429e-966d-0874ce82383c", "workflowType": "requirements-first", "specType": "feature"} diff --git a/.kiro/specs/knowledge-tenant-view/design.md b/.kiro/specs/knowledge-tenant-view/design.md new file mode 100644 index 0000000..d745c5e --- /dev/null +++ b/.kiro/specs/knowledge-tenant-view/design.md @@ -0,0 +1,310 @@ +# Design Document: 知识库租户分组展示 (knowledge-tenant-view) + +## Overview + +本设计将知识库管理页面从扁平列表改造为两层结构:第一层按 `tenant_id` 分组展示租户汇总卡片,第二层展示某租户下的知识条目列表。改造涉及三个层面: + +1. **后端 API 层** — 在 `knowledge_bp` 中新增租户汇总端点 `/api/knowledge/tenants`,并为现有 `/api/knowledge` 和 `/api/knowledge/stats` 端点增加 `tenant_id` 查询参数支持。 +2. **业务逻辑层** — 在 `KnowledgeManager` 中新增 `get_tenant_summary()` 方法,并为 `get_knowledge_paginated()`、`search_knowledge()`、`get_knowledge_stats()` 方法增加 `tenant_id` 过滤参数。`add_knowledge_entry()` 方法也需接受 `tenant_id` 参数。 +3. **前端展示层** — 在 `dashboard.js` 中实现 `Tenant_List_View` 和 `Tenant_Detail_View` 两个视图状态的切换逻辑,包括面包屑导航、统计面板上下文切换、搜索范围限定。 + +数据模型 `KnowledgeEntry` 已有 `tenant_id` 字段(`String(50)`, indexed),无需数据库迁移。 + +## Architecture + +```mermaid +graph TD + subgraph Frontend["前端 (dashboard.js)"] + TLV[Tenant_List_View
租户卡片列表] + TDV[Tenant_Detail_View
租户知识条目列表] + Stats[统计面板
全局/租户统计切换] + Breadcrumb[面包屑导航] + end + + subgraph API["Flask Blueprint (knowledge_bp)"] + EP1["GET /api/knowledge/tenants"] + EP2["GET /api/knowledge?tenant_id=X"] + EP3["GET /api/knowledge/stats?tenant_id=X"] + EP4["GET /api/knowledge/search?q=...&tenant_id=X"] + EP5["POST /api/knowledge (含 tenant_id)"] + end + + subgraph Service["KnowledgeManager"] + M1[get_tenant_summary] + M2[get_knowledge_paginated
+tenant_id filter] + M3[get_knowledge_stats
+tenant_id filter] + M4[search_knowledge
+tenant_id filter] + M5[add_knowledge_entry
+tenant_id param] + end + + subgraph DB["SQLAlchemy"] + KE[KnowledgeEntry
tenant_id indexed] + end + + TLV -->|点击租户卡片| TDV + TDV -->|面包屑返回| TLV + TLV --> EP1 + TDV --> EP2 + TDV --> EP4 + Stats --> EP3 + TDV --> EP5 + + EP1 --> M1 + EP2 --> M2 + EP3 --> M3 + EP4 --> M4 + EP5 --> M5 + + M1 --> KE + M2 --> KE + M3 --> KE + M4 --> KE + M5 --> KE +``` + +### 设计决策 + +- **不引入新模型/表**:`tenant_id` 已存在于 `KnowledgeEntry`,聚合查询通过 `GROUP BY` 实现,无需额外的 Tenant 表。 +- **视图状态管理在前端**:使用 JS 变量 `currentTenantId` 控制当前视图层级,避免引入前端路由框架。 +- **统计面板复用**:同一个统计面板根据 `currentTenantId` 是否为 `null` 决定请求全局或租户级统计。 +- **搜索范围自动限定**:当处于 `Tenant_Detail_View` 时,搜索请求自动附加 `tenant_id` 参数。 + +## Components and Interfaces + +### 1. KnowledgeManager 新增/修改方法 + +```python +# 新增方法 +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 降序排列。 + """ + +# 修改方法签名 +def get_knowledge_paginated( + self, page=1, per_page=10, + category_filter='', verified_filter='', + tenant_id: Optional[str] = None # 新增 +) -> Dict[str, Any] + +def search_knowledge( + self, query: str, top_k=3, + verified_only=True, + tenant_id: Optional[str] = None # 新增 +) -> List[Dict[str, Any]] + +def get_knowledge_stats( + self, + tenant_id: Optional[str] = None # 新增 +) -> Dict[str, Any] + +def add_knowledge_entry( + self, question, answer, category, + confidence_score=0.5, is_verified=False, + tenant_id: Optional[str] = None # 新增,默认取 config +) -> bool +``` + +### 2. Knowledge API 新增/修改端点 + +| 端点 | 方法 | 变更 | 说明 | +|------|------|------|------| +| `/api/knowledge/tenants` | GET | 新增 | 返回租户汇总数组 | +| `/api/knowledge` | GET | 修改 | 增加 `tenant_id` 查询参数 | +| `/api/knowledge/stats` | GET | 修改 | 增加 `tenant_id` 查询参数 | +| `/api/knowledge/search` | GET | 修改 | 增加 `tenant_id` 查询参数 | +| `/api/knowledge` | POST | 修改 | 请求体增加 `tenant_id` 字段 | + +### 3. 前端组件 + +| 组件 | 职责 | +|------|------| +| `loadTenantList()` | 请求 `/api/knowledge/tenants`,渲染租户卡片 | +| `loadTenantDetail(tenantId, page)` | 请求 `/api/knowledge?tenant_id=X`,渲染知识条目列表 | +| `renderBreadcrumb(tenantId)` | 渲染面包屑 "知识库 > {tenant_id}" | +| `loadKnowledgeStats(tenantId)` | 根据 tenantId 是否为 null 请求全局/租户统计 | +| `searchKnowledge()` | 搜索时自动附加 `currentTenantId` | + +## Data Models + +### KnowledgeEntry(现有,无变更) + +```python +class KnowledgeEntry(Base): + __tablename__ = "knowledge_entries" + id = Column(Integer, primary_key=True) + tenant_id = Column(String(50), nullable=False, default="default", index=True) + question = Column(Text, nullable=False) + answer = Column(Text, nullable=False) + category = Column(String(100), nullable=False) + confidence_score = Column(Float, default=0.0) + usage_count = Column(Integer, default=0) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + is_active = Column(Boolean, default=True) + is_verified = Column(Boolean, default=False) + verified_by = Column(String(100)) + verified_at = Column(DateTime) + vector_embedding = Column(Text) + search_frequency = Column(Integer, default=0) + last_accessed = Column(DateTime) + relevance_score = Column(Float) +``` + +### Tenant Summary(API 响应结构,非持久化) + +```json +{ + "tenant_id": "market_a", + "entry_count": 42, + "verified_count": 30, + "category_distribution": { + "FAQ": 20, + "故障排查": 22 + } +} +``` + +### Stats 响应结构(扩展) + +```json +{ + "total_entries": 100, + "active_entries": 80, + "category_distribution": {"FAQ": 40, "故障排查": 60}, + "average_confidence": 0.85, + "tenant_id": "market_a" // 新增,仅当按租户筛选时返回 +} +``` + + +## Correctness Properties + +*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* + +### Property 1: Tenant summary correctly aggregates active entries + +*For any* set of `KnowledgeEntry` records with mixed `is_active` and `tenant_id` values, calling `get_tenant_summary()` should return a list where each element's `entry_count` equals the number of active entries for that `tenant_id`, each `verified_count` equals the number of active+verified entries for that `tenant_id`, and each `category_distribution` correctly reflects the category counts of active entries for that `tenant_id`. + +**Validates: Requirements 1.1, 1.2** + +### Property 2: Tenant summary sorted by entry_count descending + +*For any* result returned by `get_tenant_summary()`, the list should be sorted such that for every consecutive pair of elements `(a, b)`, `a.entry_count >= b.entry_count`. + +**Validates: Requirements 1.3** + +### Property 3: Knowledge entry filtering by tenant, category, and verified status + +*For any* combination of `tenant_id`, `category_filter`, and `verified_filter` parameters, all entries returned by `get_knowledge_paginated()` should satisfy all specified filter conditions simultaneously. Specifically: every returned entry's `tenant_id` matches the requested `tenant_id`, every returned entry's `category` matches `category_filter` (if provided), and every returned entry's `is_verified` matches `verified_filter` (if provided). + +**Validates: Requirements 2.1, 2.3** + +### Property 4: Pagination consistency with tenant filter + +*For any* `tenant_id` and valid `page`/`per_page` values, the entries returned by `get_knowledge_paginated(tenant_id=X, page=P, per_page=N)` should be a correct slice of the full filtered result set. The `total` field should equal the count of all matching entries, `total_pages` should equal `ceil(total / per_page)`, and the number of returned entries should equal `min(per_page, total - (page-1)*per_page)` when `page <= total_pages`. + +**Validates: Requirements 2.2** + +### Property 5: New entry tenant association + +*For any* valid `tenant_id` and valid entry data (question, answer, category), calling `add_knowledge_entry(tenant_id=X, ...)` should result in the newly created `KnowledgeEntry` record having `tenant_id == X`. If `tenant_id` is not provided, it should default to the configured `get_config().server.tenant_id`. + +**Validates: Requirements 5.2** + +### Property 6: Search results scoped to tenant + +*For any* search query and `tenant_id`, all results returned by `search_knowledge(query=Q, tenant_id=X)` should have `tenant_id == X`. The result set should be a subset of what `search_knowledge(query=Q)` returns (without tenant filter). + +**Validates: Requirements 6.2** + +### Property 7: Stats scoped to tenant + +*For any* `tenant_id`, the statistics returned by `get_knowledge_stats(tenant_id=X)` should reflect only entries with `tenant_id == X`. Specifically, `total_entries` should equal the count of active entries for that tenant, and `average_confidence` should equal the mean confidence of those entries. When `tenant_id` is omitted, the stats should aggregate across all tenants. + +**Validates: Requirements 7.3, 7.4** + +## Error Handling + +### API 层错误处理 + +所有 API 端点已使用 `@handle_api_errors` 装饰器,该装饰器捕获以下异常: + +| 异常类型 | HTTP 状态码 | 说明 | +|----------|------------|------| +| `ValueError` | 400 | 参数校验失败(如 `page < 1`) | +| `PermissionError` | 403 | 权限不足 | +| `Exception` | 500 | 数据库查询失败等未预期错误 | + +### 业务逻辑层错误处理 + +- `get_tenant_summary()` — 数据库异常时返回空列表 `[]`,记录 error 日志。 +- `get_knowledge_paginated()` — 异常时返回空结构 `{"knowledge": [], "total": 0, ...}`(现有行为保持不变)。 +- `get_knowledge_stats()` — 异常时返回空字典 `{}`(现有行为保持不变)。 +- `add_knowledge_entry()` — 异常时返回 `False`,记录 error 日志。 + +### 前端错误处理 + +- API 请求失败时通过 `showNotification(message, 'error')` 展示错误提示。 +- 网络超时或断连时显示通用错误消息。 +- 批量操作部分失败时显示成功/失败计数。 + +## Testing Strategy + +### 测试框架 + +- **单元测试**: `pytest` +- **属性测试**: `hypothesis`(Python property-based testing 库) +- **每个属性测试最少运行 100 次迭代** + +### 属性测试(Property-Based Tests) + +每个 Correctness Property 对应一个属性测试,使用 `hypothesis` 的 `@given` 装饰器生成随机输入。 + +测试标签格式: `Feature: knowledge-tenant-view, Property {number}: {property_text}` + +| Property | 测试描述 | 生成策略 | +|----------|---------|---------| +| Property 1 | 生成随机 KnowledgeEntry 列表(混合 tenant_id、is_active),验证 `get_tenant_summary()` 聚合正确性 | `st.lists(st.builds(KnowledgeEntry, tenant_id=st.sampled_from([...]), is_active=st.booleans()))` | +| Property 2 | 验证 `get_tenant_summary()` 返回列表按 entry_count 降序 | 复用 Property 1 的生成策略 | +| Property 3 | 生成随机 tenant_id + category + verified 组合,验证过滤结果一致性 | `st.sampled_from(tenant_ids)`, `st.sampled_from(categories)`, `st.sampled_from(['true','false',''])` | +| Property 4 | 生成随机 page/per_page,验证分页切片正确性 | `st.integers(min_value=1, max_value=10)` for page/per_page | +| Property 5 | 生成随机 tenant_id 和条目数据,验证新建条目的 tenant_id | `st.text(min_size=1, max_size=50)` for tenant_id | +| Property 6 | 生成随机搜索词和 tenant_id,验证搜索结果范围 | `st.text()` for query, `st.sampled_from(tenant_ids)` | +| Property 7 | 生成随机 tenant_id,验证统计数据与手动聚合一致 | `st.sampled_from(tenant_ids)` + `st.none()` | + +### 单元测试(Unit Tests) + +单元测试聚焦于边界情况和具体示例: + +- **边界**: 无活跃条目时 `get_tenant_summary()` 返回空数组 +- **边界**: 不存在的 `tenant_id` 查询返回空列表 + `total=0` +- **示例**: 数据库异常时 API 返回 500 +- **示例**: `add_knowledge_entry` 不传 `tenant_id` 时使用配置默认值 +- **集成**: 前端 `loadTenantList()` → API → Manager 完整链路 + +### 测试配置 + +```python +from hypothesis import settings + +@settings(max_examples=100) +``` + +每个属性测试函数头部添加注释引用设计文档中的 Property 编号,例如: + +```python +# Feature: knowledge-tenant-view, Property 1: Tenant summary correctly aggregates active entries +@given(entries=st.lists(knowledge_entry_strategy(), min_size=0, max_size=50)) +def test_tenant_summary_aggregation(entries): + ... +``` diff --git a/.kiro/specs/knowledge-tenant-view/requirements.md b/.kiro/specs/knowledge-tenant-view/requirements.md new file mode 100644 index 0000000..8d78f08 --- /dev/null +++ b/.kiro/specs/knowledge-tenant-view/requirements.md @@ -0,0 +1,102 @@ +# Requirements Document + +## Introduction + +知识库租户分组展示功能。当前知识库管理页面以扁平列表展示所有知识条目,缺乏租户(市场)维度的组织结构。本功能将知识库页面改造为两层结构:第一层按租户分组展示汇总信息,第二层展示某个租户下的具体知识条目。数据库模型 `KnowledgeEntry` 已有 `tenant_id` 字段,后端需新增按租户聚合的 API,前端需实现分组视图与钻取交互。 + +## Glossary + +- **Dashboard**: Flask + Jinja2 + Bootstrap 5 构建的 Web 管理后台主页面(`dashboard.html`) +- **Knowledge_Tab**: Dashboard 中 `#knowledge-tab` 区域,用于展示和管理知识库条目 +- **Knowledge_API**: Flask Blueprint `knowledge_bp`,提供知识库相关的 REST API(`/api/knowledge/*`) +- **Knowledge_Manager**: `KnowledgeManager` 类,封装知识库的数据库查询与业务逻辑 +- **Tenant**: 租户,即市场标识(如 `market_a`、`market_b`),通过 `KnowledgeEntry.tenant_id` 字段区分 +- **Tenant_Summary**: 租户汇总信息,包含租户 ID、知识条目总数等聚合数据 +- **Tenant_List_View**: 第一层视图,以卡片或列表形式展示所有租户的汇总信息 +- **Tenant_Detail_View**: 第二层视图,展示某个租户下的具体知识条目列表(含分页、筛选) +- **KnowledgeEntry**: SQLAlchemy 数据模型,包含 `tenant_id`、`question`、`answer`、`category`、`confidence_score`、`usage_count`、`is_verified` 等字段 + +## Requirements + +### Requirement 1: 租户汇总 API + +**User Story:** 作为管理员,我希望后端提供按租户分组的知识库汇总接口,以便前端展示每个租户的知识条目统计。 + +#### Acceptance Criteria + +1. WHEN a GET request is sent to `/api/knowledge/tenants`, THE Knowledge_API SHALL return a JSON array of Tenant_Summary objects, each containing `tenant_id`, `entry_count`, `verified_count`, and `category_distribution` +2. THE Knowledge_API SHALL only count active knowledge entries (`is_active == True`) in the Tenant_Summary aggregation +3. THE Knowledge_API SHALL sort the Tenant_Summary array by `entry_count` in descending order +4. WHEN no active knowledge entries exist, THE Knowledge_API SHALL return an empty JSON array with HTTP status 200 +5. IF a database query error occurs, THEN THE Knowledge_API SHALL return an error response with HTTP status 500 and a descriptive error message + +### Requirement 2: 租户条目列表 API + +**User Story:** 作为管理员,我希望后端提供按租户筛选的知识条目分页接口,以便在点击某个租户后查看该租户下的具体知识条目。 + +#### Acceptance Criteria + +1. WHEN a GET request with query parameter `tenant_id` is sent to `/api/knowledge`, THE Knowledge_API SHALL return only the knowledge entries belonging to the specified Tenant +2. THE Knowledge_API SHALL support pagination via `page` and `per_page` query parameters when filtering by `tenant_id` +3. THE Knowledge_API SHALL support `category` and `verified` query parameters for further filtering within a Tenant +4. WHEN the `tenant_id` parameter value does not match any existing entries, THE Knowledge_API SHALL return an empty knowledge list with `total` equal to 0 and HTTP status 200 +5. THE Knowledge_Manager SHALL provide a method that accepts `tenant_id` as a filter parameter and returns paginated results + +### Requirement 3: 租户列表视图(第一层) + +**User Story:** 作为管理员,我希望知识库页面首先展示按租户分组的汇总卡片,以便快速了解各市场的知识库规模。 + +#### Acceptance Criteria + +1. WHEN the Knowledge_Tab is activated, THE Dashboard SHALL display a Tenant_List_View showing one card per Tenant +2. THE Tenant_List_View SHALL display the following information for each Tenant: tenant_id(租户名称), entry_count(知识条目总数), verified_count(已验证条目数) +3. WHEN the Tenant_List_View is loading data, THE Dashboard SHALL display a loading spinner in the Knowledge_Tab area +4. WHEN no tenants exist, THE Dashboard SHALL display a placeholder message indicating that no knowledge entries are available +5. THE Tenant_List_View SHALL refresh its data when the user clicks a refresh button + +### Requirement 4: 租户详情视图(第二层) + +**User Story:** 作为管理员,我希望点击某个租户卡片后能查看该租户下的具体知识条目列表,以便管理和审核知识内容。 + +#### Acceptance Criteria + +1. WHEN a user clicks on a Tenant card in the Tenant_List_View, THE Dashboard SHALL transition to the Tenant_Detail_View showing knowledge entries for the selected Tenant +2. THE Tenant_Detail_View SHALL display each knowledge entry with the following fields: question, answer, category, confidence_score, usage_count, is_verified status +3. THE Tenant_Detail_View SHALL provide a breadcrumb navigation showing "知识库 > {tenant_id}" to indicate the current context +4. WHEN the user clicks the breadcrumb "知识库" link, THE Dashboard SHALL navigate back to the Tenant_List_View +5. THE Tenant_Detail_View SHALL support pagination with configurable page size +6. THE Tenant_Detail_View SHALL support filtering by category and verification status + +### Requirement 5: 租户详情视图中的知识条目操作 + +**User Story:** 作为管理员,我希望在租户详情视图中能对知识条目执行添加、删除、验证等操作,以便维护知识库内容。 + +#### Acceptance Criteria + +1. WHILE viewing the Tenant_Detail_View, THE Dashboard SHALL provide buttons for adding, deleting, verifying, and unverifying knowledge entries +2. WHEN a user adds a new knowledge entry in the Tenant_Detail_View, THE Knowledge_API SHALL associate the new entry with the currently selected Tenant by setting the `tenant_id` field +3. WHEN a user performs a batch operation (batch delete, batch verify, batch unverify) in the Tenant_Detail_View, THE Dashboard SHALL refresh the Tenant_Detail_View to reflect the updated data +4. WHEN a user deletes all entries for a Tenant, THE Dashboard SHALL navigate back to the Tenant_List_View and remove the empty Tenant card +5. IF a knowledge entry operation fails, THEN THE Dashboard SHALL display an error notification with the failure reason + +### Requirement 6: 搜索功能适配 + +**User Story:** 作为管理员,我希望在租户详情视图中搜索知识条目时,搜索范围限定在当前租户内,以便精确查找。 + +#### Acceptance Criteria + +1. WHILE viewing the Tenant_Detail_View, THE Dashboard SHALL scope the knowledge search to the currently selected Tenant +2. WHEN a search query is submitted in the Tenant_Detail_View, THE Knowledge_API SHALL filter search results by the specified `tenant_id` +3. WHEN the search query is cleared, THE Dashboard SHALL restore the full paginated list for the current Tenant +4. THE Knowledge_Manager search method SHALL accept an optional `tenant_id` parameter to limit search scope + +### Requirement 7: 统计信息适配 + +**User Story:** 作为管理员,我希望知识库统计面板在租户列表视图时展示全局统计,在租户详情视图时展示当前租户的统计,以便获取准确的上下文信息。 + +#### Acceptance Criteria + +1. WHILE the Tenant_List_View is displayed, THE Dashboard SHALL show global knowledge statistics (total entries across all tenants, total verified entries, average confidence) +2. WHILE the Tenant_Detail_View is displayed, THE Dashboard SHALL show statistics scoped to the selected Tenant +3. WHEN a GET request with query parameter `tenant_id` is sent to `/api/knowledge/stats`, THE Knowledge_API SHALL return statistics filtered by the specified Tenant +4. WHEN the `tenant_id` parameter is omitted from the stats request, THE Knowledge_API SHALL return global statistics across all tenants diff --git a/.kiro/specs/knowledge-tenant-view/tasks.md b/.kiro/specs/knowledge-tenant-view/tasks.md new file mode 100644 index 0000000..682e76d --- /dev/null +++ b/.kiro/specs/knowledge-tenant-view/tasks.md @@ -0,0 +1,157 @@ +# Implementation Plan: 知识库租户分组展示 (knowledge-tenant-view) + +## Overview + +将知识库管理页面从扁平列表改造为两层结构:第一层按租户分组展示汇总卡片,第二层展示租户下的知识条目列表。改造涉及 KnowledgeManager 业务逻辑层、Flask API 层、前端 dashboard.js 三个层面。 + +## Tasks + +- [x] 1. KnowledgeManager 新增 get_tenant_summary 方法 + - [x] 1.1 在 `src/knowledge_base/knowledge_manager.py` 中新增 `get_tenant_summary()` 方法 + - 使用 SQLAlchemy `GROUP BY tenant_id` 聚合 `is_active == True` 的知识条目 + - 返回包含 `tenant_id`、`entry_count`、`verified_count`、`category_distribution` 的字典列表 + - 按 `entry_count` 降序排列 + - 数据库异常时返回空列表 `[]`,记录 error 日志 + - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5_ + + - [ ]* 1.2 为 get_tenant_summary 编写属性测试 + - **Property 1: Tenant summary correctly aggregates active entries** + - **Property 2: Tenant summary sorted by entry_count descending** + - 使用 `hypothesis` 生成随机 KnowledgeEntry 列表,验证聚合正确性和排序 + - **Validates: Requirements 1.1, 1.2, 1.3** + +- [x] 2. KnowledgeManager 现有方法增加 tenant_id 过滤 + - [x] 2.1 为 `get_knowledge_paginated()` 增加 `tenant_id` 可选参数 + - 在 `src/knowledge_base/knowledge_manager.py` 中修改方法签名,增加 `tenant_id: Optional[str] = None` + - 当 `tenant_id` 不为 None 时,在查询中增加 `KnowledgeEntry.tenant_id == tenant_id` 过滤条件 + - 返回结构不变,仅过滤范围缩小 + - _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5_ + + - [ ]* 2.2 为 get_knowledge_paginated 的 tenant_id 过滤编写属性测试 + - **Property 3: Knowledge entry filtering by tenant, category, and verified status** + - **Property 4: Pagination consistency with tenant filter** + - **Validates: Requirements 2.1, 2.2, 2.3** + + - [x] 2.3 为 `search_knowledge()` 增加 `tenant_id` 可选参数 + - 修改 `search_knowledge()`、`_search_by_embedding()`、`_search_by_keyword()` 方法签名 + - 当 `tenant_id` 不为 None 时,在查询中增加 tenant_id 过滤条件 + - _Requirements: 6.2, 6.4_ + + - [ ]* 2.4 为 search_knowledge 的 tenant_id 过滤编写属性测试 + - **Property 6: Search results scoped to tenant** + - **Validates: Requirements 6.2** + + - [x] 2.5 为 `get_knowledge_stats()` 增加 `tenant_id` 可选参数 + - 当 `tenant_id` 不为 None 时,所有统计查询增加 tenant_id 过滤 + - 返回结构中增加 `tenant_id` 字段(仅当按租户筛选时) + - _Requirements: 7.3, 7.4_ + + - [ ]* 2.6 为 get_knowledge_stats 的 tenant_id 过滤编写属性测试 + - **Property 7: Stats scoped to tenant** + - **Validates: Requirements 7.3, 7.4** + + - [x] 2.7 为 `add_knowledge_entry()` 增加 `tenant_id` 可选参数 + - 当 `tenant_id` 不为 None 时,新建条目的 `tenant_id` 设为该值 + - 当 `tenant_id` 为 None 时,使用 `get_config().server.tenant_id` 作为默认值 + - _Requirements: 5.2_ + + - [ ]* 2.8 为 add_knowledge_entry 的 tenant_id 关联编写属性测试 + - **Property 5: New entry tenant association** + - **Validates: Requirements 5.2** + +- [x] 3. Checkpoint - 确保后端业务逻辑层完成 + - Ensure all tests pass, ask the user if questions arise. + +- [x] 4. Knowledge API 层新增和修改端点 + - [x] 4.1 在 `src/web/blueprints/knowledge.py` 中新增 `GET /api/knowledge/tenants` 端点 + - 调用 `knowledge_manager.get_tenant_summary()` 返回租户汇总 JSON 数组 + - 使用 `@handle_api_errors` 装饰器处理异常 + - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5_ + + - [x] 4.2 修改 `GET /api/knowledge` 端点,增加 `tenant_id` 查询参数支持 + - 从 `request.args` 获取 `tenant_id` 参数,传递给 `get_knowledge_paginated()` + - _Requirements: 2.1, 2.2, 2.3, 2.4_ + + - [x] 4.3 修改 `GET /api/knowledge/stats` 端点,增加 `tenant_id` 查询参数支持 + - 从 `request.args` 获取 `tenant_id` 参数,传递给 `get_knowledge_stats()` + - _Requirements: 7.3, 7.4_ + + - [x] 4.4 修改 `GET /api/knowledge/search` 端点,增加 `tenant_id` 查询参数支持 + - 从 `request.args` 获取 `tenant_id` 参数,传递给 `search_knowledge()` + - _Requirements: 6.2_ + + - [x] 4.5 修改 `POST /api/knowledge` 端点,从请求体读取 `tenant_id` 字段 + - 将 `tenant_id` 传递给 `add_knowledge_entry()` + - _Requirements: 5.2_ + + - [ ]* 4.6 为新增和修改的 API 端点编写单元测试 + - 测试 `/api/knowledge/tenants` 返回正确的汇总数据 + - 测试各端点的 `tenant_id` 参数过滤行为 + - 测试空数据和异常情况 + - _Requirements: 1.1, 1.4, 1.5, 2.4_ + +- [x] 5. Checkpoint - 确保后端 API 层完成 + - Ensure all tests pass, ask the user if questions arise. + +- [x] 6. 前端 Tenant_List_View(租户列表视图) + - [x] 6.1 在 `src/web/static/js/dashboard.js` 中实现 `loadTenantList()` 函数 + - 请求 `GET /api/knowledge/tenants` 获取租户汇总数据 + - 渲染租户卡片列表,每张卡片展示 `tenant_id`、`entry_count`、`verified_count` + - 添加加载中 spinner 状态 + - 无租户时展示空状态占位提示 + - 卡片点击事件绑定,调用 `loadTenantDetail(tenantId)` + - _Requirements: 3.1, 3.2, 3.3, 3.4_ + + - [x] 6.2 实现刷新按钮功能 + - 在知识库 tab 区域添加刷新按钮,点击时重新调用 `loadTenantList()` + - _Requirements: 3.5_ + +- [x] 7. 前端 Tenant_Detail_View(租户详情视图) + - [x] 7.1 实现 `loadTenantDetail(tenantId, page)` 函数 + - 请求 `GET /api/knowledge?tenant_id=X&page=P&per_page=N` 获取知识条目 + - 渲染知识条目表格,展示 question、answer、category、confidence_score、usage_count、is_verified + - 实现分页控件 + - 支持 category 和 verified 筛选下拉框 + - _Requirements: 4.1, 4.2, 4.5, 4.6_ + + - [x] 7.2 实现面包屑导航 `renderBreadcrumb(tenantId)` + - 展示 "知识库 > {tenant_id}" 面包屑 + - 点击 "知识库" 链接时调用 `loadTenantList()` 返回租户列表视图 + - 管理 `currentTenantId` 状态变量控制视图层级 + - _Requirements: 4.3, 4.4_ + + - [x] 7.3 在 Tenant_Detail_View 中集成知识条目操作按钮 + - 复用现有的添加、删除、验证、取消验证按钮逻辑 + - 添加知识条目时自动设置 `tenant_id` 为当前选中的租户 + - 批量操作(批量删除、批量验证、批量取消验证)后刷新当前视图 + - 删除所有条目后自动返回租户列表视图 + - 操作失败时通过 `showNotification` 展示错误提示 + - _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5_ + +- [x] 8. 前端搜索和统计面板适配 + - [x] 8.1 修改搜索功能,在 Tenant_Detail_View 中自动附加 `tenant_id` 参数 + - 搜索请求附加 `&tenant_id=currentTenantId` + - 清空搜索时恢复当前租户的完整分页列表 + - _Requirements: 6.1, 6.2, 6.3_ + + - [x] 8.2 修改 `loadKnowledgeStats()` 函数,根据视图层级请求不同统计 + - 当 `currentTenantId` 为 null 时请求全局统计 + - 当 `currentTenantId` 有值时请求 `GET /api/knowledge/stats?tenant_id=X` + - _Requirements: 7.1, 7.2_ + +- [x] 9. 前端 HTML 模板更新 + - [x] 9.1 在 `src/web/templates/dashboard.html` 的 `#knowledge-tab` 区域添加必要的 DOM 容器 + - 添加面包屑容器、租户卡片列表容器、租户详情容器 + - 确保与现有 Bootstrap 5 样式一致 + - _Requirements: 3.1, 4.3_ + +- [x] 10. Final checkpoint - 确保所有功能集成完成 + - Ensure all tests pass, ask the user if questions arise. + +## Notes + +- Tasks marked with `*` are optional and can be skipped for faster MVP +- Each task references specific requirements for traceability +- Checkpoints ensure incremental validation +- Property tests validate universal correctness properties from the design document +- 数据模型 `KnowledgeEntry` 已有 `tenant_id` 字段且已建索引,无需数据库迁移 diff --git a/data/tsp_assistant.db b/data/tsp_assistant.db index 53c8b025719ce5f87a7eafd1ec068bec0f11ac5f..3bc0a5dd138209ee1385a59be451ed3e6d27d2db 100644 GIT binary patch delta 713 zcmZo@;Av>!nIJ9rf`Ng-0El5Aa-xngUoPfqIZk~YybFjO!!wlXocGP2aOFf=eSGD=BJODxSP*))OCL6~hF0~=Ey15+UX zLH;h@1dbr?uFWDBY?-(^q8Qo5m6aJMRBz7YzRhe)3<{0V|qM!~nQ(eK&KSaSV)W>J?#%~IfllV1QIMW%IY~IASm5GJ_2b1#VY|bx? zKo*Pk==m7n!(PG`ZQu)zuk01UIYxUPOi^8^1|RzWc|5 ztGSL3Y*4%P=HRahWEr%GiCcPe==WpHEWFtawUgz4S8_E~v9pUyN;0!nIJ9rfPsO*0El5AXrhiWUsj_&UgG1fIQP%t#MGBmR?GSD+JH!!y}+%$pFL74vs0~^~q2JU$NgZy2b=^R09 z>o$vAuw~j@{8@!@b1Cx-Mi%}bj0-mlvcBTl{QEwafGDdc1K(EOY+eUmQEq9@ZuV$a zPj(5Q#rmvV%|3kW;_B**ZQ+|0za}tFw*4tKx$YXr3u~hkifCJo)6s4zSsi oOPT#9i~bE`X>#Xa-< bool: + """迁移:为核心表添加 tenant_id 多租户字段""" + print(" 检查多租户 tenant_id 字段...") + tables = [ + "work_orders", "chat_sessions", "conversations", + "knowledge_entries", "analytics", "alerts", "users", + ] + try: + added = 0 + for table in tables: + if not self._column_exists(table, 'tenant_id'): + print(f" 添加 {table}.tenant_id ...") + self._add_table_columns(table, [ + ('tenant_id', "VARCHAR(50) DEFAULT 'default' NOT NULL") + ]) + added += 1 + else: + print(f" {table}.tenant_id 已存在") + print(f" tenant_id 迁移完成,新增 {added} 个表") + return True + except Exception as e: + print(f" tenant_id 迁移失败: {e}") + return False + def _add_table_columns(self, table_name: str, fields: List[tuple]) -> bool: """为表添加字段""" try: diff --git a/nginx.conf b/nginx.conf index d6b487d..b501283 100644 --- a/nginx.conf +++ b/nginx.conf @@ -56,6 +56,19 @@ http { proxy_send_timeout 30s; proxy_read_timeout 30s; } + + # SSE 流式接口 — 关闭缓冲,支持逐 token 推送 + location /api/chat/message/stream { + proxy_pass http://tsp_backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 120s; + chunked_transfer_encoding on; + } # WebSocket代理 location /ws/ { diff --git a/scripts/migrate_tenant.py b/scripts/migrate_tenant.py new file mode 100644 index 0000000..bd60bda --- /dev/null +++ b/scripts/migrate_tenant.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +多租户迁移脚本 +为现有数据表添加 tenant_id 字段,已有数据填充为 'default' +""" + +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from src.core.database import db_manager +from sqlalchemy import text, inspect + +TABLES_TO_MIGRATE = [ + "work_orders", + "chat_sessions", + "conversations", + "knowledge_entries", + "analytics", + "alerts", + "users", +] + + +def migrate(): + print("=" * 50) + print("多租户迁移: 添加 tenant_id 字段") + print("=" * 50) + + with db_manager.get_session() as session: + inspector = inspect(session.bind) + + for table in TABLES_TO_MIGRATE: + # 检查表是否存在 + if table not in inspector.get_table_names(): + print(f" [跳过] 表 {table} 不存在") + continue + + # 检查字段是否已存在 + columns = [col["name"] for col in inspector.get_columns(table)] + if "tenant_id" in columns: + print(f" [已有] 表 {table} 已包含 tenant_id") + continue + + # 添加字段 + print(f" [迁移] 表 {table} 添加 tenant_id ...") + session.execute(text( + f"ALTER TABLE {table} ADD COLUMN tenant_id VARCHAR(50) DEFAULT 'default' NOT NULL" + )) + session.commit() + print(f" [完成] 表 {table} 迁移成功") + + print("\n迁移完成!") + + +if __name__ == "__main__": + migrate() diff --git a/src/config/unified_config.py b/src/config/unified_config.py index fc0e2ac..32be254 100644 --- a/src/config/unified_config.py +++ b/src/config/unified_config.py @@ -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 diff --git a/src/core/llm_client.py b/src/core/llm_client.py index 302f176..809d39f 100644 --- a/src/core/llm_client.py +++ b/src/core/llm_client.py @@ -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 diff --git a/src/core/models.py b/src/core/models.py index edf2476..a0f8bd3 100644 --- a/src/core/models.py +++ b/src/core/models.py @@ -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) diff --git a/src/dialogue/conversation_history.py b/src/dialogue/conversation_history.py index 3056e50..87db460 100644 --- a/src/dialogue/conversation_history.py +++ b/src/dialogue/conversation_history.py @@ -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) diff --git a/src/dialogue/realtime_chat.py b/src/dialogue/realtime_chat.py index a7930e1..19fd244 100644 --- a/src/dialogue/realtime_chat.py +++ b/src/dialogue/realtime_chat.py @@ -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: """构建聊天提示词""" diff --git a/src/knowledge_base/knowledge_manager.py b/src/knowledge_base/knowledge_manager.py index f5c66f5..8970ce1 100644 --- a/src/knowledge_base/knowledge_manager.py +++ b/src/knowledge_base/knowledge_manager.py @@ -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: diff --git a/src/web/app.py b/src/web/app.py index cb0c23e..2e16218 100644 --- a/src/web/app.py +++ b/src/web/app.py @@ -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/') def get_chat_history(session_id): """获取对话历史""" diff --git a/src/web/blueprints/conversations.py b/src/web/blueprints/conversations.py index 273aee9..3ca807d 100644 --- a/src/web/blueprints/conversations.py +++ b/src/web/blueprints/conversations.py @@ -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) diff --git a/src/web/blueprints/knowledge.py b/src/web/blueprints/knowledge.py index 001f67f..34ddea2 100644 --- a/src/web/blueprints/knowledge.py +++ b/src/web/blueprints/knowledge.py @@ -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']) diff --git a/src/web/blueprints/system.py b/src/web/blueprints/system.py index f0e7240..8fdceec 100644 --- a/src/web/blueprints/system.py +++ b/src/web/blueprints/system.py @@ -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(): """保存系统设置""" diff --git a/src/web/static/js/chat_http.js b/src/web/static/js/chat_http.js index 2a5dbf5..b8c46e5 100644 --- a/src/web/static/js/chat_http.js +++ b/src/web/static/js/chat_http.js @@ -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) { diff --git a/src/web/static/js/dashboard.js b/src/web/static/js/dashboard.js index a4edf2d..74d124d 100644 --- a/src/web/static/js/dashboard.js +++ b/src/web/static/js/dashboard.js @@ -192,6 +192,12 @@ class TSPDashboard { this.isPageVisible = true; + // 知识库租户视图状态 + this.knowledgeCurrentTenantId = null; + + // 对话历史租户视图状态 + this.conversationCurrentTenantId = null; + // 分页配置 this.paginationConfig = { defaultPageSize: 10, @@ -497,8 +503,12 @@ class TSPDashboard { this.loadWorkOrders(page, true); } else if (loadFunction === 'loadKnowledge') { this.loadKnowledge(page); + } else if (loadFunction === 'loadKnowledgeTenantDetailPage') { + this.loadKnowledgeTenantDetail(this.knowledgeCurrentTenantId, page); } else if (loadFunction === 'loadConversationHistory') { this.loadConversationHistory(page); + } else if (loadFunction === 'loadConversationTenantDetailPage') { + this.loadConversationTenantDetail(this.conversationCurrentTenantId, page); } } @@ -514,8 +524,12 @@ class TSPDashboard { this.loadWorkOrders(1, true); } else if (loadFunction === 'loadKnowledge') { this.loadKnowledge(1); + } else if (loadFunction === 'loadKnowledgeTenantDetailPage') { + this.loadKnowledgeTenantDetail(this.knowledgeCurrentTenantId, 1); } else if (loadFunction === 'loadConversationHistory') { this.loadConversationHistory(1); + } else if (loadFunction === 'loadConversationTenantDetailPage') { + this.loadConversationTenantDetail(this.conversationCurrentTenantId, 1); } } @@ -1066,9 +1080,9 @@ class TSPDashboard { document.getElementById('knowledge-total').textContent = knowledge.total_entries || 0; document.getElementById('knowledge-active').textContent = knowledge.active_entries || 0; const confidencePercent = Math.round((knowledge.average_confidence || 0) * 100); - document.getElementById('knowledge-confidence').style.width = `${confidencePercent}%`; - document.getElementById('knowledge-confidence').setAttribute('aria-valuenow', confidencePercent); - document.getElementById('knowledge-confidence').textContent = `${confidencePercent}%`; + document.getElementById('knowledge-confidence-bar').style.width = `${confidencePercent}%`; + document.getElementById('knowledge-confidence-bar').setAttribute('aria-valuenow', confidencePercent); + document.getElementById('knowledge-confidence-bar').textContent = `${confidencePercent}%`; // 更新性能图表 await this.updatePerformanceChart(sessions, alerts, workorders); @@ -1510,16 +1524,22 @@ class TSPDashboard { const toolsData = await toolsResp.json(); if (data.success) { - document.getElementById('agent-current-state').textContent = data.status || '未知'; - document.getElementById('agent-active-goals').textContent = data.active_goals || 0; - const tools = (toolsData.success ? toolsData.tools : (data.tools || [])) || []; - document.getElementById('agent-available-tools').textContent = tools.length || 0; + // 更新 ReactAgent 状态 + const react = data.react_agent || {}; + const stateEl = document.getElementById('agent-current-state'); + stateEl.textContent = react.status || (data.is_active ? 'active' : 'inactive'); + stateEl.className = `badge ${data.is_active ? 'bg-success' : 'bg-secondary'}`; + document.getElementById('agent-available-tools').textContent = react.tool_count || 0; + document.getElementById('agent-max-rounds').textContent = react.max_tool_rounds || 5; + document.getElementById('agent-history-count').textContent = react.history_count || 0; - // 更新工具列表(使用真实统计) + // 工具列表 — 使用 ReactAgent 的工具定义 + const tools = (toolsData.success ? toolsData.tools : []) || []; this.updateToolsList(tools); - // 更新执行历史 - this.updateAgentExecutionHistory(data.execution_history || []); + // 执行历史 + const history = (toolsData.success ? toolsData.recent_history : []) || []; + this.updateAgentExecutionHistory(history); } } catch (error) { console.error('加载Agent数据失败:', error); @@ -1534,21 +1554,21 @@ class TSPDashboard { } const toolsHtml = tools.map(tool => { - const usage = tool.usage_count || 0; - const success = Math.round((tool.success_rate || 0) * 100); - const meta = tool.metadata || {}; + // ReactAgent 工具定义格式: { name, description, parameters } + const params = tool.parameters || {}; + const paramList = Object.entries(params).map(([k, v]) => + `${k}(${v.required ? '必填' : '可选'})` + ).join(', '); return ` -
-
- ${tool.name} - ${meta.description ? `
${meta.description}
` : ''} - 使用次数: ${usage} -
-
- ${success}% -
@@ -1561,11 +1581,11 @@ class TSPDashboard { toolsList.querySelectorAll('button[data-tool]').forEach(btn => { btn.addEventListener('click', async () => { const tool = btn.getAttribute('data-tool'); - // 简单参数输入(可扩展为动态表单) let params = {}; try { const input = prompt('请输入执行参数(JSON):', '{}'); - if (input) params = JSON.parse(input); + if (input === null) return; + params = JSON.parse(input); } catch (e) { this.showNotification('参数格式错误,应为JSON', 'warning'); return; @@ -1584,45 +1604,10 @@ class TSPDashboard { this.showNotification(res.error || `工具 ${tool} 执行失败`, 'error'); } } catch (err) { - console.error('执行工具失败:', err); this.showNotification('执行工具失败: ' + err.message, 'error'); } }); }); - - // 追加自定义工具注册入口 - const addDiv = document.createElement('div'); - addDiv.className = 'mt-3'; - addDiv.innerHTML = ` -
- - - -
- `; - toolsList.appendChild(addDiv); - document.getElementById('register-tool-btn').addEventListener('click', async () => { - const name = document.getElementById('custom-tool-name').value.trim(); - const description = document.getElementById('custom-tool-desc').value.trim(); - if (!name) { this.showNotification('请输入工具名称', 'warning'); return; } - try { - const resp = await fetch('/api/agent/tools/register', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, description }) - }); - const res = await resp.json(); - if (res.success) { - this.showNotification('工具注册成功', 'success'); - this.loadAgentData(); - } else { - this.showNotification(res.error || '工具注册失败', 'error'); - } - } catch (e) { - console.error('注册工具失败:', e); - this.showNotification('注册工具失败', 'error'); - } - }); } updateExecutionHistory(history) { @@ -2079,21 +2064,230 @@ class TSPDashboard { // 知识库管理 async loadKnowledge(page = 1) { + // 委托给租户列表视图 + this.loadKnowledgeTenantList(); + } + + // Task 6.1: 加载租户列表视图 + async loadKnowledgeTenantList() { + this.knowledgeCurrentTenantId = null; + this.renderKnowledgeBreadcrumb(null); + + // 显示租户列表容器,隐藏详情容器 + const tenantListEl = document.getElementById('knowledge-tenant-list'); + const tenantDetailEl = document.getElementById('knowledge-tenant-detail'); + const searchBar = document.getElementById('knowledge-search-bar'); + const addBtn = document.getElementById('knowledge-add-btn'); + const uploadBtn = document.getElementById('knowledge-upload-btn'); + + tenantListEl.style.display = ''; + tenantDetailEl.style.display = 'none'; + if (searchBar) searchBar.style.display = 'none'; + if (addBtn) addBtn.style.display = 'none'; + if (uploadBtn) uploadBtn.style.display = 'none'; + + // 显示加载中 + tenantListEl.innerHTML = '

加载中...

'; + + // 加载全局统计 + this.loadKnowledgeStats(null); + + try { + const response = await fetch('/api/knowledge/tenants'); + const tenants = await response.json(); + + if (!Array.isArray(tenants) || tenants.length === 0) { + tenantListEl.innerHTML = '

暂无知识库数据

'; + return; + } + + const cardsHtml = tenants.map(t => ` +
+
+
+
${t.tenant_id}
+
+
+ 知识条目 +
${t.entry_count}
+
+
+ 已验证 +
${t.verified_count}
+
+
+
+
+
+ ${t.entry_count > 0 ? Math.round(t.verified_count / t.entry_count * 100) : 0}% 已验证 +
+
+
+ `).join(''); + + tenantListEl.innerHTML = cardsHtml; + } catch (error) { + console.error('加载租户列表失败:', error); + tenantListEl.innerHTML = '
加载失败
'; + this.showNotification('加载租户列表失败', 'error'); + } + } + + // Task 6.2: 刷新按钮 + refreshKnowledge() { + if (this.knowledgeCurrentTenantId) { + this.loadKnowledgeTenantDetail(this.knowledgeCurrentTenantId, this.paginationConfig.currentKnowledgePage); + } else { + this.loadKnowledgeTenantList(); + } + } + + // Task 7.1: 加载租户详情视图 + async loadKnowledgeTenantDetail(tenantId, page = 1) { + this.knowledgeCurrentTenantId = tenantId; this.paginationConfig.currentKnowledgePage = page; + this.renderKnowledgeBreadcrumb(tenantId); + + // 隐藏租户列表,显示详情容器 + const tenantListEl = document.getElementById('knowledge-tenant-list'); + const tenantDetailEl = document.getElementById('knowledge-tenant-detail'); + const searchBar = document.getElementById('knowledge-search-bar'); + const addBtn = document.getElementById('knowledge-add-btn'); + const uploadBtn = document.getElementById('knowledge-upload-btn'); + + tenantListEl.style.display = 'none'; + tenantDetailEl.style.display = ''; + if (searchBar) searchBar.style.display = ''; + if (addBtn) addBtn.style.display = ''; + if (uploadBtn) uploadBtn.style.display = ''; + + // 清空搜索框 + const searchInput = document.getElementById('knowledge-search'); + if (searchInput) searchInput.value = ''; + + // 加载租户级统计 + this.loadKnowledgeStats(tenantId); + + const listEl = document.getElementById('knowledge-list'); + listEl.innerHTML = '
'; + try { const pageSize = this.getPageSize('knowledge-pagination'); - const response = await fetch(`/api/knowledge?page=${page}&per_page=${pageSize}`); + const categoryFilter = document.getElementById('knowledge-category-filter')?.value || ''; + const verifiedFilter = document.getElementById('knowledge-verified-filter')?.value || ''; + + let url = `/api/knowledge?tenant_id=${encodeURIComponent(tenantId)}&page=${page}&per_page=${pageSize}`; + if (categoryFilter) url += `&category=${encodeURIComponent(categoryFilter)}`; + if (verifiedFilter) url += `&verified=${encodeURIComponent(verifiedFilter)}`; + + const response = await fetch(url); const data = await response.json(); if (data.knowledge) { this.updateKnowledgeDisplay(data.knowledge); - this.updateKnowledgePagination(data); + this.updateKnowledgeTenantPagination(data); } else { - // 兼容旧格式 this.updateKnowledgeDisplay(data); } } catch (error) { - console.error('加载知识库失败:', error); + console.error('加载租户详情失败:', error); + listEl.innerHTML = '
加载失败
'; + this.showNotification('加载租户详情失败', 'error'); + } + } + + // Task 7.1: 填充分类筛选下拉框(从统计数据获取完整分类列表) + populateCategoryFilter(categories) { + const select = document.getElementById('knowledge-category-filter'); + if (!select) return; + const currentValue = select.value; + const sorted = categories.slice().sort(); + const existingOptions = Array.from(select.options).slice(1).map(o => o.value); + if (JSON.stringify(sorted) !== JSON.stringify(existingOptions)) { + select.innerHTML = '' + + sorted.map(c => ``).join(''); + } + } + + // Task 7.1: 从租户卡片进入详情(重置筛选条件) + openTenantDetail(tenantId) { + const categoryFilterEl = document.getElementById('knowledge-category-filter'); + const verifiedFilterEl = document.getElementById('knowledge-verified-filter'); + if (categoryFilterEl) categoryFilterEl.value = ''; + if (verifiedFilterEl) verifiedFilterEl.value = ''; + this.loadKnowledgeTenantDetail(tenantId, 1); + } + + // Task 7.1: 应用筛选条件 + applyKnowledgeFilters() { + if (this.knowledgeCurrentTenantId) { + this.loadKnowledgeTenantDetail(this.knowledgeCurrentTenantId, 1); + } + } + + // Task 7.1: 租户详情分页 + updateKnowledgeTenantPagination(data) { + this.createPaginationComponent(data, 'knowledge-pagination', 'loadKnowledgeTenantDetailPage', '条知识'); + } + + // Task 7.2: 面包屑导航 + renderKnowledgeBreadcrumb(tenantId) { + const breadcrumbEl = document.getElementById('knowledge-breadcrumb'); + if (!breadcrumbEl) return; + + if (!tenantId) { + breadcrumbEl.innerHTML = '
知识库
'; + } else { + breadcrumbEl.innerHTML = ` + + `; + } + } + + // Task 7.3 / 8.2: 刷新当前知识库视图的辅助方法 + async refreshKnowledgeCurrentView() { + if (this.knowledgeCurrentTenantId) { + await this.loadKnowledgeTenantDetail(this.knowledgeCurrentTenantId, this.paginationConfig.currentKnowledgePage); + } else { + await this.loadKnowledgeTenantList(); + } + } + + // Task 8.2: 加载知识库统计(支持租户级别) + async loadKnowledgeStats(tenantId) { + try { + let url = '/api/knowledge/stats'; + if (tenantId) { + url += `?tenant_id=${encodeURIComponent(tenantId)}`; + } + const response = await fetch(url); + const knowledge = await response.json(); + + const totalEl = document.getElementById('knowledge-total'); + const activeEl = document.getElementById('knowledge-active'); + const confidenceBarEl = document.getElementById('knowledge-confidence-bar'); + + if (totalEl) totalEl.textContent = knowledge.total_entries || 0; + if (activeEl) activeEl.textContent = knowledge.active_entries || 0; + + const confidencePercent = Math.round((knowledge.average_confidence || 0) * 100); + if (confidenceBarEl) { + confidenceBarEl.style.width = `${confidencePercent}%`; + confidenceBarEl.setAttribute('aria-valuenow', confidencePercent); + confidenceBarEl.textContent = `${confidencePercent}%`; + } + + // Task 7.1: 在租户详情视图中,用统计数据填充分类筛选下拉框 + if (tenantId && knowledge.category_distribution) { + this.populateCategoryFilter(Object.keys(knowledge.category_distribution)); + } + } catch (error) { + console.error('加载知识库统计失败:', error); } } @@ -2113,6 +2307,12 @@ class TSPDashboard {
+ + @@ -2170,14 +2370,27 @@ class TSPDashboard { async searchKnowledge() { const query = document.getElementById('knowledge-search').value.trim(); if (!query) { - this.loadKnowledge(); + // Task 8.1: 清空搜索时恢复当前租户的分页列表 + if (this.knowledgeCurrentTenantId) { + this.loadKnowledgeTenantDetail(this.knowledgeCurrentTenantId); + } else { + this.loadKnowledge(); + } return; } try { - const response = await fetch(`/api/knowledge/search?q=${encodeURIComponent(query)}`); + // Task 8.1: 搜索时附加 tenant_id 参数 + let url = `/api/knowledge/search?q=${encodeURIComponent(query)}`; + if (this.knowledgeCurrentTenantId) { + url += `&tenant_id=${encodeURIComponent(this.knowledgeCurrentTenantId)}`; + } + const response = await fetch(url); const results = await response.json(); this.updateKnowledgeDisplay(results); + // 搜索结果不显示分页 + const paginationEl = document.getElementById('knowledge-pagination'); + if (paginationEl) paginationEl.innerHTML = ''; } catch (error) { console.error('搜索知识库失败:', error); } @@ -2195,17 +2408,22 @@ class TSPDashboard { } try { + // Task 7.3: 添加知识条目时自动设置 tenant_id + const body = { + question, + answer, + category, + confidence_score: confidence + }; + if (this.knowledgeCurrentTenantId) { + body.tenant_id = this.knowledgeCurrentTenantId; + } const response = await fetch('/api/knowledge', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - question, - answer, - category, - confidence_score: confidence - }) + body: JSON.stringify(body) }); const data = await response.json(); @@ -2213,7 +2431,12 @@ class TSPDashboard { this.showNotification('知识添加成功', 'success'); bootstrap.Modal.getInstance(document.getElementById('addKnowledgeModal')).hide(); document.getElementById('knowledge-form').reset(); - this.loadKnowledge(); + // Task 7.3: 刷新当前视图 + if (this.knowledgeCurrentTenantId) { + this.loadKnowledgeTenantDetail(this.knowledgeCurrentTenantId, this.paginationConfig.currentKnowledgePage); + } else { + this.loadKnowledgeTenantList(); + } } else { this.showNotification('添加知识失败', 'error'); } @@ -2293,7 +2516,7 @@ class TSPDashboard { this.showNotification(`文件处理成功,生成了 ${data.knowledge_count || 0} 条知识`, 'success'); bootstrap.Modal.getInstance(document.getElementById('uploadFileModal')).hide(); document.getElementById('file-upload-form').reset(); - this.loadKnowledge(); + this.refreshKnowledgeCurrentView(); } else { this.showNotification(data.error || '文件处理失败', 'error'); } @@ -2321,7 +2544,7 @@ class TSPDashboard { const data = await response.json(); if (data.success) { this.showNotification('知识库验证成功', 'success'); - this.loadKnowledge(this.paginationConfig.currentKnowledgePage); + this.refreshKnowledgeCurrentView(); } else { this.showNotification('知识库验证失败', 'error'); } @@ -2343,7 +2566,7 @@ class TSPDashboard { const data = await response.json(); if (data.success) { this.showNotification('取消验证成功', 'success'); - this.loadKnowledge(this.paginationConfig.currentKnowledgePage); + this.refreshKnowledgeCurrentView(); } else { this.showNotification('取消验证失败', 'error'); } @@ -2366,7 +2589,19 @@ class TSPDashboard { const data = await response.json(); if (data.success) { this.showNotification('知识库条目已删除', 'success'); - this.loadKnowledge(this.paginationConfig.currentKnowledgePage); + + // Task 7.3: 删除后检查是否所有条目已删除,若是则返回租户列表 + if (this.knowledgeCurrentTenantId) { + const checkResp = await fetch(`/api/knowledge?tenant_id=${encodeURIComponent(this.knowledgeCurrentTenantId)}&page=1&per_page=1`); + const checkData = await checkResp.json(); + if (checkData.total === 0) { + this.showNotification('该租户下已无知识条目,返回租户列表', 'info'); + this.loadKnowledgeTenantList(); + return; + } + } + + this.refreshKnowledgeCurrentView(); } else { this.showNotification('知识库删除失败', 'error'); } @@ -2391,12 +2626,21 @@ class TSPDashboard { updateBatchDeleteKnowledgeButton() { const selectedCheckboxes = document.querySelectorAll('.knowledge-checkbox:checked'); const batchDeleteBtn = document.getElementById('batch-delete-knowledge'); + const batchVerifyBtn = document.getElementById('batch-verify-knowledge'); + const batchUnverifyBtn = document.getElementById('batch-unverify-knowledge'); + const hasSelection = selectedCheckboxes.length > 0; if (batchDeleteBtn) { - batchDeleteBtn.disabled = selectedCheckboxes.length === 0; - batchDeleteBtn.textContent = selectedCheckboxes.length > 0 - ? `批量删除 (${selectedCheckboxes.length})` - : '批量删除'; + batchDeleteBtn.disabled = !hasSelection; + batchDeleteBtn.innerHTML = hasSelection + ? `批量删除 (${selectedCheckboxes.length})` + : '批量删除'; + } + if (batchVerifyBtn) { + batchVerifyBtn.disabled = !hasSelection; + } + if (batchUnverifyBtn) { + batchUnverifyBtn.disabled = !hasSelection; } } @@ -2429,9 +2673,21 @@ class TSPDashboard { // 清除缓存并强制刷新 this.cache.delete('knowledge'); - await this.loadKnowledge(); + + // Task 7.3: 删除后检查是否所有条目已删除,若是则返回租户列表 + if (this.knowledgeCurrentTenantId) { + const checkResp = await fetch(`/api/knowledge?tenant_id=${encodeURIComponent(this.knowledgeCurrentTenantId)}&page=1&per_page=1`); + const checkData = await checkResp.json(); + if (checkData.total === 0) { + this.showNotification('该租户下已无知识条目,返回租户列表', 'info'); + this.loadKnowledgeTenantList(); + return; + } + } + + await this.refreshKnowledgeCurrentView(); - // 重置批量删除按钮状态 + // 重置批量操作按钮状态 this.updateBatchDeleteKnowledgeButton(); } else { this.showNotification(data.error || '批量删除失败', 'error'); @@ -2442,6 +2698,68 @@ class TSPDashboard { } } + // Task 7.3: 批量验证知识条目 + async batchVerifyKnowledge() { + const selectedCheckboxes = document.querySelectorAll('.knowledge-checkbox:checked'); + const selectedIds = Array.from(selectedCheckboxes).map(cb => parseInt(cb.value)); + + if (selectedIds.length === 0) { + this.showNotification('请选择要验证的知识库条目', 'warning'); + return; + } + + try { + const response = await fetch('/api/knowledge/batch_verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids: selectedIds }) + }); + + const data = await response.json(); + if (data.success) { + this.showNotification(data.message || '批量验证成功', 'success'); + await this.refreshKnowledgeCurrentView(); + this.updateBatchDeleteKnowledgeButton(); + } else { + this.showNotification(data.error || '批量验证失败', 'error'); + } + } catch (error) { + console.error('批量验证知识库条目失败:', error); + this.showNotification('批量验证知识库条目失败', 'error'); + } + } + + // Task 7.3: 批量取消验证知识条目 + async batchUnverifyKnowledge() { + const selectedCheckboxes = document.querySelectorAll('.knowledge-checkbox:checked'); + const selectedIds = Array.from(selectedCheckboxes).map(cb => parseInt(cb.value)); + + if (selectedIds.length === 0) { + this.showNotification('请选择要取消验证的知识库条目', 'warning'); + return; + } + + try { + const response = await fetch('/api/knowledge/batch_unverify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids: selectedIds }) + }); + + const data = await response.json(); + if (data.success) { + this.showNotification(data.message || '批量取消验证成功', 'success'); + await this.refreshKnowledgeCurrentView(); + this.updateBatchDeleteKnowledgeButton(); + } else { + this.showNotification(data.error || '批量取消验证失败', 'error'); + } + } catch (error) { + console.error('批量取消验证知识库条目失败:', error); + this.showNotification('批量取消验证知识库条目失败', 'error'); + } + } + // 工单管理 async loadWorkOrders(page = 1, forceRefresh = false) { this.paginationConfig.currentWorkOrderPage = page; @@ -3206,6 +3524,369 @@ class TSPDashboard { } } + // 对话历史租户列表视图 + async loadConversationTenantList() { + this.conversationCurrentTenantId = null; + this.renderConversationBreadcrumb(null); + + // 加载全局统计 + this.loadConversationStats(null); + + // 显示租户列表容器,隐藏详情容器 + const tenantListEl = document.getElementById('conversation-tenant-list'); + const tenantDetailEl = document.getElementById('conversation-tenant-detail'); + + if (tenantListEl) tenantListEl.style.display = ''; + if (tenantDetailEl) tenantDetailEl.style.display = 'none'; + + // 显示加载中 spinner + if (tenantListEl) { + tenantListEl.innerHTML = '

加载中...

'; + } + + try { + const response = await fetch('/api/conversations/tenants'); + const tenants = await response.json(); + + if (!Array.isArray(tenants) || tenants.length === 0) { + if (tenantListEl) { + tenantListEl.innerHTML = '

暂无对话会话数据

'; + } + return; + } + + const cardsHtml = tenants.map(t => { + const lastActive = t.last_active_time ? new Date(t.last_active_time).toLocaleString() : '无'; + return ` +
+
+
+
${t.tenant_id}
+
+
+ 会话总数 +
${t.session_count}
+
+
+ 消息总数 +
${t.message_count}
+
+
+ 活跃会话 +
${t.active_session_count}
+
+
+
+ 最近活跃: ${lastActive} +
+
+
+
+ `; + }).join(''); + + if (tenantListEl) { + tenantListEl.innerHTML = cardsHtml; + } + } catch (error) { + console.error('加载对话租户列表失败:', error); + if (tenantListEl) { + tenantListEl.innerHTML = '
加载失败
'; + } + this.showNotification('加载对话租户列表失败', 'error'); + } + } + + // Task 7.1: 加载对话历史租户详情视图 + async loadConversationTenantDetail(tenantId, page = 1) { + this.conversationCurrentTenantId = tenantId; + this.paginationConfig.currentConversationPage = page; + + // 隐藏租户列表,显示详情容器 + const tenantListEl = document.getElementById('conversation-tenant-list'); + const tenantDetailEl = document.getElementById('conversation-tenant-detail'); + + if (tenantListEl) tenantListEl.style.display = 'none'; + if (tenantDetailEl) tenantDetailEl.style.display = ''; + + // 渲染面包屑(如果 renderConversationBreadcrumb 已实现) + if (typeof this.renderConversationBreadcrumb === 'function') { + this.renderConversationBreadcrumb(tenantId); + } + + // 加载租户级统计(如果 loadConversationStats 已实现) + if (typeof this.loadConversationStats === 'function') { + this.loadConversationStats(tenantId); + } + + // 显示加载中 spinner + const sessionListEl = document.getElementById('conversation-session-list'); + if (sessionListEl) { + sessionListEl.innerHTML = '

加载中...

'; + } + + try { + const perPage = this.getPageSize('conversation-session-pagination'); + const statusFilter = document.getElementById('conversation-status-filter')?.value || ''; + const dateFilter = document.getElementById('conversation-detail-date-filter')?.value || ''; + + let url = `/api/conversations/sessions?tenant_id=${encodeURIComponent(tenantId)}&page=${page}&per_page=${perPage}`; + if (statusFilter) url += `&status=${encodeURIComponent(statusFilter)}`; + if (dateFilter) url += `&date_filter=${encodeURIComponent(dateFilter)}`; + + const response = await fetch(url); + const data = await response.json(); + + if (data.sessions) { + this.renderConversationSessionTable(data.sessions, tenantId); + this.updateConversationTenantPagination(data); + } else { + if (sessionListEl) { + sessionListEl.innerHTML = '

暂无会话数据

'; + } + } + } catch (error) { + console.error('加载租户会话列表失败:', error); + if (sessionListEl) { + sessionListEl.innerHTML = '
加载失败
'; + } + this.showNotification('加载租户会话列表失败', 'error'); + } + } + + // Task 7.1: 渲染会话表格 + renderConversationSessionTable(sessions, tenantId) { + const sessionListEl = document.getElementById('conversation-session-list'); + if (!sessionListEl) return; + + if (!sessions || sessions.length === 0) { + sessionListEl.innerHTML = '

暂无会话数据

'; + return; + } + + const statusBadge = (status) => { + if (status === 'active') return '活跃'; + if (status === 'ended') return '已结束'; + return `${status || '未知'}`; + }; + + const sourceBadge = (source) => { + if (source === 'feishu') return '飞书'; + if (source === 'websocket') return 'WebSocket'; + if (source === 'api') return 'API'; + return `${source || '未知'}`; + }; + + const formatTime = (isoStr) => { + if (!isoStr) return '-'; + return new Date(isoStr).toLocaleString(); + }; + + const rowsHtml = sessions.map(s => ` + + ${s.title || s.session_id || '-'} + ${s.message_count || 0} + ${statusBadge(s.status)} + ${sourceBadge(s.source)} + ${formatTime(s.created_at)} + ${formatTime(s.updated_at)} + + + + + `).join(''); + + sessionListEl.innerHTML = ` +
+ + + + + + + + + + + + + + ${rowsHtml} + +
会话标题消息数状态来源创建时间更新时间操作
+
+ `; + } + + // Task 7.1: 查看会话消息详情 + async viewSessionMessages(sessionId, sessionTitle) { + try { + const response = await fetch(`/api/conversations/sessions/${encodeURIComponent(sessionId)}`); + const data = await response.json(); + + if (data.success && data.messages) { + // 更新面包屑到第三层(如果已实现) + if (typeof this.renderConversationBreadcrumb === 'function') { + this.renderConversationBreadcrumb(this.conversationCurrentTenantId, sessionTitle || sessionId); + } + this.showSessionMessagesModal(data.session, data.messages); + } else { + throw new Error(data.error || '获取会话消息失败'); + } + } catch (error) { + console.error('获取会话消息失败:', error); + this.showNotification('获取会话消息失败: ' + error.message, 'error'); + } + } + + // Task 7.1: 显示会话消息模态框 + showSessionMessagesModal(session, messages) { + const messagesHtml = messages.map(msg => ` +
+
+ 用户: +
${msg.user_message || ''}
+
+
+ 助手: +
${msg.assistant_response || ''}
+
+
+ + ${msg.timestamp ? new Date(msg.timestamp).toLocaleString() : ''} + ${msg.response_time ? ` | 响应: ${msg.response_time}s` : ''} + ${msg.confidence_score ? ` | 置信度: ${Math.round(msg.confidence_score * 100)}%` : ''} + +
+
+ `).join(''); + + const modalHtml = ` + + `; + + // 移除已存在的模态框 + const existingModal = document.getElementById('sessionMessagesModal'); + if (existingModal) existingModal.remove(); + + document.body.insertAdjacentHTML('beforeend', modalHtml); + const modal = new bootstrap.Modal(document.getElementById('sessionMessagesModal')); + modal.show(); + + // 模态框关闭后清理 + document.getElementById('sessionMessagesModal').addEventListener('hidden.bs.modal', function () { + this.remove(); + }); + } + + // Task 7.1: 对话租户详情分页 + updateConversationTenantPagination(data) { + this.createPaginationComponent(data, 'conversation-session-pagination', 'loadConversationTenantDetailPage', '条会话'); + } + + // Task 7.2: 面包屑导航 + renderConversationBreadcrumb(tenantId, sessionTitle) { + const breadcrumbEl = document.getElementById('conversation-breadcrumb'); + if (!breadcrumbEl) return; + + if (!tenantId) { + breadcrumbEl.innerHTML = ''; + return; + } + + if (!sessionTitle) { + // 租户详情视图: "对话历史 > {tenant_id}" + breadcrumbEl.innerHTML = ` + + `; + } else { + // 消息详情视图: "对话历史 > {tenant_id} > {session_title}" + breadcrumbEl.innerHTML = ` + + `; + } + + this.conversationCurrentTenantId = tenantId; + } + + // Task 7.1: 应用对话筛选条件 + applyConversationFilters() { + if (this.conversationCurrentTenantId) { + this.loadConversationTenantDetail(this.conversationCurrentTenantId, 1); + } + } + + // Task 7.3: 删除会话并处理空租户自动返回 + async deleteConversationSession(sessionId) { + if (!confirm('确定要删除这个会话及其所有消息吗?')) return; + try { + const response = await fetch(`/api/conversations/sessions/${encodeURIComponent(sessionId)}`, { method: 'DELETE' }); + const data = await response.json(); + if (data.success) { + this.showNotification('会话已删除', 'success'); + // 刷新当前租户详情视图并检查是否还有剩余会话 + if (this.conversationCurrentTenantId) { + const tenantId = this.conversationCurrentTenantId; + const perPage = this.getPageSize('conversation-session-pagination'); + const checkUrl = `/api/conversations/sessions?tenant_id=${encodeURIComponent(tenantId)}&page=1&per_page=${perPage}`; + const checkResp = await fetch(checkUrl); + const checkData = await checkResp.json(); + + if (!checkData.sessions || checkData.sessions.length === 0 || checkData.total === 0) { + // 该租户下已无会话,自动返回租户列表视图 + this.loadConversationTenantList(); + } else { + // 仍有会话,刷新当前详情视图(调整页码避免越界) + const maxPage = checkData.total_pages || 1; + const currentPage = Math.min(this.paginationConfig.currentConversationPage, maxPage); + await this.loadConversationTenantDetail(tenantId, currentPage); + } + } + } else { + throw new Error(data.error || '删除失败'); + } + } catch (error) { + console.error('删除会话失败:', error); + this.showNotification('删除会话失败: ' + error.message, 'error'); + } + } + // 对话历史管理 async loadConversationHistory(page = 1) { try { @@ -3307,12 +3988,44 @@ class TSPDashboard { document.getElementById('conversation-active-users').textContent = stats.active_users || 0; } + // Task 8.3: 加载对话统计(支持租户级别) + async loadConversationStats(tenantId) { + try { + let url = '/api/conversations/analytics'; + if (tenantId) { + url += `?tenant_id=${encodeURIComponent(tenantId)}`; + } + const response = await fetch(url); + const data = await response.json(); + + const analytics = data.analytics || {}; + const convStats = analytics.conversations || {}; + + const totalEl = document.getElementById('conversation-total'); + const todayEl = document.getElementById('conversation-today'); + const avgResponseEl = document.getElementById('conversation-avg-response'); + const activeUsersEl = document.getElementById('conversation-active-users'); + + if (totalEl) totalEl.textContent = convStats.total || 0; + if (todayEl) todayEl.textContent = convStats.today || 0; + if (avgResponseEl) avgResponseEl.textContent = `${Math.round(convStats.avg_response_time || 0)}ms`; + if (activeUsersEl) activeUsersEl.textContent = convStats.active_users || 0; + } catch (error) { + console.error('加载对话统计失败:', error); + } + } + async refreshConversationHistory() { // 先尝试触发一次合并迁移(幂等,重复调用也安全) try { await fetch('/api/conversations/migrate-merge', { method: 'POST' }); } catch (e) { /* 忽略迁移失败 */ } - await this.loadConversationHistory(); + // 根据当前视图状态刷新:租户详情视图或租户列表视图 + if (this.conversationCurrentTenantId) { + await this.loadConversationTenantDetail(this.conversationCurrentTenantId, this.paginationConfig.currentConversationPage); + } else { + await this.loadConversationTenantList(); + } this.showNotification('对话历史已刷新', 'success'); } @@ -3422,10 +4135,44 @@ class TSPDashboard { } async filterConversations() { - const search = document.getElementById('conversation-search').value; - const userFilter = document.getElementById('conversation-user-filter').value; - const dateFilter = document.getElementById('conversation-date-filter').value; - + const search = document.getElementById('conversation-search').value.trim(); + const userFilter = document.getElementById('conversation-user-filter')?.value || ''; + const dateFilter = document.getElementById('conversation-date-filter')?.value || ''; + + // Task 8.1: 在 Tenant_Detail_View 中搜索时自动附加 tenant_id 参数 + if (this.conversationCurrentTenantId) { + if (!search) { + // 清空搜索时恢复当前租户的完整分页列表 + this.loadConversationTenantDetail(this.conversationCurrentTenantId); + return; + } + try { + const perPage = this.getPageSize('conversation-session-pagination'); + let url = `/api/conversations/sessions?search=${encodeURIComponent(search)}&tenant_id=${encodeURIComponent(this.conversationCurrentTenantId)}&page=1&per_page=${perPage}`; + if (dateFilter) url += `&date_filter=${encodeURIComponent(dateFilter)}`; + + const response = await fetch(url); + const data = await response.json(); + + if (data.sessions) { + this.renderConversationSessionTable(data.sessions, this.conversationCurrentTenantId); + this.updateConversationTenantPagination(data); + } else { + const sessionListEl = document.getElementById('conversation-session-list'); + if (sessionListEl) { + sessionListEl.innerHTML = '

未找到匹配的会话

'; + } + const paginationEl = document.getElementById('conversation-session-pagination'); + if (paginationEl) paginationEl.innerHTML = ''; + } + } catch (error) { + console.error('搜索租户会话失败:', error); + this.showNotification('搜索会话失败: ' + error.message, 'error'); + } + return; + } + + // 非租户详情视图:保持原有行为 try { const params = new URLSearchParams(); if (search) params.append('search', search); @@ -5181,9 +5928,24 @@ class TSPDashboard { // 系统设置 async loadSettings() { try { - const response = await fetch('/api/settings'); - const settings = await response.json(); + const [settingsResp, configResp] = await Promise.all([ + fetch('/api/settings'), + fetch('/api/runtime-config'), + ]); + const settings = await settingsResp.json(); this.updateSettingsDisplay(settings); + + // 加载运行时配置(租户、模型等) + const config = await configResp.json(); + if (config.success) { + const set = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val || '-'; }; + set('setting-tenant-id', config.tenant_id); + set('setting-llm-provider', config.llm?.provider); + set('setting-llm-model', config.llm?.model); + set('setting-llm-base-url', config.llm?.base_url); + set('setting-embedding-status', config.embedding?.enabled ? `启用 (${config.embedding.model})` : '禁用'); + set('setting-redis-status', config.redis?.enabled ? `启用 (${config.redis.host})` : '禁用'); + } } catch (error) { console.error('加载设置失败:', error); } diff --git a/src/web/templates/dashboard.html b/src/web/templates/dashboard.html index 3fcdacf..bfd03d5 100644 --- a/src/web/templates/dashboard.html +++ b/src/web/templates/dashboard.html @@ -662,19 +662,22 @@
- 当前状态: 空闲 -
-
- 活跃目标: 0 + 运行状态: active
可用工具: 0
+
+ 最大工具轮次: 5 +
+
+ 执行历史: 0 +
-
工具管理
+
ReAct 工具列表
@@ -684,23 +687,6 @@
- -
-
-
添加自定义工具
-
-
-
- -
-
- -
- -
-
@@ -892,22 +878,27 @@