feat(admin): v0.7.0 前端监控功能增强 - Dashboard统计卡片与对话追踪
- Dashboard 统计卡片增强 - 新增四个监控统计卡片:意图规则命中、Prompt模板、话术流程、护栏拦截 - 支持时间范围筛选(今日/本周/本月/最近7天/最近30天) - 显示Top 3排行数据,卡片支持点击跳转 - 完整流程测试台 - RAG实验室新增完整流程测试模式切换 - 支持12步执行流程时间线展示 - 支持步骤详情展开查看输入输出 - 流程配置开关(意图识别、话术流程、RAG检索、输出护栏、上下文记忆) - 对话追踪页面 - 对话列表支持会话ID、时间范围、流程、护栏筛选 - 对话详情展示触发规则、使用模板、话术流程 - 执行链路时间线展示 - 导出功能支持JSON/CSV格式 - 监控导航路由 - 新增 /admin/monitoring/conversations 路由 验收标准: AC-ASA-45~AC-ASA-52, AC-ASA-65~AC-ASA-68
This commit is contained in:
parent
3cf7d02daf
commit
5fbb72aa82
|
|
@ -0,0 +1,223 @@
|
||||||
|
# v0.7.0 窗口3:Dashboard + 流程测试 + 对话追踪 - 进度文档
|
||||||
|
|
||||||
|
## 1. 任务概述
|
||||||
|
|
||||||
|
实现 v0.7.0 迭代中的**核心监控基础设施**,包括 Dashboard 统计增强、完整流程测试台(12步执行链路)、对话追踪与导出功能。这是整个监控系统的核心支撑。
|
||||||
|
|
||||||
|
## 2. 需求文档引用
|
||||||
|
|
||||||
|
- spec/ai-service-admin/requirements.md - 第10节(v0.7.0),AC-ASA-45 ~ AC-ASA-52, AC-ASA-65 ~ AC-ASA-68
|
||||||
|
- spec/ai-service/requirements.md - 第13节(v0.7.0),AC-AISVC-91 ~ AC-AISVC-95, AC-AISVC-108 ~ AC-AISVC-110
|
||||||
|
|
||||||
|
## 3. 总体进度
|
||||||
|
|
||||||
|
- [x] 后端任务(6个)
|
||||||
|
- [x] T16.1-T16.5: 监控数据模型与基础设施
|
||||||
|
- [x] T16.6-T16.8: Dashboard 统计增强
|
||||||
|
- [x] T16.9-T16.12: Orchestrator 监控增强
|
||||||
|
- [x] T16.33-T16.36: 对话追踪服务
|
||||||
|
- [x] T16.37-T16.39: 对话导出服务
|
||||||
|
- [x] 前端任务(4个)
|
||||||
|
- [x] P13-02-P13-04: Dashboard 统计卡片增强
|
||||||
|
- [x] P13-05-P13-08: 完整流程测试台
|
||||||
|
- [x] P13-20-P13-23: 对话追踪页面
|
||||||
|
- [x] P13-24-P13-25: 监控导航菜单
|
||||||
|
|
||||||
|
## 4. Phase 详细进度
|
||||||
|
|
||||||
|
### Phase 1: 监控数据模型与基础设施 (T16.1-T16.5)
|
||||||
|
**状态**: ✅ 已完成
|
||||||
|
**文件修改记录**:
|
||||||
|
- ✅ 修改: ai-service/app/models/entities.py
|
||||||
|
- 扩展 ChatMessage 实体,新增监控字段: prompt_template_id, intent_rule_id, flow_instance_id, guardrail_triggered, guardrail_words
|
||||||
|
- 新增 FlowTestRecord 实体(流程测试记录)
|
||||||
|
- 新增 FlowTestStepResult 模型
|
||||||
|
- 新增 ExportTask 实体(导出任务)
|
||||||
|
- 新增 ExportTaskStatus 枚举
|
||||||
|
- 新增 ConversationDetail 模型
|
||||||
|
|
||||||
|
### Phase 2: Redis 统计缓存层 (T16.2)
|
||||||
|
**状态**: ✅ 已完成
|
||||||
|
**文件修改记录**:
|
||||||
|
- ✅ 创建: ai-service/app/services/monitoring/cache.py
|
||||||
|
- MonitoringCache 类:Redis 缓存层
|
||||||
|
- incr_counter: 原子计数器
|
||||||
|
- get/set_dashboard_stats: Dashboard 缓存
|
||||||
|
- add_to_leaderboard/get_leaderboard: 排行榜
|
||||||
|
- ✅ 修改: ai-service/app/core/config.py
|
||||||
|
- 新增 redis_url, redis_enabled, dashboard_cache_ttl, stats_counter_ttl 配置
|
||||||
|
- ✅ 修改: ai-service/pyproject.toml
|
||||||
|
- 新增 redis>=5.0.0 依赖
|
||||||
|
|
||||||
|
### Phase 3: Dashboard 统计增强 (T16.6-T16.8)
|
||||||
|
**状态**: ✅ 已完成
|
||||||
|
**文件修改记录**:
|
||||||
|
- ✅ 创建: ai-service/app/services/monitoring/dashboard_service.py
|
||||||
|
- DashboardService 类
|
||||||
|
- get_enhanced_stats: 获取增强统计
|
||||||
|
- _get_intent_rule_stats: 意图规则统计
|
||||||
|
- _get_template_stats: 模板使用统计
|
||||||
|
- _get_flow_stats: 流程激活统计
|
||||||
|
- _get_guardrail_stats: 护栏拦截统计
|
||||||
|
- ✅ 修改: ai-service/app/api/admin/dashboard.py
|
||||||
|
- 扩展 GET /admin/dashboard/stats
|
||||||
|
- 新增 start_date, end_date, include_enhanced 参数
|
||||||
|
- 返回增强监控统计
|
||||||
|
|
||||||
|
### Phase 4: Orchestrator 监控增强 (T16.9-T16.12)
|
||||||
|
**状态**: ✅ 已完成
|
||||||
|
**文件修改记录**:
|
||||||
|
- ✅ 创建: ai-service/app/services/monitoring/recorder.py
|
||||||
|
- MonitoringRecorder 类:执行记录器
|
||||||
|
- StepMetrics 数据类:步骤指标
|
||||||
|
- start_step/end_step: 步骤计时
|
||||||
|
- record_intent_hit: 记录意图命中
|
||||||
|
- record_template_usage: 记录模板使用
|
||||||
|
- record_flow_activation: 记录流程激活
|
||||||
|
- record_guardrail_block: 记录护栏拦截
|
||||||
|
- save_test_record: 保存测试记录
|
||||||
|
|
||||||
|
### Phase 5: 对话追踪服务 (T16.33-T16.36)
|
||||||
|
**状态**: ✅ 已完成
|
||||||
|
**文件修改记录**:
|
||||||
|
- ✅ 修改: ai-service/app/api/admin/monitoring.py
|
||||||
|
- GET /admin/monitoring/conversations: 对话列表
|
||||||
|
- GET /admin/monitoring/conversations/{message_id}: 对话详情
|
||||||
|
|
||||||
|
### Phase 6: 对话导出服务 (T16.37-T16.39)
|
||||||
|
**状态**: ✅ 已完成
|
||||||
|
**文件修改记录**:
|
||||||
|
- ✅ 修改: ai-service/app/api/admin/monitoring.py
|
||||||
|
- POST /admin/monitoring/conversations/export: 创建导出任务
|
||||||
|
- GET /admin/monitoring/conversations/export/{task_id}: 获取导出状态
|
||||||
|
- GET /admin/monitoring/conversations/export/{task_id}/download: 下载导出文件
|
||||||
|
|
||||||
|
### Phase 7: 流程测试 API
|
||||||
|
**状态**: ✅ 已完成
|
||||||
|
**文件修改记录**:
|
||||||
|
- ✅ 创建: ai-service/app/api/admin/flow_test.py
|
||||||
|
- POST /admin/test/flow-execution: 执行完整12步流程测试
|
||||||
|
- GET /admin/test/flow-execution/{test_id}: 获取测试结果
|
||||||
|
- GET /admin/test/flow-executions: 列出测试记录
|
||||||
|
- POST /admin/test/compare: 对比测试
|
||||||
|
- ✅ 修改: ai-service/app/api/admin/__init__.py
|
||||||
|
- ✅ 修改: ai-service/app/main.py
|
||||||
|
|
||||||
|
### Phase 8: 数据库迁移
|
||||||
|
**状态**: ✅ 已完成
|
||||||
|
**文件修改记录**:
|
||||||
|
- ✅ 创建: ai-service/scripts/migrations/002_add_monitoring_fields.sql
|
||||||
|
- chat_messages 表新增监控字段
|
||||||
|
- 创建 flow_test_records 表
|
||||||
|
- 创建 export_tasks 表
|
||||||
|
- 创建相关索引
|
||||||
|
|
||||||
|
### Phase 9: 前端 Dashboard 统计卡片增强 (P13-02-P13-04)
|
||||||
|
**状态**: ✅ 已完成
|
||||||
|
**文件修改记录**:
|
||||||
|
- ✅ 修改: ai-service-admin/src/api/dashboard.ts
|
||||||
|
- 新增 DashboardStats 接口类型定义
|
||||||
|
- 新增 IntentRuleStat, PromptTemplateStat, ScriptFlowStat, GuardrailWordStat 类型
|
||||||
|
- 扩展 getDashboardStats 支持时间范围参数
|
||||||
|
- ✅ 修改: ai-service-admin/src/views/dashboard/index.vue
|
||||||
|
- 新增时间范围筛选器(日期选择器 + 快捷选项)
|
||||||
|
- 新增四个监控统计卡片:意图规则命中、Prompt 模板、话术流程、护栏拦截
|
||||||
|
- 卡片支持点击跳转到对应监控页面
|
||||||
|
- 显示 Top 3 排行数据
|
||||||
|
|
||||||
|
### Phase 10: 前端完整流程测试台 (P13-05-P13-08)
|
||||||
|
**状态**: ✅ 已完成
|
||||||
|
**文件修改记录**:
|
||||||
|
- ✅ 创建: ai-service-admin/src/api/flow-test.ts
|
||||||
|
- FlowExecutionRequest/Response 接口
|
||||||
|
- FlowExecutionStep 接口
|
||||||
|
- executeFlowTest, getFlowTestResult, listFlowTests, compareFlowTest 函数
|
||||||
|
- ✅ 修改: ai-service-admin/src/views/rag-lab/index.vue
|
||||||
|
- 新增"完整流程测试"模式切换开关
|
||||||
|
- 新增流程配置开关(意图识别、话术流程、RAG检索、输出护栏、上下文记忆)
|
||||||
|
- 新增 12 步执行流程时间线展示
|
||||||
|
- 支持步骤展开查看详细输入输出
|
||||||
|
- 显示最终响应和置信度
|
||||||
|
|
||||||
|
### Phase 11: 前端对话追踪页面 (P13-20-P13-23)
|
||||||
|
**状态**: ✅ 已完成
|
||||||
|
**文件修改记录**:
|
||||||
|
- ✅ 修改: ai-service-admin/src/api/monitoring.ts
|
||||||
|
- 新增 ConversationItem, ConversationDetail 接口
|
||||||
|
- 新增 ExportTaskResponse, ExportRequest 接口
|
||||||
|
- 新增 listConversations, getConversationDetail 函数
|
||||||
|
- 新增 createExportTask, getExportStatus, getExportDownloadUrl 函数
|
||||||
|
- ✅ 创建: ai-service-admin/src/views/admin/monitoring/ConversationTracking.vue
|
||||||
|
- 对话列表页面(支持会话ID、时间范围、流程、护栏筛选)
|
||||||
|
- 对话详情抽屉(显示用户消息、AI回复、触发规则、使用模板、话术流程)
|
||||||
|
- 执行链路时间线展示(12步流程详情)
|
||||||
|
- 导出功能(支持 JSON/CSV 格式)
|
||||||
|
|
||||||
|
### Phase 12: 前端监控导航菜单 (P13-24-P13-25)
|
||||||
|
**状态**: ✅ 已完成
|
||||||
|
**文件修改记录**:
|
||||||
|
- ✅ 修改: ai-service-admin/src/router/index.ts
|
||||||
|
- 新增 /admin/monitoring/conversations 路由
|
||||||
|
|
||||||
|
## 5. 技术上下文
|
||||||
|
|
||||||
|
### 项目结构
|
||||||
|
- **前端**: `ai-service-admin/` - Vue 3 + Element Plus + TypeScript
|
||||||
|
- **后端**: `ai-service/` - Python FastAPI + SQLModel + PostgreSQL + Redis
|
||||||
|
|
||||||
|
### 核心约定
|
||||||
|
- 多租户隔离: 所有数据访问必须带 tenant_id 过滤
|
||||||
|
- 实体使用 SQLModel 定义,支持 Pydantic 验证
|
||||||
|
- API 使用 FastAPI Router 组织
|
||||||
|
|
||||||
|
### 新增依赖
|
||||||
|
- redis>=5.0.0: Redis 异步客户端
|
||||||
|
|
||||||
|
## 6. 会话历史
|
||||||
|
|
||||||
|
### 会话 1 (2026-02-27)
|
||||||
|
- 完成: 阅读需求文档和设计文档,创建进度文档
|
||||||
|
- 完成: 所有后端任务实现
|
||||||
|
- 问题: metadata 字段名与 SQLModel 父类冲突
|
||||||
|
- 解决方案: 重命名为 step_metadata
|
||||||
|
|
||||||
|
### 会话 2 (2026-02-27)
|
||||||
|
- 完成: 所有前端任务实现
|
||||||
|
- Dashboard 统计卡片增强
|
||||||
|
- RAG 实验室完整流程测试台
|
||||||
|
- 对话追踪页面
|
||||||
|
- 监控导航路由
|
||||||
|
|
||||||
|
## 7. 下一步行动
|
||||||
|
|
||||||
|
**任务已全部完成**
|
||||||
|
|
||||||
|
## 8. 待解决问题
|
||||||
|
|
||||||
|
暂无
|
||||||
|
|
||||||
|
## 9. 最终验收标准
|
||||||
|
|
||||||
|
### Dashboard 统计增强 (AC-AISVC-91, AC-AISVC-92)
|
||||||
|
- [x] GET /admin/dashboard/stats 返回意图规则命中率
|
||||||
|
- [x] GET /admin/dashboard/stats 返回 Prompt 模板使用次数
|
||||||
|
- [x] GET /admin/dashboard/stats 返回话术流程激活次数
|
||||||
|
- [x] GET /admin/dashboard/stats 返回护栏拦截次数
|
||||||
|
- [x] 支持时间范围筛选参数
|
||||||
|
- [x] 前端展示四个监控统计卡片
|
||||||
|
- [x] 前端支持时间范围筛选
|
||||||
|
|
||||||
|
### 完整流程测试 (AC-AISVC-93, AC-AISVC-94, AC-AISVC-95)
|
||||||
|
- [x] POST /admin/test/flow-execution 执行完整12步流程
|
||||||
|
- [x] 返回每一步的详细执行结果
|
||||||
|
- [x] 支持对比测试
|
||||||
|
- [x] 前端展示12步执行时间线
|
||||||
|
- [x] 前端支持步骤详情展开
|
||||||
|
|
||||||
|
### 对话追踪 (AC-AISVC-108, AC-AISVC-109, AC-AISVC-110)
|
||||||
|
- [x] GET /admin/monitoring/conversations 返回对话列表
|
||||||
|
- [x] GET /admin/monitoring/conversations/{id} 返回执行链路详情
|
||||||
|
- [x] POST /admin/monitoring/conversations/export 导出对话记录
|
||||||
|
- [x] 前端对话列表页面
|
||||||
|
- [x] 前端对话详情展示
|
||||||
|
- [x] 前端导出功能(JSON/CSV)
|
||||||
|
|
@ -1,8 +1,73 @@
|
||||||
import request from '@/utils/request'
|
import request from '@/utils/request'
|
||||||
|
|
||||||
export function getDashboardStats() {
|
export interface DashboardStatsParams {
|
||||||
|
start_date?: string
|
||||||
|
end_date?: string
|
||||||
|
include_enhanced?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntentRuleStat {
|
||||||
|
ruleId: string
|
||||||
|
ruleName: string
|
||||||
|
hitCount: number
|
||||||
|
hitRate: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromptTemplateStat {
|
||||||
|
templateId: string
|
||||||
|
templateName: string
|
||||||
|
usageCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScriptFlowStat {
|
||||||
|
flowId: string
|
||||||
|
flowName: string
|
||||||
|
activationCount: number
|
||||||
|
completionRate: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GuardrailWordStat {
|
||||||
|
word: string
|
||||||
|
category: string
|
||||||
|
hitCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardStats {
|
||||||
|
knowledgeBases: number
|
||||||
|
totalDocuments: number
|
||||||
|
totalMessages: number
|
||||||
|
totalSessions: number
|
||||||
|
totalTokens: number
|
||||||
|
promptTokens: number
|
||||||
|
completionTokens: number
|
||||||
|
aiRequestsCount: number
|
||||||
|
avgLatencyMs: number
|
||||||
|
lastLatencyMs: number | null
|
||||||
|
lastRequestTime: string | null
|
||||||
|
slowRequestsCount: number
|
||||||
|
errorRequestsCount: number
|
||||||
|
p95LatencyMs: number
|
||||||
|
p99LatencyMs: number
|
||||||
|
minLatencyMs: number
|
||||||
|
maxLatencyMs: number
|
||||||
|
latencyThresholdMs: number
|
||||||
|
intentRuleHitRate: number
|
||||||
|
intentRuleHitCount: number
|
||||||
|
topIntentRules: IntentRuleStat[]
|
||||||
|
promptTemplateUsageCount: number
|
||||||
|
topPromptTemplates: PromptTemplateStat[]
|
||||||
|
scriptFlowActivationCount: number
|
||||||
|
scriptFlowCompletionRate: number
|
||||||
|
topScriptFlows: ScriptFlowStat[]
|
||||||
|
guardrailBlockCount: number
|
||||||
|
guardrailBlockRate: number
|
||||||
|
topGuardrailWords: GuardrailWordStat[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDashboardStats(params?: DashboardStatsParams): Promise<DashboardStats> {
|
||||||
return request({
|
return request({
|
||||||
url: '/admin/dashboard/stats',
|
url: '/admin/dashboard/stats',
|
||||||
method: 'get'
|
method: 'get',
|
||||||
|
params
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
export interface FlowExecutionRequest {
|
||||||
|
message: string
|
||||||
|
session_id?: string
|
||||||
|
user_id?: string
|
||||||
|
enable_flow?: boolean
|
||||||
|
enable_intent?: boolean
|
||||||
|
enable_rag?: boolean
|
||||||
|
enable_guardrail?: boolean
|
||||||
|
enable_memory?: boolean
|
||||||
|
compare_mode?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlowExecutionStep {
|
||||||
|
step: number
|
||||||
|
name: string
|
||||||
|
status: 'success' | 'failed' | 'skipped'
|
||||||
|
duration_ms: number
|
||||||
|
input?: Record<string, any>
|
||||||
|
output?: Record<string, any>
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlowExecutionResponse {
|
||||||
|
testId: string
|
||||||
|
sessionId: string
|
||||||
|
status: string
|
||||||
|
steps: FlowExecutionStep[]
|
||||||
|
finalResponse: {
|
||||||
|
reply: string
|
||||||
|
confidence: number | null
|
||||||
|
should_transfer: boolean
|
||||||
|
sources?: any[]
|
||||||
|
} | null
|
||||||
|
totalDurationMs: number
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlowTestRecord {
|
||||||
|
testId: string
|
||||||
|
sessionId: string
|
||||||
|
status: string
|
||||||
|
stepCount: number
|
||||||
|
totalDurationMs: number
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlowTestListResponse {
|
||||||
|
data: FlowTestRecord[]
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompareRequest {
|
||||||
|
message: string
|
||||||
|
baseline_config?: Record<string, any>
|
||||||
|
test_config?: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompareResult {
|
||||||
|
baseline: {
|
||||||
|
sessionId: string
|
||||||
|
reply: string
|
||||||
|
confidence: number | null
|
||||||
|
durationMs: number
|
||||||
|
steps: FlowExecutionStep[]
|
||||||
|
}
|
||||||
|
test: {
|
||||||
|
sessionId: string
|
||||||
|
reply: string
|
||||||
|
confidence: number | null
|
||||||
|
durationMs: number
|
||||||
|
steps: FlowExecutionStep[]
|
||||||
|
}
|
||||||
|
comparison: {
|
||||||
|
durationDiffMs: number
|
||||||
|
confidenceDiff: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function executeFlowTest(data: FlowExecutionRequest): Promise<FlowExecutionResponse> {
|
||||||
|
return request({
|
||||||
|
url: '/admin/test/flow-execution',
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFlowTestResult(testId: string): Promise<FlowExecutionResponse> {
|
||||||
|
return request({
|
||||||
|
url: `/admin/test/flow-execution/${testId}`,
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listFlowTests(params?: {
|
||||||
|
session_id?: string
|
||||||
|
status?: string
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
}): Promise<FlowTestListResponse> {
|
||||||
|
return request({
|
||||||
|
url: '/admin/test/flow-executions',
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compareFlowTest(data: CompareRequest): Promise<CompareResult> {
|
||||||
|
return request({
|
||||||
|
url: '/admin/test/compare',
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -330,3 +330,118 @@ export function getGuardrailBlocks(
|
||||||
params
|
params
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ConversationItem {
|
||||||
|
id: string
|
||||||
|
sessionId: string
|
||||||
|
userMessage: string
|
||||||
|
aiReply: string | null
|
||||||
|
hasFlow: boolean
|
||||||
|
hasGuardrail: boolean
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConversationListResponse {
|
||||||
|
data: ConversationItem[]
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConversationDetail {
|
||||||
|
conversationId: string
|
||||||
|
sessionId: string
|
||||||
|
userMessage: string
|
||||||
|
aiReply: string | null
|
||||||
|
triggeredRules: Array<{
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
responseType: string
|
||||||
|
}>
|
||||||
|
usedTemplate: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
} | null
|
||||||
|
usedFlow: {
|
||||||
|
id: string
|
||||||
|
flowId: string
|
||||||
|
status: string
|
||||||
|
currentStep: number
|
||||||
|
} | null
|
||||||
|
executionTimeMs: number | null
|
||||||
|
confidence: number | null
|
||||||
|
shouldTransfer: boolean
|
||||||
|
guardrailTriggered: boolean
|
||||||
|
guardrailWords: string[] | null
|
||||||
|
executionSteps: Array<{
|
||||||
|
step: number
|
||||||
|
name: string
|
||||||
|
status: string
|
||||||
|
duration_ms: number
|
||||||
|
input?: Record<string, any>
|
||||||
|
output?: Record<string, any>
|
||||||
|
error?: string
|
||||||
|
}> | null
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportTaskResponse {
|
||||||
|
taskId: string
|
||||||
|
status: string
|
||||||
|
format: string
|
||||||
|
createdAt: string
|
||||||
|
fileName?: string
|
||||||
|
fileSize?: number
|
||||||
|
totalRows?: number
|
||||||
|
completedAt?: string
|
||||||
|
errorMessage?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportRequest {
|
||||||
|
format?: 'json' | 'csv'
|
||||||
|
session_id?: string
|
||||||
|
start_date?: string
|
||||||
|
end_date?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listConversations(params?: {
|
||||||
|
session_id?: string
|
||||||
|
start_date?: string
|
||||||
|
end_date?: string
|
||||||
|
has_flow?: boolean
|
||||||
|
has_guardrail?: boolean
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
}): Promise<ConversationListResponse> {
|
||||||
|
return request({
|
||||||
|
url: '/admin/monitoring/conversations',
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConversationDetail(messageId: string): Promise<ConversationDetail> {
|
||||||
|
return request({
|
||||||
|
url: `/admin/monitoring/conversations/${messageId}`,
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createExportTask(data: ExportRequest): Promise<ExportTaskResponse> {
|
||||||
|
return request({
|
||||||
|
url: '/admin/monitoring/conversations/export',
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getExportStatus(taskId: string): Promise<ExportTaskResponse> {
|
||||||
|
return request({
|
||||||
|
url: `/admin/monitoring/conversations/export/${taskId}`,
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getExportDownloadUrl(taskId: string): string {
|
||||||
|
return `/admin/monitoring/conversations/export/${taskId}/download`
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,12 @@ const routes: Array<RouteRecordRaw> = [
|
||||||
name: 'GuardrailMonitoring',
|
name: 'GuardrailMonitoring',
|
||||||
component: () => import('@/views/admin/monitoring/Guardrails.vue'),
|
component: () => import('@/views/admin/monitoring/Guardrails.vue'),
|
||||||
meta: { title: '输出护栏监控' }
|
meta: { title: '输出护栏监控' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/admin/monitoring/conversations',
|
||||||
|
name: 'ConversationTracking',
|
||||||
|
component: () => import('@/views/admin/monitoring/ConversationTracking.vue'),
|
||||||
|
meta: { title: '对话追踪' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,632 @@
|
||||||
|
<template>
|
||||||
|
<div class="conversation-tracking-page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1 class="page-title">对话追踪</h1>
|
||||||
|
<p class="page-desc">查看对话记录,追踪执行链路,导出对话数据</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-card shadow="hover" class="filter-card">
|
||||||
|
<el-form :inline="true" :model="queryParams" class="filter-form">
|
||||||
|
<el-form-item label="会话 ID">
|
||||||
|
<el-input v-model="queryParams.session_id" placeholder="输入会话 ID" clearable style="width: 200px" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="时间范围">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="dateRange"
|
||||||
|
type="daterange"
|
||||||
|
range-separator="至"
|
||||||
|
start-placeholder="开始日期"
|
||||||
|
end-placeholder="结束日期"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
style="width: 260px"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="话术流程">
|
||||||
|
<el-select v-model="queryParams.has_flow" placeholder="全部" clearable style="width: 120px">
|
||||||
|
<el-option label="有" :value="true" />
|
||||||
|
<el-option label="无" :value="false" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="护栏触发">
|
||||||
|
<el-select v-model="queryParams.has_guardrail" placeholder="全部" clearable style="width: 120px">
|
||||||
|
<el-option label="已触发" :value="true" />
|
||||||
|
<el-option label="未触发" :value="false" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="handleQuery">查询</el-button>
|
||||||
|
<el-button @click="resetQuery">重置</el-button>
|
||||||
|
<el-button type="success" @click="handleExport">
|
||||||
|
<el-icon><Download /></el-icon>
|
||||||
|
导出
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card shadow="hover" class="table-card" v-loading="loading">
|
||||||
|
<el-table :data="tableData" style="width: 100%" @row-click="handleRowClick">
|
||||||
|
<el-table-column prop="id" label="ID" width="280" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="sessionId" label="会话 ID" width="200" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="userMessage" label="用户消息" min-width="200" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="aiReply" label="AI 回复" min-width="200" show-overflow-tooltip>
|
||||||
|
<template #default="scope">
|
||||||
|
<span v-if="scope.row.aiReply">{{ scope.row.aiReply }}</span>
|
||||||
|
<span v-else class="text-muted">无回复</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="流程" width="80" align="center">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-tag v-if="scope.row.hasFlow" type="success" size="small">有</el-tag>
|
||||||
|
<span v-else class="text-muted">-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="护栏" width="80" align="center">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-tag v-if="scope.row.hasGuardrail" type="danger" size="small">触发</el-tag>
|
||||||
|
<span v-else class="text-muted">-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="createdAt" label="创建时间" width="180" />
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div class="pagination-wrapper">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="queryParams.page"
|
||||||
|
v-model:page-size="queryParams.pageSize"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
:total="total"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
@size-change="getList"
|
||||||
|
@current-change="getList"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-drawer
|
||||||
|
v-model="drawerVisible"
|
||||||
|
title="对话详情"
|
||||||
|
size="60%"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<div v-loading="detailLoading" class="detail-container">
|
||||||
|
<el-empty v-if="!conversationDetail && !detailLoading" description="暂无详情数据" />
|
||||||
|
<div v-else-if="conversationDetail">
|
||||||
|
<el-descriptions :column="2" border class="info-descriptions">
|
||||||
|
<el-descriptions-item label="对话 ID">{{ conversationDetail.conversationId }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="会话 ID">{{ conversationDetail.sessionId }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="执行耗时">{{ conversationDetail.executionTimeMs ? `${conversationDetail.executionTimeMs}ms` : '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="置信度">{{ conversationDetail.confidence ? `${(conversationDetail.confidence * 100).toFixed(1)}%` : '-' }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
|
||||||
|
<el-divider content-position="left">用户消息</el-divider>
|
||||||
|
<div class="message-box user-message">
|
||||||
|
{{ conversationDetail.userMessage }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-divider content-position="left">AI 回复</el-divider>
|
||||||
|
<div class="message-box ai-message">
|
||||||
|
{{ conversationDetail.aiReply || '无回复' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-divider content-position="left" v-if="conversationDetail.triggeredRules?.length">触发的规则</el-divider>
|
||||||
|
<div v-if="conversationDetail.triggeredRules?.length" class="rules-list">
|
||||||
|
<el-tag v-for="rule in conversationDetail.triggeredRules" :key="rule.id" class="rule-tag">
|
||||||
|
{{ rule.name }} ({{ rule.responseType }})
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-divider content-position="left" v-if="conversationDetail.usedTemplate">使用的模板</el-divider>
|
||||||
|
<div v-if="conversationDetail.usedTemplate" class="template-info">
|
||||||
|
<el-tag type="success">{{ conversationDetail.usedTemplate.name }}</el-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-divider content-position="left" v-if="conversationDetail.usedFlow">话术流程</el-divider>
|
||||||
|
<div v-if="conversationDetail.usedFlow" class="flow-info">
|
||||||
|
<el-descriptions :column="2" border size="small">
|
||||||
|
<el-descriptions-item label="状态">{{ conversationDetail.usedFlow.status }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="当前步骤">{{ conversationDetail.usedFlow.currentStep }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-divider content-position="left" v-if="conversationDetail.guardrailTriggered">护栏触发</el-divider>
|
||||||
|
<div v-if="conversationDetail.guardrailTriggered" class="guardrail-info">
|
||||||
|
<el-alert type="warning" :closable="false">
|
||||||
|
<template #title>
|
||||||
|
触发词汇: {{ conversationDetail.guardrailWords?.join(', ') || '未知' }}
|
||||||
|
</template>
|
||||||
|
</el-alert>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-divider content-position="left" v-if="conversationDetail.executionSteps?.length">执行链路</el-divider>
|
||||||
|
<div v-if="conversationDetail.executionSteps?.length" class="execution-steps">
|
||||||
|
<el-timeline>
|
||||||
|
<el-timeline-item
|
||||||
|
v-for="step in conversationDetail.executionSteps"
|
||||||
|
:key="step.step"
|
||||||
|
:type="getStepStatusType(step.status)"
|
||||||
|
:hollow="step.status === 'skipped'"
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
<el-card shadow="never" class="step-card" @click="toggleStepDetail(step.step)">
|
||||||
|
<div class="step-header">
|
||||||
|
<div class="step-info">
|
||||||
|
<span class="step-number">Step {{ step.step }}</span>
|
||||||
|
<span class="step-name">{{ getStepName(step.name) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="step-meta">
|
||||||
|
<el-tag :type="getStepStatusType(step.status)" size="small" effect="plain">
|
||||||
|
{{ step.status }}
|
||||||
|
</el-tag>
|
||||||
|
<span class="step-duration">{{ step.duration_ms }}ms</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="expandedSteps.includes(step.step)" class="step-detail">
|
||||||
|
<el-divider content-position="left">输入</el-divider>
|
||||||
|
<pre class="code-block"><code>{{ JSON.stringify(step.input, null, 2) }}</code></pre>
|
||||||
|
<el-divider content-position="left">输出</el-divider>
|
||||||
|
<pre class="code-block"><code>{{ JSON.stringify(step.output, null, 2) }}</code></pre>
|
||||||
|
<template v-if="step.error">
|
||||||
|
<el-divider content-position="left">错误</el-divider>
|
||||||
|
<el-alert type="error" :closable="false">{{ step.error }}</el-alert>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-timeline-item>
|
||||||
|
</el-timeline>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-drawer>
|
||||||
|
|
||||||
|
<el-dialog v-model="exportDialogVisible" title="导出对话记录" width="500px">
|
||||||
|
<el-form :model="exportForm" label-width="100px">
|
||||||
|
<el-form-item label="导出格式">
|
||||||
|
<el-radio-group v-model="exportForm.format">
|
||||||
|
<el-radio value="json">JSON</el-radio>
|
||||||
|
<el-radio value="csv">CSV</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="时间范围">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="exportForm.dateRange"
|
||||||
|
type="daterange"
|
||||||
|
range-separator="至"
|
||||||
|
start-placeholder="开始日期"
|
||||||
|
end-placeholder="结束日期"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="exportDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="confirmExport" :loading="exportLoading">确认导出</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="exportStatusDialogVisible" title="导出状态" width="500px">
|
||||||
|
<div v-if="exportTask" class="export-status">
|
||||||
|
<el-progress
|
||||||
|
v-if="exportTask.status === 'processing'"
|
||||||
|
:percentage="50"
|
||||||
|
:indeterminate="true"
|
||||||
|
status="warning"
|
||||||
|
/>
|
||||||
|
<el-result
|
||||||
|
v-else-if="exportTask.status === 'completed'"
|
||||||
|
icon="success"
|
||||||
|
title="导出完成"
|
||||||
|
:sub-title="`共导出 ${exportTask.totalRows} 条记录,文件大小 ${formatFileSize(exportTask.fileSize || 0)}`"
|
||||||
|
>
|
||||||
|
<template #extra>
|
||||||
|
<el-button type="primary" @click="downloadExport">下载文件</el-button>
|
||||||
|
</template>
|
||||||
|
</el-result>
|
||||||
|
<el-result
|
||||||
|
v-else-if="exportTask.status === 'failed'"
|
||||||
|
icon="error"
|
||||||
|
title="导出失败"
|
||||||
|
:sub-title="exportTask.errorMessage"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { Download } from '@element-plus/icons-vue'
|
||||||
|
import {
|
||||||
|
listConversations,
|
||||||
|
getConversationDetail,
|
||||||
|
createExportTask,
|
||||||
|
getExportStatus,
|
||||||
|
getExportDownloadUrl,
|
||||||
|
type ConversationItem,
|
||||||
|
type ConversationDetail,
|
||||||
|
type ExportTaskResponse
|
||||||
|
} from '@/api/monitoring'
|
||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const tableData = ref<ConversationItem[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const dateRange = ref<[string, string] | null>(null)
|
||||||
|
|
||||||
|
const queryParams = reactive({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
session_id: '',
|
||||||
|
has_flow: undefined as boolean | undefined,
|
||||||
|
has_guardrail: undefined as boolean | undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const drawerVisible = ref(false)
|
||||||
|
const detailLoading = ref(false)
|
||||||
|
const conversationDetail = ref<ConversationDetail | null>(null)
|
||||||
|
const expandedSteps = ref<number[]>([])
|
||||||
|
|
||||||
|
const exportDialogVisible = ref(false)
|
||||||
|
const exportStatusDialogVisible = ref(false)
|
||||||
|
const exportLoading = ref(false)
|
||||||
|
const exportTask = ref<ExportTaskResponse | null>(null)
|
||||||
|
const exportForm = reactive({
|
||||||
|
format: 'json' as 'json' | 'csv',
|
||||||
|
dateRange: null as [string, string] | null
|
||||||
|
})
|
||||||
|
|
||||||
|
const stepNameMap: Record<string, string> = {
|
||||||
|
'InputScanner': '输入扫描',
|
||||||
|
'FlowEngine': '流程引擎',
|
||||||
|
'IntentRouter': '意图路由',
|
||||||
|
'QueryRewriter': '查询重写',
|
||||||
|
'MultiKBRetrieval': '多知识库检索',
|
||||||
|
'ResultRanker': '结果排序',
|
||||||
|
'PromptBuilder': 'Prompt 构建',
|
||||||
|
'LLMGenerate': 'LLM 生成',
|
||||||
|
'OutputFilter': '输出过滤',
|
||||||
|
'Confidence': '置信度计算',
|
||||||
|
'Memory': '记忆存储',
|
||||||
|
'Response': '响应返回'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStepName = (name: string) => {
|
||||||
|
return stepNameMap[name] || name
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStepStatusType = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'success': return 'success'
|
||||||
|
case 'failed': return 'danger'
|
||||||
|
case 'skipped': return 'info'
|
||||||
|
default: return 'warning'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleStepDetail = (step: number) => {
|
||||||
|
const index = expandedSteps.value.indexOf(step)
|
||||||
|
if (index > -1) {
|
||||||
|
expandedSteps.value.splice(index, 1)
|
||||||
|
} else {
|
||||||
|
expandedSteps.value.push(step)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number) => {
|
||||||
|
if (bytes < 1024) return bytes + ' B'
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB'
|
||||||
|
return (bytes / (1024 * 1024)).toFixed(2) + ' MB'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getList = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params: any = {
|
||||||
|
page: queryParams.page,
|
||||||
|
pageSize: queryParams.pageSize
|
||||||
|
}
|
||||||
|
if (queryParams.session_id) {
|
||||||
|
params.session_id = queryParams.session_id
|
||||||
|
}
|
||||||
|
if (queryParams.has_flow !== undefined) {
|
||||||
|
params.has_flow = queryParams.has_flow
|
||||||
|
}
|
||||||
|
if (queryParams.has_guardrail !== undefined) {
|
||||||
|
params.has_guardrail = queryParams.has_guardrail
|
||||||
|
}
|
||||||
|
if (dateRange.value && dateRange.value.length === 2) {
|
||||||
|
params.start_date = dateRange.value[0]
|
||||||
|
params.end_date = dateRange.value[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await listConversations(params)
|
||||||
|
tableData.value = res.data || []
|
||||||
|
total.value = res.total || 0
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch conversations:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleQuery = () => {
|
||||||
|
queryParams.page = 1
|
||||||
|
getList()
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetQuery = () => {
|
||||||
|
queryParams.session_id = ''
|
||||||
|
queryParams.has_flow = undefined
|
||||||
|
queryParams.has_guardrail = undefined
|
||||||
|
dateRange.value = null
|
||||||
|
handleQuery()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRowClick = async (row: ConversationItem) => {
|
||||||
|
drawerVisible.value = true
|
||||||
|
detailLoading.value = true
|
||||||
|
expandedSteps.value = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
conversationDetail.value = await getConversationDetail(row.id)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch conversation detail:', error)
|
||||||
|
ElMessage.error('获取对话详情失败')
|
||||||
|
} finally {
|
||||||
|
detailLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
exportForm.format = 'json'
|
||||||
|
exportForm.dateRange = dateRange.value
|
||||||
|
exportDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmExport = async () => {
|
||||||
|
exportLoading.value = true
|
||||||
|
try {
|
||||||
|
const data: any = {
|
||||||
|
format: exportForm.format
|
||||||
|
}
|
||||||
|
if (exportForm.dateRange && exportForm.dateRange.length === 2) {
|
||||||
|
data.start_date = exportForm.dateRange[0]
|
||||||
|
data.end_date = exportForm.dateRange[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = await createExportTask(data)
|
||||||
|
exportTask.value = task
|
||||||
|
exportDialogVisible.value = false
|
||||||
|
exportStatusDialogVisible.value = true
|
||||||
|
|
||||||
|
if (task.status === 'processing') {
|
||||||
|
pollExportStatus(task.taskId)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create export task:', error)
|
||||||
|
ElMessage.error('创建导出任务失败')
|
||||||
|
} finally {
|
||||||
|
exportLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pollExportStatus = async (taskId: string) => {
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const status = await getExportStatus(taskId)
|
||||||
|
exportTask.value = status
|
||||||
|
|
||||||
|
if (status.status === 'processing') {
|
||||||
|
setTimeout(poll, 2000)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to poll export status:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await poll()
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadExport = () => {
|
||||||
|
if (exportTask.value) {
|
||||||
|
const url = getExportDownloadUrl(exportTask.value.taskId)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = request.defaults.baseURL + url
|
||||||
|
link.download = exportTask.value.fileName || 'export.json'
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
getList()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.conversation-tracking-page {
|
||||||
|
padding: 24px;
|
||||||
|
min-height: calc(100vh - 60px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-desc {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-card {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-form {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-card {
|
||||||
|
animation: fadeInUp 0.5s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-wrapper {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-container {
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-descriptions {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-box {
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-message {
|
||||||
|
background-color: var(--primary-lighter);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-message {
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-tag {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-steps {
|
||||||
|
max-height: 500px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-card {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-card:hover {
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-number {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-duration {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-detail {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block {
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-status {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.conversation-tracking-page {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-form {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,13 +1,27 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="dashboard-page">
|
<div class="dashboard-page">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1 class="page-title">控制台</h1>
|
<div class="header-content">
|
||||||
<p class="page-desc">系统概览与数据统计</p>
|
<h1 class="page-title">控制台</h1>
|
||||||
|
<p class="page-desc">系统概览与数据统计</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="dateRange"
|
||||||
|
type="daterange"
|
||||||
|
range-separator="至"
|
||||||
|
start-placeholder="开始日期"
|
||||||
|
end-placeholder="结束日期"
|
||||||
|
:shortcuts="dateShortcuts"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
@change="handleDateChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-row :gutter="20" v-loading="loading">
|
<el-row :gutter="20" v-loading="loading">
|
||||||
<el-col :xs="12" :sm="12" :md="6" :lg="6">
|
<el-col :xs="12" :sm="12" :md="6" :lg="6">
|
||||||
<el-card shadow="hover" class="stat-card">
|
<el-card shadow="hover" class="stat-card" @click="navigateTo('/admin/knowledge-bases')">
|
||||||
<div class="stat-content">
|
<div class="stat-content">
|
||||||
<div class="stat-icon primary">
|
<div class="stat-icon primary">
|
||||||
<el-icon><FolderOpened /></el-icon>
|
<el-icon><FolderOpened /></el-icon>
|
||||||
|
|
@ -20,7 +34,7 @@
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :xs="12" :sm="12" :md="6" :lg="6">
|
<el-col :xs="12" :sm="12" :md="6" :lg="6">
|
||||||
<el-card shadow="hover" class="stat-card">
|
<el-card shadow="hover" class="stat-card" @click="navigateTo('/kb')">
|
||||||
<div class="stat-content">
|
<div class="stat-content">
|
||||||
<div class="stat-icon success">
|
<div class="stat-icon success">
|
||||||
<el-icon><Document /></el-icon>
|
<el-icon><Document /></el-icon>
|
||||||
|
|
@ -33,7 +47,7 @@
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :xs="12" :sm="12" :md="6" :lg="6">
|
<el-col :xs="12" :sm="12" :md="6" :lg="6">
|
||||||
<el-card shadow="hover" class="stat-card">
|
<el-card shadow="hover" class="stat-card" @click="navigateTo('/monitoring')">
|
||||||
<div class="stat-content">
|
<div class="stat-content">
|
||||||
<div class="stat-icon warning">
|
<div class="stat-icon warning">
|
||||||
<el-icon><ChatDotSquare /></el-icon>
|
<el-icon><ChatDotSquare /></el-icon>
|
||||||
|
|
@ -60,6 +74,109 @@
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
|
|
||||||
|
<el-row :gutter="20" style="margin-top: 20px;">
|
||||||
|
<el-col :xs="24" :sm="12" :md="6" :lg="6">
|
||||||
|
<el-card shadow="hover" class="enhanced-stat-card" @click="navigateTo('/admin/monitoring/intent-rules')">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<div class="icon-wrapper primary">
|
||||||
|
<el-icon><Aim /></el-icon>
|
||||||
|
</div>
|
||||||
|
<span class="header-title">意图规则命中</span>
|
||||||
|
</div>
|
||||||
|
<el-tag type="primary" size="small" effect="plain">{{ (stats.intentRuleHitRate * 100).toFixed(1) }}%</el-tag>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="enhanced-content">
|
||||||
|
<div class="enhanced-value">{{ stats.intentRuleHitCount }}</div>
|
||||||
|
<div class="enhanced-label">命中次数</div>
|
||||||
|
<div class="top-list" v-if="stats.topIntentRules && stats.topIntentRules.length > 0">
|
||||||
|
<div class="top-item" v-for="rule in stats.topIntentRules.slice(0, 3)" :key="rule.ruleId">
|
||||||
|
<span class="top-name">{{ rule.ruleName }}</span>
|
||||||
|
<span class="top-count">{{ rule.hitCount }}次</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :sm="12" :md="6" :lg="6">
|
||||||
|
<el-card shadow="hover" class="enhanced-stat-card" @click="navigateTo('/admin/monitoring/prompt-templates')">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<div class="icon-wrapper success">
|
||||||
|
<el-icon><DocumentCopy /></el-icon>
|
||||||
|
</div>
|
||||||
|
<span class="header-title">Prompt 模板</span>
|
||||||
|
</div>
|
||||||
|
<el-tag type="success" size="small" effect="plain">使用中</el-tag>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="enhanced-content">
|
||||||
|
<div class="enhanced-value">{{ stats.promptTemplateUsageCount }}</div>
|
||||||
|
<div class="enhanced-label">使用次数</div>
|
||||||
|
<div class="top-list" v-if="stats.topPromptTemplates && stats.topPromptTemplates.length > 0">
|
||||||
|
<div class="top-item" v-for="tpl in stats.topPromptTemplates.slice(0, 3)" :key="tpl.templateId">
|
||||||
|
<span class="top-name">{{ tpl.templateName }}</span>
|
||||||
|
<span class="top-count">{{ tpl.usageCount }}次</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :sm="12" :md="6" :lg="6">
|
||||||
|
<el-card shadow="hover" class="enhanced-stat-card" @click="navigateTo('/admin/monitoring/script-flows')">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<div class="icon-wrapper warning">
|
||||||
|
<el-icon><Share /></el-icon>
|
||||||
|
</div>
|
||||||
|
<span class="header-title">话术流程</span>
|
||||||
|
</div>
|
||||||
|
<el-tag type="warning" size="small" effect="plain">{{ (stats.scriptFlowCompletionRate * 100).toFixed(1) }}%</el-tag>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="enhanced-content">
|
||||||
|
<div class="enhanced-value">{{ stats.scriptFlowActivationCount }}</div>
|
||||||
|
<div class="enhanced-label">激活次数</div>
|
||||||
|
<div class="top-list" v-if="stats.topScriptFlows && stats.topScriptFlows.length > 0">
|
||||||
|
<div class="top-item" v-for="flow in stats.topScriptFlows.slice(0, 3)" :key="flow.flowId">
|
||||||
|
<span class="top-name">{{ flow.flowName }}</span>
|
||||||
|
<span class="top-count">{{ flow.activationCount }}次</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :sm="12" :md="6" :lg="6">
|
||||||
|
<el-card shadow="hover" class="enhanced-stat-card" @click="navigateTo('/admin/monitoring/guardrails')">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<div class="icon-wrapper danger">
|
||||||
|
<el-icon><Warning /></el-icon>
|
||||||
|
</div>
|
||||||
|
<span class="header-title">护栏拦截</span>
|
||||||
|
</div>
|
||||||
|
<el-tag type="danger" size="small" effect="plain">{{ (stats.guardrailBlockRate * 100).toFixed(1) }}%</el-tag>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="enhanced-content">
|
||||||
|
<div class="enhanced-value">{{ stats.guardrailBlockCount }}</div>
|
||||||
|
<div class="enhanced-label">拦截次数</div>
|
||||||
|
<div class="top-list" v-if="stats.topGuardrailWords && stats.topGuardrailWords.length > 0">
|
||||||
|
<div class="top-item" v-for="word in stats.topGuardrailWords.slice(0, 3)" :key="word.word">
|
||||||
|
<span class="top-name">{{ word.word }}</span>
|
||||||
|
<span class="top-count">{{ word.hitCount }}次</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
<el-row :gutter="20" style="margin-top: 20px;">
|
<el-row :gutter="20" style="margin-top: 20px;">
|
||||||
<el-col :xs="24" :sm="24" :md="8" :lg="8">
|
<el-col :xs="24" :sm="24" :md="8" :lg="8">
|
||||||
<el-card shadow="hover" class="metric-card">
|
<el-card shadow="hover" class="metric-card">
|
||||||
|
|
@ -248,11 +365,64 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, computed, onMounted } from 'vue'
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
import { FolderOpened, Document, ChatDotSquare, Monitor, Cpu, InfoFilled, Connection, Timer, DataLine } from '@element-plus/icons-vue'
|
import { useRouter } from 'vue-router'
|
||||||
import { getDashboardStats } from '@/api/dashboard'
|
import { FolderOpened, Document, ChatDotSquare, Monitor, Cpu, InfoFilled, Connection, Timer, DataLine, Aim, DocumentCopy, Share, Warning } from '@element-plus/icons-vue'
|
||||||
|
import { getDashboardStats, type DashboardStats } from '@/api/dashboard'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const stats = reactive({
|
const dateRange = ref<[string, string] | null>(null)
|
||||||
|
|
||||||
|
const dateShortcuts = [
|
||||||
|
{
|
||||||
|
text: '今日',
|
||||||
|
value: () => {
|
||||||
|
const today = new Date()
|
||||||
|
today.setHours(0, 0, 0, 0)
|
||||||
|
return [today, new Date()]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '本周',
|
||||||
|
value: () => {
|
||||||
|
const today = new Date()
|
||||||
|
const day = today.getDay() || 7
|
||||||
|
const monday = new Date(today)
|
||||||
|
monday.setDate(today.getDate() - day + 1)
|
||||||
|
monday.setHours(0, 0, 0, 0)
|
||||||
|
return [monday, new Date()]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '本月',
|
||||||
|
value: () => {
|
||||||
|
const today = new Date()
|
||||||
|
const firstDay = new Date(today.getFullYear(), today.getMonth(), 1)
|
||||||
|
return [firstDay, new Date()]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '最近7天',
|
||||||
|
value: () => {
|
||||||
|
const end = new Date()
|
||||||
|
const start = new Date()
|
||||||
|
start.setDate(start.getDate() - 7)
|
||||||
|
return [start, end]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '最近30天',
|
||||||
|
value: () => {
|
||||||
|
const end = new Date()
|
||||||
|
const start = new Date()
|
||||||
|
start.setDate(start.getDate() - 30)
|
||||||
|
return [start, end]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const stats = reactive<DashboardStats>({
|
||||||
knowledgeBases: 0,
|
knowledgeBases: 0,
|
||||||
totalDocuments: 0,
|
totalDocuments: 0,
|
||||||
totalMessages: 0,
|
totalMessages: 0,
|
||||||
|
|
@ -262,14 +432,26 @@ const stats = reactive({
|
||||||
completionTokens: 0,
|
completionTokens: 0,
|
||||||
aiRequestsCount: 0,
|
aiRequestsCount: 0,
|
||||||
avgLatencyMs: 0,
|
avgLatencyMs: 0,
|
||||||
lastLatencyMs: 0,
|
lastLatencyMs: null,
|
||||||
|
lastRequestTime: null,
|
||||||
slowRequestsCount: 0,
|
slowRequestsCount: 0,
|
||||||
errorRequestsCount: 0,
|
errorRequestsCount: 0,
|
||||||
p95LatencyMs: 0,
|
p95LatencyMs: 0,
|
||||||
p99LatencyMs: 0,
|
p99LatencyMs: 0,
|
||||||
minLatencyMs: 0,
|
minLatencyMs: 0,
|
||||||
maxLatencyMs: 0,
|
maxLatencyMs: 0,
|
||||||
latencyThresholdMs: 5000
|
latencyThresholdMs: 5000,
|
||||||
|
intentRuleHitRate: 0,
|
||||||
|
intentRuleHitCount: 0,
|
||||||
|
topIntentRules: [],
|
||||||
|
promptTemplateUsageCount: 0,
|
||||||
|
topPromptTemplates: [],
|
||||||
|
scriptFlowActivationCount: 0,
|
||||||
|
scriptFlowCompletionRate: 0,
|
||||||
|
topScriptFlows: [],
|
||||||
|
guardrailBlockCount: 0,
|
||||||
|
guardrailBlockRate: 0,
|
||||||
|
topGuardrailWords: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
const errorRate = computed(() => {
|
const errorRate = computed(() => {
|
||||||
|
|
@ -299,27 +481,24 @@ const formatLatency = (ms: number | null | undefined) => {
|
||||||
return ms.toFixed(0) + 'ms'
|
return ms.toFixed(0) + 'ms'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const navigateTo = (path: string) => {
|
||||||
|
router.push(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDateChange = () => {
|
||||||
|
fetchStats()
|
||||||
|
}
|
||||||
|
|
||||||
const fetchStats = async () => {
|
const fetchStats = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res: any = await getDashboardStats()
|
const params: any = {}
|
||||||
stats.knowledgeBases = res.knowledgeBases || 0
|
if (dateRange.value && dateRange.value.length === 2) {
|
||||||
stats.totalDocuments = res.totalDocuments || 0
|
params.start_date = dateRange.value[0]
|
||||||
stats.totalMessages = res.totalMessages || 0
|
params.end_date = dateRange.value[1]
|
||||||
stats.totalSessions = res.totalSessions || 0
|
}
|
||||||
stats.totalTokens = res.totalTokens || 0
|
const res = await getDashboardStats(params)
|
||||||
stats.promptTokens = res.promptTokens || 0
|
Object.assign(stats, res)
|
||||||
stats.completionTokens = res.completionTokens || 0
|
|
||||||
stats.aiRequestsCount = res.aiRequestsCount || 0
|
|
||||||
stats.avgLatencyMs = res.avgLatencyMs || 0
|
|
||||||
stats.lastLatencyMs = res.lastLatencyMs || 0
|
|
||||||
stats.slowRequestsCount = res.slowRequestsCount || 0
|
|
||||||
stats.errorRequestsCount = res.errorRequestsCount || 0
|
|
||||||
stats.p95LatencyMs = res.p95LatencyMs || 0
|
|
||||||
stats.p99LatencyMs = res.p99LatencyMs || 0
|
|
||||||
stats.minLatencyMs = res.minLatencyMs || 0
|
|
||||||
stats.maxLatencyMs = res.maxLatencyMs || 0
|
|
||||||
stats.latencyThresholdMs = res.latencyThresholdMs || 5000
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch dashboard stats:', error)
|
console.error('Failed to fetch dashboard stats:', error)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -340,6 +519,19 @@ onMounted(() => {
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
|
|
@ -359,6 +551,13 @@ onMounted(() => {
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
animation: fadeInUp 0.5s ease-out;
|
animation: fadeInUp 0.5s ease-out;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card:nth-child(1) { animation-delay: 0s; }
|
.stat-card:nth-child(1) { animation-delay: 0s; }
|
||||||
|
|
@ -431,8 +630,15 @@ onMounted(() => {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-card {
|
.enhanced-stat-card {
|
||||||
animation: fadeInUp 0.6s ease-out;
|
animation: fadeInUp 0.6s ease-out;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enhanced-stat-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-header {
|
.card-header {
|
||||||
|
|
@ -473,6 +679,11 @@ onMounted(() => {
|
||||||
color: #D97706;
|
color: #D97706;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon-wrapper.danger {
|
||||||
|
background-color: #FEE2E2;
|
||||||
|
color: #DC2626;
|
||||||
|
}
|
||||||
|
|
||||||
.icon-wrapper.info {
|
.icon-wrapper.info {
|
||||||
background-color: #E0E7FF;
|
background-color: #E0E7FF;
|
||||||
color: #4F46E5;
|
color: #4F46E5;
|
||||||
|
|
@ -484,6 +695,57 @@ onMounted(() => {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.enhanced-content {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enhanced-value {
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enhanced-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 4px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-list {
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
padding-top: 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-name {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-count {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card {
|
||||||
|
animation: fadeInUp 0.6s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
.metric-content {
|
.metric-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -584,11 +846,6 @@ onMounted(() => {
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-label {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.requests-grid {
|
.requests-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
|
|
@ -701,6 +958,10 @@ onMounted(() => {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
|
|
@ -716,5 +977,9 @@ onMounted(() => {
|
||||||
.metric-value {
|
.metric-value {
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.enhanced-value {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,12 @@
|
||||||
</div>
|
</div>
|
||||||
<span class="header-title">调试输入</span>
|
<span class="header-title">调试输入</span>
|
||||||
</div>
|
</div>
|
||||||
|
<el-switch
|
||||||
|
v-model="flowTestMode"
|
||||||
|
active-text="完整流程测试"
|
||||||
|
inactive-text="RAG 测试"
|
||||||
|
@change="handleModeChange"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<el-form label-position="top">
|
<el-form label-position="top">
|
||||||
|
|
@ -27,7 +33,7 @@
|
||||||
placeholder="输入测试问题..."
|
placeholder="输入测试问题..."
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="知识库范围">
|
<el-form-item label="知识库范围" v-if="!flowTestMode">
|
||||||
<el-select
|
<el-select
|
||||||
v-model="kbIds"
|
v-model="kbIds"
|
||||||
multiple
|
multiple
|
||||||
|
|
@ -45,7 +51,7 @@
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="LLM 模型">
|
<el-form-item label="LLM 模型" v-if="!flowTestMode">
|
||||||
<LLMSelector
|
<LLMSelector
|
||||||
v-model="llmProvider"
|
v-model="llmProvider"
|
||||||
:providers="llmProviders"
|
:providers="llmProviders"
|
||||||
|
|
@ -56,37 +62,67 @@
|
||||||
@change="handleLLMChange"
|
@change="handleLLMChange"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="参数配置">
|
|
||||||
<div class="param-item">
|
<template v-if="flowTestMode">
|
||||||
<span class="label">Top-K</span>
|
<el-divider content-position="left">流程配置</el-divider>
|
||||||
<el-input-number v-model="topK" :min="1" :max="10" />
|
<div class="flow-config">
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="config-label">意图识别</span>
|
||||||
|
<el-switch v-model="flowConfig.enable_intent" />
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="config-label">话术流程</span>
|
||||||
|
<el-switch v-model="flowConfig.enable_flow" />
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="config-label">RAG 检索</span>
|
||||||
|
<el-switch v-model="flowConfig.enable_rag" />
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="config-label">输出护栏</span>
|
||||||
|
<el-switch v-model="flowConfig.enable_guardrail" />
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="config-label">上下文记忆</span>
|
||||||
|
<el-switch v-model="flowConfig.enable_memory" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="param-item">
|
</template>
|
||||||
<span class="label">Score Threshold</span>
|
|
||||||
<el-slider
|
<template v-if="!flowTestMode">
|
||||||
v-model="scoreThreshold"
|
<el-form-item label="参数配置">
|
||||||
:min="0"
|
<div class="param-item">
|
||||||
:max="1"
|
<span class="label">Top-K</span>
|
||||||
:step="0.1"
|
<el-input-number v-model="topK" :min="1" :max="10" />
|
||||||
show-input
|
</div>
|
||||||
/>
|
<div class="param-item">
|
||||||
</div>
|
<span class="label">Score Threshold</span>
|
||||||
<div class="param-item">
|
<el-slider
|
||||||
<span class="label">生成 AI 回复</span>
|
v-model="scoreThreshold"
|
||||||
<el-switch v-model="generateResponse" />
|
:min="0"
|
||||||
</div>
|
:max="1"
|
||||||
<div class="param-item" v-if="generateResponse">
|
:step="0.1"
|
||||||
<span class="label">流式输出</span>
|
show-input
|
||||||
<el-switch v-model="streamOutput" />
|
/>
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
<div class="param-item">
|
||||||
|
<span class="label">生成 AI 回复</span>
|
||||||
|
<el-switch v-model="generateResponse" />
|
||||||
|
</div>
|
||||||
|
<div class="param-item" v-if="generateResponse">
|
||||||
|
<span class="label">流式输出</span>
|
||||||
|
<el-switch v-model="streamOutput" />
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
<el-button
|
<el-button
|
||||||
type="primary"
|
type="primary"
|
||||||
block
|
block
|
||||||
@click="handleRun"
|
@click="handleRun"
|
||||||
:loading="loading || streaming"
|
:loading="loading || streaming"
|
||||||
>
|
>
|
||||||
{{ streaming ? '生成中...' : '运行实验' }}
|
{{ flowTestMode ? '执行流程测试' : (streaming ? '生成中...' : '运行实验') }}
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="streaming"
|
v-if="streaming"
|
||||||
|
|
@ -102,67 +138,145 @@
|
||||||
</el-col>
|
</el-col>
|
||||||
|
|
||||||
<el-col :xs="24" :sm="24" :md="14" :lg="14">
|
<el-col :xs="24" :sm="24" :md="14" :lg="14">
|
||||||
<el-tabs v-model="activeTab" type="border-card" class="result-tabs">
|
<template v-if="flowTestMode">
|
||||||
<el-tab-pane label="召回片段" name="retrieval">
|
<el-card shadow="hover" class="result-card" v-loading="loading">
|
||||||
<div v-if="retrievalResults.length === 0" class="placeholder-text">
|
<template #header>
|
||||||
暂无实验数据
|
<div class="card-header">
|
||||||
</div>
|
<div class="header-left">
|
||||||
<div v-else class="result-list">
|
<div class="icon-wrapper success">
|
||||||
<el-card
|
<el-icon><Share /></el-icon>
|
||||||
v-for="(item, index) in retrievalResults"
|
</div>
|
||||||
:key="index"
|
<span class="header-title">执行流程 (12步)</span>
|
||||||
class="result-card"
|
|
||||||
shadow="never"
|
|
||||||
>
|
|
||||||
<div class="result-header">
|
|
||||||
<el-tag size="small" type="primary">Score: {{ item.score.toFixed(4) }}</el-tag>
|
|
||||||
<span class="source">来源: {{ item.source }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="result-content">{{ item.content }}</div>
|
<div class="header-right" v-if="flowTestResult">
|
||||||
</el-card>
|
<el-tag :type="getStatusType(flowTestResult.status)" size="small">
|
||||||
|
{{ flowTestResult.status }}
|
||||||
|
</el-tag>
|
||||||
|
<span class="duration">{{ flowTestResult.totalDurationMs }}ms</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="!flowTestResult" class="placeholder-text">
|
||||||
|
切换到"完整流程测试"模式,输入测试消息后点击执行
|
||||||
</div>
|
</div>
|
||||||
</el-tab-pane>
|
|
||||||
<el-tab-pane label="最终 Prompt" name="prompt">
|
<div v-else class="flow-result">
|
||||||
<div v-if="!finalPrompt" class="placeholder-text">
|
<el-timeline>
|
||||||
等待实验运行...
|
<el-timeline-item
|
||||||
|
v-for="step in flowTestResult.steps"
|
||||||
|
:key="step.step"
|
||||||
|
:type="getStepStatusType(step.status)"
|
||||||
|
:hollow="step.status === 'skipped'"
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
<el-card shadow="never" class="step-card" @click="toggleStepDetail(step.step)">
|
||||||
|
<div class="step-header">
|
||||||
|
<div class="step-info">
|
||||||
|
<span class="step-number">Step {{ step.step }}</span>
|
||||||
|
<span class="step-name">{{ getStepName(step.name) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="step-meta">
|
||||||
|
<el-tag :type="getStepStatusType(step.status)" size="small" effect="plain">
|
||||||
|
{{ step.status }}
|
||||||
|
</el-tag>
|
||||||
|
<span class="step-duration">{{ step.duration_ms }}ms</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="expandedSteps.includes(step.step)" class="step-detail">
|
||||||
|
<el-divider content-position="left">输入</el-divider>
|
||||||
|
<pre class="code-block"><code>{{ JSON.stringify(step.input, null, 2) }}</code></pre>
|
||||||
|
<el-divider content-position="left">输出</el-divider>
|
||||||
|
<pre class="code-block"><code>{{ JSON.stringify(step.output, null, 2) }}</code></pre>
|
||||||
|
<template v-if="step.error">
|
||||||
|
<el-divider content-position="left">错误</el-divider>
|
||||||
|
<el-alert type="error" :closable="false">{{ step.error }}</el-alert>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-timeline-item>
|
||||||
|
</el-timeline>
|
||||||
|
|
||||||
|
<el-divider content-position="left" v-if="flowTestResult.finalResponse">最终响应</el-divider>
|
||||||
|
<div v-if="flowTestResult.finalResponse" class="final-response">
|
||||||
|
<div class="response-content">{{ flowTestResult.finalResponse.reply }}</div>
|
||||||
|
<div class="response-meta">
|
||||||
|
<span v-if="flowTestResult.finalResponse.confidence">
|
||||||
|
置信度: {{ (flowTestResult.finalResponse.confidence * 100).toFixed(1) }}%
|
||||||
|
</span>
|
||||||
|
<el-tag v-if="flowTestResult.finalResponse.should_transfer" type="warning" size="small">
|
||||||
|
需转人工
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="prompt-view">
|
</el-card>
|
||||||
<pre><code>{{ finalPrompt }}</code></pre>
|
</template>
|
||||||
</div>
|
|
||||||
</el-tab-pane>
|
<template v-else>
|
||||||
<el-tab-pane label="AI 回复" name="ai-response" v-if="generateResponse">
|
<el-tabs v-model="activeTab" type="border-card" class="result-tabs">
|
||||||
<StreamOutput
|
<el-tab-pane label="召回片段" name="retrieval">
|
||||||
v-if="streamOutput"
|
<div v-if="retrievalResults.length === 0" class="placeholder-text">
|
||||||
:content="streamContent"
|
暂无实验数据
|
||||||
:is-streaming="streaming"
|
</div>
|
||||||
:error="streamError"
|
<div v-else class="result-list">
|
||||||
/>
|
<el-card
|
||||||
<AIResponseViewer
|
v-for="(item, index) in retrievalResults"
|
||||||
v-else
|
:key="index"
|
||||||
:response="aiResponse"
|
class="result-card"
|
||||||
/>
|
shadow="never"
|
||||||
</el-tab-pane>
|
>
|
||||||
<el-tab-pane label="诊断信息" name="diagnostics">
|
<div class="result-header">
|
||||||
<div v-if="!diagnostics" class="placeholder-text">
|
<el-tag size="small" type="primary">Score: {{ item.score.toFixed(4) }}</el-tag>
|
||||||
等待实验运行...
|
<span class="source">来源: {{ item.source }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="diagnostics-view">
|
<div class="result-content">{{ item.content }}</div>
|
||||||
<pre><code>{{ JSON.stringify(diagnostics, null, 2) }}</code></pre>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
</el-tabs>
|
<el-tab-pane label="最终 Prompt" name="prompt">
|
||||||
|
<div v-if="!finalPrompt" class="placeholder-text">
|
||||||
|
等待实验运行...
|
||||||
|
</div>
|
||||||
|
<div v-else class="prompt-view">
|
||||||
|
<pre><code>{{ finalPrompt }}</code></pre>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
<el-tab-pane label="AI 回复" name="ai-response" v-if="generateResponse">
|
||||||
|
<StreamOutput
|
||||||
|
v-if="streamOutput"
|
||||||
|
:content="streamContent"
|
||||||
|
:is-streaming="streaming"
|
||||||
|
:error="streamError"
|
||||||
|
/>
|
||||||
|
<AIResponseViewer
|
||||||
|
v-else
|
||||||
|
:response="aiResponse"
|
||||||
|
/>
|
||||||
|
</el-tab-pane>
|
||||||
|
<el-tab-pane label="诊断信息" name="diagnostics">
|
||||||
|
<div v-if="!diagnostics" class="placeholder-text">
|
||||||
|
等待实验运行...
|
||||||
|
</div>
|
||||||
|
<div v-else class="diagnostics-view">
|
||||||
|
<pre><code>{{ JSON.stringify(diagnostics, null, 2) }}</code></pre>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</template>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { Edit } from '@element-plus/icons-vue'
|
import { Edit, Share } from '@element-plus/icons-vue'
|
||||||
import { runRagExperiment, createSSEConnection, type AIResponse, type RetrievalResult } from '@/api/rag'
|
import { runRagExperiment, createSSEConnection, type AIResponse, type RetrievalResult } from '@/api/rag'
|
||||||
import { getLLMProviders, getLLMConfig, type LLMProviderInfo } from '@/api/llm'
|
import { getLLMProviders, getLLMConfig, type LLMProviderInfo } from '@/api/llm'
|
||||||
import { listKnowledgeBases } from '@/api/kb'
|
import { listKnowledgeBases } from '@/api/kb'
|
||||||
|
import { executeFlowTest, type FlowExecutionResponse, type FlowExecutionStep } from '@/api/flow-test'
|
||||||
import { useRagLabStore } from '@/stores/ragLab'
|
import { useRagLabStore } from '@/stores/ragLab'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import AIResponseViewer from '@/components/rag/AIResponseViewer.vue'
|
import AIResponseViewer from '@/components/rag/AIResponseViewer.vue'
|
||||||
|
|
@ -204,8 +318,72 @@ const streamError = ref<string | null>(null)
|
||||||
|
|
||||||
const totalLatencyMs = ref(0)
|
const totalLatencyMs = ref(0)
|
||||||
|
|
||||||
|
const flowTestMode = ref(false)
|
||||||
|
const flowTestResult = ref<FlowExecutionResponse | null>(null)
|
||||||
|
const expandedSteps = ref<number[]>([])
|
||||||
|
|
||||||
|
const flowConfig = reactive({
|
||||||
|
enable_intent: true,
|
||||||
|
enable_flow: true,
|
||||||
|
enable_rag: true,
|
||||||
|
enable_guardrail: true,
|
||||||
|
enable_memory: true
|
||||||
|
})
|
||||||
|
|
||||||
let abortStream: (() => void) | null = null
|
let abortStream: (() => void) | null = null
|
||||||
|
|
||||||
|
const stepNameMap: Record<string, string> = {
|
||||||
|
'InputScanner': '输入扫描',
|
||||||
|
'FlowEngine': '流程引擎',
|
||||||
|
'IntentRouter': '意图路由',
|
||||||
|
'QueryRewriter': '查询重写',
|
||||||
|
'MultiKBRetrieval': '多知识库检索',
|
||||||
|
'ResultRanker': '结果排序',
|
||||||
|
'PromptBuilder': 'Prompt 构建',
|
||||||
|
'LLMGenerate': 'LLM 生成',
|
||||||
|
'OutputFilter': '输出过滤',
|
||||||
|
'Confidence': '置信度计算',
|
||||||
|
'Memory': '记忆存储',
|
||||||
|
'Response': '响应返回'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStepName = (name: string) => {
|
||||||
|
return stepNameMap[name] || name
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusType = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'success': return 'success'
|
||||||
|
case 'failed': return 'danger'
|
||||||
|
case 'partial': return 'warning'
|
||||||
|
default: return 'info'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStepStatusType = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'success': return 'success'
|
||||||
|
case 'failed': return 'danger'
|
||||||
|
case 'skipped': return 'info'
|
||||||
|
default: return 'warning'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleStepDetail = (step: number) => {
|
||||||
|
const index = expandedSteps.value.indexOf(step)
|
||||||
|
if (index > -1) {
|
||||||
|
expandedSteps.value.splice(index, 1)
|
||||||
|
} else {
|
||||||
|
expandedSteps.value.push(step)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleModeChange = () => {
|
||||||
|
flowTestResult.value = null
|
||||||
|
expandedSteps.value = []
|
||||||
|
clearResults()
|
||||||
|
}
|
||||||
|
|
||||||
const fetchKnowledgeBases = async () => {
|
const fetchKnowledgeBases = async () => {
|
||||||
kbLoading.value = true
|
kbLoading.value = true
|
||||||
try {
|
try {
|
||||||
|
|
@ -244,12 +422,40 @@ const handleRun = async () => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
clearResults()
|
if (flowTestMode.value) {
|
||||||
|
await runFlowTest()
|
||||||
if (streamOutput.value && generateResponse.value) {
|
|
||||||
await runStreamExperiment()
|
|
||||||
} else {
|
} else {
|
||||||
await runNormalExperiment()
|
clearResults()
|
||||||
|
if (streamOutput.value && generateResponse.value) {
|
||||||
|
await runStreamExperiment()
|
||||||
|
} else {
|
||||||
|
await runNormalExperiment()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runFlowTest = async () => {
|
||||||
|
loading.value = true
|
||||||
|
flowTestResult.value = null
|
||||||
|
expandedSteps.value = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await executeFlowTest({
|
||||||
|
message: query.value,
|
||||||
|
enable_flow: flowConfig.enable_flow,
|
||||||
|
enable_intent: flowConfig.enable_intent,
|
||||||
|
enable_rag: flowConfig.enable_rag,
|
||||||
|
enable_guardrail: flowConfig.enable_guardrail,
|
||||||
|
enable_memory: flowConfig.enable_memory
|
||||||
|
})
|
||||||
|
|
||||||
|
flowTestResult.value = result
|
||||||
|
ElMessage.success('流程测试完成')
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err)
|
||||||
|
ElMessage.error(err?.message || '流程测试失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -434,12 +640,49 @@ onMounted(() => {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon-wrapper.success {
|
||||||
|
background-color: #D1FAE5;
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
.header-title {
|
.header-title {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-config {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
.param-item {
|
.param-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -523,6 +766,98 @@ onMounted(() => {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.flow-result {
|
||||||
|
max-height: 700px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-card {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-card:hover {
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-number {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-duration {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-detail {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block {
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.final-response {
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-content {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.rag-lab-page {
|
.rag-lab-page {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
|
@ -541,5 +876,9 @@ onMounted(() => {
|
||||||
.param-item .label {
|
.param-item .label {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.flow-config {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue